using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Inbox; 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 Withdraw(ForeignAvatar actor, string activityId, string objectUri, string content, CancellationToken token); Task React(LocalActor me, PostEntity post, string emoji, CancellationToken token); Task 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) { Arrival.Drop(LikeHandler.MaySee(post, actor) ? "unparseable" : "not-visible"); 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) { Arrival.Drop("duplicate"); return; } Arrival.Accept("reaction"); if (!post.IsFederatedCopy) await Notifications.Add(post.GroupUserId, NotificationType.Reaction, actor.ID, actor.ActorURI, post.ID, token, emoji.Name, emoji.Url); } public async Task Withdraw(ForeignAvatar actor, string activityId, string objectUri, string content, CancellationToken token) { var reaction = activityId == default ? default : await DB.Default.Find().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().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.ID); return true; } public async Task React(LocalActor me, PostEntity post, string emoji, CancellationToken token) { var known = post.Emojis.Select(e => (e.Shortcode, e.URL)) .Concat((await DB.Default.Find().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 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().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.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 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); } } }