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()
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user