R4 the client catches up: follow requests, followers, downvotes, dismissing, filtering a post, quote policies, statistics in detail
Build / Build (push) Successful in 1m8s
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:
1 parent
b731d8c8c0
commit
b8d7858acb
25 files changed
+1543
-61
No files matched your search
+61
-6
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user