The persona's notification policy, and what it filtered
Settings has a Notifications section, also at /notifications/filtered: what notifications from people the persona does not follow, people who do not follow it, new accounts, private mentions it did not ask for and limited accounts become (accept, filter, drop), and the filtered ones, one request per account, let in (with that account from then on) or dismissed. The Notifications tab says when requests wait. e2e: a stranger's like waits in a request reached from the tab and comes back once let in; the policy and the stranger's permission are put back afterwards (Pasture.Mongo). 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
a0f07abf8c
commit
658683f816
13 files changed
+467
No files matched your search
@@ -283,6 +283,11 @@ supplies:
|
||||
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for
|
||||
administrators the servers blocked for everyone and the federation statistics. PrivaPub has no admin list of users,
|
||||
and adding an admin endpoint waits for the owner, so the Users section stays empty.
|
||||
- **Notification policy** (`Components/Settings/NotificationPolicyEditor.razor`, in Settings and at `/notifications/filtered`):
|
||||
what notifications from people the persona does not follow, people who do not follow it, new accounts, private
|
||||
mentions it did not ask for and limited accounts become (accept, filter, drop; `/api/v2/notifications/policy`), and the
|
||||
filtered ones, one request per account, let in (with that account from then on) or dismissed. The Notifications tab
|
||||
links there while requests wait.
|
||||
- **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a
|
||||
time (`scheduled_at`, at least five minutes ahead; located and group posts have none); the page moves or drops them.
|
||||
- **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
@using Microsoft.AspNetCore.Components.Rendering
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* The persona's notification policy (/api/v2/notifications/policy): what notifications from people it does not follow,
|
||||
people who do not follow it, new accounts, private mentions it did not ask for and accounts this server limits become.
|
||||
Filtered ones wait in a request per account (/api/v1/notifications/requests), let in (with that account from then
|
||||
on) or dismissed here. *@
|
||||
@if (IsLoading)
|
||||
{
|
||||
<p class="flex w-full items-center justify-center gap-2">
|
||||
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||
@Localizer["loading"]
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="flex flex-col gap-4" data-notification-policy>
|
||||
<EditForm Model="policy" OnValidSubmit="Save" class="flex flex-col gap-2">
|
||||
<label class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-sm">@Localizer["People you don't follow"]</span>
|
||||
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForNotFollowing" data-policy="for_not_following">@Actions</DSelect>
|
||||
</label>
|
||||
<label class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-sm">@Localizer["People who don't follow you"]</span>
|
||||
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForNotFollowers" data-policy="for_not_followers">@Actions</DSelect>
|
||||
</label>
|
||||
<label class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-sm">@Localizer["New accounts"]</span>
|
||||
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForNewAccounts" data-policy="for_new_accounts">@Actions</DSelect>
|
||||
</label>
|
||||
<label class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-sm">@Localizer["Private mentions you did not ask for"]</span>
|
||||
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForPrivateMentions" data-policy="for_private_mentions">@Actions</DSelect>
|
||||
</label>
|
||||
<label class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-sm">@Localizer["Accounts this server limits"]</span>
|
||||
<DSelect TValue="string" Size="DSize.Sm" class="w-40" @bind-Value="policy.ForLimitedAccounts" data-policy="for_limited_accounts">@Actions</DSelect>
|
||||
</label>
|
||||
<p class="text-xs opacity-70">@Localizer["Filtered notifications wait below, one request per account; dropped ones are never kept."]</p>
|
||||
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-start" Disabled="@(IsDefaultDisabled || isSaving)" data-policy-save>
|
||||
@Localizer["Save"]
|
||||
</DButton>
|
||||
</EditForm>
|
||||
|
||||
<section class="flex flex-col gap-2" data-notification-requests>
|
||||
<h3 class="font-semibold text-sm">@Localizer["Filtered notifications"]</h3>
|
||||
@if (requests.Count == 0)
|
||||
{
|
||||
<p class="text-sm opacity-70">@Localizer["No filtered notifications."]</p>
|
||||
}
|
||||
@foreach (var request in requests)
|
||||
{
|
||||
<div class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-2xs min-w-0" data-request="@request.Account.Acct">
|
||||
<div class="flex flex-wrap items-center gap-3 min-w-0">
|
||||
<a class="flex items-center gap-3 min-w-0 flex-1" href="@($"account/{Uri.EscapeDataString(request.Account.Id)}")">
|
||||
@* alt="": the name is next to it. *@
|
||||
<img alt="" class="h-9 w-9 flex-none object-cover rounded-full" src="@(request.Account.Avatar ?? "imgs/icon-192.png")" />
|
||||
<span class="flex flex-col min-w-0">
|
||||
<b class="truncate">@CustomEmojis.Text(request.Account.Name, request.Account.Emojis)</b>
|
||||
<span class="text-xs opacity-70 truncate">@("@" + request.Account.Acct)</span>
|
||||
</span>
|
||||
</a>
|
||||
<DBadge Size="DSize.Sm">@Localizer["{0} notifications", request.NotificationsCount]</DBadge>
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy.Contains(request.Id))"
|
||||
OnClick="@(() => Answer(request, true))" data-request-accept>@Localizer["Let in"]</DButton>
|
||||
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(request.Id))"
|
||||
OnClick="@(() => Answer(request, false))" data-request-dismiss>@Localizer["Dismiss"]</DButton>
|
||||
</div>
|
||||
@if (request.LastStatus?.Content is { Length: > 0 } content)
|
||||
{
|
||||
<div class="post-content text-sm opacity-80 break-words line-clamp-3">@((MarkupString)CustomEmojis.Html(content, request.LastStatus.Emojis))</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Inject] TimelineService TimelineService { get; set; }
|
||||
|
||||
NotificationPolicy policy = new();
|
||||
|
||||
// what a notification can become, the same for every rule
|
||||
void Actions(RenderTreeBuilder __builder)
|
||||
{
|
||||
<option value="accept">@Localizer["Accept"]</option>
|
||||
<option value="filter">@Localizer["Filter"]</option>
|
||||
<option value="drop">@Localizer["Drop"]</option>
|
||||
}
|
||||
List<NotificationRequest> requests = [];
|
||||
// the requests whose answer is on its way
|
||||
HashSet<string> busy = [];
|
||||
bool isSaving;
|
||||
string loadedPersonaId;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (CascadingState.PersonaId == loadedPersonaId)
|
||||
return;
|
||||
loadedPersonaId = CascadingState.PersonaId;
|
||||
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData()
|
||||
{
|
||||
policy = await TimelineService.NotificationPolicy() ?? new();
|
||||
requests = await TimelineService.NotificationRequests();
|
||||
}
|
||||
|
||||
async Task Save()
|
||||
{
|
||||
isSaving = true;
|
||||
var result = await TimelineService.SetNotificationPolicy(policy);
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
policy = (NotificationPolicy)result.Data ?? policy;
|
||||
Toast.AddResponseSuccess(Localizer["Notification policy saved."]);
|
||||
}
|
||||
|
||||
async Task Answer(NotificationRequest request, bool accept)
|
||||
{
|
||||
busy.Add(request.Id);
|
||||
var result = await TimelineService.AnswerNotificationRequest(request.Id, accept);
|
||||
busy.Remove(request.Id);
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
requests.Remove(request);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,8 @@ namespace decePubClient;
|
||||
[JsonSerializable(typeof(StatusSource))]
|
||||
[JsonSerializable(typeof(List<StatusEdit>))]
|
||||
[JsonSerializable(typeof(ReportRequest))]
|
||||
[JsonSerializable(typeof(NotificationPolicy))]
|
||||
[JsonSerializable(typeof(List<NotificationRequest>))]
|
||||
[JsonSerializable(typeof(Poll))]
|
||||
[JsonSerializable(typeof(PollVoteForm))]
|
||||
[JsonSerializable(typeof(MastodonList))]
|
||||
|
||||
@@ -104,6 +104,11 @@ public static class APIs
|
||||
public static string GET_M_FavouritedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/favourited_by";
|
||||
public static string GET_M_RebloggedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/reblogged_by";
|
||||
public const string POST_Reports = "api/v1/reports";
|
||||
public const string GET_NotificationPolicy = "api/v2/notifications/policy";
|
||||
public const string PATCH_NotificationPolicy = "api/v2/notifications/policy";
|
||||
public const string GET_NotificationRequests = "api/v1/notifications/requests";
|
||||
public static string POST_M_NotificationRequest(string requestId, bool accept) =>
|
||||
$"api/v1/notifications/requests/{Uri.EscapeDataString(requestId)}/{(accept ? "accept" : "dismiss")}";
|
||||
public static string PUT_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
|
||||
public const string GET_FollowedTags = "api/v1/followed_tags";
|
||||
public const string GET_GroupedNotifications = "api/v2/notifications";
|
||||
|
||||
@@ -206,6 +206,37 @@ public class StatusSource
|
||||
public string SpoilerText { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>/api/v2/notifications/policy: what notifications from people the persona does not follow, people who do not
|
||||
/// follow it, new accounts, unasked private mentions and limited accounts become: accept, filter or drop.</summary>
|
||||
public class NotificationPolicy
|
||||
{
|
||||
public string ForNotFollowing { get; set; }
|
||||
public string ForNotFollowers { get; set; }
|
||||
public string ForNewAccounts { get; set; }
|
||||
public string ForPrivateMentions { get; set; }
|
||||
public string ForLimitedAccounts { get; set; }
|
||||
public NotificationPolicySummary Summary { get; set; }
|
||||
}
|
||||
|
||||
public class NotificationPolicySummary
|
||||
{
|
||||
public int PendingRequestsCount { get; set; }
|
||||
public int PendingNotificationsCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>/api/v1/notifications/requests: the notifications the policy filtered from one account, waiting to be let in
|
||||
/// (and that account from then on) or dismissed.</summary>
|
||||
public class NotificationRequest
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
/// <summary>A number, as a string (Mastodon's).</summary>
|
||||
public string NotificationsCount { get; set; }
|
||||
public Account Account { get; set; }
|
||||
public Status LastStatus { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>/api/v1/reports: an account, and some of its posts, reported to the persona's server's moderators; forwarded,
|
||||
/// a remote account's server gets it too, from the server's own actor.</summary>
|
||||
public class ReportRequest
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@page "/notifications/filtered"
|
||||
@attribute [Authorize]
|
||||
@inherits LocalizableComponentBase
|
||||
<Title>@Localizer["Filtered notifications"]</Title>
|
||||
|
||||
@* The persona's notification policy and the requests it filtered, as in Settings; the Notifications tab links here when
|
||||
requests are waiting. *@
|
||||
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="ion-md-notifications-off text-lg"></i>
|
||||
<h1 class="font-bold truncate">@Localizer["Filtered notifications"]</h1>
|
||||
</div>
|
||||
<NotificationPolicyEditor />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized() => IsLoading = false;
|
||||
}
|
||||
@@ -32,6 +32,14 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Filter.TimelineType is TimelineType.Notifications && filteredRequests > 0)
|
||||
{
|
||||
<a class="btn btn-sm self-center" href="notifications/filtered" data-filtered-requests>
|
||||
<i class="ion-md-notifications-off"></i>
|
||||
<span>@Localizer["{0} filtered, waiting for you", filteredRequests]</span>
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (pending.Count > 0)
|
||||
{
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" class="self-center sticky top-0 z-10" OnClick="ShowPending">
|
||||
@@ -118,6 +126,8 @@
|
||||
List<ViewGroup> groups = [];
|
||||
List<MastodonList> lists = [];
|
||||
int unreadNotifications;
|
||||
// the requests the persona's notification policy filtered, shown on the Notifications tab
|
||||
int filteredRequests;
|
||||
int unreadConversations;
|
||||
string maxId;
|
||||
string minId;
|
||||
@@ -392,6 +402,7 @@
|
||||
if (newest is not null)
|
||||
await TimelineService.ReadNotificationsUpTo(newest, cancellationToken);
|
||||
unreadNotifications = 0;
|
||||
filteredRequests = (await TimelineService.NotificationPolicy(cancellationToken))?.Summary?.PendingRequestsCount ?? 0;
|
||||
}
|
||||
|
||||
/// <summary>The stream that tells this feed's new posts, or null for a feed PrivaPub does not stream.</summary>
|
||||
|
||||
@@ -140,6 +140,26 @@
|
||||
</ChildContent>
|
||||
</DCollapse>
|
||||
|
||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||
<Header>
|
||||
<p class="inline-flex items-center gap-2">
|
||||
<i class="ion-md-notifications text-xl"></i> <span>@Localizer["Notifications"]</span>
|
||||
</p>
|
||||
</Header>
|
||||
<ChildContent>
|
||||
<div class="block w-full p-3 md:p-4">
|
||||
@if (CascadingState.IsSignedIn)
|
||||
{
|
||||
<NotificationPolicyEditor />
|
||||
}
|
||||
else
|
||||
{
|
||||
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
|
||||
}
|
||||
</div>
|
||||
</ChildContent>
|
||||
</DCollapse>
|
||||
|
||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||
<Header>
|
||||
<p class="inline-flex items-center gap-2">
|
||||
|
||||
@@ -1775,4 +1775,46 @@
|
||||
<data name="Nobody this server knows of." xml:space="preserve">
|
||||
<value>Nessuno che questo server conosca.</value>
|
||||
</data>
|
||||
<data name="Filter" xml:space="preserve">
|
||||
<value>Filtra</value>
|
||||
</data>
|
||||
<data name="Drop" xml:space="preserve">
|
||||
<value>Scarta</value>
|
||||
</data>
|
||||
<data name="Dismiss" xml:space="preserve">
|
||||
<value>Ignora</value>
|
||||
</data>
|
||||
<data name="People you don't follow" xml:space="preserve">
|
||||
<value>Chi non segui</value>
|
||||
</data>
|
||||
<data name="People who don't follow you" xml:space="preserve">
|
||||
<value>Chi non ti segue</value>
|
||||
</data>
|
||||
<data name="New accounts" xml:space="preserve">
|
||||
<value>Account nuovi</value>
|
||||
</data>
|
||||
<data name="Private mentions you did not ask for" xml:space="preserve">
|
||||
<value>Menzioni private non richieste</value>
|
||||
</data>
|
||||
<data name="Accounts this server limits" xml:space="preserve">
|
||||
<value>Account limitati da questo server</value>
|
||||
</data>
|
||||
<data name="Filtered notifications wait below, one request per account; dropped ones are never kept." xml:space="preserve">
|
||||
<value>Le notifiche filtrate aspettano qui sotto, una richiesta per account; quelle scartate non vengono tenute.</value>
|
||||
</data>
|
||||
<data name="Filtered notifications" xml:space="preserve">
|
||||
<value>Notifiche filtrate</value>
|
||||
</data>
|
||||
<data name="No filtered notifications." xml:space="preserve">
|
||||
<value>Nessuna notifica filtrata.</value>
|
||||
</data>
|
||||
<data name="{0} notifications" xml:space="preserve">
|
||||
<value>{0} notifiche</value>
|
||||
</data>
|
||||
<data name="Notification policy saved." xml:space="preserve">
|
||||
<value>Regole delle notifiche salvate.</value>
|
||||
</data>
|
||||
<data name="{0} filtered, waiting for you" xml:space="preserve">
|
||||
<value>{0} filtrate, ti aspettano</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1775,4 +1775,46 @@
|
||||
<data name="Nobody this server knows of." xml:space="preserve">
|
||||
<value>Nobody this server knows of.</value>
|
||||
</data>
|
||||
<data name="Filter" xml:space="preserve">
|
||||
<value>Filter</value>
|
||||
</data>
|
||||
<data name="Drop" xml:space="preserve">
|
||||
<value>Drop</value>
|
||||
</data>
|
||||
<data name="Dismiss" xml:space="preserve">
|
||||
<value>Dismiss</value>
|
||||
</data>
|
||||
<data name="People you don't follow" xml:space="preserve">
|
||||
<value>People you don't follow</value>
|
||||
</data>
|
||||
<data name="People who don't follow you" xml:space="preserve">
|
||||
<value>People who don't follow you</value>
|
||||
</data>
|
||||
<data name="New accounts" xml:space="preserve">
|
||||
<value>New accounts</value>
|
||||
</data>
|
||||
<data name="Private mentions you did not ask for" xml:space="preserve">
|
||||
<value>Private mentions you did not ask for</value>
|
||||
</data>
|
||||
<data name="Accounts this server limits" xml:space="preserve">
|
||||
<value>Accounts this server limits</value>
|
||||
</data>
|
||||
<data name="Filtered notifications wait below, one request per account; dropped ones are never kept." xml:space="preserve">
|
||||
<value>Filtered notifications wait below, one request per account; dropped ones are never kept.</value>
|
||||
</data>
|
||||
<data name="Filtered notifications" xml:space="preserve">
|
||||
<value>Filtered notifications</value>
|
||||
</data>
|
||||
<data name="No filtered notifications." xml:space="preserve">
|
||||
<value>No filtered notifications.</value>
|
||||
</data>
|
||||
<data name="{0} notifications" xml:space="preserve">
|
||||
<value>{0} notifications</value>
|
||||
</data>
|
||||
<data name="Notification policy saved." xml:space="preserve">
|
||||
<value>Notification policy saved.</value>
|
||||
</data>
|
||||
<data name="{0} filtered, waiting for you" xml:space="preserve">
|
||||
<value>{0} filtered, waiting for you</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -108,6 +108,56 @@ namespace decePubClient.Services
|
||||
return response.IsSuccessStatusCode ? (await response.MastodonReadFromJsonAsync<UnreadCount>(cancellationToken))?.Count ?? 0 : 0;
|
||||
}
|
||||
|
||||
/// <summary>The persona's notification policy; null when it cannot be read.</summary>
|
||||
public async Task<NotificationPolicy> NotificationPolicy(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Get(APIs.Mastodon.GET_NotificationPolicy, cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(TimelineService)}.{nameof(NotificationPolicy)}", cancellationToken);
|
||||
return null;
|
||||
}
|
||||
return await response.MastodonReadFromJsonAsync<NotificationPolicy>(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Saves the persona's notification policy; Data is the policy as PrivaPub keeps it.</summary>
|
||||
public async Task<WebResult> SetNotificationPolicy(NotificationPolicy policy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Patch(APIs.Mastodon.PATCH_NotificationPolicy, new NotificationPolicy
|
||||
{
|
||||
ForNotFollowing = policy.ForNotFollowing,
|
||||
ForNotFollowers = policy.ForNotFollowers,
|
||||
ForNewAccounts = policy.ForNewAccounts,
|
||||
ForPrivateMentions = policy.ForPrivateMentions,
|
||||
ForLimitedAccounts = policy.ForLimitedAccounts
|
||||
}, cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
return new WebResult { Data = await response.MastodonReadFromJsonAsync<NotificationPolicy>(cancellationToken) };
|
||||
}
|
||||
|
||||
/// <summary>The requests gathering the filtered notifications, newest first; [] when they cannot be read.</summary>
|
||||
public async Task<List<NotificationRequest>> NotificationRequests(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Get(APIs.Mastodon.GET_NotificationRequests, ["limit=80"], cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(TimelineService)}.{nameof(NotificationRequests)}", cancellationToken);
|
||||
return [];
|
||||
}
|
||||
return await response.MastodonReadFromJsonAsync<List<NotificationRequest>>(cancellationToken) ?? [];
|
||||
}
|
||||
|
||||
/// <summary>Lets a request's notifications in, and its account from then on, or dismisses them.</summary>
|
||||
public async Task<WebResult> AnswerNotificationRequest(string requestId, bool accept, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Post(APIs.Mastodon.POST_M_NotificationRequest(requestId, accept), cancellationToken: cancellationToken);
|
||||
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// The persona's notification policy from Settings: people it does not follow are filtered, a like from one waits in a
|
||||
// request the Notifications tab leads to, and letting the request in brings the notification back; the policy is put back to accepting everything, and
|
||||
// the stranger's permission is taken away again
|
||||
public sealed class NotificationPolicyTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_strangers_like_waits_in_a_request_the_persona_lets_in()
|
||||
{
|
||||
var personas = Pasture.Personas();
|
||||
var author = personas[0];
|
||||
// (a persona of the town the author does not follow)
|
||||
var stranger = default(Pasture.Persona);
|
||||
foreach (var candidate in personas.Skip(1))
|
||||
if ((await PersonaApi.Call(author, $"/api/v1/accounts/relationships?id[]={candidate.LocalId}"))![0]!["following"]!.GetValue<bool>() == false)
|
||||
{
|
||||
stranger = candidate;
|
||||
break;
|
||||
}
|
||||
Assert.SkipWhen(stranger is null, "the town's first persona follows every other");
|
||||
var strangerAcct = (await PersonaApi.Call(stranger!, "/api/v1/accounts/verify_credentials"))!["acct"]!.GetValue<string>();
|
||||
var post = (await PersonaApi.Call(author, "/api/v1/statuses", HttpMethod.Post,
|
||||
new JsonObject { ["status"] = $"like me, stranger {Guid.NewGuid():N}"[..30], ["visibility"] = "public" }))!;
|
||||
var id = post["id"]!.GetValue<string>();
|
||||
await using var session = await SignIn.As(browser, author);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.notification-policy", "privapub", author.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/settings");
|
||||
var header = page.Locator(".collapse-title", new() { HasText = "Notifications" }).First;
|
||||
await header.ClickAsync();
|
||||
var panel = page.Locator("[data-notification-policy]");
|
||||
await panel.WaitForAsync(new() { Timeout = 30000 });
|
||||
await panel.Locator("[data-policy='for_not_following']").SelectOptionAsync("filter");
|
||||
await panel.Locator("[data-policy-save]").ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
(await PersonaApi.Call(author, "/api/v2/notifications/policy"))!["for_not_following"]!.GetValue<string>() == "filter", 15), "the policy was never saved");
|
||||
|
||||
await PersonaApi.Call(stranger!, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
|
||||
Assert.DoesNotContain((await PersonaApi.Call(author, "/api/v1/notifications?types[]=favourite"))!.AsArray(),
|
||||
n => n!["account"]!["acct"]!.GetValue<string>() == strangerAcct && n["status"]?["id"]?.GetValue<string>() == id);
|
||||
|
||||
// the Notifications tab says requests are waiting, and leads to them
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.GetByRole(AriaRole.Tab).Filter(new() { Has = page.Locator(".ion-md-notifications") }).ClickAsync();
|
||||
await page.Locator("[data-filtered-requests]").ClickAsync(new() { Timeout = 30000 });
|
||||
var request = page.Locator($"[data-notification-requests] [data-request='{strangerAcct}']");
|
||||
await request.WaitForAsync(new() { Timeout = 30000 });
|
||||
await request.Locator("[data-request-accept]").ClickAsync();
|
||||
await Assertions.Expect(request).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
Assert.Contains((await PersonaApi.Call(author, "/api/v1/notifications?types[]=favourite"))!.AsArray(),
|
||||
n => n!["account"]!["acct"]!.GetValue<string>() == strangerAcct && n["status"]?["id"]?.GetValue<string>() == id);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(author, "/api/v2/notifications/policy", HttpMethod.Patch, new JsonObject { ["for_not_following"] = "accept" });
|
||||
// (letting a request in lets its account in for good, which no API takes back)
|
||||
await Pasture.Mongo($"db.NotificationPermission.deleteMany({{AvatarId: '{author.LocalId}', FromActorURI: '{stranger!.ActorUri}'}})");
|
||||
await PersonaApi.Call(author, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,20 @@ namespace decePubClient.E2E.Support
|
||||
return JsonNode.Parse(output.Trim().Split('\n').Last())!;
|
||||
}
|
||||
|
||||
// a script on PrivaPub's own database in the pasture, to undo what no API undoes
|
||||
public static async Task Mongo(string script)
|
||||
{
|
||||
var start = new ProcessStartInfo("podman") { RedirectStandardOutput = true, RedirectStandardError = true };
|
||||
foreach (var arg in new[] { "exec", "pasture-mongo", "mongosh", "--quiet", "PrivaPub", "--eval", script })
|
||||
start.ArgumentList.Add(arg);
|
||||
using var process = Process.Start(start)!;
|
||||
var error = await process.StandardError.ReadToEndAsync();
|
||||
await process.StandardOutput.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
if (process.ExitCode != 0)
|
||||
throw new InvalidOperationException($"mongosh failed: {error}");
|
||||
}
|
||||
|
||||
// what a server holds of each object, from its database (town.sh stored)
|
||||
public static async Task<JsonObject> Stored(string platform, params string[] uris) =>
|
||||
(await Town(["stored", platform, .. uris])).AsObject();
|
||||
|
||||
Reference in new issue
Block a user