P6: emoji reactions in and out
- Incoming reactions in all three shapes: Misskey and Sharkey Likes carrying an emoji (`content` or `_misskey_reaction`), Pleroma, Akkoma and Iceshrimp.NET EmojiReacts, and their Undo. Unicode reactions are one grapheme; custom ones need their Emoji tag and keep its image; `:name@host:` is read as `name`. A Like whose content is a heart stays a favourite. - Reactions are kept per account and emoji on our posts and on remote ones we hold, and the author of a local post gets a `pleroma:emoji_reaction` notification with the emoji. - Personas react through Pleroma's API (PUT/DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji, GET for the list), which sends an EmojiReact (or its Undo) to the post's author. - Statuses carry `emoji_reactions` (read by Phanpy) and `pleroma.emoji_reactions`, with counts and whether the viewer reacted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
7be6749a23
commit
fdcf5176bc
19 files changed
+507
-12
No files matched your search
@@ -0,0 +1,76 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public class ReactionsController : MastodonController
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly IReactions _reactions;
|
||||
|
||||
public ReactionsController(DbEntities dbEntities, MastodonMapper mapper, IReactions reactions)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_mapper = mapper;
|
||||
_reactions = reactions;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/pleroma/statuses/{id}/reactions"), Scope("read:statuses", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> List(string id, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
if (post == default)
|
||||
return NotFoundError();
|
||||
var reactions = await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID).ExecuteAsync(token);
|
||||
var accounts = await _mapper.Accounts(reactions.Select(r => r.AccountId), token);
|
||||
return Json(reactions.GroupBy(r => r.Emoji).Select(group => new
|
||||
{
|
||||
name = group.Key,
|
||||
count = group.Count(),
|
||||
me = MyId != default && group.Any(r => r.AccountId == MyId),
|
||||
url = _mapper.ProxiedUrl(group.First().EmojiURL),
|
||||
accounts = group.Select(r => accounts.GetValueOrDefault(r.AccountId)).Where(a => a != default).ToList()
|
||||
}).OrderByDescending(r => r.count).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/v1/pleroma/statuses/{id}/reactions/{emoji}"), Scope("write:favourites")]
|
||||
public async Task<IActionResult> React(string id, string emoji, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
if (post == default)
|
||||
return NotFoundError();
|
||||
if (!await _reactions.React(Me, post, Uri.UnescapeDataString(emoji), token))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: That emoji cannot be used here");
|
||||
return Json(await _mapper.Status(post, MyId, token));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v1/pleroma/statuses/{id}/reactions/{emoji}"), Scope("write:favourites")]
|
||||
public async Task<IActionResult> Unreact(string id, string emoji, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
if (post == default)
|
||||
return NotFoundError();
|
||||
await _reactions.Unreact(Me, post, Uri.UnescapeDataString(emoji), token);
|
||||
return Json(await _mapper.Status(post, MyId, token));
|
||||
}
|
||||
|
||||
async Task<PostEntity> Visible(string id, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == id && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
|
||||
return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,7 +163,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[NotificationType.Favourite] = "favourite",
|
||||
[NotificationType.Reblog] = "reblog",
|
||||
[NotificationType.Update] = "update",
|
||||
[NotificationType.Poll] = "poll"
|
||||
[NotificationType.Poll] = "poll",
|
||||
[NotificationType.Reaction] = "pleroma:emoji_reaction"
|
||||
};
|
||||
|
||||
readonly MastodonMapper _mapper;
|
||||
@@ -234,7 +235,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
CreatedAt = MastodonJson.Time(n.CreatedAt),
|
||||
GroupKey = $"ungrouped-{n.ID}",
|
||||
Account = accounts[n.FromAccountId],
|
||||
Status = n.PostId == default ? default : statuses[n.PostId]
|
||||
Status = n.PostId == default ? default : statuses[n.PostId],
|
||||
Emoji = n.Emoji == default ? default : n.EmojiURL == default ? n.Emoji : $":{n.Emoji}:",
|
||||
EmojiUrl = _mapper.ProxiedUrl(n.EmojiURL)
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -161,6 +161,20 @@ namespace PrivaPub.Api.Mastodon.Entities
|
||||
public int Down { get; set; }
|
||||
}
|
||||
|
||||
public class EmojiReactionEntity
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public int Count { get; set; }
|
||||
public bool Me { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string StaticUrl { get; set; }
|
||||
}
|
||||
|
||||
public class PleromaStatus
|
||||
{
|
||||
public List<EmojiReactionEntity> EmojiReactions { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PollEntity
|
||||
{
|
||||
public string Id { get; set; }
|
||||
@@ -237,6 +251,8 @@ namespace PrivaPub.Api.Mastodon.Entities
|
||||
public string Text { get; set; }
|
||||
public List<object> Filtered { get; set; } = new();
|
||||
public PrivaPubStatus Privapub { get; set; }
|
||||
public List<EmojiReactionEntity> EmojiReactions { get; set; } = new();
|
||||
public PleromaStatus Pleroma { get; set; }
|
||||
}
|
||||
|
||||
public class MediaAttachment
|
||||
@@ -305,6 +321,8 @@ namespace PrivaPub.Api.Mastodon.Entities
|
||||
public string GroupKey { get; set; }
|
||||
public Account Account { get; set; }
|
||||
public Status Status { get; set; }
|
||||
public string Emoji { get; set; }
|
||||
public string EmojiUrl { get; set; }
|
||||
}
|
||||
|
||||
public class Context
|
||||
|
||||
@@ -36,6 +36,8 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
|
||||
string Proxied(string url) => _proxy == default ? url : _proxy.Wrap(url);
|
||||
|
||||
public string ProxiedUrl(string url) => Proxied(url);
|
||||
|
||||
string MissingAvatar => $"{_localActors.BaseAddress}/media/missing-avatar.png";
|
||||
string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png";
|
||||
|
||||
@@ -166,6 +168,19 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
? new HashSet<string>()
|
||||
: (await DB.Default.Find<Bookmark>().Match(b => b.AvatarId == viewerId && ids.Contains(b.PostId)).ExecuteAsync(token)).Select(b => b.PostId).ToHashSet();
|
||||
var pinned = (await DB.Default.Find<Pin>().Match(p => ids.Contains(p.PostId)).ExecuteAsync(token)).Select(p => p.PostId).ToHashSet();
|
||||
var reactions = (await DB.Default.Find<Reaction>().Match(r => ids.Contains(r.PostId)).ExecuteAsync(token))
|
||||
.GroupBy(r => r.PostId)
|
||||
.ToDictionary(g => g.Key, g => g.GroupBy(r => r.Emoji)
|
||||
.Select(e => new EmojiReactionEntity
|
||||
{
|
||||
Name = e.Key,
|
||||
Count = e.Count(),
|
||||
Me = viewerId != default && e.Any(r => r.AccountId == viewerId),
|
||||
Url = Proxied(e.First().EmojiURL),
|
||||
StaticUrl = Proxied(e.First().EmojiURL)
|
||||
})
|
||||
.OrderByDescending(e => e.Count)
|
||||
.ToList());
|
||||
var pollIds = all.Where(p => p.Poll != default).Select(p => p.ID).ToList();
|
||||
var ownVotes = viewerId == default || pollIds.Count == 0
|
||||
? new Dictionary<string, List<int>>()
|
||||
@@ -210,6 +225,8 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
|
||||
Emojis = Emojis(post.Emojis),
|
||||
Privapub = Extension(post),
|
||||
EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(),
|
||||
Pleroma = new PleromaStatus { EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new() },
|
||||
Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
|
||||
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace PrivaPub.Domain.Social
|
||||
public static class Notifications
|
||||
{
|
||||
public static async Task Add(string avatarId, NotificationType type, string fromAccountId, string fromActorUri, string postId,
|
||||
CancellationToken token)
|
||||
CancellationToken token, string emoji = default, string emojiUrl = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(avatarId) || avatarId == fromAccountId && type != NotificationType.Poll)
|
||||
return;
|
||||
@@ -23,7 +23,9 @@ namespace PrivaPub.Domain.Social
|
||||
FromAccountId = fromAccountId,
|
||||
FromActorURI = fromActorUri,
|
||||
PostId = postId,
|
||||
DedupeKey = $"{type}|{avatarId}|{fromActorUri}|{postId}"
|
||||
Emoji = emoji,
|
||||
EmojiURL = emojiUrl,
|
||||
DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}"
|
||||
}, token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Inbox.Handlers;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Domain.Social
|
||||
{
|
||||
public interface IReactions
|
||||
{
|
||||
Task Receive(PostEntity post, ForeignAvatar actor, string content, JsonNode tags, string activityId, CancellationToken token);
|
||||
Task<bool> Withdraw(ForeignAvatar actor, string activityId, string objectUri, string content, CancellationToken token);
|
||||
Task<bool> React(LocalActor me, PostEntity post, string emoji, CancellationToken token);
|
||||
Task<bool> Unreact(LocalActor me, PostEntity post, string emoji, CancellationToken token);
|
||||
}
|
||||
|
||||
public class Reactions : IReactions
|
||||
{
|
||||
static readonly string[] Hearts = { "❤", "❤️", "♥", "♥️" };
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IDeliveryService _delivery;
|
||||
|
||||
public Reactions(DbEntities dbEntities, IDeliveryService delivery)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_delivery = delivery;
|
||||
}
|
||||
|
||||
public static bool IsHeart(string content) => content == default || Hearts.Contains(content.Trim());
|
||||
|
||||
public async Task Receive(PostEntity post, ForeignAvatar actor, string content, JsonNode tags, string activityId, CancellationToken token)
|
||||
{
|
||||
if (!LikeHandler.MaySee(post, actor) || Normalise(content, ObjectShapes.Emojis(tags)) is not { } emoji)
|
||||
return;
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new Reaction
|
||||
{
|
||||
PostId = post.ID, AccountId = actor.ID, ActorURI = actor.ActorURI, Emoji = emoji.Name, EmojiURL = emoji.Url, ActivityURI = activityId
|
||||
}, token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!post.IsFederatedCopy)
|
||||
await Notifications.Add(post.GroupUserId, NotificationType.Reaction, actor.ID, actor.ActorURI, post.ID, token, emoji.Name, emoji.Url);
|
||||
}
|
||||
|
||||
public async Task<bool> Withdraw(ForeignAvatar actor, string activityId, string objectUri, string content, CancellationToken token)
|
||||
{
|
||||
var reaction = activityId == default
|
||||
? default
|
||||
: await DB.Default.Find<Reaction>().Match(r => r.ActivityURI == activityId && r.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
|
||||
if (reaction == default && objectUri != default && Normalise(content, new()) is { } emoji)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
|
||||
reaction = post == default
|
||||
? default
|
||||
: await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.ActorURI == actor.ActorURI && r.Emoji == emoji.Name).ExecuteFirstAsync(token);
|
||||
}
|
||||
if (reaction == default)
|
||||
return false;
|
||||
await DB.Default.DeleteAsync<Reaction>(reaction.ID);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> React(LocalActor me, PostEntity post, string emoji, CancellationToken token)
|
||||
{
|
||||
var known = post.Emojis.Select(e => (e.Shortcode, e.URL))
|
||||
.Concat((await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.EmojiURL != null).ExecuteAsync(token)).Select(r => (r.Emoji, r.EmojiURL)))
|
||||
.Select(e => new Models.Post.CustomEmoji { Shortcode = e.Item1, URL = e.Item2 })
|
||||
.ToList();
|
||||
if (Normalise(emoji, known) is not { } picked)
|
||||
return false;
|
||||
var reaction = new Reaction
|
||||
{
|
||||
PostId = post.ID, AccountId = me.Id, ActorURI = me.Uri, IsLocal = true, Emoji = picked.Name, EmojiURL = picked.Url,
|
||||
ActivityURI = me.ActivityUri($"react-{post.ID}-{Guid.NewGuid():N}")
|
||||
};
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(reaction, token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (!post.IsFederatedCopy)
|
||||
await Notifications.Add(post.GroupUserId, NotificationType.Reaction, me.Id, me.Uri, post.ID, token, picked.Name, picked.Url);
|
||||
else
|
||||
await Tell(me, post, Activity(me, post, reaction), token);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> Unreact(LocalActor me, PostEntity post, string emoji, CancellationToken token)
|
||||
{
|
||||
var name = Normalise(emoji, post.Emojis.ToList(), requireKnownCustom: false)?.Name;
|
||||
var reaction = name == default
|
||||
? default
|
||||
: await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.ActorURI == me.Uri && r.Emoji == name).ExecuteFirstAsync(token);
|
||||
if (reaction == default)
|
||||
return false;
|
||||
await DB.Default.DeleteAsync<Reaction>(reaction.ID);
|
||||
if (post.IsFederatedCopy)
|
||||
await Tell(me, post, new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.Context(),
|
||||
["id"] = me.ActivityUri($"undo-{reaction.ID}"),
|
||||
["type"] = "Undo",
|
||||
["actor"] = me.Uri,
|
||||
["object"] = Activity(me, post, reaction)
|
||||
}, token);
|
||||
return true;
|
||||
}
|
||||
|
||||
static JsonObject Activity(LocalActor me, PostEntity post, Reaction reaction)
|
||||
{
|
||||
var react = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.Context(),
|
||||
["id"] = reaction.ActivityURI,
|
||||
["type"] = "EmojiReact",
|
||||
["actor"] = me.Uri,
|
||||
["object"] = post.ObjectURI,
|
||||
["to"] = new JsonArray(post.ActorURI),
|
||||
["content"] = reaction.EmojiURL == default ? reaction.Emoji : $":{reaction.Emoji}:"
|
||||
};
|
||||
if (reaction.EmojiURL != default)
|
||||
react["tag"] = new JsonArray(new JsonObject
|
||||
{
|
||||
["type"] = "Emoji",
|
||||
["name"] = $":{reaction.Emoji}:",
|
||||
["icon"] = new JsonObject { ["type"] = "Image", ["url"] = reaction.EmojiURL }
|
||||
});
|
||||
return react;
|
||||
}
|
||||
|
||||
async Task Tell(LocalActor me, PostEntity post, JsonObject activity, CancellationToken token)
|
||||
{
|
||||
var author = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == post.ActorURI).ExecuteFirstAsync(token);
|
||||
if (!string.IsNullOrEmpty(author?.InboxURL))
|
||||
await _delivery.Enqueue(me, new[] { author.InboxURL }, activity, token);
|
||||
}
|
||||
|
||||
public sealed record Picked(string Name, string Url);
|
||||
|
||||
public static Picked Normalise(string content, List<Models.Post.CustomEmoji> emojis, bool requireKnownCustom = true)
|
||||
{
|
||||
var text = content?.Trim();
|
||||
if (string.IsNullOrEmpty(text) || text.Length > 100)
|
||||
return default;
|
||||
if (text.StartsWith(':') && text.EndsWith(':') && text.Length > 2)
|
||||
{
|
||||
var shortcode = text.Trim(':');
|
||||
var bare = shortcode.Split('@')[0];
|
||||
var emoji = emojis.FirstOrDefault(e => e.Shortcode == shortcode) ?? emojis.FirstOrDefault(e => e.Shortcode.Split('@')[0] == bare);
|
||||
if (emoji == default)
|
||||
return requireKnownCustom ? default : new Picked(bare, default);
|
||||
return new Picked(bare, emoji.URL);
|
||||
}
|
||||
if (text.Any(c => c < 128 || char.IsWhiteSpace(c)) || new StringInfo(text).LengthInTextElements != 1)
|
||||
return default;
|
||||
return new Picked(text, default);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||
|
||||
namespace PrivaPub.Federation.Inbox.Handlers
|
||||
{
|
||||
public class EmojiReactHandler : IActivityHandler
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IReactions _reactions;
|
||||
|
||||
public EmojiReactHandler(DbEntities dbEntities, IReactions reactions)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_reactions = reactions;
|
||||
}
|
||||
|
||||
public string Type => "EmojiReact";
|
||||
|
||||
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
var objectUri = Id(activity["object"]);
|
||||
var post = objectUri == default
|
||||
? default
|
||||
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
|
||||
if (post != default)
|
||||
await _reactions.Receive(post, actor, Value(activity, "content"), activity["tag"], Id(activity), token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
public class LikeHandler : IActivityHandler
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IReactions _reactions;
|
||||
|
||||
public LikeHandler(DbEntities dbEntities)
|
||||
public LikeHandler(DbEntities dbEntities, IReactions reactions)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_reactions = reactions;
|
||||
}
|
||||
|
||||
public string Type => "Like";
|
||||
@@ -34,6 +36,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
|
||||
if (post == default || !MaySee(post, actor))
|
||||
return;
|
||||
var content = Value(activity, "_misskey_reaction") ?? Value(activity, "content");
|
||||
if (!Reactions.IsHeart(content))
|
||||
{
|
||||
await _reactions.Receive(post, actor, content, activity["tag"], Id(activity), token);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Social;
|
||||
@@ -17,11 +18,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
{
|
||||
public class UndoHandler : IActivityHandler
|
||||
{
|
||||
readonly IReactions _reactions;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
|
||||
public UndoHandler(DbEntities dbEntities, ILocalActorService localActors)
|
||||
public UndoHandler(DbEntities dbEntities, ILocalActorService localActors, IReactions reactions)
|
||||
{
|
||||
_reactions = reactions;
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
}
|
||||
@@ -42,6 +45,9 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
await UndoAnnounce(innerId, actor, token);
|
||||
if (innerType is null or "Dislike")
|
||||
await UndoDislike(inner, innerId, actor, token);
|
||||
if (innerType is null or "Like" or "EmojiReact")
|
||||
await _reactions.Withdraw(actor, innerId, inner is JsonObject ? Id(inner["object"]) : default,
|
||||
inner is JsonObject ? Value(inner, "_misskey_reaction") ?? Value(inner, "content") : default, token);
|
||||
}
|
||||
|
||||
async Task UndoDislike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
|
||||
|
||||
@@ -38,6 +38,9 @@ namespace PrivaPub.Federation.Rendering
|
||||
["indexable"] = "toot:indexable",
|
||||
["blurhash"] = "toot:blurhash",
|
||||
["votersCount"] = "toot:votersCount",
|
||||
["Emoji"] = "toot:Emoji",
|
||||
["litepub"] = "http://litepub.social/ns#",
|
||||
["EmojiReact"] = "litepub:EmojiReact",
|
||||
["focalPoint"] = new JsonObject { ["@container"] = "@list", ["@id"] = "toot:focalPoint" },
|
||||
["schema"] = "http://schema.org#",
|
||||
["PropertyValue"] = "schema:PropertyValue",
|
||||
|
||||
@@ -113,6 +113,9 @@ namespace PrivaPub.Infrastructure.Data
|
||||
await DB.Default.Index<PollVote>().Key(v => v.PostId, KeyType.Ascending).Key(v => v.ActorURI, KeyType.Ascending).Key(v => v.Choice, KeyType.Ascending)
|
||||
.Option(o => o.Unique = true).CreateAsync(token);
|
||||
await DB.Default.Index<PollVote>().Key(v => v.PostId, KeyType.Ascending).Key(v => v.VoterAccountId, KeyType.Ascending).CreateAsync(token);
|
||||
await DB.Default.Index<Reaction>().Key(r => r.PostId, KeyType.Ascending).Key(r => r.ActorURI, KeyType.Ascending).Key(r => r.Emoji, KeyType.Ascending)
|
||||
.Option(o => o.Unique = true).CreateAsync(token);
|
||||
await Plain<Reaction>(token, r => r.ActivityURI);
|
||||
}
|
||||
|
||||
static async Task Unique<T>(System.Linq.Expressions.Expression<Func<T, object>> key, FilterDefinition<T> partial,
|
||||
|
||||
@@ -77,6 +77,8 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<IActivityHandler, UndoHandler>()
|
||||
.AddSingleton<IActivityHandler, LikeHandler>()
|
||||
.AddSingleton<IActivityHandler, DislikeHandler>()
|
||||
.AddSingleton<IActivityHandler, EmojiReactHandler>()
|
||||
.AddSingleton<IReactions, Reactions>()
|
||||
.AddSingleton<IActivityHandler, JoinHandler>()
|
||||
.AddSingleton<IActivityHandler, AnnounceHandler>()
|
||||
.AddSingleton<IActivityHandler, FlagHandler>()
|
||||
|
||||
@@ -10,6 +10,8 @@ namespace PrivaPub.Models.Social
|
||||
public string FromActorURI { get; set; }
|
||||
public string PostId { get; set; }
|
||||
public string DedupeKey { get; set; }
|
||||
public string Emoji { get; set; }//a reaction's emoji
|
||||
public string EmojiURL { get; set; }
|
||||
public bool IsRead { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -22,6 +24,7 @@ namespace PrivaPub.Models.Social
|
||||
Favourite,
|
||||
Reblog,
|
||||
Update,
|
||||
Poll
|
||||
Poll,
|
||||
Reaction
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models.Social
|
||||
{
|
||||
public class Reaction : Entity
|
||||
{
|
||||
public string PostId { get; set; }
|
||||
public string AccountId { get; set; }//Avatar.ID for a local persona, ForeignAvatar.ID for a remote account
|
||||
public string ActorURI { get; set; }
|
||||
public bool IsLocal { get; set; }
|
||||
public string Emoji { get; set; }//a single Unicode emoji, or a custom emoji's shortcode without colons or host
|
||||
public string EmojiURL { get; set; }//set for a custom emoji
|
||||
public string ActivityURI { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user