-
@@ -115,6 +115,7 @@
List pending = [];
List groups = [];
List 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 @@
}
}
+ /// On the Notifications tab, what is shown is read (the marker moves to the newest); elsewhere the tab's badge
+ /// counts what is unread.
+ 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;
+ }
+
/// The stream that tells this feed's new posts, or null for a feed PrivaPub does not stream.
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);
diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx
index f988115..3f7c3ef 100644
--- a/Resources/AllStrings.it.resx
+++ b/Resources/AllStrings.it.resx
@@ -1196,4 +1196,25 @@
chiuso, interrogo
+
+ {0} e altri {1} hanno apprezzato il tuo post
+
+
+ {0} e altri {1} hanno condiviso il tuo post
+
+
+ {0} e altri {1} ti seguono
+
+
+ {0} da leggere
+
+
+ {0} e {1} hanno apprezzato il tuo post
+
+
+ {0} e {1} hanno condiviso il tuo post
+
+
+ {0} e {1} ti seguono
+
diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx
index dc250ce..df6b814 100644
--- a/Resources/AllStrings.resx
+++ b/Resources/AllStrings.resx
@@ -1196,4 +1196,25 @@
closed, polling
+
+ {0} and {1} others favourited your post
+
+
+ {0} and {1} others boosted your post
+
+
+ {0} and {1} others followed you
+
+
+ {0} unread
+
+
+ {0} and {1} favourited your post
+
+
+ {0} and {1} boosted your post
+
+
+ {0} and {1} followed you
+
diff --git a/Services/TimelineService.cs b/Services/TimelineService.cs
index 49765d0..50191d1 100644
--- a/Services/TimelineService.cs
+++ b/Services/TimelineService.cs
@@ -78,25 +78,47 @@ namespace decePubClient.Services
};
}
+ /// 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.
async Task 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>(cancellationToken) ?? [];
+ var grouped = await response.MastodonReadFromJsonAsync(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
}
};
}
+ /// How many groups of notifications are unread (newer than the notifications marker).
+ public async Task UnreadNotifications(CancellationToken cancellationToken = default)
+ {
+ using var response = await httpService.Get(APIs.Mastodon.GET_UnreadNotifications, cancellationToken: cancellationToken);
+ return response.IsSuccessStatusCode ? (await response.MastodonReadFromJsonAsync(cancellationToken))?.Count ?? 0 : 0;
+ }
+
+ /// Moves the notifications marker to the newest one seen, which reads everything up to it.
+ public async Task 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();
+ }
+
/// Direct conversations, newest first; PrivaPub answers one page.
async Task Conversations(CancellationToken cancellationToken)
{
diff --git a/tests/e2e/Actions/NotificationTests.cs b/tests/e2e/Actions/NotificationTests.cs
new file mode 100644
index 0000000..770b78a
--- /dev/null
+++ b/tests/e2e/Actions/NotificationTests.cs
@@ -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();
+ 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()}']").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() == 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);
+ }
+ }
+ }
+}