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
165 lines
7.4 KiB
C#
165 lines
7.4 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Federation.Rendering;
|
|
using PrivaPub.Models.Post;
|
|
|
|
using System.Text.Json.Nodes;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace PrivaPub.Domain.Content
|
|
{
|
|
// This server's own custom emoji (completion program R7), in memory: the ones offered now (neither disabled nor deleted),
|
|
// for the places that read them one text at a time (a post published or edited, a profile saved, a Note or an actor
|
|
// rendered, a reaction, the Mastodon API's lists). Read at start, again after every change an administrator makes
|
|
// (EmojiAdminController) and when five minutes old; a process that never loaded it starts with none and loads it on
|
|
// first use.
|
|
//
|
|
// A local post or profile keeps the emoji its text named when it was written (Post.Emojis, Avatar.Emojis); what is
|
|
// rendered of them is what they are now: one disabled or deleted since is left out, and one whose picture changed is
|
|
// shown with the new one, its `updated` telling the servers that hold the old one to fetch it again.
|
|
public static class LocalEmojis
|
|
{
|
|
/// <summary>Where each emoji is served as an ActivityPub `Emoji` object: /emojis/{shortcode}, its id on other servers.
|
|
/// The plan's name, waiting for the owner's confirmation; it is frozen once it has federated (CLAUDE.md, routes).</summary>
|
|
public const string Path = "emojis";
|
|
public const int MaxShortcodeLength = 64;
|
|
public const int MaxPerObject = 64;//what ObjectShapes reads of anyone's object
|
|
const string ServerUsePrefix = "emoji:";
|
|
|
|
static readonly TimeSpan Staleness = TimeSpan.FromMinutes(5);
|
|
static readonly Regex Shortcode = new("^[a-zA-Z0-9_]{2,64}$", RegexOptions.CultureInvariant);
|
|
// `:name:` with no letter or digit right before or after it, as Mastodon scans texts (`:a::b:` is two)
|
|
static readonly Regex Named = new(@"(?<![a-zA-Z0-9]):([a-zA-Z0-9_]{2,64}):(?![a-zA-Z0-9])", RegexOptions.CultureInvariant);
|
|
|
|
public sealed record Live(string Id, string Shortcode, string Url, string StaticUrl, string ContentType, DateTime ImageUpdatedAt, string Category,
|
|
bool VisibleInPicker);
|
|
|
|
static volatile IReadOnlyDictionary<string, Live> _current = new Dictionary<string, Live>(StringComparer.Ordinal);
|
|
static DateTime _loadedAt = DateTime.MinValue;
|
|
static int _reloading;
|
|
|
|
/// <summary>The emoji offered now, by their exact shortcode.</summary>
|
|
public static IReadOnlyDictionary<string, Live> Current
|
|
{
|
|
get
|
|
{
|
|
RefreshIfStale();
|
|
return _current;
|
|
}
|
|
}
|
|
|
|
public static bool IsShortcode(string text) => !string.IsNullOrEmpty(text) && Shortcode.IsMatch(text);
|
|
|
|
/// <summary>The id an emoji federates under: {base}/emojis/{shortcode}.</summary>
|
|
public static string Uri(string baseAddress, string shortcode) => $"{baseAddress}/{Path}/{shortcode}";
|
|
|
|
/// <summary>What holds its picture (MediaAttachment.ServerUse): the janitor and the media audit leave such rows alone.</summary>
|
|
public static string ServerUse(string shortcode) => ServerUsePrefix + shortcode;
|
|
|
|
public static bool IsEmojiUse(string serverUse) => serverUse?.StartsWith(ServerUsePrefix, StringComparison.Ordinal) == true;
|
|
|
|
public static Live Find(string shortcode) =>
|
|
shortcode != default && Current.TryGetValue(shortcode, out var emoji) ? emoji : default;
|
|
|
|
public static async Task Reload(CancellationToken token = default)
|
|
{
|
|
var offered = await DB.Default.Find<LocalEmoji>().Match(e => e.DeletedAt == null && !e.Disabled).ExecuteAsync(token);
|
|
_current = offered.Where(e => IsShortcode(e.Shortcode) && !string.IsNullOrEmpty(e.Url))
|
|
.GroupBy(e => e.Shortcode, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => g.Select(e => new Live(e.ID, e.Shortcode, e.Url, e.StaticUrl ?? e.Url, e.ContentType ?? "image/png", e.ImageUpdatedAt,
|
|
e.Category, e.VisibleInPicker)).First(), StringComparer.Ordinal);
|
|
_loadedAt = DateTime.UtcNow;
|
|
}
|
|
|
|
static void RefreshIfStale()
|
|
{
|
|
if (DateTime.UtcNow - _loadedAt < Staleness || Interlocked.Exchange(ref _reloading, 1) == 1)
|
|
return;
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
await Reload(CancellationToken.None);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Serilog.Log.ForContext(typeof(LocalEmojis)).Warning(ex, "The custom emoji could not be reloaded");
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.Exchange(ref _reloading, 0);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>The emoji offered now that the texts name as `:shortcode:`, as a post or a profile keeps them; remote
|
|
/// shortcodes are not ours and are never taken.</summary>
|
|
public static List<CustomEmoji> In(IEnumerable<string> texts)
|
|
{
|
|
var current = Current;
|
|
if (current.Count == 0)
|
|
return new List<CustomEmoji>();
|
|
return texts.Where(t => !string.IsNullOrEmpty(t))
|
|
.SelectMany(t => Named.Matches(t).Select(m => m.Groups[1].Value))
|
|
.Distinct(StringComparer.Ordinal)
|
|
.Select(code => current.GetValueOrDefault(code))
|
|
.Where(e => e != default)
|
|
.Take(MaxPerObject)
|
|
.Select(e => new CustomEmoji { Shortcode = e.Shortcode, URL = e.Url, Updated = e.ImageUpdatedAt })
|
|
.ToList();
|
|
}
|
|
|
|
public static List<CustomEmoji> In(params string[] texts) => In((IEnumerable<string>)texts);
|
|
|
|
/// <summary>A persona's emoji: those its name, its bio and its fields name.</summary>
|
|
public static List<CustomEmoji> OfProfile(Models.User.Avatar avatar) =>
|
|
In(new[] { avatar.Name, avatar.Biography }.Concat((avatar.Fields ?? new()).SelectMany(f => new[] { f.Key, f.Value })));
|
|
|
|
/// <summary>What a local post's or persona's kept emoji are now: those still offered, as they are now.</summary>
|
|
public static List<Live> Of(IEnumerable<CustomEmoji> kept)
|
|
{
|
|
var current = Current;
|
|
if (kept == default || current.Count == 0)
|
|
return new List<Live>();
|
|
return kept.Where(e => e?.Shortcode != default).Select(e => current.GetValueOrDefault(e.Shortcode)).Where(e => e != default)
|
|
.DistinctBy(e => e.Shortcode).Take(MaxPerObject).ToList();
|
|
}
|
|
|
|
/// <summary>The local emoji a reaction names: `:shortcode:`, `:shortcode@this-host:` or the bare shortcode (Mastodon's
|
|
/// announcements, Pleroma's clients); none for a unicode emoji or another server's.</summary>
|
|
public static Live ForReaction(string content, string domain)
|
|
{
|
|
var text = content?.Trim();
|
|
if (string.IsNullOrEmpty(text) || text.Length > 300)
|
|
return default;
|
|
if (text.Length > 2 && text.StartsWith(':') && text.EndsWith(':'))
|
|
text = text[1..^1];
|
|
var at = text.IndexOf('@');
|
|
if (at >= 0)
|
|
{
|
|
if (!string.Equals(text[(at + 1)..], domain, StringComparison.OrdinalIgnoreCase))
|
|
return default;
|
|
text = text[..at];
|
|
}
|
|
return IsShortcode(text) ? Find(text) : default;
|
|
}
|
|
|
|
/// <summary>An emoji as a tag of a Note, Question, actor or EmojiReact, in Mastodon's shape (GoToSocial needs its id).</summary>
|
|
public static JsonObject Tag(Live emoji, string baseAddress) => Build(emoji, baseAddress, new JsonObject());
|
|
|
|
/// <summary>The document served at the emoji's id: the same object, with the context.</summary>
|
|
public static JsonObject Document(Live emoji, string baseAddress) =>
|
|
Build(emoji, baseAddress, new JsonObject { ["@context"] = ActivityPubRenderer.Context() });
|
|
|
|
static JsonObject Build(Live emoji, string baseAddress, JsonObject into)
|
|
{
|
|
into["id"] = Uri(baseAddress, emoji.Shortcode);
|
|
into["type"] = "Emoji";
|
|
into["name"] = $":{emoji.Shortcode}:";
|
|
into["updated"] = ActivityPubRenderer.Timestamp(emoji.ImageUpdatedAt);
|
|
into["icon"] = new JsonObject { ["type"] = "Image", ["mediaType"] = emoji.ContentType, ["url"] = emoji.Url };
|
|
return into;
|
|
}
|
|
}
|
|
}
|