164 lines
7.4 KiB
C#
164 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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|