R7 custom emoji in decePub: the server's own, administered, picked, suggested and shown

Administration has a Custom emoji section (EmojiPanel, data-admin-section=emoji): the server's emoji by category, added
with a picture streamed from the file, changed (category, picker, disabled, picture; never the shortcode) and deleted,
asked first, with PrivaPub's 409 and 422 in the dialog. EmojiService reads /clientapi/admin/emoji and, for everyone,
/api/v1/custom_emojis, kept a day in localStorage (CustomEmojiCache) and read again after a change here. EmojiPicker
(a DDropdown sheet on a phone, rendered only while open) offers them by category with common unicode emoji, searchable:
the composer writes the choice at the caret, a card and the announcements banner react with it. The composer suggests
custom emoji after `:` and two letters. Custom reactions toggle as :shortcode: and show their picture, notifications of
one too; located posts, announcements and NavMenu's persona names render the server's emoji.

Verified: Release build, 0 errors (the 3 warnings were there before). Not run against PrivaPub or in the pasture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-18 08:24:43 +02:00
1 parent c572471b88
commit 566b0e33ea
26 files changed
+1385 -56

No files matched your search

+68 -6
View File
@@ -26,9 +26,17 @@
@* under the words, not over their last line *@
<span class="self-end text-xs opacity-50 tabular-nums">@ContentLength/@ComposeForm.MaxContentLength</span>
</div>
@if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0)
@if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0 || suggestedEmojis.Count > 0)
{
<div class="flex flex-wrap gap-2" data-suggestions>
@foreach (var emoji in suggestedEmojis)
{
<DButton Size="DSize.Xs" class="max-w-full" KeepFocus="true" OnClick="@(() => Complete($":{emoji.Shortcode}:"))" data-emoji-suggestion="@emoji.Shortcode">
@* alt="": the shortcode is next to it. *@
<img alt="" class="h-4 w-4 flex-none object-contain" src="@emoji.Url" />
<span class="truncate">@($":{emoji.Shortcode}:")</span>
</DButton>
}
@foreach (var account in suggestedAccounts)
{
<DButton Size="DSize.Xs" class="max-w-full" KeepFocus="true" OnClick="@(() => Complete("@" + account.Acct))" data-suggestion="@("@" + account.Acct)">
@@ -200,6 +208,11 @@
<i class="ion-md-warning text-base"></i>
</DButton>
@* the server's custom emoji and common unicode ones, written where the caret is *@
<EmojiPicker OnPick="InsertEmoji" TriggerLabel="@Localizer["Insert an emoji"]">
<i class="ion-md-happy text-base" aria-hidden="true"></i>
</EmojiPicker>
@if (!form.IsLocated && GroupId is null && !form.HasPoll)
{
@* A label styled as a button opens the hidden file input. *@
@@ -275,6 +288,8 @@
[Inject] AccountService AccountService { get; set; }
[Inject] ILoggingService Logger { get; set; }
[Inject] IJSRuntime JS { get; set; }
[Inject] EmojiService EmojiService { get; set; }
[Inject] AppStatusService AppStatus { get; set; }
/// <summary>The status this answers; the reply starts with its visibility (never wider) and content warning.</summary>
[Parameter] public Status InReplyTo { get; set; }
@@ -291,8 +306,11 @@
List<string> fileErrors = [];
// what the words end with while a mention or a hashtag is being written: its sign and what follows it
static readonly Regex Typing = new(@"(?:^|\s)([@#])([\p{L}\p{N}_][\p{L}\p{N}_.\-]*(?:@[\p{L}\p{N}.\-]*)?)$", RegexOptions.Compiled);
// and while one of the server's custom emoji is: a colon and two letters at least
static readonly Regex TypingEmoji = new(@"(?:^|\s)(:)([A-Za-z0-9_]{2,64})$", RegexOptions.Compiled);
List<Account> suggestedAccounts = [];
List<StatusTag> suggestedTags = [];
List<CustomEmoji> suggestedEmojis = [];
// the newest lookup: an older one's answer comes too late to show
int suggesting;
string error;
@@ -300,11 +318,24 @@
int ContentLength => form.Content?.Length ?? 0;
// what is being written, once typing pauses, among the accounts and hashtags this server knows
// what is being written, once typing pauses, among the accounts and hashtags this server knows; a custom emoji at
// once, among the server's (read once a day)
async Task Typed()
{
var typed = Typing.Match(form.Content ?? "");
var lookup = ++suggesting;
if (TypingEmoji.Match(form.Content ?? "") is { Success: true } emoji)
{
suggestedAccounts = [];
suggestedTags = [];
var matching = await EmojiService.Matching(emoji.Groups[2].Value, 8);
if (lookup != suggesting)
return;
suggestedEmojis = matching;
return;
}
suggestedEmojis = [];
var typed = Typing.Match(form.Content ?? "");
if (!typed.Success || typed.Groups[2].Length < 2)
{
suggestedAccounts = [];
@@ -322,19 +353,42 @@
suggestedTags = hashtags ? [.. found.Hashtags.Take(5)] : [];
}
// the mention or hashtag being written, written out
// the mention, hashtag or custom emoji being written, written out
void Complete(string written)
{
var typed = Typing.Match(form.Content ?? "");
var typed = TypingEmoji.Match(form.Content ?? "") is { Success: true } emoji ? emoji : Typing.Match(form.Content ?? "");
suggesting++;
suggestedAccounts = [];
suggestedTags = [];
suggestedEmojis = [];
if (!typed.Success)
return;
form.Content = form.Content[..typed.Groups[1].Index] + written + " ";
focusPending = true;
}
// an emoji from the picker, written where the caret is (in place of what is selected), else at the end. A shortcode
// stands apart from the words around it, as PrivaPub reads `:shortcode:` only with no letter or digit next to it.
void InsertEmoji(string emoji)
{
var content = form.Content ?? string.Empty;
var (start, end) = AppStatus.CaretOf(textarea.Element) is [var from, var to] && from >= 0 && from <= to && to <= content.Length
? (from, to)
: (content.Length, content.Length);
var before = content[..start];
var after = content[end..];
var isShortcode = emoji.StartsWith(':');
var lead = isShortcode && before.Length > 0 && !char.IsWhiteSpace(before[^1]) ? " " : null;
var trail = isShortcode && (after.Length == 0 || !char.IsWhiteSpace(after[0])) ? " " : null;
form.Content = before + lead + emoji + trail + after;
caretAt = before.Length + (lead?.Length ?? 0) + emoji.Length + (trail?.Length ?? 0);
suggesting++;
suggestedAccounts = [];
suggestedTags = [];
suggestedEmojis = [];
focusPending = true;
}
string Placeholder => Editing is not null ? Localizer["Edit your post"]
: Quoting is not null ? Localizer["Say something about it"]
: InReplyTo is null ? Localizer["What's happening?"]
@@ -400,14 +454,22 @@
};
DTextarea<string> textarea;
// the words take the focus when the composer opens, and again after a suggestion is picked
// the words take the focus when the composer opens, and again after a suggestion or an emoji is picked
bool focusPending = true;
// where the caret goes then: after the emoji just written
int? caretAt;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!focusPending)
return;
focusPending = false;
if (caretAt is { } at)
{
caretAt = null;
AppStatus.PlaceCaret(textarea.Element, at);
return;
}
await textarea.Element.FocusAsync();
}