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:
1 parent
f41ebf2160
commit
827ecb770e
56 files changed
+5367
-832
No files matched your search
+85
-144
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user