Wire the client to PrivaPub: one sign-in, real feeds, a globe, nerd stats

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
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-04 11:03:14 +02:00
1 parent f41ebf2160
commit 827ecb770e
56 files changed
+5367 -832

No files matched your search

+7 -71
View File
@@ -1,4 +1,4 @@
using decePubClient.Helpers;
using decePubClient.Helpers;
using decePubClient.Models;
using PrivaPub.ClientModels;
@@ -6,22 +6,12 @@ using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>
/// The IndexedDB database "data" (schema in GenericExtensions.AddIndexedDb): messages and 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.
/// 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<Message>> GetMessages(CancellationToken cancellationToken = default);
Task<Message> GetMessage(string messageId, CancellationToken cancellationToken = default);
Task<WebResult> AddMessages(List<Message> messages, CancellationToken cancellationToken = default);
Task<WebResult> UpdateMessages(List<Message> messages, CancellationToken cancellationToken = default);
Task<WebResult> RemoveMessage(string messageId, CancellationToken cancellationToken = default);
Task<List<ClientLogs>> GetClientLogs(CancellationToken cancellationToken = default);
Task AddLog(Exception exception, string where, CancellationToken cancellationToken = default);
@@ -36,59 +26,6 @@ namespace decePubClient.Services
/// <summary>The client log keeps at most this many entries; past it the oldest half is dropped.</summary>
const int MaxLogs = 100;
#region Messages
public async Task<Message> GetMessage(string messageId, CancellationToken cancellationToken = default)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
await db.OpenIndexedDb();
var message = await db.GetByKey<string, Message>(nameof(Message), messageId);
if (message is not null)
return message;
var messages = await GetMessages(cancellationToken);
return messages.FirstOrDefault(m => m.MessageId == messageId);
}
catch (Exception ex)
{
logger.LogError(ex, $"{nameof(Storage)}.{nameof(GetMessage)}");
return default;
}
}
/// <summary>The stored messages, newest first; while none are stored, the mock feed (Faker.SeedMessages).</summary>
public async Task<List<Message>> GetMessages(CancellationToken cancellationToken = default)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
await db.OpenIndexedDb();
var messages = await db.GetAll<Message>(nameof(Message)) ?? [];
if (messages.Count == 0)
messages = Faker.SeedMessages();
return [.. messages.OrderByDescending(m => m.CreatedAt)];
}
catch (Exception ex)
{
logger.LogError(ex, $"{nameof(Storage)}.{nameof(GetMessages)}");
return [];
}
}
public Task<WebResult> AddMessages(List<Message> messages, CancellationToken cancellationToken = default) =>
Run(nameof(AddMessages), async () => await db.AddItems(nameof(Message), messages), cancellationToken);
public Task<WebResult> UpdateMessages(List<Message> messages, CancellationToken cancellationToken = default) =>
Run(nameof(UpdateMessages), async () => await db.UpdateItems(nameof(Message), messages), cancellationToken);
public Task<WebResult> RemoveMessage(string messageId, CancellationToken cancellationToken = default) =>
Run(nameof(RemoveMessage), async () => await db.DeleteByKey(nameof(Message), messageId), cancellationToken);
#endregion
#region Logs
public async Task<List<ClientLogs>> GetClientLogs(CancellationToken cancellationToken = default)
@@ -156,10 +93,9 @@ namespace decePubClient.Services
public Task<WebResult> RemoveAll(bool includeClientLogs = false, CancellationToken cancellationToken = default) =>
Run(nameof(RemoveAll), async () =>
{
var result = await db.DeleteAll(nameof(Message));
if (includeClientLogs)
result = await db.DeleteAll(nameof(ClientLogs));
return result;
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>