diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx
index 2af4c21..23cfcad 100644
--- a/Resources/AllStrings.it.resx
+++ b/Resources/AllStrings.it.resx
@@ -1775,4 +1775,46 @@
Nessuno che questo server conosca.
+
+ Filtra
+
+
+ Scarta
+
+
+ Ignora
+
+
+ Chi non segui
+
+
+ Chi non ti segue
+
+
+ Account nuovi
+
+
+ Menzioni private non richieste
+
+
+ Account limitati da questo server
+
+
+ Le notifiche filtrate aspettano qui sotto, una richiesta per account; quelle scartate non vengono tenute.
+
+
+ Notifiche filtrate
+
+
+ Nessuna notifica filtrata.
+
+
+ {0} notifiche
+
+
+ Regole delle notifiche salvate.
+
+
+ {0} filtrate, ti aspettano
+
diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx
index 60b4bc3..3a7de16 100644
--- a/Resources/AllStrings.resx
+++ b/Resources/AllStrings.resx
@@ -1775,4 +1775,46 @@
Nobody this server knows of.
+
+ Filter
+
+
+ Drop
+
+
+ Dismiss
+
+
+ People you don't follow
+
+
+ People who don't follow you
+
+
+ New accounts
+
+
+ Private mentions you did not ask for
+
+
+ Accounts this server limits
+
+
+ Filtered notifications wait below, one request per account; dropped ones are never kept.
+
+
+ Filtered notifications
+
+
+ No filtered notifications.
+
+
+ {0} notifications
+
+
+ Notification policy saved.
+
+
+ {0} filtered, waiting for you
+
diff --git a/Services/TimelineService.cs b/Services/TimelineService.cs
index a41b660..864d1f0 100644
--- a/Services/TimelineService.cs
+++ b/Services/TimelineService.cs
@@ -108,6 +108,56 @@ namespace decePubClient.Services
return response.IsSuccessStatusCode ? (await response.MastodonReadFromJsonAsync(cancellationToken))?.Count ?? 0 : 0;
}
+ /// The persona's notification policy; null when it cannot be read.
+ public async Task 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(cancellationToken);
+ }
+
+ /// Saves the persona's notification policy; Data is the policy as PrivaPub keeps it.
+ public async Task 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(cancellationToken) };
+ }
+
+ /// The requests gathering the filtered notifications, newest first; [] when they cannot be read.
+ public async Task> 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>(cancellationToken) ?? [];
+ }
+
+ /// Lets a request's notifications in, and its account from then on, or dismisses them.
+ public async Task 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);
+ }
+
/// Moves the notifications marker to the newest one seen, which reads everything up to it.
public async Task ReadNotificationsUpTo(string notificationId, CancellationToken cancellationToken = default)
{
diff --git a/tests/e2e/Actions/NotificationPolicyTests.cs b/tests/e2e/Actions/NotificationPolicyTests.cs
new file mode 100644
index 0000000..c63aa05
--- /dev/null
+++ b/tests/e2e/Actions/NotificationPolicyTests.cs
@@ -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() == 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();
+ 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();
+ 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() == "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() == strangerAcct && n["status"]?["id"]?.GetValue() == 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() == strangerAcct && n["status"]?["id"]?.GetValue() == 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);
+ }
+ }
+ }
+}
diff --git a/tests/e2e/Support/Pasture.cs b/tests/e2e/Support/Pasture.cs
index 1ae0677..d70542e 100644
--- a/tests/e2e/Support/Pasture.cs
+++ b/tests/e2e/Support/Pasture.cs
@@ -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 Stored(string platform, params string[] uris) =>
(await Town(["stored", platform, .. uris])).AsObject();