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)
|
||||
{
|
||||
<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],
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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