Files
decePubClient/Services/EmojiService.cs
T

229 lines
11 KiB
C#
Raw Normal View History

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;
}
}
}