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:
1 parent
c572471b88
commit
566b0e33ea
26 files changed
+1385
-56
No files matched your search
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user