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

+57
View File
@@ -0,0 +1,57 @@
using System.Globalization;
namespace decePubClient.Components.Nerd;
/// <summary>One line of a NerdTip; a row without a value is not shown.</summary>
public sealed record NerdRow(string Label, string Value)
{
public static NerdRow Of(string label, object value) => new(label, value switch
{
null => null,
DateTime time => Time(time),
double number => number.ToString("0.###", CultureInfo.InvariantCulture),
bool flag => flag ? "yes" : "no",
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
_ => value.ToString()
});
/// <summary>A UTC time to the millisecond, as PrivaPub records it.</summary>
public static string Time(DateTime time) =>
DateTime.SpecifyKind(time, time.Kind == DateTimeKind.Unspecified ? DateTimeKind.Utc : time.Kind).ToUniversalTime()
.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
/// <summary>A duration the way a person reads it: 850 ms, 12.4 s, 3 min 5 s, 2 h 10 min, 4 d 3 h.</summary>
public static string Duration(TimeSpan span)
{
var sign = span < TimeSpan.Zero ? "-" : string.Empty;
span = span.Duration();
return sign + (span.TotalSeconds switch
{
< 1 => $"{span.TotalMilliseconds:0} ms",
< 60 => $"{span.TotalSeconds.ToString("0.0", CultureInfo.InvariantCulture)} s",
< 3600 => $"{span.Minutes} min {span.Seconds} s",
< 86400 => $"{(int)span.TotalHours} h {span.Minutes} min",
_ => $"{(int)span.TotalDays} d {span.Hours} h"
});
}
/// <summary>A byte count: 912 B, 14.2 KiB, 3.1 MiB.</summary>
public static string Bytes(long? bytes) => bytes switch
{
null => null,
< 1024 => $"{bytes} B",
< 1024 * 1024 => $"{(bytes.Value / 1024d).ToString("0.0", CultureInfo.InvariantCulture)} KiB",
_ => $"{(bytes.Value / 1048576d).ToString("0.0", CultureInfo.InvariantCulture)} MiB"
};
/// <summary>The great-circle distance between two points in kilometres (haversine, Earth radius 6371 km).</summary>
public static double Kilometres(double latitude1, double longitude1, double latitude2, double longitude2)
{
const double radius = 6371;
double Radians(double degrees) => degrees * Math.PI / 180;
var dLatitude = Radians(latitude2 - latitude1);
var dLongitude = Radians(longitude2 - longitude1);
var a = Math.Pow(Math.Sin(dLatitude / 2), 2) + Math.Cos(Radians(latitude1)) * Math.Cos(Radians(latitude2)) * Math.Pow(Math.Sin(dLongitude / 2), 2);
return 2 * radius * Math.Asin(Math.Min(1, Math.Sqrt(a)));
}
}
+49
View File
@@ -0,0 +1,49 @@
@inherits LocalizableComponentBase
@* Nerd stats on hover or focus: a DTooltip whose content is a monospace list of label/value rows. OnOpen runs on the
first hover or focus, for stats read on demand (a post's provenance); rows with no value are left out. *@
<DTooltip Placement="Placement" Align="Align" class="@Class" TipContent="Body" @onmouseenter="Open" @onfocusin="Open">
@ChildContent
</DTooltip>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public string Heading { get; set; }
[Parameter] public IEnumerable<NerdRow> Rows { get; set; } = [];
/// <summary>Shown under the rows while OnOpen's work runs.</summary>
[Parameter] public bool Pending { get; set; }
[Parameter] public EventCallback OnOpen { get; set; }
[Parameter] public DTooltipPlacement Placement { get; set; }
[Parameter] public DTooltipAlign Align { get; set; }
[Parameter] public string Class { get; set; }
bool opened;
protected override void OnInitialized() => IsLoading = false;
async Task Open()
{
if (opened || !OnOpen.HasDelegate)
return;
opened = true;
await OnOpen.InvokeAsync();
}
RenderFragment Body => @<div class="nerd-stats flex flex-col gap-1 text-left font-mono text-[11px] leading-tight max-w-80">
@if (Heading is { Length: > 0 })
{
<p class="font-bold opacity-80">@Heading</p>
}
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5">
@foreach (var row in Rows.Where(row => !string.IsNullOrEmpty(row.Value)))
{
<dt class="opacity-60 whitespace-nowrap">@row.Label</dt>
<dd class="break-all">@row.Value</dd>
}
</dl>
@if (Pending)
{
<p class="opacity-60">@Localizer["loading"]…</p>
}
</div>;
}