[frontend/components] Rework custom emoji management

This commit is contained in:
Lilian 2025-02-18 20:02:45 +01:00
parent fcc5d555aa
commit fe0848e08c
No known key found for this signature in database
5 changed files with 212 additions and 45 deletions

View file

@ -57,6 +57,7 @@
<Text>@Loc["Mark as sensitive"]</Text>
</MenuElement>
}
<MenuElement Icon="Icons.TextAa" OnSelect="SetAliases">
<Text>@Loc["Set aliases"]</Text>
</MenuElement>
@ -77,7 +78,7 @@
@code {
[Parameter, EditorRequired] public required EmojiResponse Emoji { get; set; }
[Parameter, EditorRequired] public required string Source { get; set; }
[Parameter, EditorRequired] public required EventCallback GetEmojis { get; set; }
[Parameter] public EventCallback GetEmojis { get; set; }
private ElementReference EmojiButton { get; set; }
private Menu EmojiMenu { get; set; } = null!;

View file

@ -0,0 +1,47 @@
@using Iceshrimp.Frontend.Core.Miscellaneous
@using Iceshrimp.Frontend.Core.Services
@using Iceshrimp.Shared.Schemas.Web
@using Iceshrimp.Assets.PhosphorIcons
@inject ApiService Api;
<span @onclick="() => _visible = !_visible" class="category-name">
@Host
@if (_visible)
{
<Icon Name="Icons.CaretDown"/>
}
else
{
<Icon Name="Icons.CaretRight"/>
}
</span>
@if (_visible)
{
<div class="emoji-list">
@foreach (var emoji in Emojis)
{
<EmojiManagementEntry Emoji="@emoji" Source="remote"/>
}
</div>
@if (Pd is { Next: not null } or null)
{
<ScrollEnd ManualLoad="FetchHostEmoji" IntersectionChange="FetchHostEmoji"/>
}
}
@code {
[Parameter, EditorRequired] public required string Host { get; set; }
private PaginationData? Pd { get; set; }
private List<EmojiResponse> Emojis { get; } = [];
private bool _visible = false;
private async Task FetchHostEmoji()
{
if (Pd is { Next: null }) return;
var pq = new PaginationQuery { MaxId = Pd?.Next?.Split('=')[1], Limit = Pd?.Limit };
var res = await Api.Emoji.GetRemoteEmojiAsync(Host, pq);
Pd = res.Links;
Emojis.AddRange(res.Data);
}
}

View file

@ -0,0 +1,22 @@
.category-name {
display: block;
margin: 1rem 0 0.5rem;
font-weight: bold;
max-width: 25rem;
text-overflow: ellipsis;
overflow: clip;
min-width: 0;
}
.emoji-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
width: 100%;
}
::deep {
i {
vertical-align: middle;
}
}

View file

@ -1,5 +1,6 @@
using Iceshrimp.Frontend.Core.Miscellaneous;
using Iceshrimp.Frontend.Core.Services;
using Iceshrimp.Shared.Helpers;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Components.Forms;
@ -16,6 +17,9 @@ internal class EmojiControllerModel(ApiClient api)
public Task<PaginationWrapper<List<EmojiResponse>>> GetRemoteEmojiAsync(string instance, PaginationQuery pq) =>
api.CallAsync<PaginationWrapper<List<EmojiResponse>>>(HttpMethod.Get, $"/emoji/remote/{instance}", pq);
public Task<List<EntityWrapper<string>>> GetRemoteEmojiHostsAsync(PaginationQuery pq) =>
api.CallAsync<List<EntityWrapper<string>>>(HttpMethod.Get, "/emoji/remote/hosts", pq);
public Task<EmojiResponse> UploadEmojiAsync(IBrowserFile file, string? name) =>
api.CallAsync<EmojiResponse>(HttpMethod.Post, "/emoji",
name != null ? QueryString.Create("name", name) : QueryString.Empty, file);

View file

