Files
decePubClient/Pages/Index.razor
T
thepraandClaude Opus 5.5 827ecb770e 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
2026-10-04 11:03:14 +02:00

426 lines
14 KiB
Plaintext

@page "/"
@page "/home"
@page "/index"
@attribute [Authorize]
@inherits LocalizableComponentBase
<Title>@Localizer[Filter.TimelineType is TimelineType.Federation ? "Federated" : Filter.TimelineType.ToString()]</Title>
@* 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">
<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>
<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" />
<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>
</div>
@code {
const string ScrollId = "feed-scroll";
const string SentinelId = "feed-end";
static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(45);
[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()
{
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(Reload);
StartOnlinePolling();
IsLoading = false;
return;
}
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;
if (result.IsInvalid)
{
if (result.StatusCode is not 410)
loadError = result.ErrorMessage;
return;
}
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");
}
/// <summary>Reads the servers of the posts and places the posts on the globe.</summary>
async Task Locate(List<FeedPost> located, CancellationToken cancellationToken)
{
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;
}
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
{
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;
}
position = (latitude, longitude);
await Reload();
}
async Task Published(FeedPost post)
{
isComposing = false;
if (post is null)
{
if (Filter.TimelineType is TimelineType.Nearby or TimelineType.Groups)
await Reload();
return;
}
if (Filter.TimelineType is TimelineType.Home or TimelineType.Local || post.Status?.Visibility == "direct" && Filter.TimelineType is TimelineType.Direct)
{
posts.Insert(0, post);
await Locate([post], loadCancellation?.Token ?? default);
}
}
async Task Replied(FeedPost reply)
{
if (reply is null || Filter.TimelineType is not (TimelineType.Home or TimelineType.Local))
return;
posts.Insert(0, reply);
await Locate([reply], loadCancellation?.Token ?? default);
}
void Remove(FeedPost post)
{
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)
{
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);
}
}
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; }
}
}