diff --git a/FEDERATION.md b/FEDERATION.md index 8ea9ae7..100cfcf 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -92,12 +92,13 @@ Received: | `Update{Person}` | refetches the actor | | `Like` | counted and notified, on posts the liker could see | | `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back | +| `EmojiReact`, `Like` with an emoji `content` | an emoji reaction (FEP-c0e0; Pleroma, Akkoma, Iceshrimp.NET, Misskey, Sharkey), Unicode or a custom emoji from its `tag`; a `Like` whose content is โค stays a favourite; `Undo` takes it back | | `Join` | answered with `Ignore`: PrivaPub hosts no events yet (FEP-8a8e) | | `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin | | `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back | | `Flag` | becomes a report for this server's moderators | -Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`, +Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`, `Reject{Follow}`, `Like`, `Announce` and their `Undo`, `Flag`. A deleted post answers 410 with a `Tombstone`. - **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`. diff --git a/PrivaPub.Tests/Federation/InboxScenarioTests.cs b/PrivaPub.Tests/Federation/InboxScenarioTests.cs index c6cf546..ad94d5a 100644 --- a/PrivaPub.Tests/Federation/InboxScenarioTests.cs +++ b/PrivaPub.Tests/Federation/InboxScenarioTests.cs @@ -8,6 +8,7 @@ using PrivaPub.Federation.Actors; using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Moderation; +using PrivaPub.Domain.Social; using PrivaPub.Domain.Statuses; using PrivaPub.Federation.Objects; using PrivaPub.Federation.Outbox; @@ -54,7 +55,7 @@ namespace PrivaPub.Tests.Federation _processor = new InboxProcessor(remote, new IActivityHandler[] { new FollowHandler(db, _local, remote, delivery), - new UndoHandler(db, _local), + new UndoHandler(db, _local, new Reactions(db, delivery)), new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue, new ObjectRecords(queue)), new GroupDistributor(delivery), new ObjectRecords(queue), new PollService(db, _local, delivery, queue)), new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery)), new UpdateHandler(db, _local, remote, new GroupDistributor(delivery), new ObjectRecords(queue)) diff --git a/PrivaPub.Tests/Federation/ReactionTests.cs b/PrivaPub.Tests/Federation/ReactionTests.cs new file mode 100644 index 0000000..db10383 --- /dev/null +++ b/PrivaPub.Tests/Federation/ReactionTests.cs @@ -0,0 +1,119 @@ +using MongoDB.Entities; + +using PrivaPub.ClientModels.Post; +using PrivaPub.Domain.Social; +using PrivaPub.Federation.Objects; +using PrivaPub.Models.Post; +using PrivaPub.Models.Social; +using PrivaPub.Tests.Support; + +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Federation +{ + public class ReactionNamesTests + { + [Theory] + [InlineData(":blobcat@k.example:", "blobcat")] + [InlineData("๐Ÿ‘๐Ÿฝ", "๐Ÿ‘๐Ÿฝ")] + [InlineData("โค๏ธ", "โค๏ธ")] + [InlineData("nope", null)] + [InlineData("๐Ÿ‘๐Ÿ‘", null)] + [InlineData(":unknown:", null)] + public void Normalises_what_peers_send(string content, string expected) => + Assert.Equal(expected, Reactions.Normalise(content, new List { new() { Shortcode = "blobcat", URL = "https://k.example/e.png" } })?.Name); + } + + [Trait("Category", "Integration")] + public sealed class ReactionTests : IAsyncLifetime + { + Harness _harness; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _harness = await Harness.Start(); + } + + public async ValueTask DisposeAsync() + { + if (_harness != default) + await _harness.DisposeAsync(); + } + + static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); + + static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}"; + + async Task LocalPost() + { + var (root, alice) = await _harness.Persona("alice"); + await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "react to me" }, TestContext.Current.CancellationToken); + return await DB.Default.Find().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Misskey_and_pleroma_reactions_are_kept_apart_from_favourites() + { + var token = TestContext.Current.CancellationToken; + var post = await LocalPost(); + var misskey = new RemoteActor(_harness.Peer, "kitty"); + var pleroma = new RemoteActor(_harness.Peer, "pleroma"); + + await _harness.Deliver(misskey, "/human-centipede", new JsonObject + { + ["id"] = NewId(misskey, "likes"), ["type"] = "Like", ["actor"] = misskey.Id, ["object"] = post.ObjectURI, ["content"] = ":blobcat:", + ["_misskey_reaction"] = ":blobcat:", + ["tag"] = new JsonArray(new JsonObject { ["type"] = "Emoji", ["name"] = ":blobcat:", ["icon"] = new JsonObject { ["type"] = "Image", ["url"] = $"{Origin(misskey)}/emoji/blobcat.png" } }) + }); + await _harness.Deliver(misskey, "/human-centipede", new JsonObject + { + ["id"] = NewId(misskey, "likes"), ["type"] = "Like", ["actor"] = misskey.Id, ["object"] = post.ObjectURI, ["content"] = "โค" + }); + var fire = new JsonObject { ["id"] = NewId(pleroma, "activities"), ["type"] = "EmojiReact", ["actor"] = pleroma.Id, ["object"] = post.ObjectURI, ["content"] = "๐Ÿ”ฅ" }; + await _harness.Deliver(pleroma, "/human-centipede", fire); + + var kept = await DB.Default.Find().Match(r => r.PostId == post.ID).ExecuteAsync(token); + Assert.Equal(new HashSet { "blobcat", "๐Ÿ”ฅ" }, kept.Select(r => r.Emoji).ToHashSet()); + Assert.Equal($"{Origin(misskey)}/emoji/blobcat.png", kept.Single(r => r.Emoji == "blobcat").EmojiURL); + Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); + Assert.True(await DB.Default.Find().Match(n => n.Type == NotificationType.Reaction && n.Emoji == "๐Ÿ”ฅ").ExecuteAnyAsync(token)); + + await _harness.Deliver(pleroma, "/human-centipede", new JsonObject + { + ["id"] = NewId(pleroma, "activities"), ["type"] = "Undo", ["actor"] = pleroma.Id, ["object"] = fire.DeepClone() + }); + Assert.Equal("blobcat", Assert.Single(await DB.Default.Find().Match(r => r.PostId == post.ID).ExecuteAsync(token)).Emoji); + } + + [Fact] + public async Task A_persona_reacts_to_a_remote_post_and_takes_it_back() + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + var mallory = new RemoteActor(_harness.Peer, "mallory"); + var noteId = NewId(mallory, "notes"); + await _harness.Deliver(mallory, "/human-centipede", new JsonObject + { + ["id"] = NewId(mallory, "activities"), ["type"] = "Create", ["actor"] = mallory.Id, + ["object"] = new JsonObject + { + ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = mallory.Id, ["content"] = "