@ -14,6 +14,7 @@
@inject GlobalComponentSvc Global;
@inject IJSRuntime Js;
@inject IStringLocalizer<Localization> Loc;
@inject ILogger<CustomEmojis> Logger;
<HeadTitle Text="@Loc["Custom Emojis"]"/>
@ -37,28 +38,55 @@
<div class="emoji-search">
<input @bind="EmojiFilter" @bind:event="oninput" @bind:after="FilterEmojis" class="search" type="text"
placeholder="Search" aria-label="search"/>
@if (Source == "remote")
{
<input @bind="HostFilter" @bind:event="oninput" @bind:after="FilterEmojis" class="search" type="text"
placeholder="Host" aria-label="host"/>
}
<select class="search-from" @bind="Source" @bind:after="GetEmojis">
<option value="local">@Loc["Local"]</option>
<option value="remote">@Loc["Remote"]</option>
</select>
<select class="display-type" @bind=_displayType @bind:after="GetEmojis">
<option value="@DisplayType.All">@Loc["List"]</option>
<option value="@DisplayType.Categories">@Loc["Categories"]</option>
</select>
</div>
}
@if (State is State.Loaded)
{
@foreach (var category in Categories)
@if (_displayType is DisplayType.All )
{
<span class="category-name">@category.Key</span>
<div class="emoji-list">
@foreach (var emoji in category.Value)
@foreach (var emoji in DisplayedEmojis)
{
<EmojiManagementEntry Emoji="@emoji" Source="@Source" GetEmojis="GetEmojis"/>
}
</div>
@if (PaginationData is { Next: not null } && Source == "remote")
{
<ScrollEnd ManualLoad="FetchMore" IntersectionChange="FetchMore"></ScrollEnd>
}
}
@if (_displayType is DisplayType.Categories && Source == "local")
{
foreach (var el in LocalEmojiCategories)
{
<span class="category-name">@el.Key</span>
<div class="emoji-list">
@foreach (var emoji in el.Value)
{
<EmojiManagementEntry Emoji="emoji" Source="remote"/>
}
</div>
}
}
@if (_displayType is DisplayType.Categories && Source == "remote")
{
@foreach (var category in DisplayedCategories)
{
<HostEmojiEntry Host="@category"/>
}
<ScrollEnd IntersectionChange="FetchHosts" ManualLoad="FetchHosts"/>
}
}
@if (State is State.Empty)
@ -68,7 +96,7 @@
@if (State is State.Loading)
{
<div class="loading">
<LoadingSpinner Scale="2" />
<LoadingSpinner Scale="2"/>
</div>
}
@ -79,56 +107,121 @@
</div>
@code {
private List<EmojiResponse> Emojis { get; set; } = [];
private Dictionary<string, List<EmojiResponse>> Categories { get; set; } = new();
private string EmojiFilter { get; set; } = "";
private string HostFilter { get; set; } = "";
private string Source { get; set; } = "local";
private State State { get; set; }
private InputFile UploadInput { get; set; } = null!;
private IBrowserFile UploadFile { get; set; } = null!;
private string UploadName { get; set; } = "";
private InputFile ImportInput { get; set; } = null!;
private IBrowserFile ImportFile { get; set; } = null!;
private IJSObjectReference _module = null!;
private List<EmojiResponse> DisplayedEmojis { get; set; } = [];
private List<EmojiResponse> StoredLocalEmojis { get; set; } = [];
private List<EmojiResponse> StoredRemoteEmojis { get; set; } = [];
private Dictionary<string, List<EmojiResponse>> LocalEmojiCategories { get; set; } = new();
private HashSet<string> Categories { get; set; } = [];
private HashSet<string> DisplayedCategories { get; set; } = [];
private string EmojiFilter { get; set; } = "";
private string Source { get; set; } = "local";
private State State { get; set; }
private InputFile UploadInput { get; set; } = null!;
private IBrowserFile UploadFile { get; set; } = null!;
private InputFile ImportInput { get; set; } = null!;
private IBrowserFile ImportFile { get; set; } = null!;
private PaginationData? PaginationData { get; set; }
private IJSObjectReference _module = null!;
private DisplayType _displayType = DisplayType.All;
private void FilterEmojis()
{
Categories = Emojis
.Where(p => p.Name.Contains(EmojiFilter.Trim()) || p.Aliases.Count(a => a.Contains(EmojiFilter.Trim())) != 0)
.OrderBy(p => p.Name)
.ThenBy(p => p.Id)
.GroupBy(p => p.Category)
.Where(p => Source == "local" || (Source == "remote" && (p.Key?.Contains(HostFilter) ?? false)))
.OrderBy(p => string.IsNullOrEmpty(p.Key))
.ThenBy(p => p.Key)
.ToDictionary(p => p.Key ?? "Other", p => p.ToList());
if (Source == "remote" && _displayType == DisplayType.All || Source == "local")
{
DisplayedEmojis = StoredRemoteEmojis.Where(p => p.Name.Contains(EmojiFilter.Trim()) || p.Aliases.Count(a => a.Contains(EmojiFilter.Trim())) > 0).ToList();
}
if (Source == "remote" && _displayType == DisplayType.Categories)
{
DisplayedCategories = Categories.Where(p => p.Contains(EmojiFilter.Trim())).ToHashSet();
}
if (Source == "local" && _displayType == DisplayType.Categories)
{
LocalEmojiCategories = StoredLocalEmojis
.Where(p => p.Name.Contains(EmojiFilter.Trim()) || p.Aliases.Count(a => a.Contains(EmojiFilter.Trim())) != 0)
.OrderBy(p => p.Name)
.ThenBy(p => p.Id)
.GroupBy(p => p.Category)
.OrderBy(p => string.IsNullOrEmpty(p.Key))
.ThenBy(p => p.Key)
.ToDictionary(p => p.Key ?? "Other", p => p.ToList());
}
}
private enum DisplayType
{
Categories,
All
}
private async Task GetEmojis()
{
State = State.Loading;
if (Source == "local")
{
State = State.Loading;
StoredLocalEmojis = await Api.Emoji.GetAllEmojiAsync();
FilterEmojis();
State = StoredLocalEmojis.Count == 0 ? State.Empty : State.Loaded;
}
if (Source == "remote")
{
//TODO: impolement proper pagination
var res = await Api.Emoji.GetRemoteEmojiAsync(new PaginationQuery());
Emojis = res.Data;
}
else
{
Emojis = await Api.Emoji.GetAllEmojiAsync();
if (_displayType is DisplayType.Categories)
{
await FetchHosts();
}
else
{
var res = await Api.Emoji.GetRemoteEmojiAsync(new PaginationQuery());
PaginationData = res.Links;
StoredRemoteEmojis = res.Data;
}
}
if (Emojis.Count == 0)
if (EmojiFilter.Length == 0)
{
State = State.Empty;
switch (Source)
{
case "remote":
DisplayedEmojis = StoredRemoteEmojis;
break;
case "local":
DisplayedEmojis = StoredLocalEmojis;
break;
}
}
else
}
private async Task FetchHosts()
{
// Due to query weirdness, MinID and MaxID are reversed here
var pq = new PaginationQuery { MinId = Categories.LastOrDefault(), Limit = 250 };
var res = await Api.Emoji.GetRemoteEmojiHostsAsync(pq);
foreach (var el in res)
{
FilterEmojis();
State = State.Loaded;
var op = Categories.Add(el.Entity);
if (!op) Logger.LogWarning($"Duplicate host entry: {el.Entity}");
}
if (EmojiFilter.Length == 0) DisplayedCategories = Categories;
}
private async Task FetchMore()
{
if (PaginationData?.Next == null) return;
var pq = new PaginationQuery { MaxId = PaginationData.Next?.Split('=')[1], Limit = PaginationData.Limit };
var res = await Api.Emoji.GetRemoteEmojiAsync(pq);
PaginationData = res.Links;
StoredRemoteEmojis.AddRange(res.Data);
if (EmojiFilter.Length == 0)
{
DisplayedEmojis = StoredRemoteEmojis;
}
StateHasChanged();
}
// The <InputFile> Component is hidden, and triggered by a sepperate button.