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

+358 -138
View File
@@ -1,205 +1,425 @@
@page "/"
@page "/home"
@page "/index"
@attribute [Authorize]
@inherits LocalizableComponentBase
<Title>@Localizer["Index"]</Title>
<Title>@Localizer[Filter.TimelineType is TimelineType.Federation ? "Federated" : Filter.TimelineType.ToString()]</Title>
<div class="flex w-full h-full flex-col gap-4">
@* The feed page, the middle and right of three columns (NavMenu is the left): the feed with a tab per kind of feed,
and the globe, which follows the posts in view (GlobePanel). Below lg the globe is a banner above the feed, closed by
default on phones. Pages of older posts load as the end comes near (wwwroot/js/feed.js); newer ones are polled every
45 seconds while the page is visible and shown on request, because PrivaPub has no streaming. *@
<div class="flex flex-col lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(320px,42%)] gap-3 md:gap-4 w-full h-full">
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto @SUtility.IfTrueThen(TimeSortingType is TimeSortingType.Descending, "flex-col-reverse")">
<aside class="@(Filter.ShowGlobe ? "block" : "hidden") lg:block lg:order-last relative h-56 sm:h-64 lg:h-full flex-none min-h-0 rounded-xl neo-raised neo-xs overflow-hidden">
<GlobePanel @ref="globe" ScrollContainerId="@ScrollId" Posts="posts" />
</aside>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (Messages.Count == 0)
{
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
}
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full">
<FeedTabs Filter="Filter" Groups="groups" Stats="FeedStats()" IsComposing="isComposing" OnCompose="() => isComposing = !isComposing"
OnChanged="ChangeFeed" />
@foreach (var message in Messages)
{
<Content Message="message"
OnMessageBoost="async (m) => await OnMessageBoost(m)" OnMessageDelete="async (m) => await OnMessageDelete(m)"
OnMessageFavourite="async (m) => await OnMessageFavourite(m)" OnMessageReply="async (m) => await OnMessageSubmit(m)"
OnMessageMediaDownload="async (m) => await OnMessageMediaDownload(m)"
OnUserBlock="async (u) => await OnUserBlock(u)" OnUserDirectMessage="async (u) => await OnUserDirectMessage(u)" OnUserSilence="async (u) => await OnUserSilence(u)" />
}
@*<AuthorizeView>
<Authorized>
@foreach (var message in Messages)
{
<Content Message="message"
OnMessageReply="(m) => OnMessageReply(m)" OnMessageBoost="(m) => OnMessageBoost(m)"
OnMessageFavourite="(m) => OnMessageFavourite(m)" OnMessageDelete="(m) => OnMessageDelete(m)"
OnUserBlock="(u) => OnUserBlock(u)" OnUserDirectMessage="(u) => OnUserDirectMessage(u)" OnUserMute="(u) => OnUserMute(u)" />
}
</Authorized>
<NotAuthorized>
@foreach (var message in Messages)
{
<Content Message="message" OnMessageReply="(m) => OnMessageReply(m)" />
}
</NotAuthorized>
</AuthorizeView>*@
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
<div id="@ScrollId" class="absolute inset-0 overflow-y-auto p-3 md:p-4">
<div class="flex flex-col gap-4">
@if (isComposing)
{
<div class="p-3 md:p-4 rounded-xl neo-raised neo-xs">
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
</div>
}
@if (pending.Count > 0)
{
<DButton Size="DSize.Sm" Color="DColor.Primary" class="self-center sticky top-0 z-10" OnClick="ShowPending">
<i class="ion-md-arrow-up"></i>
<span>@Localizer["{0} new", pending.Count]</span>
</DButton>
}
@if (Filter.TimelineType is TimelineType.Nearby && position is null)
{
<div class="flex flex-col items-center gap-2 text-sm text-center">
<p>@Localizer["Nearby shows located posts written within their range of you. It needs this device's position, which goes to PrivaPub only."]</p>
@if (positionError is { Length: > 0 })
{
<p class="text-error">@positionError</p>
}
<DButton Size="DSize.Sm" OnClick="AskPosition" Disabled="IsDefaultDisabled">
<i class="ion-md-locate"></i><span>@Localizer["Use my position"]</span>
</DButton>
</div>
}
else if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (loadError is { Length: > 0 })
{
<DAlert Color="DAlertColor.Error" Variant="DAlertVariant.Soft" class="text-sm">
<span>@loadError</span>
<DButton Size="DSize.Sm" OnClick="Reload">@Localizer["Retry"]</DButton>
</DAlert>
}
else if (posts.Count == 0)
{
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
}
@foreach (var post in posts)
{
<Content @key="post.Key" Post="post" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
}
<div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60">
@if (isLoadingMore)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
else if (!IsLoading && posts.Count > 0 && maxId is null)
{
<span>@Localizer["That's all."]</span>
}
</div>
</div>
</div>
</div>
</section>
<ActionBar OnMessageSubmit="OnMessageSubmit" OnTimelineChanged="OnTimelineChanged" OnTimeSortingChanged="OnTimeSortingChanged" />
</div>
@code {
[Inject] IBlazorDownloadFileService BlazorDownloadFileService { get; set; }
[Inject] IStorage Storage { get; set; }
[Inject] ILocalStorageService LocalStorage { get; set; }
[Inject] MessagesService MessagesService { get; set; }
const string ScrollId = "feed-scroll";
const string SentinelId = "feed-end";
static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(45);
TimelineType TimelineType { get; set; } = TimelineType.Home;
TimeSortingType TimeSortingType { get; set; } = TimeSortingType.Ascending;
List<Message> Messages { get; set; } = [];
[Inject] ILocalStorageService LocalStorage { get; set; }
[Inject] IJSRuntime JS { get; set; }
[Inject] TimelineService TimelineService { get; set; }
[Inject] GroupService GroupService { get; set; }
[Inject] InstanceService InstanceService { get; set; }
[Inject] RequestStats RequestStats { get; set; }
FeedFilter Filter { get; set; } = new();
List<FeedPost> posts = [];
List<FeedPost> pending = [];
List<ViewGroup> groups = [];
string maxId;
string minId;
string loadError;
bool isLoadingMore;
bool isComposing;
(double Latitude, double Longitude)? position;
string positionError;
string loadedPersonaId;
FeedQuery lastQuery;
DateTime? lastPollAt;
TimeSpan? lastLoadTook;
GlobePanel globe;
IJSObjectReference feedModule;
DotNetObjectReference<Index> reference;
PeriodicTimer pollTimer;
CancellationTokenSource pageCancellation = new();
CancellationTokenSource loadCancellation;
protected override async Task OnInitializedAsync()
{
var filters = await LocalStorage.GetItemAsync<ActionBarFilter>(nameof(ActionBarFilter)) ?? new();
TimelineType = filters.TimelineType;
TimeSortingType = filters.TimeSortingType;
Filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new() { ShowGlobe = !CascadingState.Status.IsMobileMedia() };
if (Filter.TimelineType is TimelineType.Tag && Filter.Tag is not { Length: > 0 })
Filter.TimelineType = TimelineType.Home;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
WhenOnline(Reload);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
if (CascadingState.PersonaId is null)
{
IsLoading = false;
return;
}
await Reload();
}
// The persona changed (NavMenu's switcher): everything on the page belongs to the old one.
protected override async Task OnParametersSetAsync()
{
if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId || IsLoading && loadedPersonaId is null)
return;
await Reload();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender)
return;
reference = DotNetObjectReference.Create(this);
feedModule = await JS.InvokeAsync<IJSObjectReference>("import", "./js/feed.js");
await feedModule.InvokeVoidAsync("observeEnd", reference, SentinelId, ScrollId);
pollTimer = new(PollInterval);
_ = PollNewer(pageCancellation.Token);
}
FeedQuery Query => new(Filter.TimelineType, CascadingState.PersonaId, Filter.GroupId, groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity == true,
Filter.Tag, position?.Latitude, position?.Longitude);
/// <summary>Reads the first page of the current feed again, and the persona's groups.</summary>
async Task Reload()
{
loadCancellation?.Cancel();
loadCancellation = new();
var cancellationToken = loadCancellation.Token;
IsLoading = true;
loadError = default;
pending = [];
posts = [];
maxId = default;
minId = default;
StateHasChanged();
if (CascadingState.PersonaId != loadedPersonaId)
{
groups = await GroupService.List(CascadingState.PersonaId, cancellationToken);
loadedPersonaId = CascadingState.PersonaId;
}
if (Filter.TimelineType is TimelineType.Groups && groups.All(g => g.Id != Filter.GroupId))
Filter.GroupId = groups.FirstOrDefault()?.Id;
lastQuery = Query;
if (Filter.TimelineType is TimelineType.Nearby && position is null)
{
IsLoading = false;
return;
}
var started = DateTime.UtcNow;
var result = await TimelineService.Load(Query, cancellationToken: cancellationToken);
if (cancellationToken.IsCancellationRequested)
return;
lastLoadTook = DateTime.UtcNow - started;
IsLoading = false;
}
// The feed: the mock one in IndexedDB until the app reads PrivaPub's timeline.
async Task LoadData()
{
Messages = await Storage.GetMessages();
}
void OnTimelineChanged(TimelineType timelineType)
{
TimelineType = timelineType;
}
void OnTimeSortingChanged(TimeSortingType timeSortingType)
{
TimeSortingType = timeSortingType;
}
// Covers replies too: the form carries the RootMessageId.
async Task OnMessageSubmit(MessageForm messageForm)
{
var result = await MessagesService.SubmitMessage(messageForm, CascadingState.User);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
if (result.StatusCode is not 410)
loadError = result.ErrorMessage;
return;
}
Messages.Insert(0, result.Data as Message);
var page = (FeedPage)result.Data;
posts = page.Posts;
maxId = page.MaxId;
minId = page.MinId;
lastPollAt = DateTime.UtcNow;
StateHasChanged();
await Locate(posts, cancellationToken);
if (feedModule is not null)
await feedModule.InvokeVoidAsync("checkEnd");
}
async Task OnMessageBoost(Message message)
/// <summary>Reads the servers of the posts and places the posts on the globe.</summary>
async Task Locate(List<FeedPost> located, CancellationToken cancellationToken)
{
var result = await MessagesService.BoostUnboostMessage(message);
await InstanceService.Resolve(located.Select(p => p.Host).Append(InstanceService.SelfHost), cancellationToken);
InstanceService.Locate(located, position);
StateHasChanged();
}
[JSInvokable]
public async Task NearEnd()
{
if (IsLoading || isLoadingMore || maxId is null)
return;
isLoadingMore = true;
StateHasChanged();
var cancellationToken = loadCancellation?.Token ?? default;
var result = await TimelineService.Load(Query, maxId: maxId, cancellationToken: cancellationToken);
isLoadingMore = false;
if (cancellationToken.IsCancellationRequested)
return;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
StateHasChanged();
return;
}
if (!message.IsBoostedByCurrentUser)
var page = (FeedPage)result.Data;
var known = posts.Select(p => p.Key).ToHashSet();
var older = page.Posts.Where(p => known.Add(p.Key)).ToList();
posts.AddRange(older);
maxId = older.Count == 0 ? null : page.MaxId;
StateHasChanged();
await Locate(older, cancellationToken);
if (feedModule is not null)
await feedModule.InvokeVoidAsync("checkEnd");
}
/// <summary>Asks for posts newer than the newest shown, while the page is visible; they wait behind the "new" button.</summary>
async Task PollNewer(CancellationToken cancellationToken)
{
try
{
Messages.RemoveAll(m => m.MessageId == message.MessageId && m.BoostingUser?.UserId == CascadingState.User.Id);
while (await pollTimer.WaitForNextTickAsync(cancellationToken))
{
if (IsLoading || CascadingState.IsOffline || minId is null || Filter.TimelineType is TimelineType.Nearby or TimelineType.Direct
|| Filter.TimelineType is TimelineType.Groups && groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity != true
|| !await feedModule.InvokeAsync<bool>("isVisible", cancellationToken))
continue;
var query = Query;
var result = await TimelineService.Load(query, minId: pending.FirstOrDefault()?.CursorId ?? minId, cancellationToken: cancellationToken);
lastPollAt = DateTime.UtcNow;
if (result.IsInvalid || query != Query)
continue;
var known = posts.Concat(pending).Select(p => p.Key).ToHashSet();
var newer = ((FeedPage)result.Data).Posts.Where(p => known.Add(p.Key)).ToList();
if (newer.Count == 0)
continue;
pending.InsertRange(0, newer);
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
await CascadingState.ProcessError(ex, $"{nameof(Index)}.{nameof(PollNewer)}", cancellationToken);
}
}
async Task ShowPending()
{
var shown = pending;
pending = [];
posts.InsertRange(0, shown);
minId = shown.FirstOrDefault()?.CursorId ?? minId;
await feedModule.InvokeVoidAsync("scrollToTop", ScrollId);
await Locate(shown, loadCancellation?.Token ?? default);
}
/// <summary>Saves the choices; reloads only when they change what the feed shows (not for the globe's toggle).</summary>
async Task ChangeFeed(FeedFilter filter)
{
await LocalStorage.SetItemAsync(nameof(FeedFilter), Filter);
if (Query == lastQuery)
return;
await Reload();
}
async Task OpenTag(string tag)
{
Filter.Tag = tag;
Filter.TimelineType = TimelineType.Tag;
await ChangeFeed(Filter);
}
async Task AskPosition()
{
positionError = default;
var answer = await JS.InvokeAsync<GeoAnswer>("feedPosition");
if (answer?.Latitude is not { } latitude || answer.Longitude is not { } longitude)
{
positionError = Localizer["This device did not give its position: {0}", answer?.Error ?? "?"];
return;
}
// The feed shows a boost as a copy of the message carrying the booster.
Messages.Insert(0, new()
{
BoostingUser = CascadingState.User.ToBoostingUser(),
BoostsCounter = message.BoostsCounter,
Content = message.Content,
CreatedAt = message.CreatedAt,
IsBoostedByCurrentUser = true,
IsFavourite = message.IsFavourite,
Medias = message.Medias,
MessageId = message.MessageId,
MessageType = message.MessageType,
RootMessageId = message.RootMessageId,
Title = message.Title,
User = message.User
});
position = (latitude, longitude);
await Reload();
}
async Task OnMessageFavourite(Message message)
async Task Published(FeedPost post)
{
var result = await MessagesService.FavouriteUnfavouriteMessage(message);
if (result.IsInvalid)
Toast.AddResponseError(result);
}
async Task OnMessageDelete(Message message)
{
var result = await MessagesService.DeleteMessage(message);
if (result.IsInvalid)
isComposing = false;
if (post is null)
{
Toast.AddResponseError(result);
if (Filter.TimelineType is TimelineType.Nearby or TimelineType.Groups)
await Reload();
return;
}
Messages.Remove(message);
Toast.AddResponseSuccess(Localizer["Message deleted."]);
}
async Task OnMessageMediaDownload(Media media)
{
await BlazorDownloadFileService.DownloadFileAsync(media.FileName, media.Blob, media.ContentType);
}
async Task OnUserBlock(MessageUser messageUser)
{
var result = await MessagesService.BlockUserFromMessage(messageUser);
if (result.IsInvalid)
if (Filter.TimelineType is TimelineType.Home or TimelineType.Local || post.Status?.Visibility == "direct" && Filter.TimelineType is TimelineType.Direct)
{
Toast.AddResponseError(result);
return;
posts.Insert(0, post);
await Locate([post], loadCancellation?.Token ?? default);
}
Toast.AddResponseSuccess(Localizer["{0} blocked.", messageUser.DisplayName]);
}
async Task OnUserDirectMessage(Message message)
async Task Replied(FeedPost reply)
{
var result = await MessagesService.ReplyMessage(message, MessageType.Direct);
if (result.IsInvalid)
Toast.AddResponseError(result);
if (reply is null || Filter.TimelineType is not (TimelineType.Home or TimelineType.Local))
return;
posts.Insert(0, reply);
await Locate([reply], loadCancellation?.Token ?? default);
}
async Task OnUserSilence(MessageUser messageUser)
void Remove(FeedPost post)
{
var result = await MessagesService.SilenceUserFromMessage(messageUser);
if (result.IsInvalid)
posts.RemoveAll(p => p.Key == post.Key || p.Status?.Id == post.Status?.Id);
}
IEnumerable<NerdRow> FeedStats()
{
yield return NerdRow.Of(Localizer["items"], posts.Count);
yield return NerdRow.Of(Localizer["waiting"], pending.Count > 0 ? pending.Count : null);
yield return NerdRow.Of("min_id", minId);
yield return NerdRow.Of("max_id", maxId ?? (posts.Count > 0 ? Localizer["end"] : null));
yield return NerdRow.Of(Localizer["first page"], lastLoadTook is { } took ? NerdRow.Duration(took) : null);
yield return NerdRow.Of(Localizer["polled"], lastPollAt is { } polled ? NerdRow.Duration(DateTime.UtcNow - polled) + " " + Localizer["ago"] : null);
yield return NerdRow.Of(Localizer["poll every"], NerdRow.Duration(PollInterval));
yield return NerdRow.Of(Localizer["placed"], posts.Count > 0 ? $"{posts.Count(p => p.Location is not null)}/{posts.Count}" : null);
if (RequestStats.Latest(1).FirstOrDefault() is { } last)
{
Toast.AddResponseError(result);
return;
yield return NerdRow.Of(Localizer["last request"], $"{last.Method} {last.Route}");
yield return NerdRow.Of(Localizer["answer"], $"{last.Status} · {last.DurationMs:0} ms · {NerdRow.Bytes(last.Bytes) ?? "?"}");
yield return NerdRow.Of("X-RateLimit-Remaining", last.RateLimitRemaining);
}
}
Toast.AddResponseSuccess(Localizer["{0} muted.", messageUser.DisplayName]);
public override async ValueTask DisposeAsync()
{
await pageCancellation.CancelAsync();
pageCancellation.Dispose();
loadCancellation?.Cancel();
pollTimer?.Dispose();
try
{
if (feedModule is not null)
{
await feedModule.InvokeVoidAsync("dispose");
await feedModule.DisposeAsync();
}
}
catch (JSDisconnectedException)
{
}
reference?.Dispose();
await base.DisposeAsync();
}
sealed class GeoAnswer
{
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public string Error { get; set; }
}
}