diff --git a/Components/Content.razor b/Components/Content.razor index 31f1d12..b22d7d2 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -34,8 +34,18 @@ @if (Post.Notification is { } notification) { -

+

+ @if (Post.GroupAccounts.Count > 1) + { + + } @NotificationText(notification) @notification.CreatedAt.GetPassedTime(Localizer)

@@ -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], diff --git a/Components/Feed/FeedTabs.razor b/Components/Feed/FeedTabs.razor index 7316263..d2c93c0 100644 --- a/Components/Feed/FeedTabs.razor +++ b/Components/Feed/FeedTabs.razor @@ -14,6 +14,10 @@
+ @if (type is TimelineType.Notifications && UnreadNotifications > 0) + { + @UnreadNotifications + }
} @@ -92,6 +96,8 @@ [Parameter, EditorRequired] public FeedFilter Filter { get; set; } [Parameter] public List Groups { get; set; } = []; [Parameter] public List Lists { get; set; } = []; + /// Groups of notifications newer than the persona's marker; a badge on the Notifications tab. + [Parameter] public int UnreadNotifications { get; set; } [Parameter] public IEnumerable Stats { get; set; } = []; [Parameter] public bool IsComposing { get; set; } [Parameter] public EventCallback OnCompose { get; set; } diff --git a/MastodonJsonContext.cs b/MastodonJsonContext.cs index 2f68cf0..cc7e206 100644 --- a/MastodonJsonContext.cs +++ b/MastodonJsonContext.cs @@ -48,6 +48,9 @@ namespace decePubClient; [JsonSerializable(typeof(Tag))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(GroupedNotifications))] +[JsonSerializable(typeof(MarkersForm))] +[JsonSerializable(typeof(UnreadCount))] public partial class MastodonJsonContext : JsonSerializerContext { } diff --git a/Models/APIs.cs b/Models/APIs.cs index 1fe854f..bfc75fe 100644 --- a/Models/APIs.cs +++ b/Models/APIs.cs @@ -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"; diff --git a/Models/FeedPost.cs b/Models/FeedPost.cs index bfef689..03d5ce2 100644 --- a/Models/FeedPost.cs +++ b/Models/FeedPost.cs @@ -17,7 +17,11 @@ public class FeedPost /// The post shown; null for a notification without one (a follow). public Status Status { get; set; } public Account BoostedBy { get; init; } - public Notification Notification { get; init; } + public Notification Notification { get; set; } + /// 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. + public int GroupCount { get; set; } = 1; + public List GroupAccounts { get; set; } = []; public Conversation Conversation { get; init; } /// A located post: within its range of the reader, never on the Mastodon API. 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] }; + /// A group of notifications as one item: its newest notification, how many it holds, and a few accounts. + public static FeedPost Of(NotificationGroup group, IReadOnlyDictionary accounts, IReadOnlyDictionary 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, diff --git a/Models/Mastodon/Notification.cs b/Models/Mastodon/Notification.cs index 89221d4..eb41658 100644 --- a/Models/Mastodon/Notification.cs +++ b/Models/Mastodon/Notification.cs @@ -12,6 +12,45 @@ public class Notification public Account Account { get; set; } public Status Status { get; set; } public string Emoji { get; set; } + /// The group it belongs to (likes and boosts of one post, follows within an hour), or ungrouped-{id}. + public string GroupKey { get; set; } +} + +/// /api/v2/notifications: notifications folded into groups, with the accounts and posts they name. +public class GroupedNotifications +{ + public List Accounts { get; set; } = []; + public List Statuses { get; set; } = []; + public List 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 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; } } /// A direct conversation: its participants and its last post. diff --git a/Pages/Index.razor b/Pages/Index.razor index 6849eef..4791839 100644 --- a/Pages/Index.razor +++ b/Pages/Index.razor @@ -18,7 +18,7 @@
-
@@ -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); + } + } + } +}