@inherits LocalizableComponentBase
@using System.Globalization
@* The server's own custom emoji, for administrators (/clientapi/admin/emoji, EmojiService), grouped by category: each
with its picture, its :shortcode:, whether its picker offers it and whether it is disabled (kept, but offered and shown
nowhere). One is added with a shortcode, a category and a picture streamed from the file (PNG, GIF, WebP or JPEG,
256 KB at most), and changed in the same dialog (its category, the picker, disabled, a new picture; never its
shortcode, its id on other servers), which shows PrivaPub's refusals: 409 for a shortcode taken, 422 for a picture it
won't take. Deleting one is asked first: the posts and profiles using it show its shortcode as text. Every change
reads the server's emoji for everyone again (EmojiService.Refresh), so this client's picker has it at once. *@
@Localizer["Add an emoji"]
@Localizer["Read again"]
@Localizer["Everyone here can use them in posts, names and reactions, and other servers show them. The picker offers those in it; one written out as :shortcode: works too, unless it is disabled."]
@if (IsLoading)
{
@Localizer["loading"]
}
else if (emojis.Count == 0)
{
@Localizer["No custom emoji yet."]
}
@foreach (var group in Groups)
{
@(group.Key is { Length: > 0 } category ? category : Localizer["No category"])
@foreach (var emoji in group)
{
@($":{emoji.Shortcode}:")
@emoji.Size.GetFileSize(Localizer) ยท @Localizer["Changed {0} ago", emoji.UpdatedAt.GetPassedTime(Localizer)]
@if (emoji.ContentType == "image/gif")
{
@Localizer["Animated"]
}
@if (emoji.Disabled)
{
@Localizer["Disabled"]
}
else if (!emoji.VisibleInPicker)
{
@Localizer["Not in the picker"]
}
@Localizer["Edit"]
@Localizer["Delete"]
}
}
@if (isEditing)
{
@(editedEmoji is null ? Localizer["New emoji"] : Localizer["Edit :{0}:", editedEmoji.Shortcode])
@Localizer["Shortcode"]
:
:
@(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."])
@Localizer["Category"]
@foreach (var category in Categories)
{
}
@Localizer["The picker groups the emoji by it."]
@Localizer["Picture"]
@if ((preview ?? editedEmoji?.Url) is { Length: > 0 } shown)
{
}
@Localizer["PNG, GIF, WebP or JPEG, {0} at most. The server fits it within 256 px and keeps a GIF's animation.", EmojiService.MaxPictureBytes.GetFileSize(Localizer)]
@if (editedEmoji is not null)
{
@Localizer["Without a new one, it keeps its picture."]
}
@Localizer["Offered in the picker of every client. Out of it, it is still taken when its shortcode is written."]
@Localizer["Kept, but offered nowhere, taken into no new post and shown in none."]
@Localizer["Cancel"]
@if (isSaving)
{
}
@Localizer["Save"]
}
@code {
[Inject] EmojiService EmojiService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
[Inject] ILoggingService Logger { get; set; }
static readonly string Accept = string.Join(',', EmojiService.PictureTypes.Concat([".png", ".gif", ".webp", ".jpg", ".jpeg"]));
List emojis = [];
HashSet busy = [];
// the dialog: a new emoji (no editedEmoji) or the one being changed, as the form will save it
bool isEditing;
ViewLocalEmoji editedEmoji;
LocalEmojiForm form = new();
// the picture picked, streamed from the file when the form is saved, and a small copy of it to show
IBrowserFile picture;
string preview;
List pictureErrors = [];
// what PrivaPub answered, and its status (data-emoji-error: 409 a shortcode taken, 422 a picture refused, 400 invalid)
string error;
string errorStatus;
bool isSaving;
// those with no category first, then by category
List> Groups =>
[
.. emojis.OrderBy(e => e.Shortcode, StringComparer.OrdinalIgnoreCase)
.GroupBy(e => e.Category ?? string.Empty)
.OrderBy(g => g.Key.Length == 0 ? 0 : 1)
.ThenBy(g => g.Key, StringComparer.CurrentCultureIgnoreCase)
];
List Categories => [.. emojis.Select(e => e.Category).Where(c => c is { Length: > 0 }).Distinct().Order(StringComparer.CurrentCultureIgnoreCase)];
static string StateOf(ViewLocalEmoji emoji) => emoji.Disabled ? "disabled" : emoji.VisibleInPicker ? "picker" : "hidden";
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => emojis = await EmojiService.List();
async Task Reload()
{
IsLoading = true;
await LoadData();
IsLoading = false;
}
void New() => Open(null, new LocalEmojiForm());
void Edit(ViewLocalEmoji emoji) => Open(emoji, new LocalEmojiForm
{
Shortcode = emoji.Shortcode,
Category = emoji.Category,
VisibleInPicker = emoji.VisibleInPicker,
Disabled = emoji.Disabled
});
void Open(ViewLocalEmoji emoji, LocalEmojiForm editing)
{
editedEmoji = emoji;
form = editing;
picture = null;
preview = null;
pictureErrors = [];
error = null;
errorStatus = null;
isEditing = true;
}
void Close()
{
isEditing = false;
picture = null;
preview = null;
}
void Closed(bool open)
{
if (!open)
Close();
}
async Task Pick(InputFileChangeEventArgs eventArgs)
{
pictureErrors = [];
picture = null;
preview = null;
var file = eventArgs.File;
if (!EmojiService.PictureTypes.Contains(EmojiService.TypeOf(file)))
{
pictureErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
return;
}
if (file.Size > EmojiService.MaxPictureBytes)
{
pictureErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name, file.Size.GetFileSize(Localizer),
EmojiService.MaxPictureBytes.GetFileSize(Localizer)]);
return;
}
picture = file;
preview = await Preview(file);
}
// a small copy the browser makes (a GIF's first frame); none when it can't
async Task Preview(IBrowserFile file)
{
try
{
var small = await file.RequestImageFileAsync("image/png", 96, 96);
await using var stream = small.OpenReadStream(EmojiService.MaxPictureBytes);
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer);
return $"data:image/png;base64,{Convert.ToBase64String(buffer.ToArray())}";
}
catch (Exception ex)
{
await Logger.ProcessWarning($"No preview of {file.Name}: {ex.Message}", $"{nameof(EmojiPanel)}.{nameof(Preview)}");
return default;
}
}
async Task Save()
{
error = null;
errorStatus = null;
if (editedEmoji is null && picture is null)
{
pictureErrors = [Localizer["Choose a picture."]];
return;
}
isSaving = true;
var result = editedEmoji is null ? await EmojiService.Insert(form, picture) : await EmojiService.Update(form, picture);
isSaving = false;
if (result.IsInvalid)
{
error = result.ErrorMessage;
errorStatus = result.StatusCode.ToString(CultureInfo.InvariantCulture);
return;
}
var added = editedEmoji is null;
Close();
Toast.AddResponseSuccess(added ? Localizer["Emoji added."] : Localizer["Emoji saved."]);
if (result.Data is not ViewLocalEmoji saved)
{
await LoadData();
return;
}
emojis = [.. emojis.Where(e => e.Shortcode != saved.Shortcode), saved];
}
async Task Delete(ViewLocalEmoji emoji)
{
if (!await Confirm.Ask(Localizer["Delete :{0}:?", emoji.Shortcode],
Localizer["It is offered no more, and the posts and profiles that use it show the text :{0}: instead. Other servers keep their copy until they read those posts again.", emoji.Shortcode],
Localizer["Delete"], true))
return;
busy.Add(emoji.Shortcode);
var result = await EmojiService.Delete(emoji.Shortcode);
busy.Remove(emoji.Shortcode);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
emojis = [.. emojis.Where(e => e.Shortcode != emoji.Shortcode)];
Toast.AddResponseSuccess(Localizer["Emoji deleted."]);
}
}