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 { /// 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). public const string Path = "grimaces"; 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(@"(? _current = new Dictionary(StringComparer.Ordinal); static DateTime _loadedAt = DateTime.MinValue; static int _reloading; /// The emoji offered now, by their exact shortcode. public static IReadOnlyDictionary Current { get { RefreshIfStale(); return _current; } } public static bool IsShortcode(string text) => !string.IsNullOrEmpty(text) && Shortcode.IsMatch(text); /// The id an emoji federates under: {base}/emojis/{shortcode}. public static string Uri(string baseAddress, string shortcode) => $"{baseAddress}/{Path}/{shortcode}"; /// What holds its picture (MediaAttachment.ServerUse): the janitor and the media audit leave such rows alone. 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().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); } }); } /// 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. public static List In(IEnumerable texts) { var current = Current; if (current.Count == 0) return new List(); 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 In(params string[] texts) => In((IEnumerable)texts); /// A persona's emoji: those its name, its bio and its fields name. public static List OfProfile(Models.User.Avatar avatar) => In(new[] { avatar.Name, avatar.Biography }.Concat((avatar.Fields ?? new()).SelectMany(f => new[] { f.Key, f.Value }))); /// What a local post's or persona's kept emoji are now: those still offered, as they are now. public static List Of(IEnumerable kept) { var current = Current; if (kept == default || current.Count == 0) return new List(); return kept.Where(e => e?.Shortcode != default).Select(e => current.GetValueOrDefault(e.Shortcode)).Where(e => e != default) .DistinctBy(e => e.Shortcode).Take(MaxPerObject).ToList(); } /// 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. 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; } /// An emoji as a tag of a Note, Question, actor or EmojiReact, in Mastodon's shape (GoToSocial needs its id). public static JsonObject Tag(Live emoji, string baseAddress) => Build(emoji, baseAddress, new JsonObject()); /// The document served at the emoji's id: the same object, with the context. 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; } } }