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)