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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user