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,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."]);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
@* The administrators' announcements shown now (/api/v1/announcements) that the persona has not dismissed, one at a time
|
||||
above the feed's tabs: their words (sanitized HTML from the administrator's Markdown), when they run, and the
|
||||
reactions personas left, which the persona adds and takes back with a unicode emoji. Dismissed, one is gone for this
|
||||
reactions personas left, which the persona adds and takes back with a unicode emoji or one of the server's custom ones
|
||||
(EmojiPicker; its text shows the custom emoji it names). Dismissed, one is gone for this
|
||||
persona. New, changed and removed ones, and the reactions' counts, come through the user stream
|
||||
(StreamingService.HearAnnouncements); the banner renders itself and never touches the feed's cards. *@
|
||||
@if (Current is { } current)
|
||||
@@ -39,15 +40,15 @@
|
||||
{
|
||||
<p class="text-xs opacity-70" data-announcement-when>@When(current)</p>
|
||||
}
|
||||
<div class="post-content doc-content text-sm break-words max-h-28 md:max-h-40 overflow-y-auto">@((MarkupString)current.Content)</div>
|
||||
<div class="post-content doc-content text-sm break-words max-h-28 md:max-h-40 overflow-y-auto">@((MarkupString)CustomEmojis.Html(current.Content, current.Emojis))</div>
|
||||
<div class="flex flex-wrap items-center gap-1 pointer-coarse:gap-2" data-announcement-reactions>
|
||||
@foreach (var reaction in current.Reactions)
|
||||
{
|
||||
<DButton Size="DSize.Xs" Active="reaction.Me == true" Disabled="IsDefaultDisabled || isActing" OnClick="() => React(reaction.Name, reaction.Me != true)"
|
||||
title="@reaction.Name" aria-pressed="@(reaction.Me == true ? "true" : "false")" data-announcement-reaction="@reaction.Name">
|
||||
title="@NameOf(reaction)" aria-pressed="@(reaction.Me == true ? "true" : "false")" data-announcement-reaction="@reaction.Name">
|
||||
@if (reaction.Url is { Length: > 0 } image)
|
||||
{
|
||||
<img alt="@reaction.Name" class="h-4 w-4 object-contain" src="@image" />
|
||||
<img alt="@NameOf(reaction)" class="h-4 w-4 object-contain" src="@image" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -56,17 +57,10 @@
|
||||
<span class="tabular-nums">@reaction.Count</span>
|
||||
</DButton>
|
||||
}
|
||||
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Bottom" CloseOnContentClick="true" SheetOnNarrow="true"
|
||||
TriggerClass="@(isReactionsOpen ? "btn btn-xs btn-active" : "btn btn-xs")"
|
||||
ContentClass="mt-2 flex gap-1 p-2 rounded-box max-md:grid max-md:grid-cols-4 max-md:gap-2" title="@Localizer["React"]" data-announcement-react>
|
||||
<Trigger><i aria-hidden="true" class="ion-md-happy"></i></Trigger>
|
||||
<ChildContent>
|
||||
@foreach (var emoji in QuickReactions)
|
||||
{
|
||||
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => React(emoji, true)" data-announcement-react-emoji="@emoji">@emoji</DButton>
|
||||
}
|
||||
</ChildContent>
|
||||
</DDropdown>
|
||||
<EmojiPicker Quick="QuickReactions" OnPick="emoji => React(emoji, true)" ChoicesDisabled="isActing" TriggerLabel="@Localizer["React"]"
|
||||
TriggerClass="btn btn-xs" ActiveTriggerClass="btn btn-xs btn-active" data-announcement-react>
|
||||
<i aria-hidden="true" class="ion-md-happy"></i>
|
||||
</EmojiPicker>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -76,13 +70,16 @@
|
||||
|
||||
[Inject] AnnouncementService AnnouncementService { get; set; }
|
||||
[Inject] StreamingService Streaming { get; set; }
|
||||
[Inject] EmojiService EmojiService { get; set; }
|
||||
|
||||
List<Announcement> announcements = [];
|
||||
int index;
|
||||
bool isActing;
|
||||
bool isReactionsOpen;
|
||||
string loadedPersonaId;
|
||||
|
||||
// a custom emoji's reaction is named by its shortcode
|
||||
static string NameOf(AnnouncementReaction reaction) => reaction.Url is { Length: > 0 } ? $":{reaction.Name}:" : reaction.Name;
|
||||
|
||||
// what the persona has not dismissed, oldest first as the server lists them
|
||||
List<Announcement> Shown => [.. announcements.Where(a => a.Read != true)];
|
||||
|
||||
@@ -188,8 +185,10 @@
|
||||
reacted.Reactions = reaction.Count <= 0
|
||||
? [.. reacted.Reactions.Where(r => r.Name != reaction.Name)]
|
||||
: known is null
|
||||
? [.. reacted.Reactions, new AnnouncementReaction { Name = reaction.Name, Count = reaction.Count, Me = false }]
|
||||
: [.. reacted.Reactions.Select(r => r.Name == reaction.Name ? new AnnouncementReaction { Name = r.Name, Count = reaction.Count, Me = r.Me, Url = r.Url } : r)];
|
||||
? [.. reacted.Reactions, NewReaction(reaction)]
|
||||
: [.. reacted.Reactions.Select(r => r.Name == reaction.Name
|
||||
? new AnnouncementReaction { Name = r.Name, Count = reaction.Count, Me = r.Me, Url = r.Url, StaticUrl = r.StaticUrl }
|
||||
: r)];
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
@@ -198,6 +197,12 @@
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
// one the stream tells of first: the event names it only, so a custom emoji's pictures come from the server's emoji read
|
||||
// already (none while they aren't, until the list is read again)
|
||||
AnnouncementReaction NewReaction(AnnouncementReactionEvent reaction) => EmojiService.Known(reaction.Name) is { } custom
|
||||
? new AnnouncementReaction { Name = reaction.Name, Count = reaction.Count, Me = false, Url = custom.Url, StaticUrl = custom.StaticUrl }
|
||||
: new AnnouncementReaction { Name = reaction.Name, Count = reaction.Count, Me = false };
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
Streaming.Announced -= OnAnnounced;
|
||||
|
||||
@@ -26,9 +26,17 @@
|
||||
@* under the words, not over their last line *@
|
||||
<span class="self-end text-xs opacity-50 tabular-nums">@ContentLength/@ComposeForm.MaxContentLength</span>
|
||||
</div>
|
||||
@if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0)
|
||||
@if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0 || suggestedEmojis.Count > 0)
|
||||
{
|
||||
<div class="flex flex-wrap gap-2" data-suggestions>
|
||||
@foreach (var emoji in suggestedEmojis)
|
||||
{
|
||||
<DButton Size="DSize.Xs" class="max-w-full" KeepFocus="true" OnClick="@(() => Complete($":{emoji.Shortcode}:"))" data-emoji-suggestion="@emoji.Shortcode">
|
||||
@* alt="": the shortcode is next to it. *@
|
||||
<img alt="" class="h-4 w-4 flex-none object-contain" src="@emoji.Url" />
|
||||
<span class="truncate">@($":{emoji.Shortcode}:")</span>
|
||||
</DButton>
|
||||
}
|
||||
@foreach (var account in suggestedAccounts)
|
||||
{
|
||||
<DButton Size="DSize.Xs" class="max-w-full" KeepFocus="true" OnClick="@(() => Complete("@" + account.Acct))" data-suggestion="@("@" + account.Acct)">
|
||||
@@ -200,6 +208,11 @@
|
||||
<i class="ion-md-warning text-base"></i>
|
||||
</DButton>
|
||||
|
||||
@* the server's custom emoji and common unicode ones, written where the caret is *@
|
||||
<EmojiPicker OnPick="InsertEmoji" TriggerLabel="@Localizer["Insert an emoji"]">
|
||||
<i class="ion-md-happy text-base" aria-hidden="true"></i>
|
||||
</EmojiPicker>
|
||||
|
||||
@if (!form.IsLocated && GroupId is null && !form.HasPoll)
|
||||
{
|
||||
@* A label styled as a button opens the hidden file input. *@
|
||||
@@ -275,6 +288,8 @@
|
||||
[Inject] AccountService AccountService { get; set; }
|
||||
[Inject] ILoggingService Logger { get; set; }
|
||||
[Inject] IJSRuntime JS { get; set; }
|
||||
[Inject] EmojiService EmojiService { get; set; }
|
||||
[Inject] AppStatusService AppStatus { get; set; }
|
||||
|
||||
/// <summary>The status this answers; the reply starts with its visibility (never wider) and content warning.</summary>
|
||||
[Parameter] public Status InReplyTo { get; set; }
|
||||
@@ -291,8 +306,11 @@
|
||||
List<string> fileErrors = [];
|
||||
// what the words end with while a mention or a hashtag is being written: its sign and what follows it
|
||||
static readonly Regex Typing = new(@"(?:^|\s)([@#])([\p{L}\p{N}_][\p{L}\p{N}_.\-]*(?:@[\p{L}\p{N}.\-]*)?)$", RegexOptions.Compiled);
|
||||
// and while one of the server's custom emoji is: a colon and two letters at least
|
||||
static readonly Regex TypingEmoji = new(@"(?:^|\s)(:)([A-Za-z0-9_]{2,64})$", RegexOptions.Compiled);
|
||||
List<Account> suggestedAccounts = [];
|
||||
List<StatusTag> suggestedTags = [];
|
||||
List<CustomEmoji> suggestedEmojis = [];
|
||||
// the newest lookup: an older one's answer comes too late to show
|
||||
int suggesting;
|
||||
string error;
|
||||
@@ -300,11 +318,24 @@
|
||||
|
||||
int ContentLength => form.Content?.Length ?? 0;
|
||||
|
||||
// what is being written, once typing pauses, among the accounts and hashtags this server knows
|
||||
// what is being written, once typing pauses, among the accounts and hashtags this server knows; a custom emoji at
|
||||
// once, among the server's (read once a day)
|
||||
async Task Typed()
|
||||
{
|
||||
var typed = Typing.Match(form.Content ?? "");
|
||||
var lookup = ++suggesting;
|
||||
if (TypingEmoji.Match(form.Content ?? "") is { Success: true } emoji)
|
||||
{
|
||||
suggestedAccounts = [];
|
||||
suggestedTags = [];
|
||||
var matching = await EmojiService.Matching(emoji.Groups[2].Value, 8);
|
||||
if (lookup != suggesting)
|
||||
return;
|
||||
suggestedEmojis = matching;
|
||||
return;
|
||||
}
|
||||
|
||||
suggestedEmojis = [];
|
||||
var typed = Typing.Match(form.Content ?? "");
|
||||
if (!typed.Success || typed.Groups[2].Length < 2)
|
||||
{
|
||||
suggestedAccounts = [];
|
||||
@@ -322,19 +353,42 @@
|
||||
suggestedTags = hashtags ? [.. found.Hashtags.Take(5)] : [];
|
||||
}
|
||||
|
||||
// the mention or hashtag being written, written out
|
||||
// the mention, hashtag or custom emoji being written, written out
|
||||
void Complete(string written)
|
||||
{
|
||||
var typed = Typing.Match(form.Content ?? "");
|
||||
var typed = TypingEmoji.Match(form.Content ?? "") is { Success: true } emoji ? emoji : Typing.Match(form.Content ?? "");
|
||||
suggesting++;
|
||||
suggestedAccounts = [];
|
||||
suggestedTags = [];
|
||||
suggestedEmojis = [];
|
||||
if (!typed.Success)
|
||||
return;
|
||||
form.Content = form.Content[..typed.Groups[1].Index] + written + " ";
|
||||
focusPending = true;
|
||||
}
|
||||
|
||||
// an emoji from the picker, written where the caret is (in place of what is selected), else at the end. A shortcode
|
||||
// stands apart from the words around it, as PrivaPub reads `:shortcode:` only with no letter or digit next to it.
|
||||
void InsertEmoji(string emoji)
|
||||
{
|
||||
var content = form.Content ?? string.Empty;
|
||||
var (start, end) = AppStatus.CaretOf(textarea.Element) is [var from, var to] && from >= 0 && from <= to && to <= content.Length
|
||||
? (from, to)
|
||||
: (content.Length, content.Length);
|
||||
var before = content[..start];
|
||||
var after = content[end..];
|
||||
var isShortcode = emoji.StartsWith(':');
|
||||
var lead = isShortcode && before.Length > 0 && !char.IsWhiteSpace(before[^1]) ? " " : null;
|
||||
var trail = isShortcode && (after.Length == 0 || !char.IsWhiteSpace(after[0])) ? " " : null;
|
||||
form.Content = before + lead + emoji + trail + after;
|
||||
caretAt = before.Length + (lead?.Length ?? 0) + emoji.Length + (trail?.Length ?? 0);
|
||||
suggesting++;
|
||||
suggestedAccounts = [];
|
||||
suggestedTags = [];
|
||||
suggestedEmojis = [];
|
||||
focusPending = true;
|
||||
}
|
||||
|
||||
string Placeholder => Editing is not null ? Localizer["Edit your post"]
|
||||
: Quoting is not null ? Localizer["Say something about it"]
|
||||
: InReplyTo is null ? Localizer["What's happening?"]
|
||||
@@ -400,14 +454,22 @@
|
||||
};
|
||||
|
||||
DTextarea<string> textarea;
|
||||
// the words take the focus when the composer opens, and again after a suggestion is picked
|
||||
// the words take the focus when the composer opens, and again after a suggestion or an emoji is picked
|
||||
bool focusPending = true;
|
||||
// where the caret goes then: after the emoji just written
|
||||
int? caretAt;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!focusPending)
|
||||
return;
|
||||
focusPending = false;
|
||||
if (caretAt is { } at)
|
||||
{
|
||||
caretAt = null;
|
||||
AppStatus.PlaceCaret(textarea.Element, at);
|
||||
return;
|
||||
}
|
||||
await textarea.Element.FocusAsync();
|
||||
}
|
||||
|
||||
|
||||
+17
-14
@@ -53,6 +53,11 @@
|
||||
</span>
|
||||
}
|
||||
<span>@NotificationText(notification)</span>
|
||||
@if (notification.Type == "pleroma:emoji_reaction" && notification.EmojiUrl is { Length: > 0 } reactionPicture)
|
||||
{
|
||||
<img alt="@($":{notification.Emoji}:")" title="@($":{notification.Emoji}:")" class="h-5 w-5 object-contain" src="@reactionPicture"
|
||||
data-notification-emoji="@notification.Emoji" />
|
||||
}
|
||||
<span class="opacity-60" title="@NerdRow.Time(notification.CreatedAt)">@notification.CreatedAt.GetPassedTime(Localizer)</span>
|
||||
@if (!Compact)
|
||||
{
|
||||
@@ -220,10 +225,10 @@
|
||||
@foreach (var reaction in status.EmojiReactions)
|
||||
{
|
||||
<DButton Size="DSize.Xs" Active="reaction.Me" Disabled="@(IsDefaultDisabled || isActing || Compact || Post.Located)"
|
||||
OnClick="() => React(reaction.Name, !reaction.Me)" title="@reaction.Name">
|
||||
OnClick="() => React(ReactionOf(reaction), !reaction.Me)" title="@ReactionOf(reaction)" data-reaction="@reaction.Name">
|
||||
@if (reaction.Url is { Length: > 0 } image)
|
||||
{
|
||||
<img alt="@reaction.Name" class="h-4 w-4 object-contain" src="@image" />
|
||||
<img alt="@ReactionOf(reaction)" class="h-4 w-4 object-contain" src="@image" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -333,17 +338,12 @@
|
||||
OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" title="@Localizer["Bookmark"]">
|
||||
<i aria-hidden="true" class="ion-md-bookmark text-lg @(status.Bookmarked ? "text-warning" : null)"></i>
|
||||
</DButton>
|
||||
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Top" CloseOnContentClick="true" SheetOnNarrow="true" class="max-md:w-full"
|
||||
TriggerClass="@(isReactionsOpen ? "btn btn-sm btn-active max-md:w-full" : "btn btn-sm max-md:w-full")"
|
||||
ContentClass="mb-2 flex gap-1 p-2 rounded-box max-md:grid max-md:grid-cols-4 max-md:gap-2" title="@Localizer["React"]">
|
||||
<Trigger><i aria-hidden="true" class="ion-md-happy text-lg"></i></Trigger>
|
||||
<ChildContent>
|
||||
@foreach (var emoji in QuickReactions)
|
||||
{
|
||||
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => React(emoji, true)">@emoji</DButton>
|
||||
}
|
||||
</ChildContent>
|
||||
</DDropdown>
|
||||
@* the quick reactions, then the server's custom emoji and the rest; opens downwards, like the menu *@
|
||||
<EmojiPicker Quick="QuickReactions" OnPick="emoji => React(emoji, true)" ChoicesDisabled="isActing" TriggerLabel="@Localizer["React"]"
|
||||
class="max-md:w-full" TriggerClass="btn btn-sm max-md:w-full" ActiveTriggerClass="btn btn-sm btn-active max-md:w-full"
|
||||
data-react>
|
||||
<i aria-hidden="true" class="ion-md-happy text-lg"></i>
|
||||
</EmojiPicker>
|
||||
<span class="hidden md:contents">
|
||||
<NerdTip Heading="@Localizer["Counts"]" Rows="CountRows(status)" Class="self-center">
|
||||
<i class="ion-md-stats opacity-40" aria-label="@Localizer["Counts"]"></i>
|
||||
@@ -1012,7 +1012,9 @@
|
||||
|
||||
static readonly string[] QuickReactions = ["👍", "❤️", "😂", "🎉", "🔥", "😮", "😢"];
|
||||
|
||||
bool isReactionsOpen;
|
||||
// what PrivaPub takes for a reaction already on the post: a custom emoji's (it has a picture) as `:shortcode:`, so that
|
||||
// another server's is found among the post's; a unicode one as it is
|
||||
static string ReactionOf(EmojiReaction reaction) => reaction.Url is { Length: > 0 } ? $":{reaction.Name}:" : reaction.Name;
|
||||
|
||||
async Task React(string emoji, bool add)
|
||||
{
|
||||
@@ -1387,6 +1389,7 @@
|
||||
"poll" => Localizer["A poll you took part in has ended"],
|
||||
"update" => Localizer["{0} edited a post", name],
|
||||
"quote" => Localizer["{0} quoted your post", name],
|
||||
"pleroma:emoji_reaction" when notification.EmojiUrl is { Length: > 0 } => Localizer["{0} reacted with", name],
|
||||
"pleroma:emoji_reaction" => Localizer["{0} reacted {1}", name, notification.Emoji],
|
||||
"moderation_warning" => Localizer["This server's staff warned you"],
|
||||
_ => Localizer["{0}: {1}", name, notification.Type]
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
which also scrolls a menu cut off by its scroller into view; Escape or an outside click gives it back to the trigger.
|
||||
SheetOnNarrow makes the content a sheet rising from the bottom of a phone's screen (below md), over a dimmed page. *@
|
||||
<div @attributes="AdditionalAttributes" class="@CssClass" @onkeydown="OnKeyDown">
|
||||
<div @ref="trigger" role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true" aria-expanded="@(IsOpen ? "true" : "false")"
|
||||
@onclick="Toggle">
|
||||
<div @attributes="TriggerAttributes" @ref="trigger" role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true"
|
||||
aria-expanded="@(IsOpen ? "true" : "false")" @onclick="Toggle">
|
||||
@Trigger
|
||||
</div>
|
||||
@if (IsOpen)
|
||||
@@ -35,6 +35,8 @@
|
||||
/// <summary>Close after any click inside the content, e.g. on a menu item.</summary>
|
||||
[Parameter] public bool CloseOnContentClick { get; set; }
|
||||
[Parameter] public string TriggerClass { get; set; }
|
||||
/// <summary>Attributes of the trigger itself (an aria-label, a data- hook); the outer element takes the unmatched ones.</summary>
|
||||
[Parameter] public IReadOnlyDictionary<string, object> TriggerAttributes { get; set; }
|
||||
[Parameter] public string ContentClass { get; set; }
|
||||
/// <summary>Below md, the content is a sheet at the bottom of the screen instead of hanging from the trigger.</summary>
|
||||
[Parameter] public bool SheetOnNarrow { get; set; }
|
||||
|
||||
@@ -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