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

+338
View File
@@ -0,0 +1,338 @@
@inherits LocalizableComponentBase
@using System.Globalization
@* The server's own custom emoji, for administrators (/clientapi/admin/emoji, EmojiService), grouped by category: each
with its picture, its :shortcode:, whether its picker offers it and whether it is disabled (kept, but offered and shown
nowhere). One is added with a shortcode, a category and a picture streamed from the file (PNG, GIF, WebP or JPEG,
256 KB at most), and changed in the same dialog (its category, the picker, disabled, a new picture; never its
shortcode, its id on other servers), which shows PrivaPub's refusals: 409 for a shortcode taken, 422 for a picture it
won't take. Deleting one is asked first: the posts and profiles using it show its shortcode as text. Every change
reads the server's emoji for everyone again (EmojiService.Refresh), so this client's picker has it at once. *@
<div class="flex flex-col gap-3 w-full" data-emoji-panel>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled" OnClick="New" data-emoji-new>
<i class="ion-md-add" aria-hidden="true"></i><span>@Localizer["Add an emoji"]</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Reload" data-emoji-reload>
<i class="ion-md-refresh" aria-hidden="true"></i><span>@Localizer["Read again"]</span>
</DButton>
</div>
<p class="text-xs opacity-70">@Localizer["Everyone here can use them in posts, names and reactions, and other servers show them. The picker offers those in it; one written out as :shortcode: works too, unless it is disabled."]</p>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (emojis.Count == 0)
{
<p class="text-sm opacity-70" data-emoji-empty>@Localizer["No custom emoji yet."]</p>
}
@foreach (var group in Groups)
{
<section class="flex flex-col gap-2" data-emoji-group="@group.Key">
<h3 class="text-sm font-semibold">@(group.Key is { Length: > 0 } category ? category : Localizer["No category"])</h3>
<ul class="flex flex-col gap-2">
@foreach (var emoji in group)
{
<li @key="emoji.Shortcode" class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2 p-2 rounded-lg neo-raised neo-2xs @(emoji.Disabled ? "opacity-60" : null)"
data-emoji="@emoji.Shortcode" data-emoji-state="@StateOf(emoji)">
<img alt="@($":{emoji.Shortcode}:")" loading="lazy" draggable="false" class="h-10 w-10 flex-none object-contain rounded" src="@emoji.Url" />
<span class="flex flex-col min-w-0 flex-1">
<b class="truncate font-mono text-sm" title="@emoji.Uri">@($":{emoji.Shortcode}:")</b>
<span class="text-xs opacity-70" title="@emoji.ImageUpdatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
@emoji.Size.GetFileSize(Localizer) · @Localizer["Changed {0} ago", emoji.UpdatedAt.GetPassedTime(Localizer)]
</span>
</span>
<span class="flex flex-wrap items-center gap-1">
@if (emoji.ContentType == "image/gif")
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Outline">@Localizer["Animated"]</DBadge>
}
@if (emoji.Disabled)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error">@Localizer["Disabled"]</DBadge>
}
else if (!emoji.VisibleInPicker)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Not in the picker"]</DBadge>
}
</span>
<span class="flex items-center gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || busy.Contains(emoji.Shortcode)" OnClick="() => Edit(emoji)" data-emoji-edit>
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Edit"]</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || busy.Contains(emoji.Shortcode)" OnClick="() => Delete(emoji)" data-emoji-delete>
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Delete"]</span>
</DButton>
</span>
</li>
}
</ul>
</section>
}
</div>
@if (isEditing)
{
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-emoji-dialog>
<Heading>@(editedEmoji is null ? Localizer["New emoji"] : Localizer["Edit :{0}:", editedEmoji.Shortcode])</Heading>
<ChildContent>
<EditForm Model="form" OnValidSubmit="Save" class="flex flex-col gap-3 mt-3">
<DataAnnotationsValidator />
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Shortcode"]</span>
<DInput TValue="string" class="w-full font-mono" @bind-Value="form.Shortcode" Immediate="true" autocomplete="off" maxlength="64"
readonly="@(editedEmoji is not null)" spellcheck="false" data-emoji-shortcode>
<Prefix><span aria-hidden="true">:</span></Prefix>
<Suffix><span aria-hidden="true">:</span></Suffix>
</DInput>
<span class="text-xs opacity-70">
@(editedEmoji is null
? Localizer["Letters, digits and underscores, 2 to 64 of them. It never changes: other servers know the emoji by it."]
: Localizer["A shortcode never changes: other servers know the emoji by it."])
</span>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Category"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.Category" Immediate="true" autocomplete="off" maxlength="64" list="emoji-categories"
placeholder="@Localizer["None"]" data-emoji-category />
<datalist id="emoji-categories">
@foreach (var category in Categories)
{
<option value="@category"></option>
}
</datalist>
<span class="text-xs opacity-70">@Localizer["The picker groups the emoji by it."]</span>
</label>
<div class="flex flex-col gap-2">
<span class="text-sm">@Localizer["Picture"]</span>
<div class="flex flex-wrap items-center gap-3 pointer-coarse:gap-2">
@if ((preview ?? editedEmoji?.Url) is { Length: > 0 } shown)
{
<img alt="@Localizer["Picture"]" class="h-12 w-12 flex-none object-contain rounded neo-inset neo-2xs" src="@shown"
data-emoji-preview="@(preview is null ? "current" : "picked")" />
}
<DFileInput Size="DSize.Sm" class="flex-1 min-w-48" Accept="@Accept" OnChange="Pick" disabled="@(IsDefaultDisabled || isSaving)"
aria-label="@Localizer["Picture"]" data-emoji-picture />
</div>
<span class="text-xs opacity-70">@Localizer["PNG, GIF, WebP or JPEG, {0} at most. The server fits it within 256 px and keeps a GIF's animation.", EmojiService.MaxPictureBytes.GetFileSize(Localizer)]</span>
@if (editedEmoji is not null)
{
<span class="text-xs opacity-70">@Localizer["Without a new one, it keeps its picture."]</span>
}
</div>
<div class="flex flex-col gap-1">
<DToggle LabelText="@Localizer["In the picker"]" @bind-Value="form.VisibleInPicker" data-emoji-picker />
<span class="text-xs opacity-70">@Localizer["Offered in the picker of every client. Out of it, it is still taken when its shortcode is written."]</span>
</div>
<div class="flex flex-col gap-1">
<DToggle LabelText="@Localizer["Disabled"]" @bind-Value="form.Disabled" data-emoji-disabled />
<span class="text-xs opacity-70">@Localizer["Kept, but offered nowhere, taken into no new post and shown in none."]</span>
</div>
<div data-emoji-error="@errorStatus">
<ErrorViewer AdditionalError="@error" AdditionalErrors="pictureErrors" />
</div>
<div class="flex justify-end gap-2 pointer-coarse:gap-2">
<DButton OnClick="Close" data-emoji-cancel>@Localizer["Cancel"]</DButton>
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving" data-emoji-save>
@if (isSaving)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Save"]</span>
</DButton>
</div>
</EditForm>
</ChildContent>
</DModal>
}
@code {
[Inject] EmojiService EmojiService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
[Inject] ILoggingService Logger { get; set; }
static readonly string Accept = string.Join(',', EmojiService.PictureTypes.Concat([".png", ".gif", ".webp", ".jpg", ".jpeg"]));
List<ViewLocalEmoji> emojis = [];
HashSet<string> busy = [];
// the dialog: a new emoji (no editedEmoji) or the one being changed, as the form will save it
bool isEditing;
ViewLocalEmoji editedEmoji;
LocalEmojiForm form = new();
// the picture picked, streamed from the file when the form is saved, and a small copy of it to show
IBrowserFile picture;
string preview;
List<string> pictureErrors = [];
// what PrivaPub answered, and its status (data-emoji-error: 409 a shortcode taken, 422 a picture refused, 400 invalid)
string error;
string errorStatus;
bool isSaving;
// those with no category first, then by category
List<IGrouping<string, ViewLocalEmoji>> Groups =>
[
.. emojis.OrderBy(e => e.Shortcode, StringComparer.OrdinalIgnoreCase)
.GroupBy(e => e.Category ?? string.Empty)
.OrderBy(g => g.Key.Length == 0 ? 0 : 1)
.ThenBy(g => g.Key, StringComparer.CurrentCultureIgnoreCase)
];
List<string> Categories => [.. emojis.Select(e => e.Category).Where(c => c is { Length: > 0 }).Distinct().Order(StringComparer.CurrentCultureIgnoreCase)];
static string StateOf(ViewLocalEmoji emoji) => emoji.Disabled ? "disabled" : emoji.VisibleInPicker ? "picker" : "hidden";
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => emojis = await EmojiService.List();
async Task Reload()
{
IsLoading = true;
await LoadData();
IsLoading = false;
}
void New() => Open(null, new LocalEmojiForm());
void Edit(ViewLocalEmoji emoji) => Open(emoji, new LocalEmojiForm
{
Shortcode = emoji.Shortcode,
Category = emoji.Category,
VisibleInPicker = emoji.VisibleInPicker,
Disabled = emoji.Disabled
});
void Open(ViewLocalEmoji emoji, LocalEmojiForm editing)
{
editedEmoji = emoji;
form = editing;
picture = null;
preview = null;
pictureErrors = [];
error = null;
errorStatus = null;
isEditing = true;
}
void Close()
{
isEditing = false;
picture = null;
preview = null;
}
void Closed(bool open)
{
if (!open)
Close();
}
async Task Pick(InputFileChangeEventArgs eventArgs)
{
pictureErrors = [];
picture = null;
preview = null;
var file = eventArgs.File;
if (!EmojiService.PictureTypes.Contains(EmojiService.TypeOf(file)))
{
pictureErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
return;
}
if (file.Size > EmojiService.MaxPictureBytes)
{
pictureErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name, file.Size.GetFileSize(Localizer),
EmojiService.MaxPictureBytes.GetFileSize(Localizer)]);
return;
}
picture = file;
preview = await Preview(file);
}
// a small copy the browser makes (a GIF's first frame); none when it can't
async Task<string> Preview(IBrowserFile file)
{
try
{
var small = await file.RequestImageFileAsync("image/png", 96, 96);
await using var stream = small.OpenReadStream(EmojiService.MaxPictureBytes);
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer);
return $"data:image/png;base64,{Convert.ToBase64String(buffer.ToArray())}";
}
catch (Exception ex)
{
await Logger.ProcessWarning($"No preview of {file.Name}: {ex.Message}", $"{nameof(EmojiPanel)}.{nameof(Preview)}");
return default;
}
}
async Task Save()
{
error = null;
errorStatus = null;
if (editedEmoji is null && picture is null)
{
pictureErrors = [Localizer["Choose a picture."]];
return;
}
isSaving = true;
var result = editedEmoji is null ? await EmojiService.Insert(form, picture) : await EmojiService.Update(form, picture);
isSaving = false;
if (result.IsInvalid)
{
error = result.ErrorMessage;
errorStatus = result.StatusCode.ToString(CultureInfo.InvariantCulture);
return;
}
var added = editedEmoji is null;
Close();
Toast.AddResponseSuccess(added ? Localizer["Emoji added."] : Localizer["Emoji saved."]);
if (result.Data is not ViewLocalEmoji saved)
{
await LoadData();
return;
}
emojis = [.. emojis.Where(e => e.Shortcode != saved.Shortcode), saved];
}
async Task Delete(ViewLocalEmoji emoji)
{
if (!await Confirm.Ask(Localizer["Delete :{0}:?", emoji.Shortcode],
Localizer["It is offered no more, and the posts and profiles that use it show the text :{0}: instead. Other servers keep their copy until they read those posts again.", emoji.Shortcode],
Localizer["Delete"], true))
return;
busy.Add(emoji.Shortcode);
var result = await EmojiService.Delete(emoji.Shortcode);
busy.Remove(emoji.Shortcode);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
emojis = [.. emojis.Where(e => e.Shortcode != emoji.Shortcode)];
Toast.AddResponseSuccess(Localizer["Emoji deleted."]);
}
}
+23 -18
View File
@@ -2,7 +2,8 @@
@* The administrators' announcements shown now (/api/v1/announcements) that the persona has not dismissed, one at a time
above the feed's tabs: their words (sanitized HTML from the administrator's Markdown), when they run, and the
reactions personas left, which the persona adds and takes back with a unicode emoji. Dismissed, one is gone for this
reactions personas left, which the persona adds and takes back with a unicode emoji or one of the server's custom ones
(EmojiPicker; its text shows the custom emoji it names). Dismissed, one is gone for this
persona. New, changed and removed ones, and the reactions' counts, come through the user stream
(StreamingService.HearAnnouncements); the banner renders itself and never touches the feed's cards. *@
@if (Current is { } current)
@@ -39,15 +40,15 @@
{
<p class="text-xs opacity-70" data-announcement-when>@When(current)</p>
}
<div class="post-content doc-content text-sm break-words max-h-28 md:max-h-40 overflow-y-auto">@((MarkupString)current.Content)</div>
<div class="post-content doc-content text-sm break-words max-h-28 md:max-h-40 overflow-y-auto">@((MarkupString)CustomEmojis.Html(current.Content, current.Emojis))</div>
<div class="flex flex-wrap items-center gap-1 pointer-coarse:gap-2" data-announcement-reactions>
@foreach (var reaction in current.Reactions)
{
<DButton Size="DSize.Xs" Active="reaction.Me == true" Disabled="IsDefaultDisabled || isActing" OnClick="() => React(reaction.Name, reaction.Me != true)"
title="@reaction.Name" aria-pressed="@(reaction.Me == true ? "true" : "false")" data-announcement-reaction="@reaction.Name">
title="@NameOf(reaction)" aria-pressed="@(reaction.Me == true ? "true" : "false")" data-announcement-reaction="@reaction.Name">
@if (reaction.Url is { Length: > 0 } image)
{
<img alt="@reaction.Name" class="h-4 w-4 object-contain" src="@image" />
<img alt="@NameOf(reaction)" class="h-4 w-4 object-contain" src="@image" />
}
else
{
@@ -56,17 +57,10 @@
<span class="tabular-nums">@reaction.Count</span>
</DButton>
}
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Bottom" CloseOnContentClick="true" SheetOnNarrow="true"
TriggerClass="@(isReactionsOpen ? "btn btn-xs btn-active" : "btn btn-xs")"
ContentClass="mt-2 flex gap-1 p-2 rounded-box max-md:grid max-md:grid-cols-4 max-md:gap-2" title="@Localizer["React"]" data-announcement-react>
<Trigger><i aria-hidden="true" class="ion-md-happy"></i></Trigger>
<ChildContent>
@foreach (var emoji in QuickReactions)
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => React(emoji, true)" data-announcement-react-emoji="@emoji">@emoji</DButton>
}
</ChildContent>
</DDropdown>
<EmojiPicker Quick="QuickReactions" OnPick="emoji => React(emoji, true)" ChoicesDisabled="isActing" TriggerLabel="@Localizer["React"]"
TriggerClass="btn btn-xs" ActiveTriggerClass="btn btn-xs btn-active" data-announcement-react>
<i aria-hidden="true" class="ion-md-happy"></i>
</EmojiPicker>
</div>
</section>
}
@@ -76,13 +70,16 @@
[Inject] AnnouncementService AnnouncementService { get; set; }
[Inject] StreamingService Streaming { get; set; }
[Inject] EmojiService EmojiService { get; set; }
List<Announcement> announcements = [];
int index;
bool isActing;
bool isReactionsOpen;
string loadedPersonaId;
// a custom emoji's reaction is named by its shortcode
static string NameOf(AnnouncementReaction reaction) => reaction.Url is { Length: > 0 } ? $":{reaction.Name}:" : reaction.Name;
// what the persona has not dismissed, oldest first as the server lists them
List<Announcement> Shown => [.. announcements.Where(a => a.Read != true)];
@@ -188,8 +185,10 @@
reacted.Reactions = reaction.Count <= 0
? [.. reacted.Reactions.Where(r => r.Name != reaction.Name)]
: known is null
? [.. reacted.Reactions, new AnnouncementReaction { Name = reaction.Name, Count = reaction.Count, Me = false }]
: [.. reacted.Reactions.Select(r => r.Name == reaction.Name ? new AnnouncementReaction { Name = r.Name, Count = reaction.Count, Me = r.Me, Url = r.Url } : r)];
? [.. reacted.Reactions, NewReaction(reaction)]
: [.. reacted.Reactions.Select(r => r.Name == reaction.Name
? new AnnouncementReaction { Name = r.Name, Count = reaction.Count, Me = r.Me, Url = r.Url, StaticUrl = r.StaticUrl }
: r)];
break;
default:
return;
@@ -198,6 +197,12 @@
StateHasChanged();
});
// one the stream tells of first: the event names it only, so a custom emoji's pictures come from the server's emoji read
// already (none while they aren't, until the list is read again)
AnnouncementReaction NewReaction(AnnouncementReactionEvent reaction) => EmojiService.Known(reaction.Name) is { } custom
? new AnnouncementReaction { Name = reaction.Name, Count = reaction.Count, Me = false, Url = custom.Url, StaticUrl = custom.StaticUrl }
: new AnnouncementReaction { Name = reaction.Name, Count = reaction.Count, Me = false };
public override async ValueTask DisposeAsync()
{
Streaming.Announced -= OnAnnounced;
+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();
}
+17 -14
View File
@@ -53,6 +53,11 @@
</span>
}
<span>@NotificationText(notification)</span>
@if (notification.Type == "pleroma:emoji_reaction" && notification.EmojiUrl is { Length: > 0 } reactionPicture)
{
<img alt="@($":{notification.Emoji}:")" title="@($":{notification.Emoji}:")" class="h-5 w-5 object-contain" src="@reactionPicture"
data-notification-emoji="@notification.Emoji" />
}
<span class="opacity-60" title="@NerdRow.Time(notification.CreatedAt)">@notification.CreatedAt.GetPassedTime(Localizer)</span>
@if (!Compact)
{
@@ -220,10 +225,10 @@
@foreach (var reaction in status.EmojiReactions)
{
<DButton Size="DSize.Xs" Active="reaction.Me" Disabled="@(IsDefaultDisabled || isActing || Compact || Post.Located)"
OnClick="() => React(reaction.Name, !reaction.Me)" title="@reaction.Name">
OnClick="() => React(ReactionOf(reaction), !reaction.Me)" title="@ReactionOf(reaction)" data-reaction="@reaction.Name">
@if (reaction.Url is { Length: > 0 } image)
{
<img alt="@reaction.Name" class="h-4 w-4 object-contain" src="@image" />
<img alt="@ReactionOf(reaction)" class="h-4 w-4 object-contain" src="@image" />
}
else
{
@@ -333,17 +338,12 @@
OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" title="@Localizer["Bookmark"]">
<i aria-hidden="true" class="ion-md-bookmark text-lg @(status.Bookmarked ? "text-warning" : null)"></i>
</DButton>
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Top" CloseOnContentClick="true" SheetOnNarrow="true" class="max-md:w-full"
TriggerClass="@(isReactionsOpen ? "btn btn-sm btn-active max-md:w-full" : "btn btn-sm max-md:w-full")"
ContentClass="mb-2 flex gap-1 p-2 rounded-box max-md:grid max-md:grid-cols-4 max-md:gap-2" title="@Localizer["React"]">
<Trigger><i aria-hidden="true" class="ion-md-happy text-lg"></i></Trigger>
<ChildContent>
@foreach (var emoji in QuickReactions)
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => React(emoji, true)">@emoji</DButton>
}
</ChildContent>
</DDropdown>
@* the quick reactions, then the server's custom emoji and the rest; opens downwards, like the menu *@
<EmojiPicker Quick="QuickReactions" OnPick="emoji => React(emoji, true)" ChoicesDisabled="isActing" TriggerLabel="@Localizer["React"]"
class="max-md:w-full" TriggerClass="btn btn-sm max-md:w-full" ActiveTriggerClass="btn btn-sm btn-active max-md:w-full"
data-react>
<i aria-hidden="true" class="ion-md-happy text-lg"></i>
</EmojiPicker>
<span class="hidden md:contents">
<NerdTip Heading="@Localizer["Counts"]" Rows="CountRows(status)" Class="self-center">
<i class="ion-md-stats opacity-40" aria-label="@Localizer["Counts"]"></i>
@@ -1012,7 +1012,9 @@
static readonly string[] QuickReactions = ["👍", "❤️", "😂", "🎉", "🔥", "😮", "😢"];
bool isReactionsOpen;
// what PrivaPub takes for a reaction already on the post: a custom emoji's (it has a picture) as `:shortcode:`, so that
// another server's is found among the post's; a unicode one as it is
static string ReactionOf(EmojiReaction reaction) => reaction.Url is { Length: > 0 } ? $":{reaction.Name}:" : reaction.Name;
async Task React(string emoji, bool add)
{
@@ -1387,6 +1389,7 @@
"poll" => Localizer["A poll you took part in has ended"],
"update" => Localizer["{0} edited a post", name],
"quote" => Localizer["{0} quoted your post", name],
"pleroma:emoji_reaction" when notification.EmojiUrl is { Length: > 0 } => Localizer["{0} reacted with", name],
"pleroma:emoji_reaction" => Localizer["{0} reacted {1}", name, notification.Emoji],
"moderation_warning" => Localizer["This server's staff warned you"],
_ => Localizer["{0}: {1}", name, notification.Type]
+4 -2
View File
@@ -8,8 +8,8 @@
which also scrolls a menu cut off by its scroller into view; Escape or an outside click gives it back to the trigger.
SheetOnNarrow makes the content a sheet rising from the bottom of a phone's screen (below md), over a dimmed page. *@
<div @attributes="AdditionalAttributes" class="@CssClass" @onkeydown="OnKeyDown">
<div @ref="trigger" role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true" aria-expanded="@(IsOpen ? "true" : "false")"
@onclick="Toggle">
<div @attributes="TriggerAttributes" @ref="trigger" role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true"
aria-expanded="@(IsOpen ? "true" : "false")" @onclick="Toggle">
@Trigger
</div>
@if (IsOpen)
@@ -35,6 +35,8 @@
/// <summary>Close after any click inside the content, e.g. on a menu item.</summary>
[Parameter] public bool CloseOnContentClick { get; set; }
[Parameter] public string TriggerClass { get; set; }
/// <summary>Attributes of the trigger itself (an aria-label, a data- hook); the outer element takes the unmatched ones.</summary>
[Parameter] public IReadOnlyDictionary<string, object> TriggerAttributes { get; set; }
[Parameter] public string ContentClass { get; set; }
/// <summary>Below md, the content is a sheet at the bottom of the screen instead of hanging from the trigger.</summary>
[Parameter] public bool SheetOnNarrow { get; set; }
+186
View File
@@ -0,0 +1,186 @@
@inherits LocalizableComponentBase
@* A picker of emoji: the server's custom emoji its picker offers (EmojiService, by category) and common unicode ones
(Helpers/UnicodeEmojis.cs, by group), searched by shortcode, category or word. It is a DDropdown, a sheet on a phone,
whose trigger is the caller's ChildContent (data-emoji-picker-open); what it holds is rendered only while it is open
(data-emoji-picker), so a feed whose every card has one costs nothing until one opens, and the server's emoji are read
on the first opening. A choice (data-emoji-choice: the shortcode, or the character itself) is handed to OnPick as
`:shortcode:` for a custom emoji and as the character for a unicode one, and closes the picker. The composer writes it
into the words; a card and the announcements banner react with it, their quick reactions (Quick) first. *@
<DDropdown @attributes="AdditionalAttributes" IsOpen="isOpen" IsOpenChanged="Opened" Placement="Placement" Align="Align" SheetOnNarrow="true"
TriggerClass="@(isOpen ? ActiveTriggerClass : TriggerClass)" ContentClass="@ContentClass" TriggerAttributes="TriggerHooks" title="@TriggerLabel">
<Trigger>@ChildContent</Trigger>
<ChildContent>
@if (isOpen)
{
<div class="flex flex-col gap-2 w-full md:w-80" data-emoji-picker>
@* its form attribute names no form, so Enter in the search submits no form around the picker (the composer's) *@
<DInput TValue="string" Size="DSize.Sm" class="w-full" Value="query" ValueChanged="Search" ValueExpression="() => query" Immediate="true"
autocomplete="off" form="emoji-picker-search" placeholder="@Localizer["Search emoji"]" aria-label="@Localizer["Search emoji"]"
@onkeydown="PickFirst" data-emoji-picker-search>
<Prefix><i class="ion-md-search" aria-hidden="true"></i></Prefix>
</DInput>
<div class="flex flex-col gap-2 max-h-72 max-md:max-h-[50dvh] overflow-y-auto overscroll-contain pr-1">
@if (IsLoading)
{
<p class="flex items-center gap-2 text-xs opacity-70">
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />@Localizer["loading"]
</p>
}
@if (QuickShown.Count > 0)
{
<section class="flex flex-col gap-1" data-emoji-section="quick">
<h4 class="sticky top-0 z-[1] bg-base-100 py-1 text-xs font-semibold opacity-70">@Localizer["Often used"]</h4>
<div class="grid grid-cols-8 pointer-coarse:grid-cols-6 justify-items-center gap-1 pointer-coarse:gap-2">
@foreach (var emoji in QuickShown)
{
<DButton Size="DSize.Sm" Variant="DButtonVariant.Ghost" Shape="DButtonShape.Square" Disabled="IsDefaultDisabled || ChoicesDisabled"
OnClick="() => Pick(emoji)" data-emoji-choice="@emoji">
<span class="text-xl leading-none">@emoji</span>
</DButton>
}
</div>
</section>
}
@foreach (var group in CustomShown)
{
<section class="flex flex-col gap-1" data-emoji-section="@("custom:" + group.Key)">
<h4 class="sticky top-0 z-[1] bg-base-100 py-1 text-xs font-semibold opacity-70 truncate">@(group.Key is { Length: > 0 } category ? category : Localizer["This server's emoji"])</h4>
<div class="grid grid-cols-8 pointer-coarse:grid-cols-6 justify-items-center gap-1 pointer-coarse:gap-2">
@foreach (var emoji in group)
{
<DButton Size="DSize.Sm" Variant="DButtonVariant.Ghost" Shape="DButtonShape.Square" Disabled="IsDefaultDisabled || ChoicesDisabled"
OnClick="@(() => Pick($":{emoji.Shortcode}:"))" title="@($":{emoji.Shortcode}:")" aria-label="@($":{emoji.Shortcode}:")"
data-emoji-choice="@emoji.Shortcode">
@* alt="": the button is named by the shortcode. *@
<img alt="" loading="lazy" draggable="false" class="h-6 w-6 object-contain" src="@PictureOf(emoji)" />
</DButton>
}
</div>
</section>
}
@foreach (var group in UnicodeShown)
{
<section class="flex flex-col gap-1" data-emoji-section="@group.Key">
<h4 class="sticky top-0 z-[1] bg-base-100 py-1 text-xs font-semibold opacity-70">@Localizer[group.Name]</h4>
<div class="grid grid-cols-8 pointer-coarse:grid-cols-6 justify-items-center gap-1 pointer-coarse:gap-2">
@foreach (var emoji in group.Emojis)
{
<DButton Size="DSize.Sm" Variant="DButtonVariant.Ghost" Shape="DButtonShape.Square" Disabled="IsDefaultDisabled || ChoicesDisabled"
OnClick="() => Pick(emoji.Character)" title="@($":{emoji.Shortcode}:")" data-emoji-choice="@emoji.Character">
<span class="text-xl leading-none">@emoji.Character</span>
</DButton>
}
</div>
</section>
}
@if (!IsLoading && QuickShown.Count == 0 && CustomShown.Count == 0 && UnicodeShown.Count == 0)
{
<p class="text-sm opacity-70" data-emoji-picker-empty>@Localizer["No emoji matches."]</p>
}
</div>
</div>
}
</ChildContent>
</DDropdown>
@code {
[Inject] EmojiService EmojiService { get; set; }
/// <summary>The trigger's content: an icon, as a rule.</summary>
[Parameter] public RenderFragment ChildContent { get; set; }
/// <summary>`:shortcode:` for one of the server's custom emoji, the character itself for a unicode one.</summary>
[Parameter] public EventCallback<string> OnPick { get; set; }
/// <summary>Emoji shown first while nothing is searched (a card's quick reactions).</summary>
[Parameter] public IReadOnlyList<string> Quick { get; set; } = [];
/// <summary>The trigger's name, its title and aria-label.</summary>
[Parameter] public string TriggerLabel { get; set; }
[Parameter] public string TriggerClass { get; set; } = "btn btn-sm";
[Parameter] public string ActiveTriggerClass { get; set; } = "btn btn-sm btn-active";
[Parameter] public string ContentClass { get; set; } = "mt-2 p-2 rounded-box";
[Parameter] public DDropdownPlacement Placement { get; set; }
[Parameter] public DDropdownAlign Align { get; set; }
/// <summary>The choices can't be made now (the caller is busy with the last one).</summary>
[Parameter] public bool ChoicesDisabled { get; set; }
/// <summary>Passed to the dropdown: its class, a data- hook.</summary>
[Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object> AdditionalAttributes { get; set; }
bool isOpen;
string query;
List<CustomEmoji> custom = [];
IReadOnlyDictionary<string, object> TriggerHooks => new Dictionary<string, object>
{
["data-emoji-picker-open"] = true,
["aria-label"] = TriggerLabel
};
// what is searched: a shortcode may be typed with its colons
string Searched => query?.Trim().Trim(':') is { Length: > 0 } searched ? searched : null;
List<string> QuickShown => Searched is null ? [.. Quick] : [];
// the server's emoji by category, those with none first
List<IGrouping<string, CustomEmoji>> CustomShown =>
[
.. custom.Where(e => Searched is not { } searched || e.Shortcode.Contains(searched, StringComparison.OrdinalIgnoreCase)
|| e.Category?.Contains(searched, StringComparison.OrdinalIgnoreCase) == true)
.GroupBy(e => e.Category ?? string.Empty)
.OrderBy(g => g.Key.Length == 0 ? 0 : 1)
.ThenBy(g => g.Key, StringComparer.CurrentCultureIgnoreCase)
];
List<UnicodeEmojis.Group> UnicodeShown => Searched is not { } searched
? [.. UnicodeEmojis.Groups]
:
[
.. UnicodeEmojis.Groups
.Select(g => g with
{
Emojis = [.. g.Emojis.Where(e => e.Character == searched || e.Shortcode.Contains(searched, StringComparison.OrdinalIgnoreCase)
|| e.Words?.Contains(searched, StringComparison.OrdinalIgnoreCase) == true)]
})
.Where(g => g.Emojis.Count > 0)
];
// the still picture when the reader asked for less motion (an animated GIF moves)
string PictureOf(CustomEmoji emoji) => CascadingState.Status.PrefersReducedMotion() && emoji.StaticUrl is { Length: > 0 } still ? still : emoji.Url;
async Task Opened(bool open)
{
isOpen = open;
if (!open)
{
query = null;
return;
}
custom = await EmojiService.Custom();
IsLoading = false;
}
void Search(string searched) => query = searched;
// Enter takes the first emoji found
async Task PickFirst(KeyboardEventArgs e)
{
if (e.Key != "Enter" || Searched is null || IsDefaultDisabled || ChoicesDisabled)
return;
if (CustomShown.SelectMany(g => g).FirstOrDefault() is { } first)
{
await Pick($":{first.Shortcode}:");
return;
}
if (UnicodeShown.SelectMany(g => g.Emojis).FirstOrDefault() is { } unicode)
await Pick(unicode.Character);
}
// closed first: what the choice opens (the notice that reactions are public) is not under it
async Task Pick(string emoji)
{
isOpen = false;
query = null;
await OnPick.InvokeAsync(emoji);
}
}