Notifications in groups, and what is unread

The Notifications tab reads grouped notifications (/api/v2/notifications):
likes and boosts of one post, or follows of one hour, are one card naming
both accounts of a pair, or the newest and how many others, with their
avatars. A notification the stream tells joins its group and waits on top.
The tab's badge counts what is unread; opening the tab moves the
notifications marker to the newest shown, which reads it on PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 04:35:23 +02:00
1 parent 963565b316
commit a4d9831691
11 files changed
+263 -12

No files matched your search

+38 -3
View File
@@ -18,7 +18,7 @@
</aside>
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full">
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" Stats="FeedStats()" IsComposing="isComposing" OnCompose="() => isComposing = !isComposing"
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" Stats="FeedStats()" IsComposing="isComposing" OnCompose="() => isComposing = !isComposing"
OnChanged="ChangeFeed" />
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
@@ -115,6 +115,7 @@
List<FeedPost> pending = [];
List<ViewGroup> groups = [];
List<MastodonList> lists = [];
int unreadNotifications;
string maxId;
string minId;
string loadError;
@@ -245,6 +246,7 @@
minId = page.MinId;
lastPollAt = DateTime.UtcNow;
await Streaming.Follow(StreamOf(Filter), cancellationToken);
await ReadNotifications(cancellationToken);
StateHasChanged();
await Locate(posts, cancellationToken);
if (feedModule is not null)
@@ -329,6 +331,21 @@
}
}
/// <summary>On the Notifications tab, what is shown is read (the marker moves to the newest); elsewhere the tab's badge
/// counts what is unread.</summary>
async Task ReadNotifications(CancellationToken cancellationToken)
{
if (Filter.TimelineType is not TimelineType.Notifications)
{
unreadNotifications = await TimelineService.UnreadNotifications(cancellationToken);
return;
}
var newest = posts.Concat(pending).Select(p => p.Notification?.Id).Where(id => id is not null).Max(StringComparer.Ordinal);
if (newest is not null)
await TimelineService.ReadNotificationsUpTo(newest, cancellationToken);
unreadNotifications = 0;
}
/// <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
{
@@ -348,12 +365,29 @@
switch (message.Event)
{
case "update" when message.Status is { } status && Filter.TimelineType is not TimelineType.Notifications:
case "notification" when message.Notification is not null && Filter.TimelineType is TimelineType.Notifications:
var post = message.Status is { } s ? FeedPost.Of(s) : FeedPost.Of(message.Notification);
var post = FeedPost.Of(status);
if (posts.Concat(pending).Any(p => p.Key == post.Key))
return;
pending.Insert(0, post);
break;
case "notification" when message.Notification is { } notification && Filter.TimelineType is TimelineType.Notifications:
// a notification of a group already shown joins it, and the group waits on top as new
var key = notification.GroupKey ?? notification.Id;
var group = pending.FirstOrDefault(p => p.Key == key) ?? posts.FirstOrDefault(p => p.Key == key);
if (group is null)
{
pending.Insert(0, FeedPost.Of(notification));
break;
}
group.GroupCount++;
group.Notification = notification;
group.GroupAccounts = [.. group.GroupAccounts.Prepend(notification.Account).Where(a => a is not null).DistinctBy(a => a.Id).Take(8)];
if (posts.Remove(group))
pending.Insert(0, group);
break;
case "notification" when Filter.TimelineType is not TimelineType.Notifications:
unreadNotifications++;
break;
case "delete" when message.DeletedId is { } id:
posts.RemoveAll(p => p.Key == id || p.Status?.Id == id);
pending.RemoveAll(p => p.Key == id || p.Status?.Id == id);
@@ -373,6 +407,7 @@
var shown = pending;
pending = [];
posts.InsertRange(0, shown);
await ReadNotifications(loadCancellation?.Token ?? default);
minId = shown.FirstOrDefault()?.CursorId ?? minId;
await feedModule.InvokeVoidAsync("scrollToTop", ScrollId);
await Locate(shown, loadCancellation?.Token ?? default);