186 lines
8.6 KiB
Plaintext
186 lines
8.6 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="@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);
|
||
|
|
}
|
||
|
|
}
|