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:
1 parent
963565b316
commit
a4d9831691
11 files changed
+263
-12
No files matched your search
@@ -34,8 +34,18 @@
|
|||||||
|
|
||||||
@if (Post.Notification is { } notification)
|
@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>
|
<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>@NotificationText(notification)</span>
|
||||||
<span class="opacity-60" title="@NerdRow.Time(notification.CreatedAt)">@notification.CreatedAt.GetPassedTime(Localizer)</span>
|
<span class="opacity-60" title="@NerdRow.Time(notification.CreatedAt)">@notification.CreatedAt.GetPassedTime(Localizer)</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -663,8 +673,17 @@
|
|||||||
string NotificationText(Notification notification)
|
string NotificationText(Notification notification)
|
||||||
{
|
{
|
||||||
var name = notification.Account?.Name;
|
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
|
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],
|
"favourite" => Localizer["{0} favourited your post", name],
|
||||||
"reblog" => Localizer["{0} boosted your post", name],
|
"reblog" => Localizer["{0} boosted your post", name],
|
||||||
"mention" => Localizer["{0} mentioned you", name],
|
"mention" => Localizer["{0} mentioned you", name],
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
<Header>
|
<Header>
|
||||||
<i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i>
|
<i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i>
|
||||||
<span class="@(type == Filter.TimelineType ? null : "hidden xl:inline")">@TabName(type)</span>
|
<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>
|
</Header>
|
||||||
</DTab>
|
</DTab>
|
||||||
}
|
}
|
||||||
@@ -92,6 +96,8 @@
|
|||||||
[Parameter, EditorRequired] public FeedFilter Filter { get; set; }
|
[Parameter, EditorRequired] public FeedFilter Filter { get; set; }
|
||||||
[Parameter] public List<ViewGroup> Groups { get; set; } = [];
|
[Parameter] public List<ViewGroup> Groups { get; set; } = [];
|
||||||
[Parameter] public List<MastodonList> Lists { 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 IEnumerable<NerdRow> Stats { get; set; } = [];
|
||||||
[Parameter] public bool IsComposing { get; set; }
|
[Parameter] public bool IsComposing { get; set; }
|
||||||
[Parameter] public EventCallback OnCompose { get; set; }
|
[Parameter] public EventCallback OnCompose { get; set; }
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(Tag))]
|
[JsonSerializable(typeof(Tag))]
|
||||||
[JsonSerializable(typeof(List<Tag>))]
|
[JsonSerializable(typeof(List<Tag>))]
|
||||||
[JsonSerializable(typeof(List<TrendLink>))]
|
[JsonSerializable(typeof(List<TrendLink>))]
|
||||||
|
[JsonSerializable(typeof(GroupedNotifications))]
|
||||||
|
[JsonSerializable(typeof(MarkersForm))]
|
||||||
|
[JsonSerializable(typeof(UnreadCount))]
|
||||||
public partial class MastodonJsonContext : JsonSerializerContext
|
public partial class MastodonJsonContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -72,6 +72,9 @@ public static class APIs
|
|||||||
public static string GET_M_ListTimeline(string listId) => $"api/v1/timelines/list/{Uri.EscapeDataString(listId)}";
|
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_ScheduledStatuses = "api/v1/scheduled_statuses";
|
||||||
public const string GET_FollowedTags = "api/v1/followed_tags";
|
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_TrendingTags = "api/v1/trends/tags";
|
||||||
public const string GET_TrendingStatuses = "api/v1/trends/statuses";
|
public const string GET_TrendingStatuses = "api/v1/trends/statuses";
|
||||||
public const string GET_TrendingLinks = "api/v1/trends/links";
|
public const string GET_TrendingLinks = "api/v1/trends/links";
|
||||||
|
|||||||
+33
-3
@@ -17,7 +17,11 @@ public class FeedPost
|
|||||||
/// <summary>The post shown; null for a notification without one (a follow).</summary>
|
/// <summary>The post shown; null for a notification without one (a follow).</summary>
|
||||||
public Status Status { get; set; }
|
public Status Status { get; set; }
|
||||||
public Account BoostedBy { get; init; }
|
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; }
|
public Conversation Conversation { get; init; }
|
||||||
/// <summary>A located post: within its range of the reader, never on the Mastodon API.</summary>
|
/// <summary>A located post: within its range of the reader, never on the Mastodon API.</summary>
|
||||||
public bool Located { get; init; }
|
public bool Located { get; init; }
|
||||||
@@ -43,12 +47,38 @@ public class FeedPost
|
|||||||
|
|
||||||
public static FeedPost Of(Notification notification) => new()
|
public static FeedPost Of(Notification notification) => new()
|
||||||
{
|
{
|
||||||
Key = notification.Id,
|
Key = notification.GroupKey ?? notification.Id,
|
||||||
CursorId = notification.Id,
|
CursorId = notification.Id,
|
||||||
Status = notification.Status?.Reblog ?? notification.Status,
|
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()
|
public static FeedPost Of(Conversation conversation) => new()
|
||||||
{
|
{
|
||||||
Key = conversation.Id,
|
Key = conversation.Id,
|
||||||
|
|||||||
@@ -12,6 +12,45 @@ public class Notification
|
|||||||
public Account Account { get; set; }
|
public Account Account { get; set; }
|
||||||
public Status Status { get; set; }
|
public Status Status { get; set; }
|
||||||
public string Emoji { 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>
|
/// <summary>A direct conversation: its participants and its last post.</summary>
|
||||||
|
|||||||
+38
-3
@@ -18,7 +18,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full">
|
<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" />
|
OnChanged="ChangeFeed" />
|
||||||
|
|
||||||
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
|
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
|
||||||
@@ -115,6 +115,7 @@
|
|||||||
List<FeedPost> pending = [];
|
List<FeedPost> pending = [];
|
||||||
List<ViewGroup> groups = [];
|
List<ViewGroup> groups = [];
|
||||||
List<MastodonList> lists = [];
|
List<MastodonList> lists = [];
|
||||||
|
int unreadNotifications;
|
||||||
string maxId;
|
string maxId;
|
||||||
string minId;
|
string minId;
|
||||||
string loadError;
|
string loadError;
|
||||||
@@ -245,6 +246,7 @@
|
|||||||
minId = page.MinId;
|
minId = page.MinId;
|
||||||
lastPollAt = DateTime.UtcNow;
|
lastPollAt = DateTime.UtcNow;
|
||||||
await Streaming.Follow(StreamOf(Filter), cancellationToken);
|
await Streaming.Follow(StreamOf(Filter), cancellationToken);
|
||||||
|
await ReadNotifications(cancellationToken);
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
await Locate(posts, cancellationToken);
|
await Locate(posts, cancellationToken);
|
||||||
if (feedModule is not null)
|
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>
|
/// <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
|
static StreamSubscription StreamOf(FeedFilter filter) => filter.TimelineType switch
|
||||||
{
|
{
|
||||||
@@ -348,12 +365,29 @@
|
|||||||
switch (message.Event)
|
switch (message.Event)
|
||||||
{
|
{
|
||||||
case "update" when message.Status is { } status && Filter.TimelineType is not TimelineType.Notifications:
|
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 = FeedPost.Of(status);
|
||||||
var post = message.Status is { } s ? FeedPost.Of(s) : FeedPost.Of(message.Notification);
|
|
||||||
if (posts.Concat(pending).Any(p => p.Key == post.Key))
|
if (posts.Concat(pending).Any(p => p.Key == post.Key))
|
||||||
return;
|
return;
|
||||||
pending.Insert(0, post);
|
pending.Insert(0, post);
|
||||||
break;
|
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:
|
case "delete" when message.DeletedId is { } id:
|
||||||
posts.RemoveAll(p => p.Key == id || p.Status?.Id == id);
|
posts.RemoveAll(p => p.Key == id || p.Status?.Id == id);
|
||||||
pending.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;
|
var shown = pending;
|
||||||
pending = [];
|
pending = [];
|
||||||
posts.InsertRange(0, shown);
|
posts.InsertRange(0, shown);
|
||||||
|
await ReadNotifications(loadCancellation?.Token ?? default);
|
||||||
minId = shown.FirstOrDefault()?.CursorId ?? minId;
|
minId = shown.FirstOrDefault()?.CursorId ?? minId;
|
||||||
await feedModule.InvokeVoidAsync("scrollToTop", ScrollId);
|
await feedModule.InvokeVoidAsync("scrollToTop", ScrollId);
|
||||||
await Locate(shown, loadCancellation?.Token ?? default);
|
await Locate(shown, loadCancellation?.Token ?? default);
|
||||||
|
|||||||
@@ -1196,4 +1196,25 @@
|
|||||||
<data name="closed, polling" xml:space="preserve">
|
<data name="closed, polling" xml:space="preserve">
|
||||||
<value>chiuso, interrogo</value>
|
<value>chiuso, interrogo</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -1196,4 +1196,25 @@
|
|||||||
<data name="closed, polling" xml:space="preserve">
|
<data name="closed, polling" xml:space="preserve">
|
||||||
<value>closed, polling</value>
|
<value>closed, polling</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -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)
|
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)
|
if (!response.IsSuccessStatusCode)
|
||||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
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();
|
var (nextMaxId, prevMinId) = response.PageCursors();
|
||||||
return new WebResult
|
return new WebResult
|
||||||
{
|
{
|
||||||
Data = new FeedPage
|
Data = new FeedPage
|
||||||
{
|
{
|
||||||
Posts = [.. notifications.Select(FeedPost.Of)],
|
Posts = [.. grouped.NotificationGroups.Select(g => FeedPost.Of(g, accounts, statuses))],
|
||||||
MaxId = notifications.Count < PageSize ? null : nextMaxId ?? notifications.LastOrDefault()?.Id,
|
MaxId = grouped.NotificationGroups.Count == 0 ? null : nextMaxId,
|
||||||
MinId = prevMinId ?? notifications.FirstOrDefault()?.Id ?? minId
|
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>
|
/// <summary>Direct conversations, newest first; PrivaPub answers one page.</summary>
|
||||||
async Task<WebResult> Conversations(CancellationToken cancellationToken)
|
async Task<WebResult> Conversations(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user