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:
thepraandClaude Opus 5.5 committed 2026-10-18 08:24:43 +02:00
1 parent c572471b88
commit 566b0e33ea
26 files changed
+1385 -56

No files matched your search

+51 -6
View File
@@ -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.
+3
View File
@@ -26,6 +26,7 @@ namespace decePubClient;
[JsonSerializable(typeof(PublicActionNotices))]
[JsonSerializable(typeof(ServerInfo))]
[JsonSerializable(typeof(List<ServerInfo>))]
[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<ViewLocalEmoji>))]
public partial class ClientJsonContext : JsonSerializerContext
{
}
+338
View File
@@ -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."]);
}
}
+23 -18
View File
@@ -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;
+68 -6
View File
@@ -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
View File
@@ -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]
+4 -2
View File
@@ -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; }
+186
View File
@@ -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);
}
}
+119
View File
@@ -0,0 +1,119 @@
namespace decePubClient.Helpers
{
/// <summary>
/// The unicode emoji the picker offers (Components/EmojiPicker.razor) next to the server's custom ones: common ones by
/// group, not the whole of Unicode, each a single emoji with no skin tone or joined sequence, as PrivaPub takes a
/// reaction. Each has the shortcode Mastodon's and GitHub's pickers know it by, and a few words, which the search
/// matches (codes, never shown as text); a group's Name is a localization key.
/// </summary>
public static class UnicodeEmojis
{
public sealed record Emoji(string Character, string Shortcode, string Words = null);
public sealed record Group(string Key, string Name, IReadOnlyList<Emoji> Emojis);
public static readonly IReadOnlyList<Group> Groups =
[
new("smileys", "Smileys",
[
new("😀", "grinning", "smile happy"), new("😃", "smiley", "happy"), new("😄", "smile", "happy"), new("😁", "grin", "teeth"),
new("😆", "laughing", "lol"), new("😅", "sweat_smile", "relief"), new("🤣", "rofl", "laugh floor"), new("😂", "joy", "tears laugh lol"),
new("🙂", "slightly_smiling_face", "smile"), new("🙃", "upside_down_face", "silly"), new("😉", "wink"), new("😊", "blush", "smile"),
new("😇", "innocent", "halo angel"), new("🥰", "smiling_face_with_three_hearts", "love"), new("😍", "heart_eyes", "love"),
new("🤩", "star_struck", "wow"), new("😘", "kissing_heart", "kiss"), new("😋", "yum", "tasty"), new("😛", "stuck_out_tongue", "tongue"),
new("😜", "stuck_out_tongue_winking_eye", "tongue wink"), new("🤪", "zany_face", "crazy"), new("🤔", "thinking", "hmm"),
new("🤗", "hugs", "hug"), new("🤭", "hand_over_mouth", "oops"), new("🤫", "shushing_face", "quiet"), new("😐", "neutral_face", "meh"),
new("😑", "expressionless"), new("😶", "no_mouth", "silent"), new("😏", "smirk"), new("😒", "unamused"),
new("🙄", "roll_eyes", "eyeroll"), new("😬", "grimacing", "awkward"), new("😌", "relieved"), new("😔", "pensive", "sad"),
new("😪", "sleepy", "tired"), new("😴", "sleeping", "zzz"), new("😷", "mask", "sick"), new("🤒", "face_with_thermometer", "ill"),
new("🤢", "nauseated_face", "sick"), new("🥵", "hot_face", "heat"), new("🥶", "cold_face", "freezing"), new("🥴", "woozy_face", "dizzy"),
new("🤯", "exploding_head", "mind blown"), new("🥳", "partying_face", "party"), new("😎", "sunglasses", "cool"),
new("🤓", "nerd_face", "geek"), new("😕", "confused"), new("😟", "worried"), new("😮", "open_mouth", "wow surprised"),
new("😲", "astonished", "shocked"), new("😳", "flushed", "embarrassed"), new("🥺", "pleading_face", "please"), new("😢", "cry", "sad tear"),
new("😭", "sob", "crying"), new("😱", "scream", "fear"), new("😤", "triumph", "huff"), new("😠", "angry", "mad"), new("😡", "rage", "angry"),
new("🤬", "cursing_face", "swear"), new("💀", "skull", "dead"), new("💩", "poop", "poo"), new("🤡", "clown_face", "clown"),
new("👻", "ghost", "boo"), new("👽", "alien", "ufo"), new("🤖", "robot", "bot")
]),
new("people", "People",
[
new("👍", "thumbsup", "+1 like yes"), new("👎", "thumbsdown", "-1 dislike no"), new("👌", "ok_hand", "okay"), new("✌️", "v", "peace victory"),
new("🤞", "crossed_fingers", "luck"), new("🤟", "love_you_gesture"), new("🤘", "metal", "rock"), new("👋", "wave", "hello bye"),
new("👏", "clap", "applause"), new("🙌", "raised_hands", "hooray"), new("👐", "open_hands"), new("🤝", "handshake", "deal"),
new("🙏", "pray", "thanks please"), new("✍️", "writing_hand", "write"), new("💪", "muscle", "strong"), new("👀", "eyes", "look"),
new("🧠", "brain", "smart"), new("👶", "baby"), new("🧑", "adult", "person"), new("🤷", "shrug", "dunno"),
new("🤦", "facepalm"), new("🙋", "raising_hand", "me"), new("🙇", "bow", "sorry"), new("💃", "dancer", "dance"),
new("🕺", "man_dancing", "dance")
]),
new("nature", "Animals and nature",
[
new("🐶", "dog", "puppy"), new("🐱", "cat", "kitten"), new("🐭", "mouse"), new("🐰", "rabbit", "bunny"), new("🦊", "fox_face", "fox"),
new("🐻", "bear"), new("🐼", "panda_face", "panda"), new("🐨", "koala"), new("🐯", "tiger"), new("🦁", "lion"),
new("🐮", "cow"), new("🐷", "pig"), new("🐸", "frog"), new("🐵", "monkey_face", "monkey"), new("🐔", "chicken"),
new("🐧", "penguin"), new("🐦", "bird"), new("🦆", "duck"), new("🦉", "owl"), new("🦇", "bat"), new("🐺", "wolf"),
new("🐴", "horse"), new("🦄", "unicorn"), new("🐝", "bee"), new("🐛", "bug"), new("🦋", "butterfly"),
new("🐌", "snail"), new("🐢", "turtle"), new("🐍", "snake"), new("🐙", "octopus"), new("🐟", "fish"),
new("🐬", "dolphin"), new("🐳", "whale"), new("🦀", "crab"), new("🌵", "cactus"), new("🌲", "evergreen_tree", "tree"),
new("🌳", "deciduous_tree", "tree"), new("🌴", "palm_tree", "tree"), new("🌱", "seedling", "plant"), new("🍀", "four_leaf_clover", "luck"),
new("🍁", "maple_leaf", "autumn"), new("🍄", "mushroom"), new("🌸", "cherry_blossom", "flower"), new("🌹", "rose", "flower"),
new("🌻", "sunflower", "flower"), new("🌷", "tulip", "flower"), new("🌍", "earth_africa", "world globe"), new("🌙", "crescent_moon", "moon night"),
new("☀️", "sunny", "sun"), new("🌈", "rainbow"), new("☁️", "cloud"), new("❄️", "snowflake", "snow winter"), new("⚡", "zap", "lightning"),
new("🌊", "ocean", "wave sea")
]),
new("food", "Food and drink",
[
new("🍎", "apple"), new("🍐", "pear"), new("🍊", "tangerine", "orange"), new("🍋", "lemon"), new("🍌", "banana"),
new("🍉", "watermelon"), new("🍇", "grapes"), new("🍓", "strawberry"), new("🍒", "cherries"), new("🍑", "peach"),
new("🥑", "avocado"), new("🍅", "tomato"), new("🌶️", "hot_pepper", "chili"), new("🥕", "carrot"), new("🌽", "corn"),
new("🥐", "croissant"), new("🍞", "bread"), new("🧀", "cheese"), new("🍕", "pizza"), new("🍔", "hamburger", "burger"),
new("🍟", "fries"), new("🌭", "hotdog"), new("🌮", "taco"), new("🍝", "spaghetti", "pasta"), new("🍜", "ramen", "noodles"),
new("🍣", "sushi"), new("🍦", "icecream"), new("🍩", "doughnut", "donut"), new("🍪", "cookie"), new("🎂", "birthday", "cake"),
new("🍰", "cake"), new("🍫", "chocolate_bar", "chocolate"), new("🍿", "popcorn"), new("☕", "coffee"), new("🍵", "tea"),
new("🍺", "beer"), new("🍻", "beers", "cheers"), new("🍷", "wine_glass", "wine"), new("🥂", "clinking_glasses", "cheers toast"),
new("🍸", "cocktail")
]),
new("activities", "Activities",
[
new("⚽", "soccer", "football"), new("🏀", "basketball"), new("🏈", "football"), new("⚾", "baseball"), new("🎾", "tennis"),
new("🏐", "volleyball"), new("🎱", "8ball", "pool"), new("🏓", "ping_pong"), new("🏆", "trophy", "win"),
new("🥇", "1st_place_medal", "gold"), new("🎮", "video_game", "game"), new("🎲", "game_die", "dice"), new("♟️", "chess_pawn", "chess"),
new("🎯", "dart", "target"), new("🎨", "art", "paint"), new("🎭", "performing_arts", "theatre"), new("🎬", "clapper", "film"),
new("🎤", "microphone", "sing"), new("🎧", "headphones", "music"), new("🎸", "guitar"), new("🎹", "musical_keyboard", "piano"),
new("🎵", "musical_note", "music"), new("🎉", "tada", "party hooray"), new("🎊", "confetti_ball", "party"), new("🎈", "balloon"),
new("🎁", "gift", "present"), new("🎄", "christmas_tree"), new("🎃", "jack_o_lantern", "halloween")
]),
new("travel", "Travel and places",
[
new("🚗", "car"), new("🚕", "taxi"), new("🚌", "bus"), new("🚲", "bike", "bicycle"), new("🛴", "kick_scooter", "scooter"),
new("🚂", "steam_locomotive", "train"), new("🚆", "train2", "train"), new("✈️", "airplane", "plane flight"), new("🚀", "rocket", "launch"),
new("🛸", "flying_saucer", "ufo"), new("⛵", "boat", "sail"), new("🚢", "ship"), new("🏠", "house", "home"),
new("🏡", "house_with_garden", "home"), new("🏢", "office", "building"), new("🏰", "european_castle", "castle"), new("⛪", "church"),
new("🗼", "tokyo_tower", "tower"), new("🗽", "statue_of_liberty"), new("🏔️", "mountain_snow", "mountain"),
new("🏖️", "beach_umbrella", "beach"), new("🏕️", "camping"), new("🌋", "volcano"), new("🗺️", "world_map", "map"),
new("🧭", "compass"), new("⛺", "tent", "camping"), new("🌃", "night_with_stars", "night city"), new("🌅", "sunrise")
]),
new("objects", "Objects",
[
new("📱", "iphone", "phone mobile"), new("💻", "computer", "laptop"), new("⌨️", "keyboard"), new("🖥️", "desktop_computer", "computer"),
new("💾", "floppy_disk", "save"), new("📷", "camera", "photo"), new("🎥", "movie_camera", "video"), new("📺", "tv", "television"),
new("📻", "radio"), new("⏰", "alarm_clock", "time"), new("⌛", "hourglass", "time"), new("💡", "bulb", "idea"),
new("🔦", "flashlight", "torch"), new("📚", "books", "read"), new("📖", "book", "read"), new("📝", "memo", "note write"),
new("✏️", "pencil2", "pencil write"), new("📌", "pushpin", "pin"), new("📎", "paperclip"), new("✂️", "scissors", "cut"),
new("🔒", "lock", "private"), new("🔑", "key"), new("🔨", "hammer", "tool"), new("🔧", "wrench", "tool"), new("⚙️", "gear", "settings"),
new("🧲", "magnet"), new("🧪", "test_tube", "science"), new("🔭", "telescope", "space"), new("💊", "pill", "medicine"),
new("🛒", "shopping_cart", "shop"), new("💰", "moneybag", "money"), new("💸", "money_with_wings", "money"), new("📦", "package", "box"),
new("✉️", "envelope", "mail letter"), new("📣", "mega", "announce"), new("🔔", "bell", "notification")
]),
new("symbols", "Symbols",
[
new("❤️", "heart", "love red"), new("🧡", "orange_heart", "love"), new("💛", "yellow_heart", "love"), new("💚", "green_heart", "love"),
new("💙", "blue_heart", "love"), new("💜", "purple_heart", "love"), new("🖤", "black_heart", "love"), new("🤍", "white_heart", "love"),
new("💔", "broken_heart", "sad"), new("💕", "two_hearts", "love"), new("💖", "sparkling_heart", "love"), new("💯", "100", "perfect"),
new("✨", "sparkles", "shiny"), new("⭐", "star"), new("🌟", "star2", "glowing star"), new("💫", "dizzy", "star"), new("🔥", "fire", "hot lit"),
new("💥", "boom", "explosion"), new("💢", "anger", "angry"), new("💬", "speech_balloon", "comment talk"), new("💤", "zzz", "sleep"),
new("✅", "white_check_mark", "done yes"), new("❌", "x", "no cross"), new("❓", "question"), new("❗", "exclamation"),
new("⚠️", "warning"), new("♻️", "recycle"), new("➕", "heavy_plus_sign", "plus"), new("➖", "heavy_minus_sign", "minus"),
new("🆗", "ok"), new("🆒", "cool"), new("🆕", "new")
])
];
}
}
+2
View File
@@ -68,6 +68,8 @@ namespace decePubClient;
[JsonSerializable(typeof(Announcement))]
[JsonSerializable(typeof(List<Announcement>))]
[JsonSerializable(typeof(AnnouncementReactionEvent))]
[JsonSerializable(typeof(CustomEmoji))]
[JsonSerializable(typeof(List<CustomEmoji>))]
public partial class MastodonJsonContext : JsonSerializerContext
{
}
+5
View File
@@ -78,6 +78,10 @@ public static class APIs
public const string GET_AdminRelays = "clientapi/admin/relays";
public const string POST_AdminRelayInsert = "clientapi/admin/relays/insert";
public static string POST_M_AdminRelayDelete(string address) => $"clientapi/admin/relays/delete?address={Uri.EscapeDataString(address)}";
public const string GET_AdminEmojis = "clientapi/admin/emoji/list";
public const string POST_AdminEmojiInsert = "clientapi/admin/emoji/insert";
public const string POST_AdminEmojiUpdate = "clientapi/admin/emoji/update";
public static string POST_M_AdminEmojiDelete(string shortcode) => $"clientapi/admin/emoji/delete?shortcode={Uri.EscapeDataString(shortcode)}";
public const string GET_AdminDomainBlocks = "clientapi/admin/domainblocks/list";
public const string POST_AdminDomainBlock = "clientapi/admin/domainblocks/insert";
public static string POST_M_AdminDomainUnblock(string domain) => $"clientapi/admin/domainblocks/delete?domain={Uri.EscapeDataString(domain)}";
@@ -145,6 +149,7 @@ public static class APIs
public const string GET_InstanceTermsOfService = "api/v1/instance/terms_of_service";
public const string GET_InstanceDomainBlocks = "api/v1/instance/domain_blocks";
public const string GET_Announcements = "api/v1/announcements";
public const string GET_CustomEmojis = "api/v1/custom_emojis";
public static string POST_M_DismissAnnouncement(string announcementId) => $"api/v1/announcements/{Uri.EscapeDataString(announcementId)}/dismiss";
public static string M_AnnouncementReaction(string announcementId, string emoji) =>
$"api/v1/announcements/{Uri.EscapeDataString(announcementId)}/reactions/{Uri.EscapeDataString(emoji)}";
+14
View File
@@ -0,0 +1,14 @@
using decePubClient.Models.Mastodon;
namespace decePubClient.Models;
/// <summary>
/// The server's custom emoji its picker offers (/api/v1/custom_emojis), as EmojiService last read them: kept in
/// localStorage under its type name and used for a day, then read again (at once after an administrator changes one in
/// this client).
/// </summary>
public class CustomEmojiCache
{
public DateTime FetchedAt { get; set; }
public List<CustomEmoji> Emojis { get; set; } = [];
}
+1
View File
@@ -72,6 +72,7 @@ public class FeedPost
Account = sample.FirstOrDefault(),
Status = status,
Emoji = group.Emoji,
EmojiUrl = group.EmojiUrl,
GroupKey = group.GroupKey,
ModerationWarning = group.ModerationWarning
},
+4
View File
@@ -73,11 +73,15 @@ public class AccountField
public DateTime? VerifiedAt { get; set; }
}
/// <summary>A custom emoji: one a post or an account names (its server's), or one of this server's own as
/// /api/v1/custom_emojis lists them for the picker, with its category.</summary>
public class CustomEmoji
{
public string Shortcode { get; set; }
public string Url { get; set; }
public string StaticUrl { get; set; }
public bool VisibleInPicker { get; set; }
public string Category { get; set; }
}
public class Relationship
+5 -1
View File
@@ -15,15 +15,19 @@ public class Announcement
/// <summary>Whether the persona dismissed it.</summary>
public bool? Read { get; set; }
public List<AnnouncementReaction> Reactions { get; set; } = [];
/// <summary>The server's custom emoji its text names.</summary>
public List<CustomEmoji> Emojis { get; set; } = [];
}
/// <summary>A unicode emoji chosen on an announcement, how many personas chose it, and whether this one did.</summary>
/// <summary>An emoji chosen on an announcement (a unicode one, or the shortcode of one of the server's custom emoji, which
/// has its pictures), how many personas chose it, and whether this one did.</summary>
public class AnnouncementReaction
{
public string Name { get; set; }
public int Count { get; set; }
public bool? Me { get; set; }
public string Url { get; set; }
public string StaticUrl { get; set; }
}
/// <summary>The streaming API's announcement.reaction payload: the emoji, how many chose it now, and on which.</summary>
+4
View File
@@ -11,7 +11,10 @@ public class Notification
public DateTime CreatedAt { get; set; }
public Account Account { get; set; }
public Status Status { get; set; }
/// <summary>A reaction's emoji: the emoji itself, or a custom emoji's shortcode.</summary>
public string Emoji { get; set; }
/// <summary>A custom emoji reaction's picture.</summary>
public string EmojiUrl { get; set; }
/// <summary>The group it belongs to (likes and boosts of one post, follows within an hour), or ungrouped-{id}.</summary>
public string GroupKey { get; set; }
/// <summary>A moderation_warning's: what this server's staff did and told the persona. Its Account is the persona
@@ -52,6 +55,7 @@ public class NotificationGroup
public List<string> SampleAccountIds { get; set; } = [];
public string StatusId { get; set; }
public string Emoji { get; set; }
public string EmojiUrl { get; set; }
/// <summary>A moderation_warning group's newest warning.</summary>
public AccountWarning ModerationWarning { get; set; }
}
+15 -2
View File
@@ -6,8 +6,8 @@
@* Moderators handle reports, moderate accounts here and elsewhere (Accounts) and read the staff's log. Administrators
also change the logins and their personas (Users & personas), say what the server is (Instance), set its rules, write
its announcements, block servers for everyone, choose its relays and media limits, read the federation statistics, and
back up and restore the server. PrivaPub checks the role on every call; the sections only follow it. Each section is
its announcements, add its custom emoji, block servers for everyone, choose its relays and media limits, read the
federation statistics, and back up and restore the server. PrivaPub checks the role on every call; the sections only follow it. Each section is
a collapse marked data-admin-section; its panel loads when the page opens. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
@@ -105,6 +105,19 @@
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="emoji">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-happy text-xl"></i> <span>@Localizer["Custom emoji"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<EmojiPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="blocks">
<Header>
<p class="inline-flex items-center gap-2">
+1
View File
@@ -48,6 +48,7 @@ builder.Services.AddOptions()
.AddScoped<ModerationService>()
.AddScoped<AboutService>()
.AddScoped<AnnouncementService>()
.AddScoped<EmojiService>()
.AddScoped<ArchiveService>()
.AddScoped<ListService>()
.AddScoped<FilterService>()
+117
View File
@@ -3482,4 +3482,121 @@
<data name="Delete for good" xml:space="preserve">
<value>Elimina per sempre</value>
</data>
<data name="Custom emoji" xml:space="preserve">
<value>Emoji personalizzate</value>
</data>
<data name="Add an emoji" xml:space="preserve">
<value>Aggiungi un'emoji</value>
</data>
<data name="No custom emoji yet." xml:space="preserve">
<value>Ancora nessuna emoji personalizzata.</value>
</data>
<data name="No category" xml:space="preserve">
<value>Nessuna categoria</value>
</data>
<data name="Shortcode" xml:space="preserve">
<value>Codice</value>
</data>
<data name="Category" xml:space="preserve">
<value>Categoria</value>
</data>
<data name="In the picker" xml:space="preserve">
<value>Nel selettore</value>
</data>
<data name="Disabled" xml:space="preserve">
<value>Disattivata</value>
</data>
<data name="Not in the picker" xml:space="preserve">
<value>Non nel selettore</value>
</data>
<data name="Animated" xml:space="preserve">
<value>Animata</value>
</data>
<data name="New emoji" xml:space="preserve">
<value>Nuova emoji</value>
</data>
<data name="Edit :{0}:" xml:space="preserve">
<value>Modifica :{0}:</value>
</data>
<data name="Delete :{0}:?" xml:space="preserve">
<value>Eliminare :{0}:?</value>
</data>
<data name="Emoji added." xml:space="preserve">
<value>Emoji aggiunta.</value>
</data>
<data name="Emoji saved." xml:space="preserve">
<value>Emoji salvata.</value>
</data>
<data name="Emoji deleted." xml:space="preserve">
<value>Emoji eliminata.</value>
</data>
<data name="Choose a picture." xml:space="preserve">
<value>Scegli un'immagine.</value>
</data>
<data name="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." xml:space="preserve">
<value>Chiunque qui può usarle nei post, nei nomi e nelle reazioni, e gli altri server le mostrano. Il selettore offre quelle che vi sono; scritta come :codice:, un'emoji funziona comunque, a meno che sia disattivata.</value>
</data>
<data name="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." xml:space="preserve">
<value>Non viene più offerta, e i post e i profili che la usano mostrano al suo posto il testo :{0}:. Gli altri server ne tengono la copia finché non rileggono quei post.</value>
</data>
<data name="Letters, digits and underscores, 2 to 64 of them. It never changes: other servers know the emoji by it." xml:space="preserve">
<value>Lettere, cifre e trattini bassi, da 2 a 64. Non cambia mai: gli altri server conoscono l'emoji con questo codice.</value>
</data>
<data name="A shortcode never changes: other servers know the emoji by it." xml:space="preserve">
<value>Il codice non cambia mai: gli altri server conoscono l'emoji con esso.</value>
</data>
<data name="The picker groups the emoji by it." xml:space="preserve">
<value>Il selettore raggruppa le emoji per categoria.</value>
</data>
<data name="PNG, GIF, WebP or JPEG, {0} at most. The server fits it within 256 px and keeps a GIF's animation." xml:space="preserve">
<value>PNG, GIF, WebP o JPEG, al massimo {0}. Il server la adatta entro 256 px e mantiene l'animazione di una GIF.</value>
</data>
<data name="Without a new one, it keeps its picture." xml:space="preserve">
<value>Senza una nuova, mantiene la sua immagine.</value>
</data>
<data name="Offered in the picker of every client. Out of it, it is still taken when its shortcode is written." xml:space="preserve">
<value>Offerta nel selettore di ogni client. Fuori da esso, viene comunque riconosciuta quando se ne scrive il codice.</value>
</data>
<data name="Kept, but offered nowhere, taken into no new post and shown in none." xml:space="preserve">
<value>Conservata, ma non offerta da nessuna parte, non usata nei nuovi post e mostrata in nessuno.</value>
</data>
<data name="Search emoji" xml:space="preserve">
<value>Cerca emoji</value>
</data>
<data name="No emoji matches." xml:space="preserve">
<value>Nessuna emoji corrisponde.</value>
</data>
<data name="Often used" xml:space="preserve">
<value>Usate spesso</value>
</data>
<data name="This server's emoji" xml:space="preserve">
<value>Emoji di questo server</value>
</data>
<data name="Smileys" xml:space="preserve">
<value>Faccine</value>
</data>
<data name="Animals and nature" xml:space="preserve">
<value>Animali e natura</value>
</data>
<data name="Food and drink" xml:space="preserve">
<value>Cibo e bevande</value>
</data>
<data name="Activities" xml:space="preserve">
<value>Attività</value>
</data>
<data name="Travel and places" xml:space="preserve">
<value>Viaggi e luoghi</value>
</data>
<data name="Objects" xml:space="preserve">
<value>Oggetti</value>
</data>
<data name="Symbols" xml:space="preserve">
<value>Simboli</value>
</data>
<data name="Insert an emoji" xml:space="preserve">
<value>Inserisci un'emoji</value>
</data>
<data name="{0} reacted with" xml:space="preserve">
<value>{0} ha reagito con</value>
</data>
</root>
+117
View File
@@ -3482,4 +3482,121 @@
<data name="Delete for good" xml:space="preserve">
<value>Delete for good</value>
</data>
<data name="Custom emoji" xml:space="preserve">
<value>Custom emoji</value>
</data>
<data name="Add an emoji" xml:space="preserve">
<value>Add an emoji</value>
</data>
<data name="No custom emoji yet." xml:space="preserve">
<value>No custom emoji yet.</value>
</data>
<data name="No category" xml:space="preserve">
<value>No category</value>
</data>
<data name="Shortcode" xml:space="preserve">
<value>Shortcode</value>
</data>
<data name="Category" xml:space="preserve">
<value>Category</value>
</data>
<data name="In the picker" xml:space="preserve">
<value>In the picker</value>
</data>
<data name="Disabled" xml:space="preserve">
<value>Disabled</value>
</data>
<data name="Not in the picker" xml:space="preserve">
<value>Not in the picker</value>
</data>
<data name="Animated" xml:space="preserve">
<value>Animated</value>
</data>
<data name="New emoji" xml:space="preserve">
<value>New emoji</value>
</data>
<data name="Edit :{0}:" xml:space="preserve">
<value>Edit :{0}:</value>
</data>
<data name="Delete :{0}:?" xml:space="preserve">
<value>Delete :{0}:?</value>
</data>
<data name="Emoji added." xml:space="preserve">
<value>Emoji added.</value>
</data>
<data name="Emoji saved." xml:space="preserve">
<value>Emoji saved.</value>
</data>
<data name="Emoji deleted." xml:space="preserve">
<value>Emoji deleted.</value>
</data>
<data name="Choose a picture." xml:space="preserve">
<value>Choose a picture.</value>
</data>
<data name="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." xml:space="preserve">
<value>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.</value>
</data>
<data name="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." xml:space="preserve">
<value>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.</value>
</data>
<data name="Letters, digits and underscores, 2 to 64 of them. It never changes: other servers know the emoji by it." xml:space="preserve">
<value>Letters, digits and underscores, 2 to 64 of them. It never changes: other servers know the emoji by it.</value>
</data>
<data name="A shortcode never changes: other servers know the emoji by it." xml:space="preserve">
<value>A shortcode never changes: other servers know the emoji by it.</value>
</data>
<data name="The picker groups the emoji by it." xml:space="preserve">
<value>The picker groups the emoji by it.</value>
</data>
<data name="PNG, GIF, WebP or JPEG, {0} at most. The server fits it within 256 px and keeps a GIF's animation." xml:space="preserve">
<value>PNG, GIF, WebP or JPEG, {0} at most. The server fits it within 256 px and keeps a GIF's animation.</value>
</data>
<data name="Without a new one, it keeps its picture." xml:space="preserve">
<value>Without a new one, it keeps its picture.</value>
</data>
<data name="Offered in the picker of every client. Out of it, it is still taken when its shortcode is written." xml:space="preserve">
<value>Offered in the picker of every client. Out of it, it is still taken when its shortcode is written.</value>
</data>
<data name="Kept, but offered nowhere, taken into no new post and shown in none." xml:space="preserve">
<value>Kept, but offered nowhere, taken into no new post and shown in none.</value>
</data>
<data name="Search emoji" xml:space="preserve">
<value>Search emoji</value>
</data>
<data name="No emoji matches." xml:space="preserve">
<value>No emoji matches.</value>
</data>
<data name="Often used" xml:space="preserve">
<value>Often used</value>
</data>
<data name="This server's emoji" xml:space="preserve">
<value>This server's emoji</value>
</data>
<data name="Smileys" xml:space="preserve">
<value>Smileys</value>
</data>
<data name="Animals and nature" xml:space="preserve">
<value>Animals and nature</value>
</data>
<data name="Food and drink" xml:space="preserve">
<value>Food and drink</value>
</data>
<data name="Activities" xml:space="preserve">
<value>Activities</value>
</data>
<data name="Travel and places" xml:space="preserve">
<value>Travel and places</value>
</data>
<data name="Objects" xml:space="preserve">
<value>Objects</value>
</data>
<data name="Symbols" xml:space="preserve">
<value>Symbols</value>
</data>
<data name="Insert an emoji" xml:space="preserve">
<value>Insert an emoji</value>
</data>
<data name="{0} reacted with" xml:space="preserve">
<value>{0} reacted with</value>
</data>
</root>
+4 -2
View File
@@ -10,7 +10,8 @@ namespace decePubClient.Services
{
/// <summary>
/// The administrators' announcements as the current persona reads them (/api/v1/announcements): the ones shown now,
/// dismissed one by one, and reacted to with a unicode emoji (eight different ones at most on each). Dismissals and
/// dismissed one by one, and reacted to with a unicode emoji or one of the server's custom emoji (eight different ones at
/// most on each). Dismissals and
/// reactions are the persona's own and never leave PrivaPub; the stream tells of new, changed and removed ones
/// (StreamingService.Announced). The administrators write them through InstanceAdminService.
/// </summary>
@@ -35,7 +36,8 @@ namespace decePubClient.Services
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Adds the persona's reaction, or takes it back; PrivaPub refuses anything but one unicode emoji (422).</summary>
/// <summary>Adds the persona's reaction, or takes it back: one unicode emoji, or one of the server's custom emoji by its
/// shortcode (with its colons or without); PrivaPub refuses anything else (422).</summary>
public async Task<WebResult> React(string announcementId, string emoji, bool add, CancellationToken cancellationToken = default)
{
var route = APIs.Mastodon.M_AnnouncementReaction(announcementId, emoji);
+7
View File
@@ -42,4 +42,11 @@ public class AppStatusService(IJSRuntime jsRuntime)
// a sideways-scrolling strip of tabs shows its selected tab
public void RevealActive(ElementReference strip) => JsSyncRuntime.InvokeVoid("revealActive", strip);
// where a text field's selection starts and ends (the caret, when nothing is selected), in UTF-16 units as C# counts
// them; null when it can't say
public int[] CaretOf(ElementReference field) => JsSyncRuntime.Invoke<int[]>("caretOf", field);
// the field takes the focus with its caret there
public void PlaceCaret(ElementReference field, int at) => JsSyncRuntime.InvokeVoid("placeCaret", field, at);
}
+229
View File
@@ -0,0 +1,229 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.RegularExpressions;
using Blazored.LocalStorage;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using Microsoft.AspNetCore.Components.Forms;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
namespace decePubClient.Services
{
/// <summary>
/// The server's own custom emoji (PrivaPub's completion program R7). For everyone: the ones its picker offers
/// (/api/v1/custom_emojis, read with no credentials; neither disabled nor kept out of the picker), kept in memory and in
/// localStorage for a day (CustomEmojiCache), and read again at once after an administrator changes one in this client.
/// The picker, the composer's `:co` suggestions and located posts read them. For administrators
/// (/clientapi/admin/emoji, PrivaPub's EmojiAdminController): every emoji, disabled ones too, added and changed with its
/// picture streamed from the browser's file as multipart `image`, and deleted. Lists come back empty on failure;
/// nothing throws.
/// </summary>
public partial class EmojiService(IHttpService httpService, ILocalStorageService localStorage, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>The largest picture PrivaPub takes for an emoji (its MediaService.EmojiMaxUploadBytes).</summary>
public const long MaxPictureBytes = 256 * 1024;
/// <summary>The kinds of picture PrivaPub takes for an emoji; it keeps a PNG, or an animated GIF.</summary>
public static readonly string[] PictureTypes = ["image/png", "image/gif", "image/webp", "image/jpeg"];
static readonly Dictionary<string, string> Extensions = new(StringComparer.OrdinalIgnoreCase)
{
[".png"] = "image/png", [".gif"] = "image/gif", [".webp"] = "image/webp", [".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg"
};
static readonly TimeSpan Lifetime = TimeSpan.FromDays(1);
// after a failed read, what was held (or nothing) is used this long before the server is asked again
static readonly TimeSpan RetryAfter = TimeSpan.FromMinutes(5);
const string StorageKey = nameof(CustomEmojiCache);
// `:shortcode:` with no letter or digit right before or after it, as PrivaPub and Mastodon read texts
[GeneratedRegex(@"(?<![a-zA-Z0-9]):([a-zA-Z0-9_]{2,64}):(?![a-zA-Z0-9])")]
private static partial Regex Named();
CustomEmojiCache cache;
bool isStoredRead;
Task<List<CustomEmoji>> reading;
/// <summary>What a picked file is: the browser's word, else its extension's; null when neither says.</summary>
public static string TypeOf(IBrowserFile file) => file.ContentType is { Length: > 0 } type
? type.ToLowerInvariant()
: Extensions.GetValueOrDefault(Path.GetExtension(file.Name) ?? string.Empty);
/// <summary>The emoji the server's picker offers, by shortcode: kept a day, and read once however many ask at a time.</summary>
public async Task<List<CustomEmoji>> Custom(CancellationToken cancellationToken = default)
{
if (IsFresh(cache))
return cache.Emojis;
reading ??= Read(cancellationToken);
var emojis = await reading;
reading = null;
return emojis;
}
/// <summary>One of them by its exact shortcode, from what was read already; null when it isn't known.</summary>
public CustomEmoji Known(string shortcode) => cache?.Emojis.Find(e => e.Shortcode == shortcode);
/// <summary>The ones the texts name as `:shortcode:`, for this server's own words that come without their emoji
/// (a located post).</summary>
public async Task<List<CustomEmoji>> NamedIn(IEnumerable<string> texts, CancellationToken cancellationToken = default)
{
var codes = texts.Where(t => !string.IsNullOrEmpty(t)).SelectMany(t => Named().Matches(t).Select(m => m.Groups[1].Value)).ToHashSet(StringComparer.Ordinal);
if (codes.Count == 0)
return [];
return [.. (await Custom(cancellationToken)).Where(e => codes.Contains(e.Shortcode))];
}
/// <summary>The ones whose shortcode holds what is typed, those starting with it first, `count` at most.</summary>
public async Task<List<CustomEmoji>> Matching(string typed, int count, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(typed))
return [];
return [.. (await Custom(cancellationToken))
.Where(e => e.Shortcode.Contains(typed, StringComparison.OrdinalIgnoreCase))
.OrderBy(e => e.Shortcode.StartsWith(typed, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(e => e.Shortcode.Length)
.Take(count)];
}
/// <summary>Reads them again now, after this client changed one.</summary>
public async Task Refresh(CancellationToken cancellationToken = default)
{
isStoredRead = true;
cache = null;
await Custom(cancellationToken);
}
static bool IsFresh(CustomEmojiCache held) => held is not null && DateTime.UtcNow - held.FetchedAt < Lifetime;
async Task<List<CustomEmoji>> Read(CancellationToken cancellationToken)
{
if (!isStoredRead)
{
isStoredRead = true;
cache = await Stored(cancellationToken);
if (IsFresh(cache))
return cache.Emojis;
}
using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_CustomEmojis, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
if (result.StatusCode is not (int)HttpStatusCode.Gone)
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(EmojiService)}.{nameof(Custom)}", cancellationToken);
// what was held (a day old or more) stays in use a while, so a server out of reach isn't asked at every keystroke
cache = new CustomEmojiCache { FetchedAt = DateTime.UtcNow - Lifetime + RetryAfter, Emojis = cache?.Emojis ?? [] };
return cache.Emojis;
}
var emojis = await response.MastodonReadFromJsonAsync<List<CustomEmoji>>(cancellationToken) ?? [];
cache = new CustomEmojiCache
{
FetchedAt = DateTime.UtcNow,
Emojis = [.. emojis.Where(e => e.Shortcode is { Length: > 0 } && e.Url is { Length: > 0 }).OrderBy(e => e.Shortcode, StringComparer.Ordinal)]
};
await Store(cache, cancellationToken);
return cache.Emojis;
}
async Task<CustomEmojiCache> Stored(CancellationToken cancellationToken)
{
try
{
return await localStorage.GetItemAsync<CustomEmojiCache>(StorageKey, cancellationToken);
}
catch (Exception ex)
{
await logger.ProcessWarning(ex.Message, $"{nameof(EmojiService)}.{nameof(Stored)}", cancellationToken);
return null;
}
}
async Task Store(CustomEmojiCache read, CancellationToken cancellationToken)
{
try
{
await localStorage.SetItemAsync(StorageKey, read, cancellationToken);
}
catch (Exception ex)
{
await logger.ProcessWarning(ex.Message, $"{nameof(EmojiService)}.{nameof(Store)}", cancellationToken);
}
}
/// <summary>Every emoji of the server, disabled ones too (deleted ones never), by shortcode.</summary>
public async Task<List<ViewLocalEmoji>> List(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_AdminEmojis, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<List<ViewLocalEmoji>>(cancellationToken) ?? [];
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.Forbidden))
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(EmojiService)}.{nameof(List)}", cancellationToken);
return [];
}
/// <summary>Adds an emoji with its picture; Data is the ViewLocalEmoji. PrivaPub answers 409 for a shortcode taken
/// (whatever its case), 422 for a picture it refuses (over 256 KB, of another kind, unreadable, or one it can't make
/// small enough) and 400 for a form that isn't valid.</summary>
public Task<WebResult> Insert(LocalEmojiForm form, IBrowserFile picture, CancellationToken cancellationToken = default) =>
Save(APIs.ClientApi.POST_AdminEmojiInsert, form, picture, cancellationToken);
/// <summary>Makes the emoji with the form's shortcode what the form says (its category, the picker, disabled or not),
/// with a new picture when one is given; an emoji is never renamed. Data is the ViewLocalEmoji.</summary>
public Task<WebResult> Update(LocalEmojiForm form, IBrowserFile picture, CancellationToken cancellationToken = default) =>
Save(APIs.ClientApi.POST_AdminEmojiUpdate, form, picture, cancellationToken);
/// <summary>Deletes an emoji: offered nowhere, and the posts and profiles that use it show its shortcode as text.</summary>
public async Task<WebResult> Delete(string shortcode, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminEmojiDelete(shortcode), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
await Refresh(cancellationToken);
return new WebResult();
}
async Task<WebResult> Save(string route, LocalEmojiForm form, IBrowserFile picture, CancellationToken cancellationToken)
{
using var response = await httpService.Post(route, (Func<HttpContent>)(() => Multipart(form, picture, cancellationToken)),
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var saved = await response.DefaultReadFromJsonAsync<ViewLocalEmoji>(cancellationToken);
await Refresh(cancellationToken);
return new WebResult { Data = saved };
}
// LocalEmojiForm as form fields, and the picture streamed from the file (never read into memory) as `image`
static MultipartFormDataContent Multipart(LocalEmojiForm form, IBrowserFile picture, CancellationToken cancellationToken)
{
var content = new MultipartFormDataContent
{
{ new StringContent(form.Shortcode ?? string.Empty), "shortcode" },
{ new StringContent(form.Category?.Trim() ?? string.Empty), "category" },
{ new StringContent(form.VisibleInPicker ? "true" : "false"), "visibleInPicker" },
{ new StringContent(form.Disabled ? "true" : "false"), "disabled" }
};
if (picture is null)
return content;
var image = new StreamContent(picture.OpenReadStream(MaxPictureBytes, cancellationToken));
image.Headers.ContentType = new MediaTypeHeaderValue(TypeOf(picture) ?? "application/octet-stream");
content.Add(image, "image", picture.Name);
return content;
}
}
}
+7 -2
View File
@@ -20,7 +20,7 @@ namespace decePubClient.Services
/// circle post reads as private there); located posts come from /clientapi/post/nearby with their authors read from
/// /api/v1/accounts, and have no next page. Results are WebResults with a FeedPage as Data; nothing throws.
/// </summary>
public class TimelineService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
public class TimelineService(IHttpService httpService, EmojiService emojiService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
public const int PageSize = 20;
/// <summary>The most ids /api/v1/statuses reads at once.</summary>
@@ -238,7 +238,8 @@ namespace decePubClient.Services
return new WebResult { Data = new FeedPage { Posts = [.. ids.Select(id => statuses.GetValueOrDefault(id)).Where(s => s is not null).Select(FeedPost.Of)] } };
}
/// <summary>Located posts within their range of the reader, nearest first, with their authors.</summary>
/// <summary>Located posts within their range of the reader, nearest first, with their authors. They are this server's,
/// and /clientapi sends no emoji with them: the server's custom emoji their words name are taken from its list.</summary>
async Task<WebResult> Nearby(FeedQuery query, CancellationToken cancellationToken)
{
using var response = await httpService.Get(APIs.ClientApi.GET_Nearby,
@@ -255,6 +256,9 @@ namespace decePubClient.Services
foreach (var authorId in posts.Select(p => p.AuthorAvatarId).Where(id => id is not null).Distinct())
if (await Account(authorId, cancellationToken) is { } account)
authors[authorId] = account;
var emojis = new Dictionary<string, List<CustomEmoji>>();
foreach (var post in posts)
emojis[post.Id] = await emojiService.NamedIn([post.ContentHtml, post.Title], cancellationToken);
return new WebResult
{
@@ -279,6 +283,7 @@ namespace decePubClient.Services
CreatedAt = post.CreationDate,
Sensitive = post.HasContentWarning,
Visibility = post.Visibility,
Emojis = emojis.GetValueOrDefault(post.Id) ?? [],
Privapub = post.Title is { Length: > 0 } ? new() { Title = post.Title } : null
}
})
+24 -3
View File
@@ -21,7 +21,7 @@
@if (CascadingState.IsSignedIn)
{
<DDropdown @bind-IsOpen="isPersonaMenuOpen" CloseOnContentClick="true" class="w-full rounded-xl bg-cover bg-no-repeat bg-right"
<DDropdown @bind-IsOpen="isPersonaMenuOpen" @bind-IsOpen:after="PersonaMenuToggled" CloseOnContentClick="true" class="w-full rounded-xl bg-cover bg-no-repeat bg-right"
TriggerClass="flex w-full gap-3 p-3 md:p-4 neo-raised neo-xs rounded-xl text-left cursor-pointer"
ContentClass="mt-2 flex flex-col gap-2 p-2 rounded-box w-full" title="@Localizer["Switch persona"]"
style="@PersonaBackground">
@@ -29,7 +29,14 @@
<img alt="" class="h-8 w-8 md:h-12 md:w-12 flex-none object-cover rounded-full neo-raised neo-2xs"
src="@(CascadingState.Persona?.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col w-full min-w-0 gap-1">
<b class="truncate" title="@CascadingState.Persona?.Name">@(CascadingState.Persona?.Name ?? Localizer["No persona"])</b>
@if (CascadingState.Persona is { } shown)
{
<b class="truncate" title="@shown.Name" data-nav-persona-name>@CustomEmojis.Text(shown.Name, shown.Emojis)</b>
}
else
{
<b class="truncate">@Localizer["No persona"]</b>
}
<span class="truncate text-xs opacity-70">@(CascadingState.Persona is { } persona ? "@" + persona.Acct : null)</span>
</span>
<i class="ion-md-swap self-center opacity-60"></i>
@@ -43,7 +50,7 @@
OnClick="() => SwitchPersona(persona.Id)" data-persona-switch="@persona.UserName"
data-persona-state="@(CascadingState.CanUse(persona) ? "normal" : "suspended")">
<img alt="" class="h-5 w-5 object-cover rounded-full" src="@(persona.PictureURL ?? "imgs/icon-192.png")" />
<span class="truncate">@(persona.Name ?? persona.UserName)</span>
<span class="truncate">@CustomEmojis.Text(persona.Name ?? persona.UserName, EmojisOf(persona))</span>
@if (!CascadingState.CanUse(persona))
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error" class="ml-auto">@Localizer["Suspended"]</DBadge>
@@ -154,6 +161,8 @@
[Inject] OverlayService Overlays { get; set; }
[Inject] EmojiService EmojiService { get; set; }
bool isMenuOpen;
IDisposable overlay;
@@ -174,6 +183,18 @@
}
}
bool isPersonaMenuOpen;
// the server's custom emoji, for the other personas' names (/clientapi sends none with them), read when the menu opens
List<CustomEmoji> serverEmojis = [];
// the current persona's are its account's; another's, the server's its name names
IReadOnlyCollection<CustomEmoji> EmojisOf(ViewAvatar persona) =>
persona.Id == CascadingState.PersonaId && CascadingState.Persona is { } current ? current.Emojis : serverEmojis;
async Task PersonaMenuToggled()
{
if (isPersonaMenuOpen && CascadingState.Personas.Count > 1)
serverEmojis = await EmojiService.Custom();
}
string PersonaBackground => CascadingState.Persona?.Header is { Length: > 0 } header && !header.Contains("/missing-")
? $"background-image:linear-gradient(to left, var(--color-base-100) 55%, transparent), url('{header}');"
+20
View File
@@ -131,6 +131,26 @@ window.copyText = async text => {
return false
}
}
// where a text field's selection starts and ends (the composer writes an emoji there); null when it can't say
window.caretOf = field => {
try {
return typeof field?.selectionStart == "number" ? [field.selectionStart, field.selectionEnd] : null
} catch (e) {
console.error(e)
window.logFromJs(e.message, "caretOf")
return null
}
}
// the field takes the focus with its caret at that offset (after the emoji just written)
window.placeCaret = (field, at) => {
try {
field?.focus()
field?.setSelectionRange(at, at)
} catch (e) {
console.error(e)
window.logFromJs(e.message, "placeCaret")
}
}
// a phone: narrower than Tailwind's md, or held sideways (as short as the short: variant)
window.isMobileMedia = () => {
try {