R7 custom emoji in decePub: the server's own, administered, picked, suggested and shown
Administration has a Custom emoji section (EmojiPanel, data-admin-section=emoji): the server's emoji by category, added with a picture streamed from the file, changed (category, picker, disabled, picture; never the shortcode) and deleted, asked first, with PrivaPub's 409 and 422 in the dialog. EmojiService reads /clientapi/admin/emoji and, for everyone, /api/v1/custom_emojis, kept a day in localStorage (CustomEmojiCache) and read again after a change here. EmojiPicker (a DDropdown sheet on a phone, rendered only while open) offers them by category with common unicode emoji, searchable: the composer writes the choice at the caret, a card and the announcements banner react with it. The composer suggests custom emoji after `:` and two letters. Custom reactions toggle as :shortcode: and show their picture, notifications of one too; located posts, announcements and NavMenu's persona names render the server's emoji. Verified: Release build, 0 errors (the 3 warnings were there before). Not run against PrivaPub or in the pasture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
c572471b88
commit
566b0e33ea
26 files changed
+1385
-56
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
@inherits LocalizableComponentBase
|
||||
@using System.Globalization
|
||||
|
||||
@* The server's own custom emoji, for administrators (/clientapi/admin/emoji, EmojiService), grouped by category: each
|
||||
with its picture, its :shortcode:, whether its picker offers it and whether it is disabled (kept, but offered and shown
|
||||
nowhere). One is added with a shortcode, a category and a picture streamed from the file (PNG, GIF, WebP or JPEG,
|
||||
256 KB at most), and changed in the same dialog (its category, the picker, disabled, a new picture; never its
|
||||
shortcode, its id on other servers), which shows PrivaPub's refusals: 409 for a shortcode taken, 422 for a picture it
|
||||
won't take. Deleting one is asked first: the posts and profiles using it show its shortcode as text. Every change
|
||||
reads the server's emoji for everyone again (EmojiService.Refresh), so this client's picker has it at once. *@
|
||||
<div class="flex flex-col gap-3 w-full" data-emoji-panel>
|
||||
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="IsDefaultDisabled" OnClick="New" data-emoji-new>
|
||||
<i class="ion-md-add" aria-hidden="true"></i><span>@Localizer["Add an emoji"]</span>
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Reload" data-emoji-reload>
|
||||
<i class="ion-md-refresh" aria-hidden="true"></i><span>@Localizer["Read again"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
<p class="text-xs opacity-70">@Localizer["Everyone here can use them in posts, names and reactions, and other servers show them. The picker offers those in it; one written out as :shortcode: works too, unless it is disabled."]</p>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<p class="flex w-full items-center justify-center gap-2">
|
||||
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||
@Localizer["loading"]
|
||||
</p>
|
||||
}
|
||||
else if (emojis.Count == 0)
|
||||
{
|
||||
<p class="text-sm opacity-70" data-emoji-empty>@Localizer["No custom emoji yet."]</p>
|
||||
}
|
||||
|
||||
@foreach (var group in Groups)
|
||||
{
|
||||
<section class="flex flex-col gap-2" data-emoji-group="@group.Key">
|
||||
<h3 class="text-sm font-semibold">@(group.Key is { Length: > 0 } category ? category : Localizer["No category"])</h3>
|
||||
<ul class="flex flex-col gap-2">
|
||||
@foreach (var emoji in group)
|
||||
{
|
||||
<li @key="emoji.Shortcode" class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2 p-2 rounded-lg neo-raised neo-2xs @(emoji.Disabled ? "opacity-60" : null)"
|
||||
data-emoji="@emoji.Shortcode" data-emoji-state="@StateOf(emoji)">
|
||||
<img alt="@($":{emoji.Shortcode}:")" loading="lazy" draggable="false" class="h-10 w-10 flex-none object-contain rounded" src="@emoji.Url" />
|
||||
<span class="flex flex-col min-w-0 flex-1">
|
||||
<b class="truncate font-mono text-sm" title="@emoji.Uri">@($":{emoji.Shortcode}:")</b>
|
||||
<span class="text-xs opacity-70" title="@emoji.ImageUpdatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
|
||||
@emoji.Size.GetFileSize(Localizer) · @Localizer["Changed {0} ago", emoji.UpdatedAt.GetPassedTime(Localizer)]
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex flex-wrap items-center gap-1">
|
||||
@if (emoji.ContentType == "image/gif")
|
||||
{
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Outline">@Localizer["Animated"]</DBadge>
|
||||
}
|
||||
@if (emoji.Disabled)
|
||||
{
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error">@Localizer["Disabled"]</DBadge>
|
||||
}
|
||||
else if (!emoji.VisibleInPicker)
|
||||
{
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Not in the picker"]</DBadge>
|
||||
}
|
||||
</span>
|
||||
<span class="flex items-center gap-2 pointer-coarse:gap-2">
|
||||
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || busy.Contains(emoji.Shortcode)" OnClick="() => Edit(emoji)" data-emoji-edit>
|
||||
<i class="ion-md-create" aria-hidden="true"></i><span>@Localizer["Edit"]</span>
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled || busy.Contains(emoji.Shortcode)" OnClick="() => Delete(emoji)" data-emoji-delete>
|
||||
<i class="ion-md-trash text-error" aria-hidden="true"></i><span>@Localizer["Delete"]</span>
|
||||
</DButton>
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isEditing)
|
||||
{
|
||||
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-emoji-dialog>
|
||||
<Heading>@(editedEmoji is null ? Localizer["New emoji"] : Localizer["Edit :{0}:", editedEmoji.Shortcode])</Heading>
|
||||
<ChildContent>
|
||||
<EditForm Model="form" OnValidSubmit="Save" class="flex flex-col gap-3 mt-3">
|
||||
<DataAnnotationsValidator />
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["Shortcode"]</span>
|
||||
<DInput TValue="string" class="w-full font-mono" @bind-Value="form.Shortcode" Immediate="true" autocomplete="off" maxlength="64"
|
||||
readonly="@(editedEmoji is not null)" spellcheck="false" data-emoji-shortcode>
|
||||
<Prefix><span aria-hidden="true">:</span></Prefix>
|
||||
<Suffix><span aria-hidden="true">:</span></Suffix>
|
||||
</DInput>
|
||||
<span class="text-xs opacity-70">
|
||||
@(editedEmoji is null
|
||||
? Localizer["Letters, digits and underscores, 2 to 64 of them. It never changes: other servers know the emoji by it."]
|
||||
: Localizer["A shortcode never changes: other servers know the emoji by it."])
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["Category"]</span>
|
||||
<DInput TValue="string" class="w-full" @bind-Value="form.Category" Immediate="true" autocomplete="off" maxlength="64" list="emoji-categories"
|
||||
placeholder="@Localizer["None"]" data-emoji-category />
|
||||
<datalist id="emoji-categories">
|
||||
@foreach (var category in Categories)
|
||||
{
|
||||
<option value="@category"></option>
|
||||
}
|
||||
</datalist>
|
||||
<span class="text-xs opacity-70">@Localizer["The picker groups the emoji by it."]</span>
|
||||
</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm">@Localizer["Picture"]</span>
|
||||
<div class="flex flex-wrap items-center gap-3 pointer-coarse:gap-2">
|
||||
@if ((preview ?? editedEmoji?.Url) is { Length: > 0 } shown)
|
||||
{
|
||||
<img alt="@Localizer["Picture"]" class="h-12 w-12 flex-none object-contain rounded neo-inset neo-2xs" src="@shown"
|
||||
data-emoji-preview="@(preview is null ? "current" : "picked")" />
|
||||
}
|
||||
<DFileInput Size="DSize.Sm" class="flex-1 min-w-48" Accept="@Accept" OnChange="Pick" disabled="@(IsDefaultDisabled || isSaving)"
|
||||
aria-label="@Localizer["Picture"]" data-emoji-picture />
|
||||
</div>
|
||||
<span class="text-xs opacity-70">@Localizer["PNG, GIF, WebP or JPEG, {0} at most. The server fits it within 256 px and keeps a GIF's animation.", EmojiService.MaxPictureBytes.GetFileSize(Localizer)]</span>
|
||||
@if (editedEmoji is not null)
|
||||
{
|
||||
<span class="text-xs opacity-70">@Localizer["Without a new one, it keeps its picture."]</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<DToggle LabelText="@Localizer["In the picker"]" @bind-Value="form.VisibleInPicker" data-emoji-picker />
|
||||
<span class="text-xs opacity-70">@Localizer["Offered in the picker of every client. Out of it, it is still taken when its shortcode is written."]</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<DToggle LabelText="@Localizer["Disabled"]" @bind-Value="form.Disabled" data-emoji-disabled />
|
||||
<span class="text-xs opacity-70">@Localizer["Kept, but offered nowhere, taken into no new post and shown in none."]</span>
|
||||
</div>
|
||||
<div data-emoji-error="@errorStatus">
|
||||
<ErrorViewer AdditionalError="@error" AdditionalErrors="pictureErrors" />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pointer-coarse:gap-2">
|
||||
<DButton OnClick="Close" data-emoji-cancel>@Localizer["Cancel"]</DButton>
|
||||
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isSaving" data-emoji-save>
|
||||
@if (isSaving)
|
||||
{
|
||||
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
<span>@Localizer["Save"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
</EditForm>
|
||||
</ChildContent>
|
||||
</DModal>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Inject] EmojiService EmojiService { get; set; }
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
[Inject] ILoggingService Logger { get; set; }
|
||||
|
||||
static readonly string Accept = string.Join(',', EmojiService.PictureTypes.Concat([".png", ".gif", ".webp", ".jpg", ".jpeg"]));
|
||||
|
||||
List<ViewLocalEmoji> emojis = [];
|
||||
HashSet<string> busy = [];
|
||||
// the dialog: a new emoji (no editedEmoji) or the one being changed, as the form will save it
|
||||
bool isEditing;
|
||||
ViewLocalEmoji editedEmoji;
|
||||
LocalEmojiForm form = new();
|
||||
// the picture picked, streamed from the file when the form is saved, and a small copy of it to show
|
||||
IBrowserFile picture;
|
||||
string preview;
|
||||
List<string> pictureErrors = [];
|
||||
// what PrivaPub answered, and its status (data-emoji-error: 409 a shortcode taken, 422 a picture refused, 400 invalid)
|
||||
string error;
|
||||
string errorStatus;
|
||||
bool isSaving;
|
||||
|
||||
// those with no category first, then by category
|
||||
List<IGrouping<string, ViewLocalEmoji>> Groups =>
|
||||
[
|
||||
.. emojis.OrderBy(e => e.Shortcode, StringComparer.OrdinalIgnoreCase)
|
||||
.GroupBy(e => e.Category ?? string.Empty)
|
||||
.OrderBy(g => g.Key.Length == 0 ? 0 : 1)
|
||||
.ThenBy(g => g.Key, StringComparer.CurrentCultureIgnoreCase)
|
||||
];
|
||||
|
||||
List<string> Categories => [.. emojis.Select(e => e.Category).Where(c => c is { Length: > 0 }).Distinct().Order(StringComparer.CurrentCultureIgnoreCase)];
|
||||
|
||||
static string StateOf(ViewLocalEmoji emoji) => emoji.Disabled ? "disabled" : emoji.VisibleInPicker ? "picker" : "hidden";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData() => emojis = await EmojiService.List();
|
||||
|
||||
async Task Reload()
|
||||
{
|
||||
IsLoading = true;
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
void New() => Open(null, new LocalEmojiForm());
|
||||
|
||||
void Edit(ViewLocalEmoji emoji) => Open(emoji, new LocalEmojiForm
|
||||
{
|
||||
Shortcode = emoji.Shortcode,
|
||||
Category = emoji.Category,
|
||||
VisibleInPicker = emoji.VisibleInPicker,
|
||||
Disabled = emoji.Disabled
|
||||
});
|
||||
|
||||
void Open(ViewLocalEmoji emoji, LocalEmojiForm editing)
|
||||
{
|
||||
editedEmoji = emoji;
|
||||
form = editing;
|
||||
picture = null;
|
||||
preview = null;
|
||||
pictureErrors = [];
|
||||
error = null;
|
||||
errorStatus = null;
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
void Close()
|
||||
{
|
||||
isEditing = false;
|
||||
picture = null;
|
||||
preview = null;
|
||||
}
|
||||
|
||||
void Closed(bool open)
|
||||
{
|
||||
if (!open)
|
||||
Close();
|
||||
}
|
||||
|
||||
async Task Pick(InputFileChangeEventArgs eventArgs)
|
||||
{
|
||||
pictureErrors = [];
|
||||
picture = null;
|
||||
preview = null;
|
||||
var file = eventArgs.File;
|
||||
if (!EmojiService.PictureTypes.Contains(EmojiService.TypeOf(file)))
|
||||
{
|
||||
pictureErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
|
||||
return;
|
||||
}
|
||||
if (file.Size > EmojiService.MaxPictureBytes)
|
||||
{
|
||||
pictureErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name, file.Size.GetFileSize(Localizer),
|
||||
EmojiService.MaxPictureBytes.GetFileSize(Localizer)]);
|
||||
return;
|
||||
}
|
||||
|
||||
picture = file;
|
||||
preview = await Preview(file);
|
||||
}
|
||||
|
||||
// a small copy the browser makes (a GIF's first frame); none when it can't
|
||||
async Task<string> Preview(IBrowserFile file)
|
||||
{
|
||||
try
|
||||
{
|
||||
var small = await file.RequestImageFileAsync("image/png", 96, 96);
|
||||
await using var stream = small.OpenReadStream(EmojiService.MaxPictureBytes);
|
||||
using var buffer = new MemoryStream();
|
||||
await stream.CopyToAsync(buffer);
|
||||
return $"data:image/png;base64,{Convert.ToBase64String(buffer.ToArray())}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Logger.ProcessWarning($"No preview of {file.Name}: {ex.Message}", $"{nameof(EmojiPanel)}.{nameof(Preview)}");
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
async Task Save()
|
||||
{
|
||||
error = null;
|
||||
errorStatus = null;
|
||||
if (editedEmoji is null && picture is null)
|
||||
{
|
||||
pictureErrors = [Localizer["Choose a picture."]];
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
var result = editedEmoji is null ? await EmojiService.Insert(form, picture) : await EmojiService.Update(form, picture);
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
error = result.ErrorMessage;
|
||||
errorStatus = result.StatusCode.ToString(CultureInfo.InvariantCulture);
|
||||
return;
|
||||
}
|
||||
|
||||
var added = editedEmoji is null;
|
||||
Close();
|
||||
Toast.AddResponseSuccess(added ? Localizer["Emoji added."] : Localizer["Emoji saved."]);
|
||||
if (result.Data is not ViewLocalEmoji saved)
|
||||
{
|
||||
await LoadData();
|
||||
return;
|
||||
}
|
||||
|
||||
emojis = [.. emojis.Where(e => e.Shortcode != saved.Shortcode), saved];
|
||||
}
|
||||
|
||||
async Task Delete(ViewLocalEmoji emoji)
|
||||
{
|
||||
if (!await Confirm.Ask(Localizer["Delete :{0}:?", emoji.Shortcode],
|
||||
Localizer["It is offered no more, and the posts and profiles that use it show the text :{0}: instead. Other servers keep their copy until they read those posts again.", emoji.Shortcode],
|
||||
Localizer["Delete"], true))
|
||||
return;
|
||||
|
||||
busy.Add(emoji.Shortcode);
|
||||
var result = await EmojiService.Delete(emoji.Shortcode);
|
||||
busy.Remove(emoji.Shortcode);
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
emojis = [.. emojis.Where(e => e.Shortcode != emoji.Shortcode)];
|
||||
Toast.AddResponseSuccess(Localizer["Emoji deleted."]);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
@* The administrators' announcements shown now (/api/v1/announcements) that the persona has not dismissed, one at a time
|
||||
above the feed's tabs: their words (sanitized HTML from the administrator's Markdown), when they run, and the
|
||||
reactions personas left, which the persona adds and takes back with a unicode emoji. Dismissed, one is gone for this
|
||||
reactions personas left, which the persona adds and takes back with a unicode emoji or one of the server's custom ones
|
||||
(EmojiPicker; its text shows the custom emoji it names). Dismissed, one is gone for this
|
||||
persona. New, changed and removed ones, and the reactions' counts, come through the user stream
|
||||
(StreamingService.HearAnnouncements); the banner renders itself and never touches the feed's cards. *@
|
||||
@if (Current is { } current)
|
||||
@@ -39,15 +40,15 @@
|
||||
{
|
||||
<p class="text-xs opacity-70" data-announcement-when>@When(current)</p>
|
||||
}
|
||||
<div class="post-content doc-content text-sm break-words max-h-28 md:max-h-40 overflow-y-auto">@((MarkupString)current.Content)</div>
|
||||
<div class="post-content doc-content text-sm break-words max-h-28 md:max-h-40 overflow-y-auto">@((MarkupString)CustomEmojis.Html(current.Content, current.Emojis))</div>
|
||||
<div class="flex flex-wrap items-center gap-1 pointer-coarse:gap-2" data-announcement-reactions>
|
||||
@foreach (var reaction in current.Reactions)
|
||||
{
|
||||
<DButton Size="DSize.Xs" Active="reaction.Me == true" Disabled="IsDefaultDisabled || isActing" OnClick="() => React(reaction.Name, reaction.Me != true)"
|
||||
title="@reaction.Name" aria-pressed="@(reaction.Me == true ? "true" : "false")" data-announcement-reaction="@reaction.Name">
|
||||
title="@NameOf(reaction)" aria-pressed="@(reaction.Me == true ? "true" : "false")" data-announcement-reaction="@reaction.Name">
|
||||
@if (reaction.Url is { Length: > 0 } image)
|
||||
{
|
||||
<img alt="@reaction.Name" class="h-4 w-4 object-contain" src="@image" />
|
||||
<img alt="@NameOf(reaction)" class="h-4 w-4 object-contain" src="@image" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -56,17 +57,10 @@
|
||||
<span class="tabular-nums">@reaction.Count</span>
|
||||
</DButton>
|
||||
}
|
||||
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Bottom" CloseOnContentClick="true" SheetOnNarrow="true"
|
||||
TriggerClass="@(isReactionsOpen ? "btn btn-xs btn-active" : "btn btn-xs")"
|
||||
ContentClass="mt-2 flex gap-1 p-2 rounded-box max-md:grid max-md:grid-cols-4 max-md:gap-2" title="@Localizer["React"]" data-announcement-react>
|
||||
<Trigger><i aria-hidden="true" class="ion-md-happy"></i></Trigger>
|
||||
<ChildContent>
|
||||
@foreach (var emoji in QuickReactions)
|
||||
{
|
||||
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => React(emoji, true)" data-announcement-react-emoji="@emoji">@emoji</DButton>
|
||||
}
|
||||
</ChildContent>
|
||||
</DDropdown>
|
||||
<EmojiPicker Quick="QuickReactions" OnPick="emoji => React(emoji, true)" ChoicesDisabled="isActing" TriggerLabel="@Localizer["React"]"
|
||||
TriggerClass="btn btn-xs" ActiveTriggerClass="btn btn-xs btn-active" data-announcement-react>
|
||||
<i aria-hidden="true" class="ion-md-happy"></i>
|
||||
</EmojiPicker>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -76,13 +70,16 @@
|
||||
|
||||
[Inject] AnnouncementService AnnouncementService { get; set; }
|
||||
[Inject] StreamingService Streaming { get; set; }
|
||||
[Inject] EmojiService EmojiService { get; set; }
|
||||
|
||||
List<Announcement> announcements = [];
|
||||
int index;
|
||||
bool isActing;
|
||||
bool isReactionsOpen;
|
||||
string loadedPersonaId;
|
||||
|
||||
// a custom emoji's reaction is named by its shortcode
|
||||
static string NameOf(AnnouncementReaction reaction) => reaction.Url is { Length: > 0 } ? $":{reaction.Name}:" : reaction.Name;
|
||||
|
||||
// what the persona has not dismissed, oldest first as the server lists them
|
||||
List<Announcement> Shown => [.. announcements.Where(a => a.Read != true)];
|
||||
|
||||
@@ -188,8 +185,10 @@
|
||||
reacted.Reactions = reaction.Count <= 0
|
||||
? [.. reacted.Reactions.Where(r => r.Name != reaction.Name)]
|
||||
: known is null
|
||||
? [.. reacted.Reactions, new AnnouncementReaction { Name = reaction.Name, Count = reaction.Count, Me = false }]
|
||||
: [.. reacted.Reactions.Select(r => r.Name == reaction.Name ? new AnnouncementReaction { Name = r.Name, Count = reaction.Count, Me = r.Me, Url = r.Url } : r)];
|
||||
? [.. reacted.Reactions, NewReaction(reaction)]
|
||||
: [.. reacted.Reactions.Select(r => r.Name == reaction.Name
|
||||
? new AnnouncementReaction { Name = r.Name, Count = reaction.Count, Me = r.Me, Url = r.Url, StaticUrl = r.StaticUrl }
|
||||
: r)];
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
@@ -198,6 +197,12 @@
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
// one the stream tells of first: the event names it only, so a custom emoji's pictures come from the server's emoji read
|
||||
// already (none while they aren't, until the list is read again)
|
||||
AnnouncementReaction NewReaction(AnnouncementReactionEvent reaction) => EmojiService.Known(reaction.Name) is { } custom
|
||||
? new AnnouncementReaction { Name = reaction.Name, Count = reaction.Count, Me = false, Url = custom.Url, StaticUrl = custom.StaticUrl }
|
||||
: new AnnouncementReaction { Name = reaction.Name, Count = reaction.Count, Me = false };
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
Streaming.Announced -= OnAnnounced;
|
||||
|
||||
@@ -26,9 +26,17 @@
|
||||
@* under the words, not over their last line *@
|
||||
<span class="self-end text-xs opacity-50 tabular-nums">@ContentLength/@ComposeForm.MaxContentLength</span>
|
||||
</div>
|
||||
@if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0)
|
||||
@if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0 || suggestedEmojis.Count > 0)
|
||||
{
|
||||
<div class="flex flex-wrap gap-2" data-suggestions>
|
||||
@foreach (var emoji in suggestedEmojis)
|
||||
{
|
||||
<DButton Size="DSize.Xs" class="max-w-full" KeepFocus="true" OnClick="@(() => Complete($":{emoji.Shortcode}:"))" data-emoji-suggestion="@emoji.Shortcode">
|
||||
@* alt="": the shortcode is next to it. *@
|
||||
<img alt="" class="h-4 w-4 flex-none object-contain" src="@emoji.Url" />
|
||||
<span class="truncate">@($":{emoji.Shortcode}:")</span>
|
||||
</DButton>
|
||||
}
|
||||
@foreach (var account in suggestedAccounts)
|
||||
{
|
||||
<DButton Size="DSize.Xs" class="max-w-full" KeepFocus="true" OnClick="@(() => Complete("@" + account.Acct))" data-suggestion="@("@" + account.Acct)">
|
||||
@@ -200,6 +208,11 @@
|
||||
<i class="ion-md-warning text-base"></i>
|
||||
</DButton>
|
||||
|
||||
@* the server's custom emoji and common unicode ones, written where the caret is *@
|
||||
<EmojiPicker OnPick="InsertEmoji" TriggerLabel="@Localizer["Insert an emoji"]">
|
||||
<i class="ion-md-happy text-base" aria-hidden="true"></i>
|
||||
</EmojiPicker>
|
||||
|
||||
@if (!form.IsLocated && GroupId is null && !form.HasPoll)
|
||||
{
|
||||
@* A label styled as a button opens the hidden file input. *@
|
||||
@@ -275,6 +288,8 @@
|
||||
[Inject] AccountService AccountService { get; set; }
|
||||
[Inject] ILoggingService Logger { get; set; }
|
||||
[Inject] IJSRuntime JS { get; set; }
|
||||
[Inject] EmojiService EmojiService { get; set; }
|
||||
[Inject] AppStatusService AppStatus { get; set; }
|
||||
|
||||
/// <summary>The status this answers; the reply starts with its visibility (never wider) and content warning.</summary>
|
||||
[Parameter] public Status InReplyTo { get; set; }
|
||||
@@ -291,8 +306,11 @@
|
||||
List<string> fileErrors = [];
|
||||
// what the words end with while a mention or a hashtag is being written: its sign and what follows it
|
||||
static readonly Regex Typing = new(@"(?:^|\s)([@#])([\p{L}\p{N}_][\p{L}\p{N}_.\-]*(?:@[\p{L}\p{N}.\-]*)?)$", RegexOptions.Compiled);
|
||||
// and while one of the server's custom emoji is: a colon and two letters at least
|
||||
static readonly Regex TypingEmoji = new(@"(?:^|\s)(:)([A-Za-z0-9_]{2,64})$", RegexOptions.Compiled);
|
||||
List<Account> suggestedAccounts = [];
|
||||
List<StatusTag> suggestedTags = [];
|
||||
List<CustomEmoji> suggestedEmojis = [];
|
||||
// the newest lookup: an older one's answer comes too late to show
|
||||
int suggesting;
|
||||
string error;
|
||||
@@ -300,11 +318,24 @@
|
||||
|
||||
int ContentLength => form.Content?.Length ?? 0;
|
||||
|
||||
// what is being written, once typing pauses, among the accounts and hashtags this server knows
|
||||
// what is being written, once typing pauses, among the accounts and hashtags this server knows; a custom emoji at
|
||||
// once, among the server's (read once a day)
|
||||
async Task Typed()
|
||||
{
|
||||
var typed = Typing.Match(form.Content ?? "");
|
||||
var lookup = ++suggesting;
|
||||
if (TypingEmoji.Match(form.Content ?? "") is { Success: true } emoji)
|
||||
{
|
||||
suggestedAccounts = [];
|
||||
suggestedTags = [];
|
||||
var matching = await EmojiService.Matching(emoji.Groups[2].Value, 8);
|
||||
if (lookup != suggesting)
|
||||
return;
|
||||
suggestedEmojis = matching;
|
||||
return;
|
||||
}
|
||||
|
||||
suggestedEmojis = [];
|
||||
var typed = Typing.Match(form.Content ?? "");
|
||||
if (!typed.Success || typed.Groups[2].Length < 2)
|
||||
{
|
||||
suggestedAccounts = [];
|
||||
@@ -322,19 +353,42 @@
|
||||
suggestedTags = hashtags ? [.. found.Hashtags.Take(5)] : [];
|
||||
}
|
||||
|
||||
// the mention or hashtag being written, written out
|
||||
// the mention, hashtag or custom emoji being written, written out
|
||||
void Complete(string written)
|
||||
{
|
||||
var typed = Typing.Match(form.Content ?? "");
|
||||
var typed = TypingEmoji.Match(form.Content ?? "") is { Success: true } emoji ? emoji : Typing.Match(form.Content ?? "");
|
||||
suggesting++;
|
||||
suggestedAccounts = [];
|
||||
suggestedTags = [];
|
||||
suggestedEmojis = [];
|
||||
if (!typed.Success)
|
||||
return;
|
||||
form.Content = form.Content[..typed.Groups[1].Index] + written + " ";
|
||||
focusPending = true;
|
||||
}
|
||||
|
||||
// an emoji from the picker, written where the caret is (in place of what is selected), else at the end. A shortcode
|
||||
// stands apart from the words around it, as PrivaPub reads `:shortcode:` only with no letter or digit next to it.
|
||||
void InsertEmoji(string emoji)
|
||||
{
|
||||
var content = form.Content ?? string.Empty;
|
||||
var (start, end) = AppStatus.CaretOf(textarea.Element) is [var from, var to] && from >= 0 && from <= to && to <= content.Length
|
||||
? (from, to)
|
||||
: (content.Length, content.Length);
|
||||
var before = content[..start];
|
||||
var after = content[end..];
|
||||
var isShortcode = emoji.StartsWith(':');
|
||||
var lead = isShortcode && before.Length > 0 && !char.IsWhiteSpace(before[^1]) ? " " : null;
|
||||
var trail = isShortcode && (after.Length == 0 || !char.IsWhiteSpace(after[0])) ? " " : null;
|
||||
form.Content = before + lead + emoji + trail + after;
|
||||
caretAt = before.Length + (lead?.Length ?? 0) + emoji.Length + (trail?.Length ?? 0);
|
||||
suggesting++;
|
||||
suggestedAccounts = [];
|
||||
suggestedTags = [];
|
||||
suggestedEmojis = [];
|
||||
focusPending = true;
|
||||
}
|
||||
|
||||
string Placeholder => Editing is not null ? Localizer["Edit your post"]
|
||||
: Quoting is not null ? Localizer["Say something about it"]
|
||||
: InReplyTo is null ? Localizer["What's happening?"]
|
||||
@@ -400,14 +454,22 @@
|
||||
};
|
||||
|
||||
DTextarea<string> textarea;
|
||||
// the words take the focus when the composer opens, and again after a suggestion is picked
|
||||
// the words take the focus when the composer opens, and again after a suggestion or an emoji is picked
|
||||
bool focusPending = true;
|
||||
// where the caret goes then: after the emoji just written
|
||||
int? caretAt;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!focusPending)
|
||||
return;
|
||||
focusPending = false;
|
||||
if (caretAt is { } at)
|
||||
{
|
||||
caretAt = null;
|
||||
AppStatus.PlaceCaret(textarea.Element, at);
|
||||
return;
|
||||
}
|
||||
await textarea.Element.FocusAsync();
|
||||
}
|
||||
|
||||
|
||||
+17
-14
@@ -53,6 +53,11 @@
|
||||
</span>
|
||||
}
|
||||
<span>@NotificationText(notification)</span>
|
||||
@if (notification.Type == "pleroma:emoji_reaction" && notification.EmojiUrl is { Length: > 0 } reactionPicture)
|
||||
{
|
||||
<img alt="@($":{notification.Emoji}:")" title="@($":{notification.Emoji}:")" class="h-5 w-5 object-contain" src="@reactionPicture"
|
||||
data-notification-emoji="@notification.Emoji" />
|
||||
}
|
||||
<span class="opacity-60" title="@NerdRow.Time(notification.CreatedAt)">@notification.CreatedAt.GetPassedTime(Localizer)</span>
|
||||
@if (!Compact)
|
||||
{
|
||||
@@ -220,10 +225,10 @@
|
||||
@foreach (var reaction in status.EmojiReactions)
|
||||
{
|
||||
<DButton Size="DSize.Xs" Active="reaction.Me" Disabled="@(IsDefaultDisabled || isActing || Compact || Post.Located)"
|
||||
OnClick="() => React(reaction.Name, !reaction.Me)" title="@reaction.Name">
|
||||
OnClick="() => React(ReactionOf(reaction), !reaction.Me)" title="@ReactionOf(reaction)" data-reaction="@reaction.Name">
|
||||
@if (reaction.Url is { Length: > 0 } image)
|
||||
{
|
||||
<img alt="@reaction.Name" class="h-4 w-4 object-contain" src="@image" />
|
||||
<img alt="@ReactionOf(reaction)" class="h-4 w-4 object-contain" src="@image" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -333,17 +338,12 @@
|
||||
OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" title="@Localizer["Bookmark"]">
|
||||
<i aria-hidden="true" class="ion-md-bookmark text-lg @(status.Bookmarked ? "text-warning" : null)"></i>
|
||||
</DButton>
|
||||
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Top" CloseOnContentClick="true" SheetOnNarrow="true" class="max-md:w-full"
|
||||
TriggerClass="@(isReactionsOpen ? "btn btn-sm btn-active max-md:w-full" : "btn btn-sm max-md:w-full")"
|
||||
ContentClass="mb-2 flex gap-1 p-2 rounded-box max-md:grid max-md:grid-cols-4 max-md:gap-2" title="@Localizer["React"]">
|
||||
<Trigger><i aria-hidden="true" class="ion-md-happy text-lg"></i></Trigger>
|
||||
<ChildContent>
|
||||
@foreach (var emoji in QuickReactions)
|
||||
{
|
||||
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => React(emoji, true)">@emoji</DButton>
|
||||
}
|
||||
</ChildContent>
|
||||
</DDropdown>
|
||||
@* the quick reactions, then the server's custom emoji and the rest; opens downwards, like the menu *@
|
||||
<EmojiPicker Quick="QuickReactions" OnPick="emoji => React(emoji, true)" ChoicesDisabled="isActing" TriggerLabel="@Localizer["React"]"
|
||||
class="max-md:w-full" TriggerClass="btn btn-sm max-md:w-full" ActiveTriggerClass="btn btn-sm btn-active max-md:w-full"
|
||||
data-react>
|
||||
<i aria-hidden="true" class="ion-md-happy text-lg"></i>
|
||||
</EmojiPicker>
|
||||
<span class="hidden md:contents">
|
||||
<NerdTip Heading="@Localizer["Counts"]" Rows="CountRows(status)" Class="self-center">
|
||||
<i class="ion-md-stats opacity-40" aria-label="@Localizer["Counts"]"></i>
|
||||
@@ -1012,7 +1012,9 @@
|
||||
|
||||
static readonly string[] QuickReactions = ["👍", "❤️", "😂", "🎉", "🔥", "😮", "😢"];
|
||||
|
||||
bool isReactionsOpen;
|
||||
// what PrivaPub takes for a reaction already on the post: a custom emoji's (it has a picture) as `:shortcode:`, so that
|
||||
// another server's is found among the post's; a unicode one as it is
|
||||
static string ReactionOf(EmojiReaction reaction) => reaction.Url is { Length: > 0 } ? $":{reaction.Name}:" : reaction.Name;
|
||||
|
||||
async Task React(string emoji, bool add)
|
||||
{
|
||||
@@ -1387,6 +1389,7 @@
|
||||
"poll" => Localizer["A poll you took part in has ended"],
|
||||
"update" => Localizer["{0} edited a post", name],
|
||||
"quote" => Localizer["{0} quoted your post", name],
|
||||
"pleroma:emoji_reaction" when notification.EmojiUrl is { Length: > 0 } => Localizer["{0} reacted with", name],
|
||||
"pleroma:emoji_reaction" => Localizer["{0} reacted {1}", name, notification.Emoji],
|
||||
"moderation_warning" => Localizer["This server's staff warned you"],
|
||||
_ => Localizer["{0}: {1}", name, notification.Type]
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
which also scrolls a menu cut off by its scroller into view; Escape or an outside click gives it back to the trigger.
|
||||
SheetOnNarrow makes the content a sheet rising from the bottom of a phone's screen (below md), over a dimmed page. *@
|
||||
<div @attributes="AdditionalAttributes" class="@CssClass" @onkeydown="OnKeyDown">
|
||||
<div @ref="trigger" role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true" aria-expanded="@(IsOpen ? "true" : "false")"
|
||||
@onclick="Toggle">
|
||||
<div @attributes="TriggerAttributes" @ref="trigger" role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true"
|
||||
aria-expanded="@(IsOpen ? "true" : "false")" @onclick="Toggle">
|
||||
@Trigger
|
||||
</div>
|
||||
@if (IsOpen)
|
||||
@@ -35,6 +35,8 @@
|
||||
/// <summary>Close after any click inside the content, e.g. on a menu item.</summary>
|
||||
[Parameter] public bool CloseOnContentClick { get; set; }
|
||||
[Parameter] public string TriggerClass { get; set; }
|
||||
/// <summary>Attributes of the trigger itself (an aria-label, a data- hook); the outer element takes the unmatched ones.</summary>
|
||||
[Parameter] public IReadOnlyDictionary<string, object> TriggerAttributes { get; set; }
|
||||
[Parameter] public string ContentClass { get; set; }
|
||||
/// <summary>Below md, the content is a sheet at the bottom of the screen instead of hanging from the trigger.</summary>
|
||||
[Parameter] public bool SheetOnNarrow { get; set; }
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* A picker of emoji: the server's custom emoji its picker offers (EmojiService, by category) and common unicode ones
|
||||
(Helpers/UnicodeEmojis.cs, by group), searched by shortcode, category or word. It is a DDropdown, a sheet on a phone,
|
||||
whose trigger is the caller's ChildContent (data-emoji-picker-open); what it holds is rendered only while it is open
|
||||
(data-emoji-picker), so a feed whose every card has one costs nothing until one opens, and the server's emoji are read
|
||||
on the first opening. A choice (data-emoji-choice: the shortcode, or the character itself) is handed to OnPick as
|
||||
`:shortcode:` for a custom emoji and as the character for a unicode one, and closes the picker. The composer writes it
|
||||
into the words; a card and the announcements banner react with it, their quick reactions (Quick) first. *@
|
||||
<DDropdown @attributes="AdditionalAttributes" IsOpen="isOpen" IsOpenChanged="Opened" Placement="Placement" Align="Align" SheetOnNarrow="true"
|
||||
TriggerClass="@(isOpen ? ActiveTriggerClass : TriggerClass)" ContentClass="@ContentClass" TriggerAttributes="TriggerHooks" title="@TriggerLabel">
|
||||
<Trigger>@ChildContent</Trigger>
|
||||
<ChildContent>
|
||||
@if (isOpen)
|
||||
{
|
||||
<div class="flex flex-col gap-2 w-full md:w-80" data-emoji-picker>
|
||||
@* its form attribute names no form, so Enter in the search submits no form around the picker (the composer's) *@
|
||||
<DInput TValue="string" Size="DSize.Sm" class="w-full" Value="query" ValueChanged="Search" ValueExpression="() => query" Immediate="true"
|
||||
autocomplete="off" form="emoji-picker-search" placeholder="@Localizer["Search emoji"]" aria-label="@Localizer["Search emoji"]"
|
||||
@onkeydown="PickFirst" data-emoji-picker-search>
|
||||
<Prefix><i class="ion-md-search" aria-hidden="true"></i></Prefix>
|
||||
</DInput>
|
||||
<div class="flex flex-col gap-2 max-h-72 max-md:max-h-[50dvh] overflow-y-auto overscroll-contain pr-1">
|
||||
@if (IsLoading)
|
||||
{
|
||||
<p class="flex items-center gap-2 text-xs opacity-70">
|
||||
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />@Localizer["loading"]
|
||||
</p>
|
||||
}
|
||||
@if (QuickShown.Count > 0)
|
||||
{
|
||||
<section class="flex flex-col gap-1" data-emoji-section="quick">
|
||||
<h4 class="sticky top-0 z-[1] bg-base-100 py-1 text-xs font-semibold opacity-70">@Localizer["Often used"]</h4>
|
||||
<div class="grid grid-cols-8 pointer-coarse:grid-cols-6 justify-items-center gap-1 pointer-coarse:gap-2">
|
||||
@foreach (var emoji in QuickShown)
|
||||
{
|
||||
<DButton Size="DSize.Sm" Variant="DButtonVariant.Ghost" Shape="DButtonShape.Square" Disabled="IsDefaultDisabled || ChoicesDisabled"
|
||||
OnClick="() => Pick(emoji)" data-emoji-choice="@emoji">
|
||||
<span class="text-xl leading-none">@emoji</span>
|
||||
</DButton>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@foreach (var group in CustomShown)
|
||||
{
|
||||
<section class="flex flex-col gap-1" data-emoji-section="@("custom:" + group.Key)">
|
||||
<h4 class="sticky top-0 z-[1] bg-base-100 py-1 text-xs font-semibold opacity-70 truncate">@(group.Key is { Length: > 0 } category ? category : Localizer["This server's emoji"])</h4>
|
||||
<div class="grid grid-cols-8 pointer-coarse:grid-cols-6 justify-items-center gap-1 pointer-coarse:gap-2">
|
||||
@foreach (var emoji in group)
|
||||
{
|
||||
<DButton Size="DSize.Sm" Variant="DButtonVariant.Ghost" Shape="DButtonShape.Square" Disabled="IsDefaultDisabled || ChoicesDisabled"
|
||||
OnClick="@(() => Pick($":{emoji.Shortcode}:"))" title="@($":{emoji.Shortcode}:")" aria-label="@($":{emoji.Shortcode}:")"
|
||||
data-emoji-choice="@emoji.Shortcode">
|
||||
@* alt="": the button is named by the shortcode. *@
|
||||
<img alt="" loading="lazy" draggable="false" class="h-6 w-6 object-contain" src="@PictureOf(emoji)" />
|
||||
</DButton>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@foreach (var group in UnicodeShown)
|
||||
{
|
||||
<section class="flex flex-col gap-1" data-emoji-section="@group.Key">
|
||||
<h4 class="sticky top-0 z-[1] bg-base-100 py-1 text-xs font-semibold opacity-70">@Localizer[group.Name]</h4>
|
||||
<div class="grid grid-cols-8 pointer-coarse:grid-cols-6 justify-items-center gap-1 pointer-coarse:gap-2">
|
||||
@foreach (var emoji in group.Emojis)
|
||||
{
|
||||
<DButton Size="DSize.Sm" Variant="DButtonVariant.Ghost" Shape="DButtonShape.Square" Disabled="IsDefaultDisabled || ChoicesDisabled"
|
||||
OnClick="() => Pick(emoji.Character)" title="@($":{emoji.Shortcode}:")" data-emoji-choice="@emoji.Character">
|
||||
<span class="text-xl leading-none">@emoji.Character</span>
|
||||
</DButton>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@if (!IsLoading && QuickShown.Count == 0 && CustomShown.Count == 0 && UnicodeShown.Count == 0)
|
||||
{
|
||||
<p class="text-sm opacity-70" data-emoji-picker-empty>@Localizer["No emoji matches."]</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</ChildContent>
|
||||
</DDropdown>
|
||||
|
||||
@code {
|
||||
[Inject] EmojiService EmojiService { get; set; }
|
||||
|
||||
/// <summary>The trigger's content: an icon, as a rule.</summary>
|
||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||
/// <summary>`:shortcode:` for one of the server's custom emoji, the character itself for a unicode one.</summary>
|
||||
[Parameter] public EventCallback<string> OnPick { get; set; }
|
||||
/// <summary>Emoji shown first while nothing is searched (a card's quick reactions).</summary>
|
||||
[Parameter] public IReadOnlyList<string> Quick { get; set; } = [];
|
||||
/// <summary>The trigger's name, its title and aria-label.</summary>
|
||||
[Parameter] public string TriggerLabel { get; set; }
|
||||
[Parameter] public string TriggerClass { get; set; } = "btn btn-sm";
|
||||
[Parameter] public string ActiveTriggerClass { get; set; } = "btn btn-sm btn-active";
|
||||
[Parameter] public string ContentClass { get; set; } = "mt-2 p-2 rounded-box";
|
||||
[Parameter] public DDropdownPlacement Placement { get; set; }
|
||||
[Parameter] public DDropdownAlign Align { get; set; }
|
||||
/// <summary>The choices can't be made now (the caller is busy with the last one).</summary>
|
||||
[Parameter] public bool ChoicesDisabled { get; set; }
|
||||
/// <summary>Passed to the dropdown: its class, a data- hook.</summary>
|
||||
[Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object> AdditionalAttributes { get; set; }
|
||||
|
||||
bool isOpen;
|
||||
string query;
|
||||
List<CustomEmoji> custom = [];
|
||||
|
||||
IReadOnlyDictionary<string, object> TriggerHooks => new Dictionary<string, object>
|
||||
{
|
||||
["data-emoji-picker-open"] = true,
|
||||
["aria-label"] = TriggerLabel
|
||||
};
|
||||
|
||||
// what is searched: a shortcode may be typed with its colons
|
||||
string Searched => query?.Trim().Trim(':') is { Length: > 0 } searched ? searched : null;
|
||||
|
||||
List<string> QuickShown => Searched is null ? [.. Quick] : [];
|
||||
|
||||
// the server's emoji by category, those with none first
|
||||
List<IGrouping<string, CustomEmoji>> CustomShown =>
|
||||
[
|
||||
.. custom.Where(e => Searched is not { } searched || e.Shortcode.Contains(searched, StringComparison.OrdinalIgnoreCase)
|
||||
|| e.Category?.Contains(searched, StringComparison.OrdinalIgnoreCase) == true)
|
||||
.GroupBy(e => e.Category ?? string.Empty)
|
||||
.OrderBy(g => g.Key.Length == 0 ? 0 : 1)
|
||||
.ThenBy(g => g.Key, StringComparer.CurrentCultureIgnoreCase)
|
||||
];
|
||||
|
||||
List<UnicodeEmojis.Group> UnicodeShown => Searched is not { } searched
|
||||
? [.. UnicodeEmojis.Groups]
|
||||
:
|
||||
[
|
||||
.. UnicodeEmojis.Groups
|
||||
.Select(g => g with
|
||||
{
|
||||
Emojis = [.. g.Emojis.Where(e => e.Character == searched || e.Shortcode.Contains(searched, StringComparison.OrdinalIgnoreCase)
|
||||
|| e.Words?.Contains(searched, StringComparison.OrdinalIgnoreCase) == true)]
|
||||
})
|
||||
.Where(g => g.Emojis.Count > 0)
|
||||
];
|
||||
|
||||
// the still picture when the reader asked for less motion (an animated GIF moves)
|
||||
string PictureOf(CustomEmoji emoji) => CascadingState.Status.PrefersReducedMotion() && emoji.StaticUrl is { Length: > 0 } still ? still : emoji.Url;
|
||||
|
||||
async Task Opened(bool open)
|
||||
{
|
||||
isOpen = open;
|
||||
if (!open)
|
||||
{
|
||||
query = null;
|
||||
return;
|
||||
}
|
||||
|
||||
custom = await EmojiService.Custom();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
void Search(string searched) => query = searched;
|
||||
|
||||
// Enter takes the first emoji found
|
||||
async Task PickFirst(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key != "Enter" || Searched is null || IsDefaultDisabled || ChoicesDisabled)
|
||||
return;
|
||||
|
||||
if (CustomShown.SelectMany(g => g).FirstOrDefault() is { } first)
|
||||
{
|
||||
await Pick($":{first.Shortcode}:");
|
||||
return;
|
||||
}
|
||||
if (UnicodeShown.SelectMany(g => g.Emojis).FirstOrDefault() is { } unicode)
|
||||
await Pick(unicode.Character);
|
||||
}
|
||||
|
||||
// closed first: what the choice opens (the notice that reactions are public) is not under it
|
||||
async Task Pick(string emoji)
|
||||
{
|
||||
isOpen = false;
|
||||
query = null;
|
||||
await OnPick.InvokeAsync(emoji);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
])
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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)}";
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -72,6 +72,7 @@ public class FeedPost
|
||||
Account = sample.FirstOrDefault(),
|
||||
Status = status,
|
||||
Emoji = group.Emoji,
|
||||
EmojiUrl = group.EmojiUrl,
|
||||
GroupKey = group.GroupKey,
|
||||
ModerationWarning = group.ModerationWarning
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -48,6 +48,7 @@ builder.Services.AddOptions()
|
||||
.AddScoped<ModerationService>()
|
||||
.AddScoped<AboutService>()
|
||||
.AddScoped<AnnouncementService>()
|
||||
.AddScoped<EmojiService>()
|
||||
.AddScoped<ArchiveService>()
|
||||
.AddScoped<ListService>()
|
||||
.AddScoped<FilterService>()
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}');"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user