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

+20 -1
View File
@@ -34,8 +34,18 @@
@if (Post.Notification is { } notification)
{
<p class="inline-flex flex-wrap items-center gap-2 min-w-0 text-xs">
<p class="inline-flex flex-wrap items-center gap-2 min-w-0 text-xs" data-notification="@notification.Type" data-count="@Post.GroupCount">
<i aria-hidden="true" class="@NotificationIcon(notification.Type)"></i>
@if (Post.GroupAccounts.Count > 1)
{
<span class="flex" aria-hidden="true">
@foreach (var account in Post.GroupAccounts.Take(4))
{
@* alt="": the names are in the text next to them. *@
<img alt="" class="h-5 w-5 -ml-2 first:ml-0 rounded-full object-cover ring-2 ring-base-100" src="@(account.Avatar ?? "imgs/icon-192.png")" title="@account.Name" />
}
</span>
}
<span>@NotificationText(notification)</span>
<span class="opacity-60" title="@NerdRow.Time(notification.CreatedAt)">@notification.CreatedAt.GetPassedTime(Localizer)</span>
</p>
@@ -663,8 +673,17 @@
string NotificationText(Notification notification)
{
var name = notification.Account?.Name;
var others = Post.GroupCount - 1;
// two are both named; more are the newest and a count
var second = others == 1 && Post.GroupAccounts.Count > 1 ? Post.GroupAccounts[1].Name : null;
return notification.Type switch
{
"favourite" when second is not null => Localizer["{0} and {1} favourited your post", name, second],
"reblog" when second is not null => Localizer["{0} and {1} boosted your post", name, second],
"follow" when second is not null => Localizer["{0} and {1} followed you", name, second],
"favourite" when others > 0 => Localizer["{0} and {1} others favourited your post", name, others],
"reblog" when others > 0 => Localizer["{0} and {1} others boosted your post", name, others],
"follow" when others > 0 => Localizer["{0} and {1} others followed you", name, others],
"favourite" => Localizer["{0} favourited your post", name],
"reblog" => Localizer["{0} boosted your post", name],
"mention" => Localizer["{0} mentioned you", name],
+6
View File
@@ -14,6 +14,10 @@
<Header>
<i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i>
<span class="@(type == Filter.TimelineType ? null : "hidden xl:inline")">@TabName(type)</span>
@if (type is TimelineType.Notifications && UnreadNotifications > 0)
{
<DBadge Size="DSize.Xs" Color="DColor.Primary" title="@Localizer["{0} unread", UnreadNotifications]" data-unread>@UnreadNotifications</DBadge>
}
</Header>
</DTab>
}
@@ -92,6 +96,8 @@
[Parameter, EditorRequired] public FeedFilter Filter { get; set; }
[Parameter] public List<ViewGroup> Groups { get; set; } = [];
[Parameter] public List<MastodonList> Lists { get; set; } = [];
/// <summary>Groups of notifications newer than the persona's marker; a badge on the Notifications tab.</summary>
[Parameter] public int UnreadNotifications { get; set; }
[Parameter] public IEnumerable<NerdRow> Stats { get; set; } = [];
[Parameter] public bool IsComposing { get; set; }
[Parameter] public EventCallback OnCompose { get; set; }
+3
View File
@@ -48,6 +48,9 @@ namespace decePubClient;
[JsonSerializable(typeof(Tag))]
[JsonSerializable(typeof(List<Tag>))]
[JsonSerializable(typeof(List<TrendLink>))]
[JsonSerializable(typeof(GroupedNotifications))]
[JsonSerializable(typeof(MarkersForm))]
[JsonSerializable(typeof(UnreadCount))]
public partial class MastodonJsonContext : JsonSerializerContext
{
}
+3
View File
@@ -72,6 +72,9 @@ public static class APIs
public static string GET_M_ListTimeline(string listId) => $"api/v1/timelines/list/{Uri.EscapeDataString(listId)}";
public const string GET_ScheduledStatuses = "api/v1/scheduled_statuses";
public const string GET_FollowedTags = "api/v1/followed_tags";
public const string GET_GroupedNotifications = "api/v2/notifications";
public const string GET_UnreadNotifications = "api/v2/notifications/unread_count";
public const string POST_Markers = "api/v1/markers";
public const string GET_TrendingTags = "api/v1/trends/tags";
public const string GET_TrendingStatuses = "api/v1/trends/statuses";
public const string GET_TrendingLinks = "api/v1/trends/links";
+33 -3
View File
@@ -17,7 +17,11 @@ public class FeedPost
/// <summary>The post shown; null for a notification without one (a follow).</summary>
public Status Status { get; set; }
public Account BoostedBy { get; init; }
public Notification Notification { get; init; }
public Notification Notification { get; set; }
/// <summary>For a notification standing for a group (likes of one post, follows of one hour): how many, and a few of
/// the accounts, newest first.</summary>
public int GroupCount { get; set; } = 1;
public List<Account> GroupAccounts { get; set; } = [];
public Conversation Conversation { get; init; }
/// <summary>A located post: within its range of the reader, never on the Mastodon API.</summary>
public bool Located { get; init; }
@@ -43,12 +47,38 @@ public class FeedPost
public static FeedPost Of(Notification notification) => new()
{
Key = notification.Id,
Key = notification.GroupKey ?? notification.Id,
CursorId = notification.Id,
Status = notification.Status?.Reblog ?? notification.Status,
Notification = notification
Notification = notification,
GroupAccounts = notification.Account is null ? [] : [notification.Account]
};
/// <summary>A group of notifications as one item: its newest notification, how many it holds, and a few accounts.</summary>
public static FeedPost Of(NotificationGroup group, IReadOnlyDictionary<string, Account> accounts, IReadOnlyDictionary<string, Status> statuses)
{
var sample = group.SampleAccountIds.Select(accounts.GetValueOrDefault).Where(a => a is not null).ToList();
var status = group.StatusId is null ? null : statuses.GetValueOrDefault(group.StatusId);
return new()
{
Key = group.GroupKey,
CursorId = group.MostRecentNotificationId,
Status = status?.Reblog ?? status,
Notification = new Notification
{
Id = group.MostRecentNotificationId,
Type = group.Type,
CreatedAt = group.LatestPageNotificationAt,
Account = sample.FirstOrDefault(),
Status = status,
Emoji = group.Emoji,
GroupKey = group.GroupKey
},
GroupCount = Math.Max(1, group.NotificationsCount),
GroupAccounts = sample
};
}
public static FeedPost Of(Conversation conversation) => new()
{
Key = conversation.Id,
+39
View File
@@ -12,6 +12,45 @@ public class Notification
public Account Account { get; set; }
public Status Status { get; set; }
public string Emoji { get; set; }
/// <summary>The group it belongs to (likes and boosts of one post, follows within an hour), or ungrouped-{id}.</summary>
public string GroupKey { get; set; }
}
/// <summary>/api/v2/notifications: notifications folded into groups, with the accounts and posts they name.</summary>
public class GroupedNotifications
{
public List<Account> Accounts { get; set; } = [];
public List<Status> Statuses { get; set; } = [];
public List<NotificationGroup> NotificationGroups { get; set; } = [];
}
public class NotificationGroup
{
public string GroupKey { get; set; }
public int NotificationsCount { get; set; }
public string Type { get; set; }
public string MostRecentNotificationId { get; set; }
public string PageMinId { get; set; }
public string PageMaxId { get; set; }
public DateTime LatestPageNotificationAt { get; set; }
public List<string> SampleAccountIds { get; set; } = [];
public string StatusId { get; set; }
public string Emoji { get; set; }
}
public class MarkersForm
{
public MarkerForm Notifications { get; set; }
}
public class MarkerForm
{
public string LastReadId { get; set; }
}
public class UnreadCount
{
public int Count { get; set; }
}
/// <summary>A direct conversation: its participants and its last post.</summary>
+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);
+21
View File
@@ -1196,4 +1196,25 @@
<data name="closed, polling" xml:space="preserve">
<value>chiuso, interrogo</value>
</data>
<data name="{0} and {1} others favourited your post" xml:space="preserve">
<value>{0} e altri {1} hanno apprezzato il tuo post</value>
</data>
<data name="{0} and {1} others boosted your post" xml:space="preserve">
<value>{0} e altri {1} hanno condiviso il tuo post</value>
</data>
<data name="{0} and {1} others followed you" xml:space="preserve">
<value>{0} e altri {1} ti seguono</value>
</data>
<data name="{0} unread" xml:space="preserve">
<value>{0} da leggere</value>
</data>
<data name="{0} and {1} favourited your post" xml:space="preserve">
<value>{0} e {1} hanno apprezzato il tuo post</value>
</data>
<data name="{0} and {1} boosted your post" xml:space="preserve">
<value>{0} e {1} hanno condiviso il tuo post</value>
</data>
<data name="{0} and {1} followed you" xml:space="preserve">
<value>{0} e {1} ti seguono</value>
</data>
</root>
+21
View File
@@ -1196,4 +1196,25 @@
<data name="closed, polling" xml:space="preserve">
<value>closed, polling</value>
</data>
<data name="{0} and {1} others favourited your post" xml:space="preserve">
<value>{0} and {1} others favourited your post</value>
</data>
<data name="{0} and {1} others boosted your post" xml:space="preserve">
<value>{0} and {1} others boosted your post</value>
</data>
<data name="{0} and {1} others followed you" xml:space="preserve">
<value>{0} and {1} others followed you</value>
</data>
<data name="{0} unread" xml:space="preserve">
<value>{0} unread</value>
</data>
<data name="{0} and {1} favourited your post" xml:space="preserve">
<value>{0} and {1} favourited your post</value>
</data>
<data name="{0} and {1} boosted your post" xml:space="preserve">
<value>{0} and {1} boosted your post</value>
</data>
<data name="{0} and {1} followed you" xml:space="preserve">
<value>{0} and {1} followed you</value>
</data>
</root>
+27 -5
View File
@@ -78,25 +78,47 @@ namespace decePubClient.Services
};
}
/// <summary>Notifications folded into groups (/api/v2/notifications): likes and boosts of one post, follows of one
/// hour, each an item with its count and a few of its accounts. Paged by notification ids from the Link header.</summary>
async Task<WebResult> Notifications(string maxId, string minId, CancellationToken cancellationToken)
{
using var response = await httpService.Get(APIs.Mastodon.GET_Notifications, Paging([], maxId, minId), cancellationToken: cancellationToken);
using var response = await httpService.Get(APIs.Mastodon.GET_GroupedNotifications, Paging([], maxId, minId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var notifications = await response.MastodonReadFromJsonAsync<List<Notification>>(cancellationToken) ?? [];
var grouped = await response.MastodonReadFromJsonAsync<GroupedNotifications>(cancellationToken) ?? new GroupedNotifications();
var accounts = grouped.Accounts.DistinctBy(a => a.Id).ToDictionary(a => a.Id);
var statuses = grouped.Statuses.DistinctBy(s => s.Id).ToDictionary(s => s.Id);
var (nextMaxId, prevMinId) = response.PageCursors();
return new WebResult
{
Data = new FeedPage
{
Posts = [.. notifications.Select(FeedPost.Of)],
MaxId = notifications.Count < PageSize ? null : nextMaxId ?? notifications.LastOrDefault()?.Id,
MinId = prevMinId ?? notifications.FirstOrDefault()?.Id ?? minId
Posts = [.. grouped.NotificationGroups.Select(g => FeedPost.Of(g, accounts, statuses))],
MaxId = grouped.NotificationGroups.Count == 0 ? null : nextMaxId,
MinId = prevMinId ?? grouped.NotificationGroups.FirstOrDefault()?.MostRecentNotificationId ?? minId
}
};
}
/// <summary>How many groups of notifications are unread (newer than the notifications marker).</summary>
public async Task<int> UnreadNotifications(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_UnreadNotifications, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? (await response.MastodonReadFromJsonAsync<UnreadCount>(cancellationToken))?.Count ?? 0 : 0;
}
/// <summary>Moves the notifications marker to the newest one seen, which reads everything up to it.</summary>
public async Task<WebResult> ReadNotificationsUpTo(string notificationId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_Markers, new MarkersForm { Notifications = new MarkerForm { LastReadId = notificationId } },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
/// <summary>Direct conversations, newest first; PrivaPub answers one page.</summary>
async Task<WebResult> Conversations(CancellationToken cancellationToken)
{
+52
View File
@@ -0,0 +1,52 @@
using System.Text.Json.Nodes;
using decePubClient.E2E.Support;
using Microsoft.Playwright;
namespace decePubClient.E2E.Actions
{
// Notifications as groups: two likes of one post are one card naming both, the tab's badge counts what is unread, and
// reading the tab moves the marker so PrivaPub counts nothing unread
public sealed class NotificationTests(Browser browser)
{
[Fact]
public async Task Two_likes_of_one_post_are_one_card_and_reading_them_clears_the_badge()
{
var personas = Pasture.Personas();
Assert.SkipWhen(personas.Count < 3, "the town has fewer than three personas");
var (author, first, second) = (personas[0], personas[1], personas[2]);
var post = (await PersonaApi.Call(author, "/api/v1/statuses", HttpMethod.Post,
new JsonObject { ["status"] = $"like me {Guid.NewGuid():N}"[..16], ["visibility"] = "public" }))!;
var id = post["id"]!.GetValue<string>();
await using var session = await SignIn.As(browser, author);
var page = session.Page;
try
{
await PersonaApi.Call(first, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
await PersonaApi.Call(second, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
await Results.Cell("client.render.notification-group", "privapub", author.UserName, async () =>
{
await page.GotoAsync($"{Pasture.Site}/");
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
await page.GotoAsync($"{Pasture.Site}/");
await page.Locator("[data-unread]").WaitForAsync(new() { Timeout = 30000 });
await page.GetByRole(AriaRole.Tab).Filter(new() { Has = page.Locator(".ion-md-notifications") }).ClickAsync();
var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").Filter(new() { Has = page.Locator("[data-notification='favourite']") });
await card.WaitForAsync(new() { Timeout = 30000 });
await Assertions.Expect(card.Locator("[data-notification='favourite']")).ToHaveAttributeAsync("data-count", "2");
await Assertions.Expect(card).ToContainTextAsync($"{second.Name} and {first.Name} favourited your post");
Assert.True(await Pasture.Eventually(async () =>
(await PersonaApi.Call(author, "/api/v2/notifications/unread_count"))!["count"]!.GetValue<int>() == 0, 15), "reading the tab left notifications unread");
await Assertions.Expect(page.Locator("[data-unread]")).ToHaveCountAsync(0);
await session.AssertGuards();
}, page);
}
finally
{
await PersonaApi.Call(author, $"/api/v1/statuses/{id}", HttpMethod.Delete);
}
}
}
}