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

+61 -6
View File
@@ -6,7 +6,9 @@
@* The current persona's filters (/api/v2/filters): words, phrases and posts it would rather not see, where (home and
lists, notifications, the public feeds, threads, profiles), how (folded behind the filter's title, left out, or with
media blurred) and until when. PrivaPub marks every post with the filters it matches; the cards apply them
(Content.FilterIn). Filters are the persona's own and never leave PrivaPub. *@
(Content.FilterIn), and a card's "Filter this post" adds a post to one. The posts a filter holds are read by id when it
opens (/api/v1/statuses?id[]=) and show their author and words, linking their thread. Filters are the persona's own
and never leave PrivaPub. *@
<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">
@@ -124,11 +126,33 @@
@if (filter.Statuses.Count > 0)
{
<h2 class="font-semibold text-sm">@Localizer["Posts filtered one by one"]</h2>
<div class="flex flex-col gap-1">
<div class="flex flex-col gap-2">
@foreach (var item in filter.Statuses)
{
<div class="flex items-center gap-2 text-sm">
<a class="link flex-1 min-w-0 truncate" href="@($"expand/{Uri.EscapeDataString(item.StatusId)}")">@item.StatusId</a>
<div class="flex items-center gap-2 text-sm" data-filter-status="@item.StatusId">
@if (PostOf(item) is { } post)
{
<a class="flex flex-1 items-center gap-2 min-w-0 rounded-lg p-2 neo-inset neo-2xs pointer-coarse:min-h-11"
href="@($"expand/{Uri.EscapeDataString(post.Id)}")">
@* alt="": the name is next to it. *@
<img alt="" class="h-8 w-8 flex-none object-cover rounded-full" src="@(post.Account?.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<span class="truncate text-xs">
<b>@CustomEmojis.Text(post.Account?.Name, post.Account?.Emojis ?? [])</b>
<span class="opacity-60">@("@" + post.Account?.Acct)</span>
</span>
<span class="truncate opacity-80">@(Excerpt(post) is { Length: > 0 } excerpt ? excerpt : Localizer["Media"])</span>
</span>
</a>
}
else if (readPostIds.Contains(item.StatusId))
{
<span class="flex-1 min-w-0 opacity-70 italic" data-filter-status-missing>@Localizer["Post no longer available"]</span>
}
else
{
<span class="flex-1 min-w-0"><DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" /></span>
}
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => RemoveStatus(filter, item))">
<i class="ion-md-close"></i><span class="hidden sm:inline">@Localizer["Remove"]</span>
</DButton>
@@ -170,6 +194,7 @@
static readonly FilterAction[] Actions = [FilterAction.Warn, FilterAction.Hide, FilterAction.Blur];
[Inject] FilterService FilterService { get; set; }
[Inject] TimelineService TimelineService { get; set; }
List<Filter> filters = [];
TitleForm draft = new();
@@ -178,6 +203,9 @@
string loadedPersonaId;
bool isSaving;
bool isConfirmingDelete;
// the posts the filters hold, read by id when a filter opens; an id read but missing is a post no longer available
Dictionary<string, Status> filteredPosts = new();
HashSet<string> readPostIds = [];
(int Seconds, string Label)[] Expiries =>
[
@@ -240,6 +268,8 @@
{
IsLoading = true;
open = null;
filteredPosts = new();
readPostIds = [];
filters = await FilterService.All();
IsLoading = false;
}
@@ -260,10 +290,10 @@
filters.Add(filter);
draft = new();
Toast.AddResponseSuccess(Localizer["Filter {0} made.", filter.Title]);
Toggle(filter);
await Toggle(filter);
}
void Toggle(Filter filter)
async Task Toggle(Filter filter)
{
isConfirmingDelete = false;
if (open?.Id == filter.Id)
@@ -281,6 +311,31 @@
ExpiresIn = KeepExpiry,
Keywords = [.. filter.Keywords.Select(k => new KeywordRow { Id = k.Id, Keyword = k.Keyword, WholeWord = k.WholeWord, WasWholeWord = k.WholeWord })]
};
await ReadPosts(filter);
}
// its posts not read yet, 20 a request (TimelineService.Read); the ones that are gone stay missing
async Task ReadPosts(Filter filter)
{
var ids = filter.Statuses.Select(s => s.StatusId).Where(id => id is not null && !readPostIds.Contains(id)).Distinct().ToList();
if (ids.Count == 0)
return;
foreach (var (id, status) in await TimelineService.Read(ids))
filteredPosts[id] = status;
readPostIds.UnionWith(ids);
}
Status PostOf(FilterStatus item) => item.StatusId is { } id ? filteredPosts.GetValueOrDefault(id) : null;
// what the post says, as plain text: its content warning when it has one, else the start of its words
static string Excerpt(Status status)
{
var html = status.SpoilerText is { Length: > 0 } warning ? warning : status.Content;
if (string.IsNullOrEmpty(html))
return null;
var text = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ");
text = System.Net.WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ")).Trim();
return text.Length <= 160 ? text : text[..160].TrimEnd() + "…";
}
void ToggleContext(FilterContext context, bool on)
+44 -6
View File
@@ -41,12 +41,36 @@
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
</PhoneSheet>
@if (Filter.TimelineType is TimelineType.Notifications && filteredRequests > 0)
@if (Filter.TimelineType is TimelineType.Notifications && (filteredRequests > 0 || followRequests > 0))
{
<a class="btn btn-sm self-center" href="notifications/filtered" data-filtered-requests>
<i class="ion-md-notifications-off"></i>
<span>@Localizer["{0} filtered, waiting for you", filteredRequests]</span>
</a>
<div class="flex flex-wrap justify-center gap-2">
@if (followRequests > 0)
{
<DButton Size="DSize.Sm" OnClick="() => isFollowRequestsOpen = true" data-follow-requests-open>
<i class="ion-md-person-add" aria-hidden="true"></i>
<span>@Localizer["{0} follow requests waiting", followRequests]</span>
</DButton>
}
@if (filteredRequests > 0)
{
<a class="btn btn-sm" href="notifications/filtered" data-filtered-requests>
<i class="ion-md-notifications-off"></i>
<span>@Localizer["{0} filtered, waiting for you", filteredRequests]</span>
</a>
}
</div>
}
@if (isFollowRequestsOpen)
{
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="open => isFollowRequestsOpen = open" CloseText="@Localizer["Close"]"
data-follow-requests-dialog>
<Heading>@Localizer["Follow requests"]</Heading>
<ChildContent>
<div class="mt-3">
<FollowRequestsPanel OnAnswered="FollowRequestAnswered" />
</div>
</ChildContent>
</DModal>
}
@if (pending.Count > 0)
@@ -93,7 +117,7 @@
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty *@
<Virtualize Items="posts" Context="post" ItemSize="320" OverscanCount="8">
<Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
<Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnDismissed="Dismissed" OnReplied="Replied" OnTag="OpenTag" />
</Virtualize>
<div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60">
@@ -127,6 +151,7 @@
[Inject] InstanceService InstanceService { get; set; }
[Inject] RequestStats RequestStats { get; set; }
[Inject] FeedMemory Memory { get; set; }
[Inject] AccountService AccountService { get; set; }
FeedFilter Filter { get; set; } = new();
List<FeedPost> posts = [];
@@ -136,6 +161,9 @@
int unreadNotifications;
// the requests the persona's notification policy filtered, shown on the Notifications tab
int filteredRequests;
// the accounts waiting for the persona to accept their follow (the first page), shown on the Notifications tab
int followRequests;
bool isFollowRequestsOpen;
int unreadConversations;
string maxId;
string minId;
@@ -431,8 +459,11 @@
await TimelineService.ReadNotificationsUpTo(newest, cancellationToken);
unreadNotifications = 0;
filteredRequests = (await TimelineService.NotificationPolicy(cancellationToken))?.Summary?.PendingRequestsCount ?? 0;
followRequests = (await AccountService.FollowRequests(null, cancellationToken)).Data is AccountPage requests ? requests.Accounts.Count : 0;
}
void FollowRequestAnswered(bool accepted) => followRequests = Math.Max(0, followRequests - 1);
/// <summary>The stream that tells this feed's new posts, or null for a feed PrivaPub does not stream.</summary>
static StreamSubscription StreamOf(FeedFilter filter) => filter.TimelineType switch
{
@@ -568,6 +599,13 @@
posts.RemoveAll(p => p.Key == post.Key || p.Status?.Id == post.Status?.Id);
}
// a dismissed notification is that item alone: others about the same post stay
void Dismissed(FeedPost post)
{
posts.RemoveAll(p => p.Key == post.Key);
pending.RemoveAll(p => p.Key == post.Key);
}
IEnumerable<NerdRow> FeedStats()
{
yield return NerdRow.Of(Localizer["items"], posts.Count);
+31 -4
View File
@@ -6,7 +6,9 @@
@* An account as the current persona sees it (/api/v1/accounts/{id}): its header, bio and fields, its counts, what it is
to the persona (following, followed by, requested, blocked, muted) with the actions that change it, and its posts,
page by page, as posts only, posts and replies, or media. A remote account's profile comes from what PrivaPub knows,
never from its server directly. *@
never from its server directly. On the persona's own profile its counts open its followers and the accounts it follows
(AccountListDialog: PrivaPub lists them to the persona alone, so another profile keeps plain counts), and the follow
requests waiting for it show above its posts. *@
<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">
@@ -142,13 +144,34 @@
</dl>
}
<div class="flex flex-wrap gap-4 px-4 py-3 text-sm tabular-nums">
<div class="flex flex-wrap items-center gap-4 px-4 py-3 text-sm tabular-nums">
<span><b>@account.StatusesCount</b> @Localizer["posts"]</span>
<span><b>@account.FollowingCount</b> @Localizer["following"]</span>
<span><b>@account.FollowersCount</b> @Localizer["followers"]</span>
@if (IsOwn)
{
<button type="button" class="link link-hover touch-target" @onclick="() => showAccounts = false" data-profile-following>
<b>@account.FollowingCount</b> @Localizer["following"]
</button>
<button type="button" class="link link-hover touch-target" @onclick="() => showAccounts = true" data-profile-followers>
<b>@account.FollowersCount</b> @Localizer["followers"]
</button>
}
else
{
<span><b>@account.FollowingCount</b> @Localizer["following"]</span>
<span><b>@account.FollowersCount</b> @Localizer["followers"]</span>
}
</div>
</article>
@if (IsOwn)
{
@if (showAccounts is { } followers)
{
<AccountListDialog AccountId="@account.Id" Followers="followers" OnClosed="@(() => showAccounts = null)" OnRemoved="FollowerRemoved" />
}
<FollowRequestsPanel HideWhenEmpty="true" ShowHeading="true" />
}
<DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden">
<DTab><Header><span class="capitalize">@Localizer["posts"]</span></Header></DTab>
<DTab><Header>@Localizer["Posts and replies"]</Header></DTab>
@@ -212,6 +235,8 @@
bool isLoadingMore;
bool isListMenuOpen;
bool isReporting;
// the persona's own followers (true) or the accounts it follows (false), while that list is open
bool? showAccounts;
List<MastodonList> allLists = [];
HashSet<string> inLists = [];
@@ -359,6 +384,8 @@
});
}
void FollowerRemoved(Account follower) => account.FollowersCount = Math.Max(0, account.FollowersCount - 1);
void Remove(FeedPost post)
{
posts.RemoveAll(p => p.Key == post.Key);