hi

", + ["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri), + ["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" }) + } + }); + var post = await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(token); + + Assert.True(await _harness.Reactions.React(alice, post, "๐ŸŽ‰", token)); + Assert.False(await _harness.Reactions.React(alice, post, "not an emoji", token)); + Assert.True(await _harness.Reactions.Unreact(alice, post, "๐ŸŽ‰", token)); + + var sent = await _harness.Outgoing(mallory.Id + "/inbox"); + var react = Assert.Single(sent, a => a["type"]!.GetValue() == "EmojiReact"); + Assert.Equal("๐ŸŽ‰", react["content"]!.GetValue()); + Assert.Equal(noteId, react["object"]!.GetValue()); + Assert.Equal(react["id"]!.GetValue(), Assert.Single(sent, a => a["type"]!.GetValue() == "Undo")["object"]!["id"]!.GetValue()); + } + } +} diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index 9228545..7aa32f9 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -45,6 +45,7 @@ namespace PrivaPub.Tests.Support Groups = new GroupDistributor(Delivery); Records = new ObjectRecords(Queue); Polls = new PollService(Db, Local, Delivery, Queue); + Reactions = new Reactions(Db, Delivery); RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records); Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger.Instance); Processor = new InboxProcessor(Remote, new IActivityHandler[] @@ -52,8 +53,9 @@ namespace PrivaPub.Tests.Support new FollowHandler(Db, Local, Remote, Delivery), new AcceptHandler(Db, Local), new RejectHandler(Db, Local), - new UndoHandler(Db, Local), - new LikeHandler(Db), + new UndoHandler(Db, Local, Reactions), + new LikeHandler(Db, Reactions), + new EmojiReactHandler(Db, Reactions), new DislikeHandler(Db), new JoinHandler(Db, Local, Delivery), new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote), @@ -93,6 +95,7 @@ namespace PrivaPub.Tests.Support public RemotePosts RemotePosts { get; } public ObjectRecords Records { get; } public PollService Polls { get; } + public Reactions Reactions { get; } public TimelineService Timelines { get; } public RelationshipService Relationships { get; } public ReportService Reports { get; } diff --git a/PrivaPub/Api/Mastodon/Controllers/ReactionsController.cs b/PrivaPub/Api/Mastodon/Controllers/ReactionsController.cs new file mode 100644 index 0000000..aa6336e --- /dev/null +++ b/PrivaPub/Api/Mastodon/Controllers/ReactionsController.cs @@ -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 List(string id, CancellationToken token) + { + var post = await Visible(id, token); + if (post == default) + return NotFoundError(); + var reactions = await DB.Default.Find().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 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 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 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; + } + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs b/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs index b26b2ef..860f706 100644 --- a/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs @@ -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(); } diff --git a/PrivaPub/Api/Mastodon/Entities/Entities.cs b/PrivaPub/Api/Mastodon/Entities/Entities.cs index 5b2da74..14dfa54 100644 --- a/PrivaPub/Api/Mastodon/Entities/Entities.cs +++ b/PrivaPub/Api/Mastodon/Entities/Entities.cs @@ -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 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 Filtered { get; set; } = new(); public PrivaPubStatus Privapub { get; set; } + public List 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 diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index 38b2d46..64c6132 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -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() : (await DB.Default.Find().Match(b => b.AvatarId == viewerId && ids.Contains(b.PostId)).ExecuteAsync(token)).Select(b => b.PostId).ToHashSet(); var pinned = (await DB.Default.Find().Match(p => ids.Contains(p.PostId)).ExecuteAsync(token)).Select(p => p.PostId).ToHashSet(); + var reactions = (await DB.Default.Find().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>() @@ -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() }; diff --git a/PrivaPub/Domain/Social/Notifications.cs b/PrivaPub/Domain/Social/Notifications.cs index 436fbf2..68afeea 100644 --- a/PrivaPub/Domain/Social/Notifications.cs +++ b/PrivaPub/Domain/Social/Notifications.cs @@ -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) diff --git a/PrivaPub/Domain/Social/Reactions.cs b/PrivaPub/Domain/Social/Reactions.cs new file mode 100644 index 0000000..3439902 --- /dev/null +++ b/PrivaPub/Domain/Social/Reactions.cs @@ -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 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) + 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 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); + } + } +} diff --git a/PrivaPub/Federation/Inbox/Handlers/EmojiReactHandler.cs b/PrivaPub/Federation/Inbox/Handlers/EmojiReactHandler.cs new file mode 100644 index 0000000..42e4f8b --- /dev/null +++ b/PrivaPub/Federation/Inbox/Handlers/EmojiReactHandler.cs @@ -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); + } + } +} diff --git a/PrivaPub/Federation/Inbox/Handlers/LikeHandler.cs b/PrivaPub/Federation/Inbox/Handlers/LikeHandler.cs index f583fe7..dccc912 100644 --- a/PrivaPub/Federation/Inbox/Handlers/LikeHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/LikeHandler.cs @@ -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 { diff --git a/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs b/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs index 2378c2b..7de10a3 100644 --- a/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs @@ -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) diff --git a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs index e916228..821c427 100644 --- a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs +++ b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs @@ -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", diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs index 0c3e8a8..9eb97f7 100644 --- a/PrivaPub/Infrastructure/Data/Indexes.cs +++ b/PrivaPub/Infrastructure/Data/Indexes.cs @@ -113,6 +113,9 @@ namespace PrivaPub.Infrastructure.Data await DB.Default.Index().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().Key(v => v.PostId, KeyType.Ascending).Key(v => v.VoterAccountId, KeyType.Ascending).CreateAsync(token); + await DB.Default.Index().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(token, r => r.ActivityURI); } static async Task Unique(System.Linq.Expressions.Expression> key, FilterDefinition partial, diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index 66cbe85..9e32e93 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -77,6 +77,8 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/PrivaPub/Models/Social/Notification.cs b/PrivaPub/Models/Social/Notification.cs index 75cf3d2..0665b59 100644 --- a/PrivaPub/Models/Social/Notification.cs +++ b/PrivaPub/Models/Social/Notification.cs @@ -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 } } diff --git a/PrivaPub/Models/Social/Reaction.cs b/PrivaPub/Models/Social/Reaction.cs new file mode 100644 index 0000000..735abd8 --- /dev/null +++ b/PrivaPub/Models/Social/Reaction.cs @@ -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; + } +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 21b5c57..87b70f5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -456,7 +456,8 @@ it, raw where it doesn't. - read every key; verify `QuoteAuthorization` on all of its fields; handle revocation; - be quotable: `canQuote`, answer `QuoteRequest`, serve and revoke stamps; - then advertise `api_versions.mastodon โ‰ฅ 7` (4.5.0). -- **Emoji reactions** in all three inbound forms, plus outbound `EmojiReact`, exposed as `emoji_reactions`. +- **Emoji reactions** in all three inbound forms, plus outbound `EmojiReact`, exposed as `emoji_reactions` (done in + v1.11.0; Pleroma's `/api/v1/pleroma/statuses/:id/reactions` endpoints and `pleroma:emoji_reaction` notifications). - **Link cards:** - from the object, or from FEP-8967 `preview`, without fetching; - fetching the page itself is the owner's decision (INTEROP ยง6.1).