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
339 lines
13 KiB
Plaintext
339 lines
13 KiB
Plaintext
@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."]);
|
|
}
|
|
}
|