From 566b0e33ea10c70ade22d693c99cda88822fa74f Mon Sep 17 00:00:00 2001 From: thepra Date: Sun, 18 Oct 2026 08:24:43 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof --- CLAUDE.md | 57 +++- ClientJsonContext.cs | 3 + Components/Admin/EmojiPanel.razor | 338 +++++++++++++++++++++++ Components/AnnouncementsBanner.razor | 41 +-- Components/Composer.razor | 74 ++++- Components/Content.razor | 31 ++- Components/Daisy/Actions/DDropdown.razor | 6 +- Components/EmojiPicker.razor | 186 +++++++++++++ Helpers/UnicodeEmojis.cs | 119 ++++++++ MastodonJsonContext.cs | 2 + Models/APIs.cs | 5 + Models/CustomEmojiCache.cs | 14 + Models/FeedPost.cs | 1 + Models/Mastodon/Account.cs | 4 + Models/Mastodon/Announcement.cs | 6 +- Models/Mastodon/Notification.cs | 4 + Pages/Administration.razor | 17 +- Program.cs | 1 + Resources/AllStrings.it.resx | 117 ++++++++ Resources/AllStrings.resx | 117 ++++++++ Services/AnnouncementService.cs | 6 +- Services/AppStatusService.cs | 7 + Services/EmojiService.cs | 229 +++++++++++++++ Services/TimelineService.cs | 9 +- Shared/NavMenu.razor | 27 +- wwwroot/main.js | 20 ++ 26 files changed, 1385 insertions(+), 56 deletions(-) create mode 100644 Components/Admin/EmojiPanel.razor create mode 100644 Components/EmojiPicker.razor create mode 100644 Helpers/UnicodeEmojis.cs create mode 100644 Models/CustomEmojiCache.cs create mode 100644 Services/EmojiService.cs diff --git a/CLAUDE.md b/CLAUDE.md index da65e20..e1b21f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -319,7 +319,8 @@ Never redeclare the base's members in a component. ban, two-factor reset and deletion, and a persona's or a group's deletion with the administrator's password on `/clientapi/admin`), `AboutService` (what the server says of itself to anyone: the instance API, its documents and published domain blocks, never with credentials), `AnnouncementService` (`/api/v1/announcements`: dismissing and - reacting), `MediaService` (uploads, and deleting those no post holds), `GroupService`, `InstanceService` and + reacting), `EmojiService` (the server's custom emoji: `/api/v1/custom_emojis` for everyone, cached, and + `/clientapi/admin/emoji` for administrators; see **Custom emoji**), `MediaService` (uploads, and deleting those no post holds), `GroupService`, `InstanceService` and `ProvenanceService`. - **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction, poll vote or downvote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in @@ -328,7 +329,8 @@ Never redeclare the base's members in a component. shows who takes part. An event card shows the persona's participation (`privapub.event.participation`: pending, accepted, rejected) with Join or Leave, except for events by invitation or taken on another site (`join_mode`). - **What a card shows of other platforms:** custom emoji (`Helpers/CustomEmojis.cs`: a post's or account's `:shortcode:` - in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag), a threadiverse + in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag; this server's own + come in `emojis` the same way, see **Custom emoji**), a threadiverse post's downvotes next to its favourites with the persona's own as a toggle (`data-downvote`, in More on a phone as `data-downvote-phone`; `StatusAction.Downvote`/`Undownvote`, PrivaPub's `/statuses/:id/downvote|undownvote`, answered with the post, its `privapub.votes.downvoted` set and its favourite dropped). PrivaPub sends votes on every post, so @@ -407,7 +409,8 @@ Never redeclare the base's members in a component. (`media_attributes`). Its visibility and poll stay. - A new post's media are sensitive when the persona's "always mark media as sensitive" (`source.sensitive`) says so. A mention or hashtag being written at the end of the words is offered what PrivaPub already knows - (`/api/v2/search`, never resolving), once typing pauses. + (`/api/v2/search`, never resolving), once typing pauses; a custom emoji (`:` and two letters at least) is offered the + server's own at once, and the emoji button opens the picker (see **Custom emoji**). An edited post's card says so, and its versions open from there (`/statuses/:id/history`, newest first; PrivaPub keeps a remote post's earlier versions from its edits and from Pleroma's `formerRepresentations`). - **Threads** (`Pages/Thread.razor` at `/expand/{id}`): opening a remote thread makes PrivaPub read its replies @@ -455,7 +458,7 @@ Never redeclare the base's members in a component. Blocked servers (`blocks`), Relays (`relays`), Media limits (`media`), Statistics (`statistics`) and Backups (`backups`). Moderators also see Accounts (`accounts`), administrators Users & personas (`users`). PrivaPub checks the role on every call (moderators: `/clientapi/moderator`, administrators: `/clientapi/admin`, checked against its database - too). Custom emoji come with R7: until then they have no section. + too). Administrators also see Custom emoji (`emoji`, after Announcements; see **Custom emoji**). - **Users & personas** (`UsersPanel`, `data-users`, `/clientapi/admin/roots`; the one place a login is shown with its personas): newest first, 50 at a time (`data-users-more`), found by a login's or a persona's name (`data-users-search`; `data-users-reload`). Each login (`data-user="{login name}"`, `data-user-role` user|moderator|admin, @@ -526,6 +529,48 @@ Never redeclare the base's members in a component. - **A post from the feed:** a moderator removes another's post from its card's menu (`data-moderate-remove-post`, `ActDialog`, `/clientapi/moderator/posts/remove`): one written here is deleted as its author would, and its author warned; a copy of one from elsewhere is hidden here. The page takes the card out. +- **Custom emoji** (PrivaPub's R7: the server's own emoji, `Services/EmojiService.cs`). + - **For everyone:** `GET /api/v1/custom_emojis` (no credentials; PrivaPub lists only those neither disabled nor kept out + of its picker) is read once a day and kept in localStorage (`CustomEmojiCache`, registered in `ClientJsonContext`); + a failed read keeps what was held and asks again five minutes later. An administrator's change in this client reads it + again at once (`EmojiService.Refresh`). An emoji out of the picker is still taken by PrivaPub when its `:shortcode:` is + written, but the client knows only the listed ones. + - **Rendering:** a local post, profile and announcement carry the emoji they name in `emojis` like a remote one, so + `CustomEmojis` shows them (the card, Profile, the announcements banner, NavMenu's persona name; NavMenu's other + personas, which `/clientapi` sends without emoji, take the server's list when the menu opens). A located post + (`/clientapi/post/nearby`, no `emojis`) takes the server's emoji its words name (`EmojiService.NamedIn`). A reaction + with a custom emoji shows its picture (Pleroma's `url`; an announcement's `url`, or the server's list for one the + stream tells of first) and is toggled as `:shortcode:` (`Content.ReactionOf`), which PrivaPub matches among the + post's own emoji for another server's; a notification of one says "reacted with" and its picture + (`emoji_url`, `data-notification-emoji`). + - **The picker** (`Components/EmojiPicker.razor`): a `DDropdown SheetOnNarrow` whose trigger is the caller's + (`data-emoji-picker-open`, set through `DDropdown.TriggerAttributes`), holding, only while open (`data-emoji-picker`), + a search (`input[data-emoji-picker-search]`, its `form` naming no form; Enter takes the first found), the caller's + quick choices (`Quick`), the server's emoji by category (none first) and common unicode ones by group + (`Helpers/UnicodeEmojis.cs`, single emoji only, searched by their Mastodon/GitHub shortcode and a few words). Each + choice is `data-emoji-choice` (the shortcode, or the character) and goes to `OnPick` as `:shortcode:` or the + character; it closes the picker first. A GIF shows its still picture under `prefers-reduced-motion`. The card's React + button (`data-react`) and the announcements banner's (`data-announcement-react`) react with it, after their quick + reactions; the composer's emoji button writes it where the caret is, replacing a selection, else at the end, a + shortcode with a space on each side where a letter would touch it (`AppStatusService.CaretOf` / `PlaceCaret`, + `main.js`'s `caretOf` and `placeCaret`). + - **Suggestions:** in the composer, `:` and two letters or more at the end of the words offer the matching emoji + (shortcodes starting with it first, eight at most) among the mention and hashtag suggestions, as + `data-emoji-suggestion="{shortcode}"`; one picked is written `:shortcode: `. + - **Administration** (`Components/Admin/EmojiPanel.razor`, `data-emoji-panel`, `data-admin-section="emoji"`; + `/clientapi/admin/emoji/list|insert|update|delete`): every emoji, disabled ones too, grouped by category + (`data-emoji-group="{category}"`, empty for none): each `data-emoji="{shortcode}"` with `data-emoji-state` + picker|hidden|disabled, its picture, size and badges, `data-emoji-edit` and `data-emoji-delete` (asked first: the + posts and profiles using it show `:shortcode:` as text). `data-emoji-new` and `data-emoji-edit` open + `data-emoji-dialog`: `input[data-emoji-shortcode]` (read-only when editing: a shortcode is the emoji's id elsewhere and + never changes), `input[data-emoji-category]` (a datalist of the categories in use), `data-emoji-picture` (a file input: + PNG, GIF, WebP or JPEG, 256 KB at most, checked here first; `data-emoji-preview` current|picked), the toggles + `data-emoji-picker` (in the picker) and `data-emoji-disabled`, `data-emoji-save`, `data-emoji-cancel`. The form is + `LocalEmojiForm` (validated by its annotations) sent as multipart with the picture streamed from the file as `image`, + the whole state on an update, the picture only when a new one is picked. PrivaPub's refusals show in the dialog, + their status on `data-emoji-error` (409 a shortcode taken in any case, 422 a picture refused, 400 an invalid form). + `data-emoji-reload` reads the list again. Note that `data-emoji-picker` names both the open picker (a `div`) and the + dialog's toggle (an `input`). - **Statistics** (`Components/Admin/StatisticsPanel.razor`, `/clientapi/admin/statistics`, typed in ClientModels' `Admin/ViewStatistics.cs`): the period's overview, the servers (by traffic, failures, latency or name), the crawler, the ledger's events and the server's own days. Counted per server, never per account. @@ -643,8 +688,8 @@ Never redeclare the base's members in a component. D fields' parse errors (`DInputBase`, `DCalendar`) through the static `ErrorMessages.InvalidValue` / `InvalidDate`. A new key needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime. -**Client storage** is localStorage (Blazored.LocalStorage, camelCase JSON): `AuthData`, `PublicCacheData`, `FeedFilter` -and the server cache `ServerInfos`, keyed by `nameof(Type)`. There is no IndexedDB: `ILoggingService` writes to the +**Client storage** is localStorage (Blazored.LocalStorage, camelCase JSON): `AuthData`, `PublicCacheData`, `FeedFilter`, +the server cache `ServerInfos` and the server's custom emoji `CustomEmojiCache`, keyed by `nameof(Type)`. There is no IndexedDB: `ILoggingService` writes to the browser console only, and `main.js` deletes the database (`data`) where the client once kept its log, wherever a browser still holds it. diff --git a/ClientJsonContext.cs b/ClientJsonContext.cs index b045bd6..48cec2d 100644 --- a/ClientJsonContext.cs +++ b/ClientJsonContext.cs @@ -26,6 +26,7 @@ namespace decePubClient; [JsonSerializable(typeof(PublicActionNotices))] [JsonSerializable(typeof(ServerInfo))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(CustomEmojiCache))] [JsonSerializable(typeof(UploadMedia))] [JsonSerializable(typeof(ValidationErrors))] [JsonSerializable(typeof(WebResult))] @@ -111,6 +112,8 @@ namespace decePubClient; [JsonSerializable(typeof(AdminPasswordForm))] [JsonSerializable(typeof(DeletePersonaForm))] [JsonSerializable(typeof(DeleteGroupForm))] +[JsonSerializable(typeof(ViewLocalEmoji))] +[JsonSerializable(typeof(List))] public partial class ClientJsonContext : JsonSerializerContext { } diff --git a/Components/Admin/EmojiPanel.razor b/Components/Admin/EmojiPanel.razor new file mode 100644 index 0000000..4797e2b --- /dev/null +++ b/Components/Admin/EmojiPanel.razor @@ -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. *@ +
+
+ + @Localizer["Add an emoji"] + + + @Localizer["Read again"] + +
+

@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."]

+ + @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else if (emojis.Count == 0) + { +

@Localizer["No custom emoji yet."]

+ } + + @foreach (var group in Groups) + { +
+

@(group.Key is { Length: > 0 } category ? category : Localizer["No category"])

+
    + @foreach (var emoji in group) + { +
  • + @($ + + @($":{emoji.Shortcode}:") + + @emoji.Size.GetFileSize(Localizer) ยท @Localizer["Changed {0} ago", emoji.UpdatedAt.GetPassedTime(Localizer)] + + + + @if (emoji.ContentType == "image/gif") + { + @Localizer["Animated"] + } + @if (emoji.Disabled) + { + @Localizer["Disabled"] + } + else if (!emoji.VisibleInPicker) + { + @Localizer["Not in the picker"] + } + + + + @Localizer["Edit"] + + + @Localizer["Delete"] + + +
  • + } +
+
+ } +
+ +@if (isEditing) +{ + + @(editedEmoji is null ? Localizer["New emoji"] : Localizer["Edit :{0}:", editedEmoji.Shortcode]) + + + + + +
+ @Localizer["Picture"] +
+ @if ((preview ?? editedEmoji?.Url) is { Length: > 0 } shown) + { + @Localizer[ + } + +
+ @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)] + @if (editedEmoji is not null) + { + @Localizer["Without a new one, it keeps its picture."] + } +
+
+ + @Localizer["Offered in the picker of every client. Out of it, it is still taken when its shortcode is written."] +
+
+ + @Localizer["Kept, but offered nowhere, taken into no new post and shown in none."] +
+
+ +
+
+ @Localizer["Cancel"] + + @if (isSaving) + { + + } + @Localizer["Save"] + +
+
+
+
+} + +@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 emojis = []; + HashSet 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 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> 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 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 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."]); + } +} diff --git a/Components/AnnouncementsBanner.razor b/Components/AnnouncementsBanner.razor index 5f8d269..50f47dc 100644 --- a/Components/AnnouncementsBanner.razor +++ b/Components/AnnouncementsBanner.razor @@ -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 @@ {

@When(current)

} -
@((MarkupString)current.Content)
+
@((MarkupString)CustomEmojis.Html(current.Content, current.Emojis))
@foreach (var reaction in current.Reactions) { + title="@NameOf(reaction)" aria-pressed="@(reaction.Me == true ? "true" : "false")" data-announcement-reaction="@reaction.Name"> @if (reaction.Url is { Length: > 0 } image) { - @reaction.Name + @NameOf(reaction) } else { @@ -56,17 +57,10 @@ @reaction.Count } - - - - @foreach (var emoji in QuickReactions) - { - @emoji - } - - + + +
} @@ -76,13 +70,16 @@ [Inject] AnnouncementService AnnouncementService { get; set; } [Inject] StreamingService Streaming { get; set; } + [Inject] EmojiService EmojiService { get; set; } List 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 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; diff --git a/Components/Composer.razor b/Components/Composer.razor index 72c6b68..f29efc8 100644 --- a/Components/Composer.razor +++ b/Components/Composer.razor @@ -26,9 +26,17 @@ @* under the words, not over their last line *@ @ContentLength/@ComposeForm.MaxContentLength - @if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0) + @if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0 || suggestedEmojis.Count > 0) {
+ @foreach (var emoji in suggestedEmojis) + { + + @* alt="": the shortcode is next to it. *@ + + @($":{emoji.Shortcode}:") + + } @foreach (var account in suggestedAccounts) { @@ -200,6 +208,11 @@ + @* the server's custom emoji and common unicode ones, written where the caret is *@ + + + + @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; } /// The status this answers; the reply starts with its visibility (never wider) and content warning. [Parameter] public Status InReplyTo { get; set; } @@ -291,8 +306,11 @@ List 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 suggestedAccounts = []; List suggestedTags = []; + List 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 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(); } diff --git a/Components/Content.razor b/Components/Content.razor index f59c784..5c15ec6 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -53,6 +53,11 @@ } @NotificationText(notification) + @if (notification.Type == "pleroma:emoji_reaction" && notification.EmojiUrl is { Length: > 0 } reactionPicture) + { + @($ + } @notification.CreatedAt.GetPassedTime(Localizer) @if (!Compact) { @@ -220,10 +225,10 @@ @foreach (var reaction in status.EmojiReactions) { + OnClick="() => React(ReactionOf(reaction), !reaction.Me)" title="@ReactionOf(reaction)" data-reaction="@reaction.Name"> @if (reaction.Url is { Length: > 0 } image) { - @reaction.Name + @ReactionOf(reaction) } else { @@ -333,17 +338,12 @@ OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" title="@Localizer["Bookmark"]"> - - - - @foreach (var emoji in QuickReactions) - { - @emoji - } - - + @* the quick reactions, then the server's custom emoji and the rest; opens downwards, like the menu *@ + + +