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
169 lines
7.8 KiB
C#
169 lines
7.8 KiB
C#
using MongoDB.Driver;
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Domain.Content;
|
|
using PrivaPub.Domain.Timelines;
|
|
using PrivaPub.Models.Social;
|
|
|
|
using System.Linq.Expressions;
|
|
|
|
namespace PrivaPub.Domain.Social
|
|
{
|
|
public sealed record AnnouncementDraft(string Text, DateTime? StartsAt, DateTime? EndsAt, bool AllDay, bool IsPublished);
|
|
|
|
public interface IAnnouncements
|
|
{
|
|
Task<List<Announcement>> Shown(CancellationToken token);
|
|
Task<Announcement> FindShown(string id, CancellationToken token);
|
|
Task<bool> Dismiss(string id, string avatarId, CancellationToken token);
|
|
/// <summary>200 when it is done, 404 for an announcement not shown, 422 (with why) for a name that isn't one emoji.</summary>
|
|
Task<(int Status, string Error)> React(string id, string avatarId, string name, bool add, CancellationToken token);
|
|
Task<List<Announcement>> All(CancellationToken token);
|
|
Task<Announcement> Insert(AnnouncementDraft draft, CancellationToken token);
|
|
Task<Announcement> Update(string id, AnnouncementDraft draft, CancellationToken token);
|
|
Task<Announcement> Delete(string id, CancellationToken token);
|
|
}
|
|
|
|
// The administrators' announcements (completion program R5): written on the administrator's page, shown to every persona
|
|
// while published and not ended, each persona's dismissal and reactions kept apart and never federated. Every change a
|
|
// client shows is told to every persona's home stream, as Mastodon tells it: `announcement` (made, edited or published),
|
|
// `announcement.reaction` and `announcement.delete` (deleted or no longer shown).
|
|
public class Announcements : IAnnouncements
|
|
{
|
|
public const int MaxReactionNames = 8;//as Mastodon: eight different emoji on one announcement
|
|
const int MaxNameLength = 32;
|
|
|
|
public static Expression<Func<Announcement, bool>> IsShown()
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
return a => a.IsPublished && (a.EndsAt == null || a.EndsAt > now);
|
|
}
|
|
|
|
static bool Showing(Announcement a) => a != default && a.IsPublished && (a.EndsAt == null || a.EndsAt > DateTime.UtcNow);
|
|
|
|
// as Mastodon orders them: by when they are about, else when they were published
|
|
public async Task<List<Announcement>> Shown(CancellationToken token) =>
|
|
(await DB.Default.Find<Announcement>().Match(IsShown()).ExecuteAsync(token))
|
|
.OrderBy(a => a.StartsAt ?? a.PublishedAt ?? a.CreatedAt).ThenBy(a => a.ID).ToList();
|
|
|
|
public Task<Announcement> FindShown(string id, CancellationToken token) =>
|
|
string.IsNullOrEmpty(id) ? Task.FromResult<Announcement>(default)
|
|
: DB.Default.Find<Announcement>().Match(a => a.ID == id).Match(IsShown()).ExecuteFirstAsync(token);
|
|
|
|
public async Task<bool> Dismiss(string id, string avatarId, CancellationToken token)
|
|
{
|
|
if (await FindShown(id, token) == default)
|
|
return false;
|
|
try
|
|
{
|
|
await DB.Default.Update<AnnouncementRead>()
|
|
.Match(r => r.AnnouncementId == id && r.AvatarId == avatarId)
|
|
.Modify(b => b.SetOnInsert(r => r.ReadAt, DateTime.UtcNow))
|
|
.Option(o => o.IsUpsert = true)
|
|
.ExecuteAsync(token);
|
|
}
|
|
catch (MongoException ex) when (ex is MongoCommandException { Code: 11000 } or MongoWriteException { WriteError.Category: ServerErrorCategory.DuplicateKey })
|
|
{
|
|
//dismissed at the same moment by another request
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public async Task<(int Status, string Error)> React(string id, string avatarId, string name, bool add, CancellationToken token)
|
|
{
|
|
if (await FindShown(id, token) == default)
|
|
return (StatusCodes.Status404NotFound, "Record not found");
|
|
// one unicode emoji, normalised as a post's reactions are, or one of the server's custom emoji that is offered, by its
|
|
// shortcode (R7), as Mastodon takes them
|
|
if (LocalEmojis.ForReaction(name, default) is { } custom)
|
|
name = custom.Shortcode;
|
|
else
|
|
{
|
|
var picked = Reactions.Normalise(name, new List<Models.Post.CustomEmoji>(), requireKnownCustom: true);
|
|
if (picked == default || picked.Url != default || picked.Name.Length > MaxNameLength)
|
|
return (StatusCodes.Status422UnprocessableEntity, "Validation failed: Name is not a recognized emoji");
|
|
name = picked.Name;
|
|
}
|
|
if (!add)
|
|
{
|
|
var removed = await DB.Default.DeleteAsync<AnnouncementReaction>(r => r.AnnouncementId == id && r.AvatarId == avatarId && r.Name == name);
|
|
if (removed.DeletedCount > 0)
|
|
Streams.ToUsers(new Streams.Event("announcement.reaction", id, name));
|
|
return (StatusCodes.Status200OK, default);
|
|
}
|
|
if (await DB.Default.Find<AnnouncementReaction>().Match(r => r.AnnouncementId == id && r.AvatarId == avatarId && r.Name == name).ExecuteAnyAsync(token))
|
|
return (StatusCodes.Status200OK, default);
|
|
var names = await DB.Default.Fluent<AnnouncementReaction>().Match(r => r.AnnouncementId == id).Group(r => r.Name, g => g.Key).ToListAsync(token);
|
|
if (!names.Contains(name) && names.Count >= MaxReactionNames)
|
|
return (StatusCodes.Status422UnprocessableEntity, "Validation failed: Maximum number of reactions reached");
|
|
try
|
|
{
|
|
await DB.Default.SaveAsync(new AnnouncementReaction { AnnouncementId = id, AvatarId = avatarId, Name = name }, token);
|
|
}
|
|
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
|
{
|
|
return (StatusCodes.Status200OK, default);//the same reaction at the same moment
|
|
}
|
|
Streams.ToUsers(new Streams.Event("announcement.reaction", id, name));
|
|
return (StatusCodes.Status200OK, default);
|
|
}
|
|
|
|
public async Task<List<Announcement>> All(CancellationToken token) =>
|
|
await DB.Default.Find<Announcement>().Sort(a => a.ID, Order.Descending).Limit(200).ExecuteAsync(token);
|
|
|
|
public async Task<Announcement> Insert(AnnouncementDraft draft, CancellationToken token)
|
|
{
|
|
var announcement = new Announcement();
|
|
Apply(announcement, draft);
|
|
await DB.Default.SaveAsync(announcement, token);
|
|
if (Showing(announcement))
|
|
Streams.ToUsers(new Streams.Event("announcement", announcement.ID));
|
|
return announcement;
|
|
}
|
|
|
|
public async Task<Announcement> Update(string id, AnnouncementDraft draft, CancellationToken token)
|
|
{
|
|
var announcement = string.IsNullOrEmpty(id) ? default : await DB.Default.Find<Announcement>().Match(a => a.ID == id).ExecuteFirstAsync(token);
|
|
if (announcement == default)
|
|
return default;
|
|
var wasShown = Showing(announcement);
|
|
Apply(announcement, draft);
|
|
announcement.UpdatedAt = DateTime.UtcNow;
|
|
await DB.Default.SaveAsync(announcement, token);
|
|
if (Showing(announcement))
|
|
Streams.ToUsers(new Streams.Event("announcement", announcement.ID));
|
|
else if (wasShown)
|
|
Streams.ToUsers(new Streams.Event("announcement.delete", announcement.ID));
|
|
return announcement;
|
|
}
|
|
|
|
public async Task<Announcement> Delete(string id, CancellationToken token)
|
|
{
|
|
var announcement = string.IsNullOrEmpty(id) ? default : await DB.Default.Find<Announcement>().Match(a => a.ID == id).ExecuteFirstAsync(token);
|
|
if (announcement == default)
|
|
return default;
|
|
await DB.Default.DeleteAsync<Announcement>(a => a.ID == id);
|
|
await DB.Default.DeleteAsync<AnnouncementRead>(r => r.AnnouncementId == id);
|
|
await DB.Default.DeleteAsync<AnnouncementReaction>(r => r.AnnouncementId == id);
|
|
if (Showing(announcement))
|
|
Streams.ToUsers(new Streams.Event("announcement.delete", id));
|
|
return announcement;
|
|
}
|
|
|
|
static void Apply(Announcement announcement, AnnouncementDraft draft)
|
|
{
|
|
announcement.Text = draft.Text?.Trim();
|
|
announcement.Html = ContentRenderer.Document(announcement.Text);
|
|
announcement.StartsAt = Utc(draft.StartsAt);
|
|
announcement.EndsAt = Utc(draft.EndsAt);
|
|
announcement.AllDay = draft.AllDay;
|
|
if (draft.IsPublished && !announcement.IsPublished)
|
|
announcement.PublishedAt = DateTime.UtcNow;
|
|
announcement.IsPublished = draft.IsPublished;
|
|
}
|
|
|
|
static DateTime? Utc(DateTime? value) => value == default ? (DateTime?)null : value.Value.Kind == DateTimeKind.Local ? value.Value.ToUniversalTime()
|
|
: DateTime.SpecifyKind(value.Value, DateTimeKind.Utc);
|
|
}
|
|
}
|