Files
SocialPub/PrivaPub/Api/Mastodon/Controllers/AnnouncementsController.cs
T
thepraandClaude Opus 5.5 d08fbabaa5 R7 custom emoji: the server's own, added by administrators, sent on posts, profiles and reactions
LocalEmoji, a new collection (unique shortcode whatever its case, never renamed: the shortcode is its id elsewhere),
managed on /clientapi/admin/emoji/list|insert|update|delete and kept in the staff's log (emoji.insert, .update,
.delete). Its picture goes through IMediaService.EmojiImage: PNG, GIF, WebP or JPEG up to 256 KB, a PNG within 256 px
with alpha kept (an animated GIF or WebP stays an animated GIF, with a still PNG), keep=none, shrunk until it fits
GoToSocial's 100 KiB, held by ServerUse "emoji:<shortcode>" so the janitor and the audit leave it. LocalEmojis keeps the
offered ones in memory, reloaded on every change: GET /api/v1/custom_emojis is real; local posts (words, title, warning,
poll options, each revision) and profiles keep the emoji they name; Notes, Questions, actors and EmojiReacts carry
Mastodon's Emoji tag with its id, served as an Emoji at /emojis/{shortcode} (410 once deleted; the path awaits the
owner's confirmation); a persona reacts with one (Pleroma's reactions, announcements). A restore keeps the live emoji
as it keeps the settings, and trashes server pictures nothing holds. tools/pasture/scenarios/emoji.sh joins batch A.

Verified: built, and run locally against a scratch database (uploads of every type, animation kept, metadata gone,
refusals, rendering, reactions, history, announcements, delete and revive, backup and restore, media audit). The
pasture scenario and the sweep were not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 05:57:25 +02:00

106 lines
4.6 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Domain.Content;
using PrivaPub.Domain.Social;
using PrivaPub.Models.Social;
namespace PrivaPub.Api.Mastodon.Controllers
{
// Mastodon's announcements: the administrators' (Domain/Social/Announcements.cs), each persona's dismissals and
// reactions its own. Anonymous readers get the list without `read` or `me`.
public class AnnouncementsController : MastodonController
{
readonly IAnnouncements _announcements;
public AnnouncementsController(IAnnouncements announcements)
{
_announcements = announcements;
}
[HttpGet("/api/v1/announcements"), Scope("read", requiresUser: false), AllowAnonymous]
public async Task<IActionResult> List(CancellationToken token) =>
Json(await Map(await _announcements.Shown(token), MyId, token));
[HttpPost("/api/v1/announcements/{id}/dismiss"), Scope("write:accounts")]
public async Task<IActionResult> Dismiss(string id, CancellationToken token) =>
await _announcements.Dismiss(id, MyId, token) ? Json(new { }) : NotFoundError();
[HttpPut("/api/v1/announcements/{id}/reactions/{name}"), Scope("write:favourites")]
public Task<IActionResult> React(string id, string name, CancellationToken token) => Reaction(id, name, add: true, token);
[HttpDelete("/api/v1/announcements/{id}/reactions/{name}"), Scope("write:favourites")]
public Task<IActionResult> Unreact(string id, string name, CancellationToken token) => Reaction(id, name, add: false, token);
async Task<IActionResult> Reaction(string id, string name, bool add, CancellationToken token)
{
var (status, error) = await _announcements.React(id, MyId, name, add, token);
return error == default ? Json(new { }) : Error(status, error);
}
/// <summary>Mastodon's Announcement entities, as the viewer (a persona, or nobody) sees them.</summary>
public static async Task<List<Dictionary<string, object>>> Map(IReadOnlyList<Announcement> announcements, string viewerId, CancellationToken token)
{
var ids = announcements.Select(a => a.ID).ToList();
if (ids.Count == 0)
return new List<Dictionary<string, object>>();
var reactions = await DB.Default.Find<AnnouncementReaction>().Match(r => ids.Contains(r.AnnouncementId)).ExecuteAsync(token);
var read = viewerId == default
? new HashSet<string>()
: (await DB.Default.Find<AnnouncementRead>().Match(r => r.AvatarId == viewerId && ids.Contains(r.AnnouncementId)).ExecuteAsync(token))
.Select(r => r.AnnouncementId).ToHashSet();
return announcements.Select(a =>
{
var entity = new Dictionary<string, object>
{
["id"] = a.ID,
["content"] = a.Html ?? string.Empty,
["starts_at"] = a.StartsAt == default ? default : MastodonJson.Time(a.StartsAt.Value),
["ends_at"] = a.EndsAt == default ? default : MastodonJson.Time(a.EndsAt.Value),
["all_day"] = a.AllDay,
["published_at"] = MastodonJson.Time(a.PublishedAt ?? a.CreatedAt),
["updated_at"] = MastodonJson.Time(a.UpdatedAt),
["mentions"] = Array.Empty<object>(),
["statuses"] = Array.Empty<object>(),
["tags"] = Array.Empty<object>(),
["emojis"] = Mappers.MastodonMapper.LocalEmojiEntities(LocalEmojis.In(a.Text)),//the server's custom emoji its text uses (R7)
["reactions"] = reactions.Where(r => r.AnnouncementId == a.ID)
.GroupBy(r => r.Name)
.OrderBy(g => g.Min(r => r.CreatedAt))
.Select(g => Reaction(g.Key, g.Count(), viewerId == default ? default : g.Any(r => r.AvatarId == viewerId)))
.ToList()
};
if (viewerId != default)
entity["read"] = read.Contains(a.ID);
return entity;
}).ToList();
}
// a reaction: a unicode emoji's has no url, one of the server's custom emoji's has its pictures (R7); `me` only for a
// persona
static Dictionary<string, object> Reaction(string name, int count, bool? me)
{
var reaction = new Dictionary<string, object> { ["name"] = name, ["count"] = count };
if (me is { } mine)
reaction["me"] = mine;
if (LocalEmojis.Find(name) is { } custom)
{
reaction["url"] = custom.Url;
reaction["static_url"] = custom.StaticUrl;
}
return reaction;
}
/// <summary>The `announcement.reaction` event's payload: the emoji, how many chose it now, and on which.</summary>
public static async Task<Dictionary<string, object>> ReactionEvent(string announcementId, string name, CancellationToken token) => new()
{
["name"] = name,
["count"] = await DB.Default.CountAsync<AnnouncementReaction>(r => r.AnnouncementId == announcementId && r.Name == name, token),
["announcement_id"] = announcementId
};
}
}