Files
decePubClient/Components/EmojiPicker.razor
T
thepraandClaude Opus 5.5 697b2c8897
Build / Build (push) Successful in 33s
Animations, GIFs and the globe now move for every reader
decePub no longer looks at the system's "reduce motion" setting, so everything moves the same way whatever it says:
- a GIF video in a post plays by itself, muted and looping, instead of waiting for a tap; it now also starts on a page
  just opened, before the first tap, where the browser used to hold it still;
- animated custom emoji move in the emoji picker instead of showing a still picture;
- the globe turns by itself while no located post is in view, flies to the posts, and its pulses, arcs and Sun move;
- going back to the top of a feed scrolls smoothly;
- tooltips, dropdowns, menus, folding sections, toasts, loading spinners, placeholders, progress bars, radio buttons
  and ratings keep their animations: the build now takes daisyUI's reduce-motion rules out of the stylesheet
  (tools/css/motion.py, run after every Tailwind build, in watch mode too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4YtEKqxKjKyHkFzuRVMNA
2026-11-09 20:48:47 +01:00

184 lines
8.3 KiB
Plaintext

@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="@emoji.Url" />
</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)
];
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);
}
}