- 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
180 lines
6.7 KiB
C#
180 lines
6.7 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|