R7 custom emoji: the server's own, added by administrators, sent on posts, profiles and reactions
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
This commit is contained in:
1 parent
9f68ca3ed0
commit
d08fbabaa5
38 files changed
+1117
-54
No files matched your search
@@ -0,0 +1,164 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ namespace PrivaPub.Domain.Media
|
||||
// What the media directory holds against what the database says holds it, and, with fix, the two made to agree:
|
||||
// - a persona's current avatar or header with no row (they had none before) becomes a row of its own;
|
||||
// - media of a deleted post or of a deleted persona go to the trash, and a picture of the server's own (ServerUse) that
|
||||
// its settings no longer name;
|
||||
// its settings no longer name (its thumbnail) or no custom emoji holds;
|
||||
// - rows whose files are gone go to the trash;
|
||||
// - files nothing holds (deleted posts' media and replaced pictures, from before media were trashed) get a trashed row
|
||||
// each.
|
||||
@@ -60,9 +60,12 @@ namespace PrivaPub.Domain.Media
|
||||
var ownerIds = rows.Select(r => r.OwnerAvatarId).Where(id => id != null).Distinct().ToList();
|
||||
var liveOwners = (await DB.Default.Find<Avatar>().Match(a => ownerIds.Contains(a.ID) && !a.DeletionAt.HasValue).Project(a => a.Include(x => x.ID)).ExecuteAsync(token))
|
||||
.Select(a => a.ID).ToHashSet();
|
||||
// (the server's own pictures belong to nobody: they are held while the settings name them)
|
||||
// (the server's own pictures belong to nobody: they are held while the settings name them, or a custom emoji that is
|
||||
// not deleted, disabled ones too)
|
||||
var settings = await DB.Default.Find<Models.ServerSettings>().Match(x => x.ID == Models.ServerSettings.Name).ExecuteFirstAsync(token);
|
||||
var serverHeld = new HashSet<string> { settings?.ThumbnailMediaId };
|
||||
serverHeld.UnionWith((await DB.Default.Find<Models.Post.LocalEmoji>().Match(e => e.DeletedAt == null).Project(e => e.Include(x => x.MediaId))
|
||||
.ExecuteAsync(token)).Select(e => e.MediaId).Where(id => id != null));
|
||||
var ofDeleted = rows.Where(r => r.ServerUse != null ? !serverHeld.Contains(r.ID)
|
||||
: (r.PostId != null && !livePosts.Contains(r.PostId)) || (r.OwnerAvatarId != null && !liveOwners.Contains(r.OwnerAvatarId))).ToList();
|
||||
var missing = rows.Except(ofDeleted).Where(r => !string.IsNullOrEmpty(r.FilePath) && !files.Contains(r.FilePath)).ToList();
|
||||
|
||||
@@ -49,6 +49,10 @@ namespace PrivaPub.Domain.Media
|
||||
/// <summary>A picture of the server's own (its thumbnail, "thumbnail"), fitted within 1200×630, as a row nobody owns,
|
||||
/// held by its <see cref="MediaAttachment.ServerUse"/>.</summary>
|
||||
Task<MediaOutcome> ServerImage(string use, IFormFile file, CancellationToken token);
|
||||
/// <summary>A custom emoji's picture (R7): a PNG at most 256 px on its longer side, alpha kept, or an animated GIF
|
||||
/// that stays one, small enough for every peer (EmojiMaxBytes); a row nobody owns, held by ServerUse
|
||||
/// "emoji:<shortcode>".</summary>
|
||||
Task<MediaOutcome> EmojiImage(string shortcode, IFormFile file, CancellationToken token);
|
||||
/// <summary>Trashes the media <paramref name="which"/> matches (and that isn't trashed yet): each row is marked in one
|
||||
/// conditional update, so a row attached meanwhile is left alone, and its files stop being served at once.</summary>
|
||||
Task<long> Trash(System.Linq.Expressions.Expression<Func<MediaAttachment, bool>> which, string reason, CancellationToken token);
|
||||
@@ -299,6 +303,108 @@ namespace PrivaPub.Domain.Media
|
||||
return new MediaOutcome(attachment);
|
||||
}
|
||||
|
||||
// what an emoji's picture may be sent as, and how big (Mastodon's limit for one, 256 KB)
|
||||
static readonly string[] EmojiTypes = { "image/png", "image/gif", "image/webp", "image/jpeg" };
|
||||
public const long EmojiMaxUploadBytes = 256 * 1024;
|
||||
// what it may be once made: GoToSocial's default for another server's emoji (media-emoji-remote-max-size, 100 KiB) is
|
||||
// the smallest any peer takes; Mastodon's is 256 KB
|
||||
public const long EmojiMaxBytes = 100 * 1024;
|
||||
public const int EmojiSide = 256;
|
||||
// tried in turn until the picture fits EmojiMaxBytes: 256 px as it is, then with a palette, then fewer pixels
|
||||
static readonly (int Side, bool Palette)[] EmojiAttempts = { (256, false), (256, true), (192, true), (128, true), (96, true), (64, true) };
|
||||
|
||||
// As safe as any upload (checked before it is decoded, the allowed loaders alone, written without its metadata).
|
||||
// A still picture becomes a PNG, its transparency kept; an animated GIF or WebP becomes an animated GIF, since a PNG
|
||||
// would lose the animation and GIF is the animated picture every peer shows as an emoji, with a still PNG of its first
|
||||
// frame for static_url.
|
||||
public async Task<MediaOutcome> EmojiImage(string shortcode, IFormFile file, CancellationToken token)
|
||||
{
|
||||
var contentType = file?.ContentType?.Split(';')[0].Trim().ToLowerInvariant();
|
||||
if (file == default || file.Length == 0)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Image can't be blank");
|
||||
if (!EmojiTypes.Contains(contentType))
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: An emoji's image is a PNG, GIF, WebP or JPEG");
|
||||
if (file.Length > EmojiMaxUploadBytes)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: An emoji's image is at most 256 KB");
|
||||
var input = await Read(file, token);
|
||||
if (Refusal(input, _options.CurrentValue) is { } refused)
|
||||
return refused;
|
||||
byte[] bytes, still = default;
|
||||
int width, height;
|
||||
bool animated;
|
||||
await _processing.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
var header = Inspect(input);
|
||||
animated = header.Pages > 1 && (header.Loader?.StartsWith("gifload", StringComparison.Ordinal) == true
|
||||
|| header.Loader?.StartsWith("webpload", StringComparison.Ordinal) == true);
|
||||
(bytes, still, width, height) = animated ? EmojiAnimation(input) : EmojiStill(input);
|
||||
}
|
||||
catch (VipsException ex)
|
||||
{
|
||||
_logger.LogInformation("Refused an emoji's picture: {Error}", ex.Message);
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_processing.Release();
|
||||
}
|
||||
if (bytes == default)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The image can't be made small enough for an emoji (100 KB)");
|
||||
var attachment = new MediaAttachment
|
||||
{
|
||||
Kind = "emoji",
|
||||
ServerUse = Content.LocalEmojis.ServerUse(shortcode),
|
||||
ContentType = animated ? "image/gif" : "image/png",
|
||||
FilePath = await Save(bytes, animated ? "gif" : "png", token),
|
||||
PreviewPath = still == default ? default : await Save(still, "png", token),
|
||||
Size = bytes.Length,
|
||||
Width = width,
|
||||
Height = height,
|
||||
AttachedAt = DateTime.UtcNow
|
||||
};
|
||||
await DB.Default.SaveAsync(attachment, token);
|
||||
return new MediaOutcome(attachment);
|
||||
}
|
||||
|
||||
static (byte[] Bytes, byte[] Still, int Width, int Height) EmojiStill(byte[] input)
|
||||
{
|
||||
foreach (var (side, palette) in EmojiAttempts)
|
||||
{
|
||||
using var image = Image.ThumbnailBuffer(input, side, height: side, size: Enums.Size.Down);
|
||||
using var pixels = EmojiPixels(image);
|
||||
var bytes = pixels.WriteToBuffer(palette ? ".png[keep=none,compression=9,palette=true]" : ".png[keep=none,compression=9]");
|
||||
if (bytes.Length <= EmojiMaxBytes)
|
||||
return (bytes, default, pixels.Width, pixels.Height);
|
||||
}
|
||||
return default;
|
||||
}
|
||||
|
||||
// every frame shrunk to the side (thumbnail with n=-1 resizes each page), written as a GIF; its first frame as a PNG
|
||||
static (byte[] Bytes, byte[] Still, int Width, int Height) EmojiAnimation(byte[] input)
|
||||
{
|
||||
foreach (var side in EmojiAttempts.Select(a => a.Side).Distinct())
|
||||
{
|
||||
using var frames = Image.ThumbnailBuffer(input, side, height: side, optionString: "n=-1", size: Enums.Size.Down);
|
||||
using var pixels = EmojiPixels(frames);
|
||||
var bytes = pixels.WriteToBuffer(".gif[keep=none]");
|
||||
if (bytes.Length > EmojiMaxBytes)
|
||||
continue;
|
||||
using var first = Image.ThumbnailBuffer(input, side, height: side, size: Enums.Size.Down);
|
||||
using var still = EmojiPixels(first);
|
||||
var height = pixels.Contains("page-height") ? (int)pixels.Get("page-height") : pixels.Height;
|
||||
return (bytes, still.WriteToBuffer(".png[keep=none,compression=9,palette=true]"), pixels.Width, height);
|
||||
}
|
||||
return default;
|
||||
}
|
||||
|
||||
// 8-bit sRGB, its alpha kept (thumbnail already brought its colours into sRGB; a 16-bit or grey picture is made so)
|
||||
static Image EmojiPixels(Image image)
|
||||
{
|
||||
using var srgb = image.Interpretation == Enums.Interpretation.Srgb ? image.Copy() : image.Colourspace(Enums.Interpretation.Srgb);
|
||||
return srgb.Format == Enums.BandFormat.Uchar ? srgb.Copy() : srgb.Cast(Enums.BandFormat.Uchar);
|
||||
}
|
||||
|
||||
// a login over its quota (Media:QuotaBytesPerRoot; none when 0) uploads nothing more
|
||||
async Task<MediaOutcome> OverQuota(string avatarId, long incoming, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -367,6 +367,9 @@ namespace PrivaPub.Domain.Portability
|
||||
}
|
||||
if (Text(note["type"]) == "Question")
|
||||
post.Poll = Poll(note);
|
||||
// this server's custom emoji the words name, as a post written here gets them (R7)
|
||||
post.Emojis = Content.LocalEmojis.In(new[] { post.Text ?? post.ContentHtml, post.Title, post.SpoilerText }
|
||||
.Concat(post.Poll?.Options.Select(o => o.Title) ?? Enumerable.Empty<string>()));
|
||||
// the conversation (FEP-7888) of its thread's first post, when the thread starts in the archive
|
||||
if (visibility is PostVisibility.Public or PostVisibility.Unlisted && Root(run, original) is { } root && run.Posts.TryGetValue(root, out var first))
|
||||
post.ContextURI = first.Uri + "/context";
|
||||
@@ -812,6 +815,7 @@ namespace PrivaPub.Domain.Portability
|
||||
.Take(4).ToDictionary(f => Text(f["name"]).Trim(), f => Plain(Text(f["value"]) ?? string.Empty));
|
||||
if (fields.Count > 0)
|
||||
avatar.Fields = fields;
|
||||
avatar.Emojis = Content.LocalEmojis.OfProfile(avatar);//this server's custom emoji the profile names (R7)
|
||||
if (actor["manuallyApprovesFollowers"]?.GetValueKind() is { } locked and (System.Text.Json.JsonValueKind.True or System.Text.Json.JsonValueKind.False))
|
||||
avatar.Settings.IsLocked = locked == System.Text.Json.JsonValueKind.True;
|
||||
if (actor["discoverable"]?.GetValueKind() is { } discoverable and (System.Text.Json.JsonValueKind.True or System.Text.Json.JsonValueKind.False))
|
||||
|
||||
@@ -73,11 +73,17 @@ namespace PrivaPub.Domain.Social
|
||||
{
|
||||
if (await FindShown(id, token) == default)
|
||||
return (StatusCodes.Status404NotFound, "Record not found");
|
||||
// one unicode emoji (custom emoji come with R7): the same normalisation as a post's reactions
|
||||
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;
|
||||
// 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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Content;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Inbox;
|
||||
@@ -84,13 +85,20 @@ namespace PrivaPub.Domain.Social
|
||||
return true;
|
||||
}
|
||||
|
||||
// a unicode emoji, one of this server's custom emoji that is offered (`:code:`, as Akkoma names its own; R7), or a custom
|
||||
// emoji the post or its reactions already show (`:code@host:` for another server's)
|
||||
public async Task<bool> React(LocalActor me, PostEntity post, string emoji, CancellationToken token)
|
||||
{
|
||||
var known = post.Emojis.Select(e => (e.Shortcode, e.URL))
|
||||
.Concat((await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.EmojiURL != null).ExecuteAsync(token)).Select(r => (r.Emoji, r.EmojiURL)))
|
||||
.Select(e => new Models.Post.CustomEmoji { Shortcode = e.Item1, URL = e.Item2 })
|
||||
.ToList();
|
||||
if (Normalise(emoji, known) is not { } picked)
|
||||
var picked = LocalEmojis.ForReaction(emoji, me.Domain) is { } local ? new Picked(local.Shortcode, local.Url) : default;
|
||||
if (picked == default)
|
||||
{
|
||||
var known = post.Emojis.Select(e => (e.Shortcode, e.URL))
|
||||
.Concat((await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.EmojiURL != null).ExecuteAsync(token)).Select(r => (r.Emoji, r.EmojiURL)))
|
||||
.Select(e => new Models.Post.CustomEmoji { Shortcode = e.Item1, URL = e.Item2 })
|
||||
.ToList();
|
||||
picked = Normalise(emoji, known);
|
||||
}
|
||||
if (picked == default)
|
||||
return false;
|
||||
var reaction = new Reaction
|
||||
{
|
||||
@@ -114,7 +122,7 @@ namespace PrivaPub.Domain.Social
|
||||
|
||||
public async Task<bool> Unreact(LocalActor me, PostEntity post, string emoji, CancellationToken token)
|
||||
{
|
||||
var name = Normalise(emoji, post.Emojis.ToList(), requireKnownCustom: false)?.Name;
|
||||
var name = LocalEmojis.ForReaction(emoji, me.Domain)?.Shortcode ?? Normalise(emoji, post.Emojis.ToList(), requireKnownCustom: false)?.Name;
|
||||
var reaction = name == default
|
||||
? default
|
||||
: await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.ActorURI == me.Uri && r.Emoji == name).ExecuteFirstAsync(token);
|
||||
@@ -145,13 +153,17 @@ namespace PrivaPub.Domain.Social
|
||||
["to"] = new JsonArray(post.ActorURI),
|
||||
["content"] = reaction.EmojiURL == default ? reaction.Emoji : $":{reaction.Emoji}:"
|
||||
};
|
||||
// one of this server's emoji goes as posts carry it (its id, `updated`, the picture's type), as Misskey and Akkoma
|
||||
// register it; another server's as the post showed it
|
||||
if (reaction.EmojiURL != default)
|
||||
react["tag"] = new JsonArray(new JsonObject
|
||||
{
|
||||
["type"] = "Emoji",
|
||||
["name"] = $":{reaction.Emoji}:",
|
||||
["icon"] = new JsonObject { ["type"] = "Image", ["url"] = reaction.EmojiURL }
|
||||
});
|
||||
react["tag"] = new JsonArray(LocalEmojis.Find(reaction.Emoji) is { } local && local.Url == reaction.EmojiURL
|
||||
? LocalEmojis.Tag(local, me.BaseAddress)
|
||||
: new JsonObject
|
||||
{
|
||||
["type"] = "Emoji",
|
||||
["name"] = $":{reaction.Emoji}:",
|
||||
["icon"] = new JsonObject { ["type"] = "Image", ["url"] = reaction.EmojiURL }
|
||||
});
|
||||
return react;
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
QuoteState = quoted == default ? QuoteState.None : quotePermission == QuotePermission.Granted ? QuoteState.Accepted : QuoteState.Pending,
|
||||
Approval = replyPermission == QuotePermission.AskFirst ? ApprovalState.Pending : ApprovalState.None
|
||||
};
|
||||
post.Emojis = Emojis(post);
|
||||
post.ID = (string)post.GenerateNewID();
|
||||
post.ObjectURI = author.PostUri(post.ID);
|
||||
post.Url = author.PostHtmlUrl(post.ID);
|
||||
@@ -300,6 +301,10 @@ namespace PrivaPub.Domain.Statuses
|
||||
return new StatusOutcome(post);
|
||||
}
|
||||
|
||||
// the server's custom emoji its words, title, warning and poll options name (R7): only ours that are offered now
|
||||
static List<CustomEmoji> Emojis(PostEntity post) =>
|
||||
LocalEmojis.In(new[] { post.Text, post.Title, post.SpoilerText }.Concat(post.Poll?.Options.Select(o => o.Title) ?? Enumerable.Empty<string>()));
|
||||
|
||||
public async Task<StatusOutcome> Edit(LocalActor author, string postId, StatusDraft draft, CancellationToken token)
|
||||
{
|
||||
var post = await Own(author, postId, token);
|
||||
@@ -319,7 +324,8 @@ namespace PrivaPub.Domain.Statuses
|
||||
SpoilerText = post.SpoilerText,
|
||||
ContentHtml = post.ContentHtml,
|
||||
HasContentWarning = post.HasContentWarning,
|
||||
EditedAt = post.EditedAt ?? post.CreationDate
|
||||
EditedAt = post.EditedAt ?? post.CreationDate,
|
||||
Emojis = post.Emojis.ToList()
|
||||
});
|
||||
if (post.Revisions.Count > MaxRevisions)
|
||||
post.Revisions.RemoveRange(0, post.Revisions.Count - MaxRevisions);
|
||||
@@ -363,6 +369,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
? post.Mentions.Concat(rendered.Mentions.Select(ToMention)).DistinctBy(m => m.ActorURI).ToList()
|
||||
: rendered.Mentions.Select(ToMention).ToList();
|
||||
post.Tags = rendered.Tags.ToList();
|
||||
post.Emojis = Emojis(post);
|
||||
post.EditedAt = DateTime.UtcNow;
|
||||
post.UpdateDate = post.EditedAt;
|
||||
await DB.Default.SaveAsync(post, token);
|
||||
|
||||
Reference in new issue
Block a user