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

+107
View File
@@ -0,0 +1,107 @@
@inherits LocalizableComponentBase
@* The feed page's tabs, one per kind of feed, scrolling sideways on narrow screens. Groups adds a choice of the
persona's communities and circles, Tag the hashtag being followed. The stats icon shows how the current feed was
read; the buttons on the right open the composer and, on narrow screens, the globe. *@
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 min-w-0">
<div class="flex-1 min-w-0 overflow-x-auto">
<DTabs Active="ActiveIndex" ActiveChanged="Select" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max">
@foreach (var type in Types)
{
<DTab Class="gap-1 whitespace-nowrap">
<Header>
<i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i>
<span class="@(type == Filter.TimelineType ? null : "hidden xl:inline")">@TabName(type)</span>
</Header>
</DTab>
}
</DTabs>
</div>
<NerdTip Heading="@Localizer["Feed"]" Rows="Stats" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
<i class="ion-md-stats opacity-50" aria-label="@Localizer["Feed"]"></i>
</NerdTip>
<DButton Size="DSize.Sm" class="lg:hidden" Active="Filter.ShowGlobe" OnClick="ToggleGlobe" title="@Localizer["Globe"]">
<i class="ion-md-planet text-base"></i>
</DButton>
<DButton Size="DSize.Sm" Active="IsComposing" OnClick="() => OnCompose.InvokeAsync()" title="@Localizer["Post"]">
<i class="ion-md-create text-base"></i>
<span class="hidden sm:inline">@Localizer["Post"]</span>
</DButton>
</div>
@if (Filter.TimelineType is TimelineType.Groups)
{
@if (Groups.Count == 0)
{
<p class="text-xs opacity-70">@Localizer["This persona is in no community or circle yet."]</p>
}
else
{
<DSelect TValue="string" Size="DSize.Sm" class="w-full sm:w-auto" Value="Filter.GroupId" ValueChanged="SelectGroup" ValueExpression="() => Filter.GroupId">
<Prefix><i class="@(SelectedGroup?.IsCommunity == false ? "ion-md-lock" : "ion-md-contacts")"></i></Prefix>
<ChildContent>
@foreach (var group in Groups)
{
<option value="@group.Id">@(group.IsCommunity ? "◎" : "◉") @(group.Name ?? group.UserName)</option>
}
</ChildContent>
</DSelect>
}
}
else if (Filter.TimelineType is TimelineType.Tag)
{
<p class="text-sm inline-flex items-center gap-2"><i class="ion-md-pricetag"></i><b>#@Filter.Tag</b></p>
}
</div>
@code {
[Parameter, EditorRequired] public FeedFilter Filter { get; set; }
[Parameter] public List<ViewGroup> Groups { get; set; } = [];
[Parameter] public IEnumerable<NerdRow> Stats { get; set; } = [];
[Parameter] public bool IsComposing { get; set; }
[Parameter] public EventCallback OnCompose { get; set; }
/// <summary>Raised after Filter changed (tab, group or globe).</summary>
[Parameter] public EventCallback<FeedFilter> OnChanged { get; set; }
static readonly TimelineType[] Always =
[
TimelineType.Home, TimelineType.Local, TimelineType.Federation, TimelineType.Nearby, TimelineType.Groups, TimelineType.Direct,
TimelineType.Notifications, TimelineType.Favourites, TimelineType.Bookmarks
];
List<TimelineType> Types => Filter.Tag is { Length: > 0 } ? [.. Always, TimelineType.Tag] : [.. Always];
int ActiveIndex => Types.IndexOf(Filter.TimelineType);
ViewGroup SelectedGroup => Groups.FirstOrDefault(g => g.Id == Filter.GroupId);
protected override void OnInitialized() => IsLoading = false;
string TabName(TimelineType type) => type switch
{
TimelineType.Federation => Localizer["Federated"],
TimelineType.Tag => "#" + Filter.Tag,
_ => Localizer[type.ToString()]
};
async Task Select(int index)
{
Filter.TimelineType = Types[index];
if (Filter.TimelineType is TimelineType.Groups && SelectedGroup is null)
Filter.GroupId = Groups.FirstOrDefault()?.Id;
await OnChanged.InvokeAsync(Filter);
}
async Task SelectGroup(string groupId)
{
Filter.GroupId = groupId;
await OnChanged.InvokeAsync(Filter);
}
async Task ToggleGlobe()
{
Filter.ShowGlobe = !Filter.ShowGlobe;
await OnChanged.InvokeAsync(Filter);
}
}
+184
View File
@@ -0,0 +1,184 @@
@inherits LocalizableComponentBase
@* The feed's 3D globe (wwwroot/js/globe.js on the vendored globe.gl): it looks at the average place of the posts in
view and shows where a hovered post is. The posts' places go to the module in one call whenever they change; the
corner shows the camera and the frame rate, its tooltip the rest of the globe's numbers. *@
<div class="relative w-full h-full">
<div @ref="element" class="absolute inset-0" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div>
<div class="absolute left-2 bottom-2 flex items-center gap-2 text-[10px] font-mono opacity-70 pointer-events-auto">
<NerdTip Heading="@Localizer["Globe"]" Rows="StatsRows()" Placement="DTooltipPlacement.Top" Align="DTooltipAlign.Start">
<span class="badge badge-ghost badge-xs gap-1">
<i class="ion-md-planet"></i>
@if (stats is { Error: null } live)
{
<span>@Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @live.Fps fps</span>
}
else if (stats?.Error is { Length: > 0 })
{
<span>@Localizer["The globe could not start."]</span>
}
else
{
<span>@Localizer["loading"]…</span>
}
</span>
</NerdTip>
</div>
</div>
@code {
[Inject] IJSRuntime JS { get; set; }
[Inject] InstanceService InstanceService { get; set; }
/// <summary>The id of the element that scrolls the feed's cards.</summary>
[Parameter, EditorRequired] public string ScrollContainerId { get; set; }
[Parameter] public List<FeedPost> Posts { get; set; } = [];
ElementReference element;
IJSObjectReference module;
PeriodicTimer statsTimer;
readonly CancellationTokenSource cancellation = new();
string sentFingerprint;
GlobeStats stats;
protected override void OnInitialized() => IsLoading = false;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
try
{
if (firstRender)
{
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js");
await module.InvokeVoidAsync("create", element, ScrollContainerId);
statsTimer = new(TimeSpan.FromSeconds(1));
_ = ReadStats(cancellation.Token);
}
await SendPosts();
}
catch (JSDisconnectedException)
{
}
catch (Exception ex) when (ex is JSException or InvalidOperationException)
{
await CascadingState.ProcessError(ex, $"{nameof(GlobePanel)}.{nameof(OnAfterRenderAsync)}");
}
}
/// <summary>Sends the located posts when they changed since the last call.</summary>
async Task SendPosts()
{
if (module is null)
return;
var located = Posts.Where(p => p.Location is not null).ToList();
var self = InstanceService.Known(InstanceService.SelfHost);
var fingerprint = string.Join('|', located.Select(p => $"{p.Key}:{p.Location.Latitude}:{p.Location.Longitude}")) + $"#{self?.Geo?.Latitude}";
if (fingerprint == sentFingerprint)
return;
sentFingerprint = fingerprint;
var points = located.Select(p =>
{
var server = InstanceService.Known(p.Host)?.Geo;
return new GlobePoint(p.Key, p.Location.Latitude, p.Location.Longitude, p.Location.Source.ToString().ToLowerInvariant(), p.Location.Label, p.Host,
server?.Latitude, server?.Longitude, p.Located ? p.RangeKm : null);
}).ToList();
await module.InvokeVoidAsync("setPosts", points, SelfPoint(self));
}
static GlobeSelf SelfPoint(ServerInfo self)
{
if (self?.Geo is { Latitude: { } latitude, Longitude: { } longitude } geo)
return new(latitude, longitude, $"PrivaPub · {geo.City ?? self.Host}", self.Host);
return CountryPoints.Of(self?.Geo?.Country) is { } country
? new(country.Latitude, country.Longitude, $"PrivaPub · {InstanceService.CountryName(self.Geo.Country)}", self.Host)
: null;
}
async Task ReadStats(CancellationToken cancellationToken)
{
try
{
while (await statsTimer.WaitForNextTickAsync(cancellationToken))
{
stats = await module.InvokeAsync<GlobeStats>("stats", cancellationToken);
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
}
catch (JSDisconnectedException)
{
}
}
IEnumerable<NerdRow> StatsRows()
{
if (stats is null)
yield break;
yield return NerdRow.Of(Localizer["camera"], $"{Fixed(stats.Latitude)}, {Fixed(stats.Longitude)} @ {Fixed(stats.Altitude)}");
yield return NerdRow.Of(Localizer["in view"], $"{stats.VisibleLocated}/{stats.Visible} {Localizer["placed"]}");
yield return NerdRow.Of(Localizer["centre of view"], stats.CentroidLatitude is { } latitude ? $"{Fixed(latitude)}, {Fixed(stats.CentroidLongitude)}" : null);
yield return NerdRow.Of(Localizer["spread"], stats.SpreadKm > 0 ? $"{stats.SpreadKm:N0} km" : null);
yield return NerdRow.Of(Localizer["points"], $"{stats.Points} ({stats.Posts} {Localizer["posts"]}, {stats.Servers} {Localizer["servers"]})");
yield return NerdRow.Of(Localizer["hovered"], stats.Hovered is { } hovered ? $"{hovered} ({stats.Arcs} {Localizer["arcs"]}, {stats.Rings} {Localizer["rings"]})" : null);
yield return NerdRow.Of("fps", stats.Paused ? Localizer["paused"] : stats.Fps.ToString());
yield return NerdRow.Of(Localizer["auto-rotate"], stats.AutoRotate);
yield return NerdRow.Of(Localizer["texture"], stats.Texture is { } texture ? $"{texture} 4096×2048" : null);
yield return NerdRow.Of("WebGL", stats.Renderer);
yield return NerdRow.Of(Localizer["error"], stats.Error);
}
static string Fixed(double? value) => value?.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture) ?? "–";
public override async ValueTask DisposeAsync()
{
await cancellation.CancelAsync();
cancellation.Dispose();
statsTimer?.Dispose();
try
{
if (module is not null)
{
await module.InvokeVoidAsync("dispose");
await module.DisposeAsync();
}
}
catch (JSDisconnectedException)
{
}
await base.DisposeAsync();
}
sealed record GlobePoint(string Key, double Lat, double Lng, string Src, string Label, string Host, double? ServerLat, double? ServerLng, double? Range);
sealed record GlobeSelf(double Lat, double Lng, string Label, string Host);
sealed class GlobeStats
{
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public double? Altitude { get; set; }
public int Visible { get; set; }
public int VisibleLocated { get; set; }
public double? CentroidLatitude { get; set; }
public double? CentroidLongitude { get; set; }
public double SpreadKm { get; set; }
public int Points { get; set; }
public int Posts { get; set; }
public int Servers { get; set; }
public int Fps { get; set; }
public string Renderer { get; set; }
public string Texture { get; set; }
public bool AutoRotate { get; set; }
public bool Paused { get; set; }
public string Hovered { get; set; }
public int Arcs { get; set; }
public int Rings { get; set; }
public string Error { get; set; }
}
}