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 = "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(@"(? _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;
}
}
}