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."]);
}
}