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

+85 -144
View File
@@ -1,82 +1,68 @@
@page "/expand"
@page "/expand/{messageId}"
@page "/expand/{statusId}"
@attribute [Authorize]
@inherits LocalizableComponentBase
<Title>@Localizer["Thread"]</Title>
<div class="flex w-full h-full flex-col gap-4">
<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")">
@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 (IsNotFound)
{
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["This message doesn't exist."]
</p>
}
else
{
@foreach (var message in Ancestors)
{
<Content CssContainer="ml-6 w-auto" IncludeExpand="false" Message="message"
OnMessageBoost="async (m) => await OnMessageBoost(m)" OnMessageDelete="async (m) => await OnMessageDelete(m)"
OnMessageFavourite="async (m) => await OnMessageFavourite(m)" OnMessageReply="async (m) => await OnMessageReply(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)" />
}
<Content Highlighted="true" IncludeExpand="false" Message="CurrentMessage"
OnMessageBoost="async (m) => await OnMessageBoost(m)" OnMessageDelete="async (m) => await OnMessageDelete(m)"
OnMessageFavourite="async (m) => await OnMessageFavourite(m)" OnMessageReply="async (m) => await OnMessageReply(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)" />
@foreach (var message in Descendants)
{
<Content CssContainer="ml-6 w-auto" IncludeExpand="false" Message="message"
OnMessageBoost="async (m) => await OnMessageBoost(m)" OnMessageDelete="async (m) => await OnMessageDelete(m)"
OnMessageFavourite="async (m) => await OnMessageFavourite(m)" OnMessageReply="async (m) => await OnMessageReply(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)" />
}
}
@* A post with its thread (/api/v1/statuses/{id}/context): the posts it answers above, the post, the answers below.
The cards do their own actions; a reply or a deletion updates the thread here. *@
<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">
<div class="flex items-center gap-2">
<DButton Size="DSize.Sm" OnClick="Back" title="@Localizer["Back"]">
<i class="ion-md-arrow-back"></i>
</DButton>
<h1 class="font-bold">@Localizer["Thread"]</h1>
</div>
</section>
</div>
@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 (current is null)
{
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["This message doesn't exist."]
</p>
}
else
{
@foreach (var post in ancestors)
{
<Content @key="post.Key" Post="post" CssContainer="ml-6 w-auto" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
}
<Content @key="current.Key" Post="current" Highlighted="true" IncludeExpand="false" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
@foreach (var post in descendants)
{
<Content @key="post.Key" Post="post" CssContainer="ml-6 w-auto" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
}
}
</div>
</section>
@code {
[Inject] IStorage DbStorage { get; set; }
[Inject] StatusService StatusService { get; set; }
[Inject] InstanceService InstanceService { get; set; }
[Inject] ILocalStorageService LocalStorage { get; set; }
[Inject] IBlazorDownloadFileService BlazorDownloadFileService { get; set; }
[Inject] MessagesService MessagesService { get; set; }
[Parameter] public string MessageId { get; set; }
[Inject] IJSRuntime JS { get; set; }
List<Message> Ancestors { get; set; } = [];
Message CurrentMessage { get; set; } = new();
List<Message> Descendants { get; set; } = [];
bool IsNotFound { get; set; }
TimeSortingType TimeSortingType { get; set; } = TimeSortingType.Ascending;
[Parameter] public string StatusId { get; set; }
FeedPost current;
List<FeedPost> ancestors = [];
List<FeedPost> descendants = [];
string loadedId;
// Parameters rather than initialization: following a link to another message reuses this page.
protected override async Task OnParametersSetAsync()
{
if (string.IsNullOrEmpty(MessageId))
{
Navigation.NavigateTo("");
if (StatusId == loadedId)
return;
}
IsLoading = true;
var filters = await LocalStorage.GetItemAsync<ActionBarFilter>(nameof(ActionBarFilter)) ?? new();
TimeSortingType = filters.TimeSortingType;
loadedId = StatusId;
if (CascadingState.IsOffline)
{
@@ -87,103 +73,58 @@
}
await LoadData();
IsLoading = false;
}
// The thread: the message from IndexedDB, the ones around it mock (Faker) until the app reads PrivaPub's context.
async Task LoadData()
{
var message = await DbStorage.GetMessage(MessageId);
IsNotFound = message is null;
if (IsNotFound)
return;
CurrentMessage = message;
(Ancestors, Descendants) = Faker.Thread(message);
}
async Task OnMessageReply(MessageForm messageForm)
{
var result = await MessagesService.SubmitMessage(messageForm, CascadingState.User);
if (result.IsInvalid)
IsLoading = true;
var status = await StatusService.Get(StatusId);
if (status is null)
{
Toast.AddResponseError(result);
current = null;
IsLoading = false;
return;
}
Descendants.Add(result.Data as Message);
var context = await StatusService.Context(status.Id);
current = FeedPost.Of(status);
ancestors = [.. (context?.Ancestors ?? []).Select(FeedPost.Of)];
descendants = [.. (context?.Descendants ?? []).Select(FeedPost.Of)];
IsLoading = false;
var all = ancestors.Append(current).Concat(descendants).ToList();
await InstanceService.Resolve(all.Select(p => p.Host).Append(InstanceService.SelfHost));
InstanceService.Locate(all);
}
async Task OnMessageBoost(Message message)
void Remove(FeedPost post)
{
if (message.IsBoostedByCurrentUser)
return;
var result = await MessagesService.BoostUnboostMessage(message);
if (result.IsInvalid)
Toast.AddResponseError(result);
}
async Task OnMessageFavourite(Message message)
{
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)
if (post.Key == current?.Key)
{
Toast.AddResponseError(result);
current = null;
return;
}
ancestors.RemoveAll(p => p.Key == post.Key);
descendants.RemoveAll(p => p.Key == post.Key);
}
Toast.AddResponseSuccess(Localizer["Message deleted."]);
if (message == CurrentMessage)
{
Navigation.NavigateTo("");
async Task Replied(FeedPost reply)
{
if (reply is null)
return;
}
Ancestors.Remove(message);
Descendants.Remove(message);
descendants.Add(reply);
await InstanceService.Resolve([reply.Host]);
InstanceService.Locate([reply]);
}
async Task OnMessageMediaDownload(Media media)
async Task OpenTag(string tag)
{
await BlazorDownloadFileService.DownloadFileAsync(media.FileName, media.Blob, media.ContentType);
var filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new();
filter.Tag = tag;
filter.TimelineType = TimelineType.Tag;
await LocalStorage.SetItemAsync(nameof(FeedFilter), filter);
Navigation.NavigateTo("");
}
async Task OnUserBlock(MessageUser messageUser)
{
var result = await MessagesService.BlockUserFromMessage(messageUser);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(Localizer["{0} blocked.", messageUser.DisplayName]);
}
async Task OnUserDirectMessage(Message message)
{
var result = await MessagesService.ReplyMessage(message, MessageType.Direct);
if (result.IsInvalid)
Toast.AddResponseError(result);
}
async Task OnUserSilence(MessageUser messageUser)
{
var result = await MessagesService.SilenceUserFromMessage(messageUser);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(Localizer["{0} muted.", messageUser.DisplayName]);
}
async Task Back() => await JS.InvokeVoidAsync("history.back");
}
+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; }
}
}
+118 -3
View File
@@ -1,15 +1,130 @@
@page "/login"
@page "/login"
@inherits LocalizableComponentBase
<Title>@Localizer["Login"]</Title>
@* One sign-in for both of PrivaPub's APIs: the root signs in here, then picks the persona to act as when it has more than
one (CascadingState.LoadSession picks the last one used, or the only one). *@
<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">
<LoginDisplay />
<div class="flex flex-col items-center gap-6 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
@if (!CascadingState.IsSignedIn)
{
<EditForm Model="form" OnValidSubmit="SignIn" class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm">
<DataAnnotationsValidator />
<h1 class="text-xl font-bold">@Localizer["Sign in to PrivaPub"]</h1>
<p class="text-sm opacity-70">@Localizer["One login for all of your personas: each one is signed in separately and never linked to the others."]</p>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Username"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocomplete="username" autocapitalize="none" spellcheck="false">
<Prefix><i class="ion-md-person"></i></Prefix>
</DInput>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="form.Password" autocomplete="current-password">
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
<ErrorViewer AdditionalError="@error" />
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking">
@if (isWorking)
{
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Login"]</span>
</DButton>
</EditForm>
}
else if (CascadingState.Personas.Count == 0)
{
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm">
<h1 class="text-xl font-bold">@Localizer["No persona yet"]</h1>
<p class="text-sm">@Localizer["This login has no persona to act as. Create one on PrivaPub first."]</p>
<DButton OnClick="SignOut">@Localizer["Logout"]</DButton>
</div>
}
else
{
<div class="flex flex-col gap-4 w-full max-w-md mt-6 md:mt-16">
<h1 class="text-xl font-bold text-center">@Localizer["Who are you today?"]</h1>
<p class="text-sm opacity-70 text-center">@Localizer["Each persona has its own token; PrivaPub never tells anyone they share a login."]</p>
@foreach (var persona in CascadingState.Personas)
{
<button type="button" class="flex items-center gap-3 p-3 rounded-xl text-left neo-raised neo-xs hover:neo-sm transition-shadow @(persona.Id == CascadingState.PersonaId ? "ring-2 ring-primary" : null)"
disabled="@(IsDefaultDisabled || isWorking)" @onclick="() => Choose(persona.Id)">
<img alt="" class="h-12 w-12 flex-none object-cover rounded-full neo-raised neo-2xs" src="@(persona.PictureURL ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<b class="truncate">@(persona.Name ?? persona.UserName)</b>
<span class="text-xs opacity-70 truncate">@(persona.Handle ?? "@" + persona.UserName)</span>
</span>
@if (persona.Id == CascadingState.PersonaId)
{
<i class="ion-md-checkmark-circle text-primary text-xl ml-auto"></i>
}
</button>
}
<ErrorViewer AdditionalError="@error" />
</div>
}
</div>
</section>
@code {
[Inject] AuthService AuthService { get; set; }
readonly LoginForm form = new();
string error;
bool isWorking;
[SupplyParameterFromQuery] public string ReturnUrl { get; set; }
protected override void OnInitialized() => IsLoading = false;
async Task SignIn()
{
error = default;
isWorking = true;
var result = await AuthService.Login(form);
if (result.IsInvalid)
{
error = result.ErrorMessage;
isWorking = false;
return;
}
await CascadingState.LoadSession();
isWorking = false;
if (CascadingState.Personas.Count == 1 && CascadingState.Persona is not null)
Continue();
}
async Task Choose(string personaId)
{
error = default;
isWorking = true;
var result = await CascadingState.SwitchPersona(personaId);
isWorking = false;
if (result.IsInvalid)
{
error = result.ErrorMessage;
return;
}
Continue();
}
async Task SignOut() => await CascadingState.SignOut();
/// <summary>Back where the reader was sent from, never off the app.</summary>
void Continue()
{
var target = ReturnUrl is { Length: > 0 } url && !url.Contains("://") && !url.StartsWith("//") && !url.StartsWith("login") ? url : "";
Navigation.NavigateTo(target);
}
}
+4 -8
View File
@@ -1,4 +1,4 @@
@page "/logout"
@page "/logout"
@inherits LocalizableComponentBase
<Title>@Localizer["Logout"]</Title>
@@ -11,14 +11,10 @@
</section>
@code {
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
// The app signs in with its own token (TokenAuthStateProvider), so signing out clears that token and the local
// database. The template's authentication/logout route can't do it: RemoteAuthenticatorView needs the OIDC
// provider this app replaces, and throws.
// Revokes every persona token the client holds, ends the root's session and clears the local data.
protected override async Task OnInitializedAsync()
{
await AuthStateProvider.LogoutAsync(true);
Navigation.NavigateTo("");
await CascadingState.SignOut();
Navigation.NavigateTo("login");
}
}