R4 the client catches up: follow requests, followers, downvotes, dismissing, filtering a post, quote policies, statistics in detail
Build / Build (push) Successful in 1m8s

Follow requests answered on the persona's own profile and from the Notifications tab; its followers and the accounts it
follows listed there, a follower removed (asked first). Downvotes on threadiverse posts, after the public-vote notice.
A notification dismissed from its line. "Filter this post" from a card's menu, and the Filters page showing the posts a
filter holds. Who may quote the persona's own posts, and revoking a quote of one. Statistics: a server in detail with
"describe again", the ledger's events, the server's own days. The composer deletes an upload taken out before posting.
MediaType.Documents goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-17 04:02:20 +02:00
1 parent b731d8c8c0
commit b8d7858acb
25 files changed
+1543 -61

No files matched your search

+136 -5
View File
@@ -7,8 +7,11 @@
quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id.
The persona's filters apply where the page says the card is (FilterIn): a
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their author deletes them through /clientapi. Nerd stats are on
the time, the author and its server's history, the place or the CDN, the media and the counts. *@
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media; "Filter this
post" puts it in one of them. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their
author deletes them through /clientapi. A threadiverse post takes a downvote; the persona's own public post is given who
may quote it, and a post quoting it can have its quote revoked; a notification is dismissed from its line. Nerd stats
are on the time, the author and its server's history, the place or the CDN, the media and the counts. *@
@if (!IsFilteredOut)
{
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
@@ -49,6 +52,13 @@
}
<span>@NotificationText(notification)</span>
<span class="opacity-60" title="@NerdRow.Time(notification.CreatedAt)">@notification.CreatedAt.GetPassedTime(Localizer)</span>
@if (!Compact)
{
<DButton Size="DSize.Xs" class="ml-auto" Disabled="@(IsDefaultDisabled || isActing)" OnClick="DismissNotification" title="@Localizer["Dismiss"]"
aria-label="@Localizer["Dismiss"]" data-notification-dismiss>
<i class="ion-md-close" aria-hidden="true"></i>
</DButton>
}
</p>
}
else if (Post.BoostedBy is { } booster)
@@ -283,11 +293,24 @@
<span class="hidden md:inline">@status.FavouritesCount</span>
}
</DButton>
@* a threadiverse post's downvotes: its upvotes are its favourites *@
@if (status.Privapub?.Votes is { Down: > 0 } votes)
@* a threadiverse post's downvotes: its upvotes are its favourites, and the persona's downvote replaces its
favourite (on a phone, in More) *@
@if (CanDownvote(status) && status.Privapub.Votes is { } votes)
{
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="votes.Downvoted == true" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Downvote(status)" title="@(votes.Downvoted == true ? Localizer["Remove the downvote"] : Localizer["Downvote"])"
aria-pressed="@(votes.Downvoted == true ? "true" : "false")" data-downvote="@(votes.Downvoted == true ? "on" : "off")">
<i aria-hidden="true" class="ion-md-arrow-down text-lg @(votes.Downvoted == true ? "text-warning" : null)"></i>
@if (votes.Down > 0)
{
<span class="tabular-nums" title="@Localizer["Downvotes"]" data-downvotes>@votes.Down</span>
}
</DButton>
}
else if (status.Privapub?.Votes is { Down: > 0 } ownVotes)
{
<span class="hidden md:inline-flex items-center gap-1 px-1 text-sm opacity-70 tabular-nums" title="@Localizer["Downvotes"]" data-downvotes>
<i aria-hidden="true" class="ion-md-arrow-down"></i>@votes.Down
<i aria-hidden="true" class="ion-md-arrow-down"></i>@ownVotes.Down
</span>
}
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
@@ -335,6 +358,18 @@
<i aria-hidden="true" class="ion-md-bookmark text-base @(status.Bookmarked ? "text-warning" : null)"></i>
<span>@Localizer["Bookmark"]</span>
</DButton>
@if (CanDownvote(status) && status.Privapub.Votes is { } phoneVotes)
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" Active="phoneVotes.Downvoted == true"
Disabled="IsDefaultDisabled || isActing" OnClick="() => Downvote(status)" data-downvote-phone="@(phoneVotes.Downvoted == true ? "on" : "off")">
<i aria-hidden="true" class="ion-md-arrow-down text-base @(phoneVotes.Downvoted == true ? "text-warning" : null)"></i>
<span>@(phoneVotes.Downvoted == true ? Localizer["Remove the downvote"] : Localizer["Downvote"])</span>
@if (phoneVotes.Down > 0)
{
<span class="opacity-70 tabular-nums">@phoneVotes.Down</span>
}
</DButton>
}
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" OnClick="OpenDetails" data-details>
<i aria-hidden="true" class="ion-md-stats text-base"></i>
<span>@Localizer["Details"]</span>
@@ -376,6 +411,17 @@
}
@if (!IsOwn)
{
@if (QuotesOwnPost(status))
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="RevokeQuote" data-revoke-quote>
<i aria-hidden="true" class="ion-md-quote text-base text-error"></i>
<span>@Localizer["Revoke quote"]</span>
</DButton>
}
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="@(() => isFilteringPost = true)" data-filter-post>
<i aria-hidden="true" class="ion-md-funnel text-base"></i>
<span>@Localizer["Filter this post"]</span>
</DButton>
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="@(() => isReporting = true)" data-report>
<i aria-hidden="true" class="ion-md-flag text-base text-warning"></i>
<span>@Localizer["Report"]</span>
@@ -398,6 +444,11 @@
<i aria-hidden="true" class="ion-md-pin text-base"></i>
<span>@(status.Pinned ? Localizer["Unpin from profile"] : Localizer["Pin on profile"])</span>
</DButton>
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing"
OnClick="@(() => isChoosingQuotePolicy = true)" data-quote-policy="@StatusService.QuotePolicyValue(StatusService.QuotePolicyOf(status))">
<i aria-hidden="true" class="ion-md-quote text-base"></i>
<span>@Localizer["Who may quote it"]</span>
</DButton>
}
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="ToggleEdit" data-edit>
<i aria-hidden="true" class="ion-md-create text-base"></i>
@@ -461,6 +512,14 @@
{
<InteractedByDialog StatusId="@status.Id" Boosts="boosts" OnClosed="@(() => showInteractedBy = null)" />
}
@if (isFilteringPost)
{
<FilterPostDialog StatusId="@status.Id" OnClosed="@(() => isFilteringPost = false)" OnFiltered="Filtered" />
}
@if (isChoosingQuotePolicy)
{
<QuotePolicyDialog Status="status" OnClosed="@(() => isChoosingQuotePolicy = false)" OnChanged="QuotePolicyChanged" />
}
@if (isHistoryOpen)
{
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
@@ -521,6 +580,8 @@
[Parameter, EditorRequired] public FeedPost Post { get; set; }
[Parameter] public EventCallback<FeedPost> OnDeleted { get; set; }
/// <summary>Its notification was dismissed: the page takes the item out.</summary>
[Parameter] public EventCallback<FeedPost> OnDismissed { get; set; }
[Parameter] public EventCallback<FeedPost> OnReplied { get; set; }
[Parameter] public EventCallback<string> OnTag { get; set; }
[Parameter] public string CssContainer { get; set; }
@@ -547,6 +608,8 @@
bool isReadingServer;
bool isHistoryOpen;
bool isReporting;
bool isFilteringPost;
bool isChoosingQuotePolicy;
// who liked it (false) or boosted it (true), when that list is open
bool? showInteractedBy;
List<StatusEdit> history = [];
@@ -824,6 +887,74 @@
Post.Status = updated.Reblog ?? updated;
}
// a threadiverse post (Lemmy, PieFed, Mbin, NodeBB…) takes downvotes. PrivaPub sends votes on every post, so the card
// offers one where they mean something: the author's server is a threadiverse one, the post is a community's Page, or it
// has downvotes already. Not on the persona's own post.
bool CanDownvote(Status status) => !Compact && !Post.Located && !IsOwn && status.Privapub?.Votes is { } votes
&& (votes.Down > 0 || votes.Downvoted == true || status.Privapub.ObjectType == "Page" || IsThreadiverse(Server?.Software));
static bool IsThreadiverse(string software) => software?.ToLowerInvariant() is "lemmy" or "piefed" or "mbin" or "kbin" or "sublinks" or "lotide" or "nodebb";
// a vote is public, like a reaction: the persona is told so once, before its first
async Task Downvote(Status status)
{
var on = status.Privapub?.Votes?.Downvoted != true;
if (on && !await Notices.Confirm(CascadingState.PersonaId))
return;
await Act(on ? StatusAction.Downvote : StatusAction.Undownvote);
}
// the notification goes, its whole group with it (/api/v2/notifications/:group_key/dismiss)
async Task DismissNotification()
{
var notification = Post.Notification;
isActing = true;
var result = await TimelineService.DismissNotification(notification.GroupKey ?? $"ungrouped-{notification.Id}");
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
await OnDismissed.InvokeAsync(Post);
}
// in a filter now: the post is read again, so the filter applies here at once
async Task Filtered()
{
isFilteringPost = false;
if (await StatusService.Get(Status.Id) is { } filtered)
Post.Status = filtered.Reblog ?? filtered;
}
void QuotePolicyChanged(Status updated)
{
isChoosingQuotePolicy = false;
Post.Status = updated.Reblog ?? updated;
}
// someone else's post quoting the persona's own: the persona may take its permission back
bool QuotesOwnPost(Status status) => !IsOwn && CascadingState.PersonaId is { } personaId && status.Quote?.QuotedStatus?.Account?.Id == personaId;
async Task RevokeQuote()
{
if (!await Confirm.Ask(Localizer["Revoke this quote of your post?"], Localizer["Your post no longer shows in it."], Localizer["Revoke quote"], true))
return;
isActing = true;
var result = await StatusService.RevokeQuote(Status.Quote.QuotedStatus.Id, Status.Id);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var updated = (Status)result.Data;
Post.Status = updated.Reblog ?? updated;
Toast.AddResponseSuccess(Localizer["Quote revoked."]);
}
[Inject] AccountService AccountService { get; set; }
[Inject] PublicNoticeService Notices { get; set; }