One sign-in for both of PrivaPub's APIs: the root logs in on /clientapi, picks a persona, and each persona gets its own Mastodon token in exchange for the JWT (PersonaExchange). HttpService picks credentials and JSON by route (JWT and camelCase for /clientapi, the persona token and snake_case for /api, nothing for /oauth), retries a refused persona token once, and records every request for the nerd stats. The feed page is three columns: navigation with a persona switcher, the feed with a tab per kind (home, local, federated, nearby, communities and circles, direct, notifications, favourites, bookmarks, hashtags), and a 3D globe that looks at the spherical mean of the posts in view and draws a pulse and an arc to a hovered one. Posts sit at their own place, their event's venue, the reader (located posts) or their author's server as PrivaPub publishes it. Cards favourite, boost, bookmark, reply, mute, block and delete; the composer posts with a content warning, visibility, media and alt text, or as a located post. Older pages load as the end nears; newer ones are polled while visible. Nerd stats in tooltips: provenance, author and server, place, media, counts, paging, the globe's camera and frame rate. The mock (Faker, MessagesService, the Message models and store) and the OIDC template leftovers are gone. Strings in English and Italian. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
124 lines
4.0 KiB
C#
124 lines
4.0 KiB
C#
using decePubClient.Helpers;
|
|
using decePubClient.Models;
|
|
|
|
using PrivaPub.ClientModels;
|
|
|
|
namespace decePubClient.Services
|
|
{
|
|
/// <summary>
|
|
/// The IndexedDB database "data" (schema in GenericExtensions.AddIndexedDb): the client log. Lists come back empty on
|
|
/// failure, mutations as an invalid WebResult; nothing throws. Failures are logged with ILogger only, because
|
|
/// ILoggingService writes its log through this class.
|
|
/// </summary>
|
|
public interface IStorage
|
|
{
|
|
Task<List<ClientLogs>> GetClientLogs(CancellationToken cancellationToken = default);
|
|
|
|
Task AddLog(Exception exception, string where, CancellationToken cancellationToken = default);
|
|
|
|
Task AddLog(string message, string where, CancellationToken cancellationToken = default);
|
|
|
|
Task<WebResult> RemoveAll(bool includeClientLogs = false, CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
public class Storage(IndexedDb db, ILogger<Storage> logger) : IStorage
|
|
{
|
|
/// <summary>The client log keeps at most this many entries; past it the oldest half is dropped.</summary>
|
|
const int MaxLogs = 100;
|
|
|
|
#region Logs
|
|
|
|
public async Task<List<ClientLogs>> GetClientLogs(CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
await db.OpenIndexedDb();
|
|
var logs = await db.GetAll<ClientLogs>(nameof(ClientLogs)) ?? [];
|
|
return [.. logs.OrderByDescending(log => log.TimeStamp)];
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, $"{nameof(Storage)}.{nameof(GetClientLogs)}");
|
|
return [];
|
|
}
|
|
}
|
|
|
|
public Task AddLog(Exception exception, string where, CancellationToken cancellationToken = default) =>
|
|
AddLog(new ClientLogs
|
|
{
|
|
Where = where,
|
|
Exception = new()
|
|
{
|
|
Type = exception.GetType().ToString(),
|
|
HelpLink = exception.HelpLink,
|
|
HResult = exception.HResult.ToString(),
|
|
InnerExceptionMessage = exception.InnerException?.Message,
|
|
Message = exception.Message,
|
|
Source = exception.Source,
|
|
StackTrace = exception.StackTrace,
|
|
TargetSiteName = exception.TargetSite?.Name
|
|
}
|
|
}, cancellationToken);
|
|
|
|
public Task AddLog(string message, string where, CancellationToken cancellationToken = default) =>
|
|
AddLog(new ClientLogs { Where = where, WarningMessage = message }, cancellationToken);
|
|
|
|
/// <summary>Numbers the entry after the newest one and trims the log; never throws.</summary>
|
|
async Task AddLog(ClientLogs log, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
await db.OpenIndexedDb();
|
|
|
|
var maxKey = await db.GetMaxKey<long>(nameof(ClientLogs));
|
|
var minKey = await db.GetMinKey<long>(nameof(ClientLogs));
|
|
if (maxKey - minKey >= MaxLogs)
|
|
for (var key = minKey; key < minKey + MaxLogs / 2; key++)
|
|
_ = await db.DeleteByKey(nameof(ClientLogs), key);
|
|
|
|
log.Id = maxKey + 1;
|
|
var result = await db.AddItems<ClientLogs>(nameof(ClientLogs), [log]);
|
|
logger.LogDebug($"{nameof(AddLog)}() add logs result = {result}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, $"{nameof(Storage)}.{nameof(AddLog)}");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
public Task<WebResult> RemoveAll(bool includeClientLogs = false, CancellationToken cancellationToken = default) =>
|
|
Run(nameof(RemoveAll), async () =>
|
|
{
|
|
if (!includeClientLogs)
|
|
return "nothing to remove";
|
|
return await db.DeleteAll(nameof(ClientLogs));
|
|
}, cancellationToken);
|
|
|
|
/// <summary>Runs a write and turns its outcome into a WebResult.</summary>
|
|
async Task<WebResult> Run(string caller, Func<Task<string>> write, CancellationToken cancellationToken)
|
|
{
|
|
var result = new WebResult();
|
|
try
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
await db.OpenIndexedDb();
|
|
logger.LogInformation("{Caller}: {Result}", caller, await write());
|
|
return result;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return SUtility.DefaultTaskCancelledResult;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, $"{nameof(Storage)}.{caller}");
|
|
return result.Invalidate(ex.Message, exception: ex);
|
|
}
|
|
}
|
|
}
|
|
}
|