From 5d5f1d69093054bfb031edb6e16a180173f6399d Mon Sep 17 00:00:00 2001
From: thepra
Date: Tue, 6 Oct 2026 15:48:33 +0200
Subject: [PATCH] A persona's notification policy and its requests
Mastodon 4.3's policy was a stub that accepted everything. It is now kept per persona: notifications from accounts it
does not follow, accounts that do not follow it (or only for three days), accounts newer than 30 days, private mentions
it did not ask for and silenced accounts are accepted, filtered or dropped. Filtered ones stay out of every list and
count unless asked for, gathered in a request per account; letting a request in lets that account in for good,
dismissing it deletes them. Everything is accepted until the persona chooses.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
---
CLAUDE.md | 5 +
.../Http/NotificationPolicyTests.cs | 119 ++++++++++++
.../NotificationRequestsController.cs | 178 ++++++++++++++++++
.../Mastodon/Controllers/SearchController.cs | 24 ---
.../Controllers/TimelinesController.cs | 14 +-
.../Domain/Social/NotificationPolicies.cs | 137 ++++++++++++++
PrivaPub/Domain/Social/Notifications.cs | 12 +-
PrivaPub/Infrastructure/Data/Indexes.cs | 5 +-
PrivaPub/Models/Social/Notification.cs | 1 +
PrivaPub/Models/Social/NotificationPolicy.cs | 45 +++++
10 files changed, 511 insertions(+), 29 deletions(-)
create mode 100644 PrivaPub.Tests/Http/NotificationPolicyTests.cs
create mode 100644 PrivaPub/Api/Mastodon/Controllers/NotificationRequestsController.cs
create mode 100644 PrivaPub/Domain/Social/NotificationPolicies.cs
create mode 100644 PrivaPub/Models/Social/NotificationPolicy.cs
diff --git a/CLAUDE.md b/CLAUDE.md
index 68aa35a..9c8e0b1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -272,6 +272,11 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
6. Advertise `4.2.0 (compatible; PrivaPub)` until streaming and Web Push exist too; `api_versions.mastodon = 7` stays,
because clients show quotes from it, so everything a 4.3+ client calls because of it must answer: grouped
notifications (`/api/v2/notifications`, its unread count, groups, accounts and dismiss) and the notification policy.
+ The policy is real (`Domain/Social/NotificationPolicies.cs`, applied in `Notifications.Add`): accepted by default,
+ it filters (into a `NotificationRequest` per account, out of every list and count unless `include_filtered`) or drops
+ what strangers, accounts that do not follow the persona (or only for three days), accounts newer than 30 days,
+ unasked private mentions and silenced accounts send; accepting a request lets that account in for good
+ (`NotificationPermission`).
7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt,
cover, the author's source, link, video, audio and event details, a remote post's own place (Pixelfed's `location`,
never federated again), and up/down votes. Every media URL in it goes
diff --git a/PrivaPub.Tests/Http/NotificationPolicyTests.cs b/PrivaPub.Tests/Http/NotificationPolicyTests.cs
new file mode 100644
index 0000000..c65486d
--- /dev/null
+++ b/PrivaPub.Tests/Http/NotificationPolicyTests.cs
@@ -0,0 +1,119 @@
+using MongoDB.Entities;
+
+using PrivaPub.Models.Social;
+using PrivaPub.Tests.Support;
+using PrivaPub.Tests.Support.Host;
+
+using System.Net;
+
+namespace PrivaPub.Tests.Http
+{
+ // Mastodon's notification policy and requests (4.3): accepted by default; a policy filters what strangers, new accounts
+ // or unasked private mentions send into a request per account, which the persona lets in (and that account from then
+ // on) or dismisses; or drops it
+ [Trait("Category", "Integration")]
+ public sealed class NotificationPolicyTests : IAsyncLifetime
+ {
+ PrivaPubHost _host;
+
+ public async ValueTask InitializeAsync()
+ {
+ Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
+ _host = await PrivaPubHost.Shared();
+ }
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+
+ static async Task> Notifiers(Mastodon persona, string query = "") =>
+ (await persona.Client.Get("/api/v1/notifications" + query)).Ok().Array.Select(n => n!["account"].Text("id"));
+
+ [Fact]
+ public async Task Everything_is_accepted_until_the_persona_chooses()
+ {
+ var alice = await _host.Mastodon("alice");
+ var stranger = await _host.Mastodon("stranger");
+ var post = (await alice.Status("like me")).Text("id");
+
+ var policy = (await alice.Client.Get("/api/v2/notifications/policy")).Ok().Body;
+ (await stranger.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok();
+
+ Assert.Equal(["accept"], new[] { "for_not_following", "for_not_followers", "for_new_accounts", "for_private_mentions", "for_limited_accounts" }
+ .Select(key => policy.Text(key)).Distinct());
+ Assert.Contains(stranger.Id, await Notifiers(alice));
+ Assert.Empty((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array);
+ }
+
+ [Fact]
+ public async Task A_strangers_notifications_wait_in_a_request_which_lets_them_and_the_stranger_in()
+ {
+ var alice = await _host.Mastodon("alice");
+ var friend = await _host.Mastodon("friend");
+ var stranger = await _host.Mastodon("stranger");
+ (await alice.Client.Post($"/api/v1/accounts/{friend.Id}/follow")).Ok();
+ var post = (await alice.Status("like me")).Text("id");
+ var policy = (await alice.Client.Patch("/api/v2/notifications/policy", ("for_not_following", "filter"))).Ok().Body;
+ Assert.Equal("filter", policy.Text("for_not_following"));
+
+ (await friend.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok();
+ (await stranger.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok();
+
+ Assert.Equal([friend.Id], await Notifiers(alice));
+ Assert.Equal(1, (await alice.Client.Get("/api/v1/notifications/unread_count")).Ok().Body["count"]!.GetValue());
+ Assert.DoesNotContain(stranger.Id, (await alice.Client.Get("/api/v2/notifications")).Ok().Body["accounts"]!.AsArray().Select(a => a.Text("id")));
+ var request = Assert.Single((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array)!;
+ Assert.Equal((stranger.Id, "1", post), (request["account"].Text("id"), request.Text("notifications_count"), request["last_status"].Text("id")));
+ var summary = (await alice.Client.Get("/api/v2/notifications/policy")).Ok().Body["summary"]!;
+ Assert.Equal((1, 1), (summary["pending_requests_count"]!.GetValue(), summary["pending_notifications_count"]!.GetValue()));
+ Assert.Equal([stranger.Id], await Notifiers(alice, $"?account_id={stranger.Id}&include_filtered=true"));
+
+ (await alice.Client.Post($"/api/v1/notifications/requests/{request.Text("id")}/accept")).Ok();
+
+ Assert.Contains(stranger.Id, await Notifiers(alice));
+ Assert.Empty((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array);
+ var again = (await alice.Status("like me again")).Text("id");
+ (await stranger.Client.Post($"/api/v1/statuses/{again}/favourite")).Ok();
+ Assert.Equal(2, (await Notifiers(alice, "?types[]=favourite")).Count(id => id == stranger.Id));
+ }
+
+ [Fact]
+ public async Task A_dismissed_request_takes_its_notifications_with_it_and_a_dropped_one_is_never_kept()
+ {
+ var alice = await _host.Mastodon("alice");
+ var stranger = await _host.Mastodon("stranger");
+ var newcomer = await _host.Mastodon("newcomer");
+ var post = (await alice.Status("like me")).Text("id");
+ // (v1 speaks of filtering only)
+ Assert.True((await alice.Client.Put("/api/v1/notifications/policy", ("filter_not_following", "true"))).Ok().Body.Flag("filter_not_following"));
+ (await stranger.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok();
+ var request = Assert.Single((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array)!;
+
+ (await alice.Client.Post($"/api/v1/notifications/requests/{request.Text("id")}/dismiss")).Ok();
+
+ Assert.Empty((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array);
+ Assert.Empty(await Notifiers(alice, $"?account_id={stranger.Id}&include_filtered=true"));
+ Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/notifications/requests/{request.Text("id")}")).Status);
+
+ (await alice.Client.Patch("/api/v2/notifications/policy", ("for_new_accounts", "drop"))).Ok();
+ (await newcomer.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok();
+ Assert.Empty(await Notifiers(alice, $"?account_id={newcomer.Id}&include_filtered=true"));
+ Assert.Empty((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array);
+ }
+
+ [Fact]
+ public async Task An_unasked_private_mention_is_filtered_and_one_answering_the_persona_is_not()
+ {
+ var alice = await _host.Mastodon("alice");
+ var stranger = await _host.Mastodon("stranger");
+ (await alice.Client.Patch("/api/v2/notifications/policy", ("for_private_mentions", "filter"))).Ok();
+ var asked = (await alice.Status($"@{stranger.UserName} write to me", ("visibility", "direct"))).Text("id");
+
+ await stranger.Status($"@{alice.UserName} hello out of nowhere", ("visibility", "direct"));
+ await stranger.Status($"@{alice.UserName} as you asked", ("visibility", "direct"), ("in_reply_to_id", asked));
+
+ var mentions = (await alice.Client.Get("/api/v1/notifications?types[]=mention")).Ok().Array.Select(n => n!["status"].Text("content")).ToList();
+ Assert.Contains(mentions, c => c.Contains("as you asked"));
+ Assert.DoesNotContain(mentions, c => c.Contains("out of nowhere"));
+ Assert.Single((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array);
+ }
+ }
+}
diff --git a/PrivaPub/Api/Mastodon/Controllers/NotificationRequestsController.cs b/PrivaPub/Api/Mastodon/Controllers/NotificationRequestsController.cs
new file mode 100644
index 0000000..7cffccc
--- /dev/null
+++ b/PrivaPub/Api/Mastodon/Controllers/NotificationRequestsController.cs
@@ -0,0 +1,178 @@
+using Microsoft.AspNetCore.Mvc;
+
+using MongoDB.Driver;
+using MongoDB.Entities;
+
+using PrivaPub.Api.Mastodon.Infrastructure;
+using PrivaPub.Api.Mastodon.Mappers;
+using PrivaPub.Domain.Social;
+using PrivaPub.Models.Social;
+using PrivaPub.StaticServices;
+
+namespace PrivaPub.Api.Mastodon.Controllers
+{
+ // Mastodon's notification policy and requests (4.3): what the persona's notifications from strangers, new accounts,
+ // unasked private mentions and silenced accounts become, and the requests that gather the filtered ones, one per
+ // account, let in or dismissed
+ public class NotificationRequestsController : MastodonController
+ {
+ readonly MastodonMapper _mapper;
+ readonly DbEntities _dbEntities;
+
+ public NotificationRequestsController(MastodonMapper mapper, DbEntities dbEntities)
+ {
+ _mapper = mapper;
+ _dbEntities = dbEntities;
+ }
+
+ static string Name(NotificationAction action) => action switch
+ {
+ NotificationAction.Filter => "filter",
+ NotificationAction.Drop => "drop",
+ _ => "accept"
+ };
+
+ static NotificationAction? Parse(string value) => value?.ToLowerInvariant() switch
+ {
+ "accept" => NotificationAction.Accept,
+ "filter" => NotificationAction.Filter,
+ "drop" => NotificationAction.Drop,
+ _ => null
+ };
+
+ async Task
"
});
-
- [HttpGet("/api/v1/notifications/policy"), Scope("read:notifications")]
- public IActionResult NotificationPolicyV1() => Json(new
- {
- filter_not_following = false,
- filter_not_followers = false,
- filter_new_accounts = false,
- filter_private_mentions = false,
- summary = new { pending_requests_count = 0, pending_notifications_count = 0 }
- });
-
- [HttpGet("/api/v2/notifications/policy"), Scope("read:notifications")]
- public IActionResult NotificationPolicyV2() => Json(new
- {
- for_not_following = "accept",
- for_not_followers = "accept",
- for_new_accounts = "accept",
- for_private_mentions = "accept",
- for_limited_accounts = "accept",
- summary = new { pending_requests_count = 0, pending_notifications_count = 0 }
- });
-
- [HttpGet("/api/v1/notifications/requests"), Scope("read:notifications")]
- public IActionResult NotificationRequests() => Json(Array.Empty());
}
}
diff --git a/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs b/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs
index 0222b08..67d55de 100644
--- a/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs
+++ b/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
+using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Entities;
@@ -231,6 +232,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
var types = Params.List("types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList();
var excluded = Params.List("exclude_types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList();
var query = _dbEntities.Notifications.Match(n => n.AvatarId == MyId);
+ // what the persona's notification policy filtered stays in its requests, unless asked for (a request's own page)
+ if (Params.Bool("include_filtered") != true)
+ query.Match(NotFiltered);
if (types.Count > 0)
query.Match(n => types.Contains(n.Type));
if (excluded.Count > 0)
@@ -240,6 +244,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
return query;
}
+ // (notifications made before the policy have no Filtered at all)
+ static readonly FilterDefinition NotFiltered = Builders.Filter.Ne(n => n.Filtered, true);
+
[HttpGet("/api/v1/notifications"), Scope("read:notifications")]
public async Task List(CancellationToken token)
{
@@ -283,7 +290,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
async Task> Members(string key, CancellationToken token) =>
InGroup(key) is { } filter
- ? await _dbEntities.Notifications.Match(filter).Sort(n => n.ID, Order.Descending).Limit(80).ExecuteAsync(token)
+ ? await _dbEntities.Notifications.Match(filter).Match(NotFiltered).Sort(n => n.ID, Order.Descending).Limit(80).ExecuteAsync(token)
: new List();
async Task Groups(List page, ISet grouped, CancellationToken token)
@@ -294,7 +301,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
foreach (var group in shown.GroupBy(n => GroupKey(n, grouped)))
{
var newest = mapped[group.First().ID];
- var total = group.Key.StartsWith("ungrouped-", StringComparison.Ordinal) ? 1 : await DB.Default.CountAsync(InGroup(group.Key), token);
+ var total = group.Key.StartsWith("ungrouped-", StringComparison.Ordinal) ? 1
+ : await DB.Default.CountAsync(Builders.Filter.And(Builders.Filter.Where(InGroup(group.Key)), NotFiltered), token);
groups.Add(new
{
group_key = group.Key,
@@ -390,7 +398,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/notifications/unread_count"), Scope("read:notifications")]
public async Task UnreadCount(CancellationToken token)
{
- var unread = await DB.Default.Find().Match(n => n.AvatarId == MyId && !n.IsRead)
+ var unread = await DB.Default.Find().Match(n => n.AvatarId == MyId && !n.IsRead).Match(NotFiltered)
.Sort(n => n.ID, Order.Descending).Limit(Math.Clamp(Params.Int("limit") ?? 100, 1, 1000)).ExecuteAsync(token);
return Json(new { count = (await Map(unread, token)).Count });
}
diff --git a/PrivaPub/Domain/Social/NotificationPolicies.cs b/PrivaPub/Domain/Social/NotificationPolicies.cs
new file mode 100644
index 0000000..a66b3d7
--- /dev/null
+++ b/PrivaPub/Domain/Social/NotificationPolicies.cs
@@ -0,0 +1,137 @@
+using MongoDB.Driver;
+using MongoDB.Entities;
+
+using PrivaPub.Models.Federation;
+using PrivaPub.Models.Post;
+using PrivaPub.Models.Social;
+using PrivaPub.Models.User;
+
+using PostEntity = PrivaPub.Models.Post.Post;
+
+namespace PrivaPub.Domain.Social
+{
+ // A persona's notification policy (Mastodon 4.3), applied as each notification is made: accepted, filtered into a
+ // request per account, or dropped. Polls ending and edits of posts the persona answered are never filtered.
+ public static class NotificationPolicies
+ {
+ static readonly TimeSpan NewAccount = TimeSpan.FromDays(30);
+ // someone who followed the persona lately is not yet a follower for the policy: following, then writing, is how
+ // a newcomer gets past it
+ static readonly TimeSpan NewFollower = TimeSpan.FromDays(3);
+
+ public static async Task Of(string avatarId, CancellationToken token) =>
+ await DB.Default.Find().Match(p => p.AvatarId == avatarId).ExecuteFirstAsync(token)
+ ?? new NotificationPolicy { AvatarId = avatarId };
+
+ public static async Task Save(NotificationPolicy policy, CancellationToken token) =>
+ await DB.Default.Update().Match(p => p.AvatarId == policy.AvatarId)
+ .Modify(p => p.ForNotFollowing, policy.ForNotFollowing)
+ .Modify(p => p.ForNotFollowers, policy.ForNotFollowers)
+ .Modify(p => p.ForNewAccounts, policy.ForNewAccounts)
+ .Modify(p => p.ForPrivateMentions, policy.ForPrivateMentions)
+ .Modify(p => p.ForLimitedAccounts, policy.ForLimitedAccounts)
+ .Option(o => o.IsUpsert = true)
+ .ExecuteAsync(token);
+
+ public static async Task Judge(string avatarId, NotificationType type, string fromAccountId, string fromActorUri, string postId,
+ CancellationToken token)
+ {
+ if (type is NotificationType.Poll or NotificationType.Update || string.IsNullOrEmpty(fromActorUri))
+ return NotificationAction.Accept;
+ var policy = await DB.Default.Find().Match(p => p.AvatarId == avatarId).ExecuteFirstAsync(token);
+ if (policy is null or { ForNotFollowing: NotificationAction.Accept, ForNotFollowers: NotificationAction.Accept, ForNewAccounts: NotificationAction.Accept,
+ ForPrivateMentions: NotificationAction.Accept, ForLimitedAccounts: NotificationAction.Accept })
+ return NotificationAction.Accept;
+ if (await DB.Default.Find().Match(p => p.AvatarId == avatarId && p.FromActorURI == fromActorUri).ExecuteAnyAsync(token))
+ return NotificationAction.Accept;
+
+ var actions = new List();
+ var following = await DB.Default.Find()
+ .Match(f => f.AvatarId == avatarId && f.TargetActorURI == fromActorUri && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
+ if (!following)
+ actions.Add(policy.ForNotFollowing);
+ if (policy.ForNotFollowers != NotificationAction.Accept)
+ {
+ var since = DateTime.UtcNow - NewFollower;
+ if (!await DB.Default.Find()
+ .Match(f => f.LocalActorId == avatarId && f.ActorURI == fromActorUri && f.IsAccepted && f.CreationDate <= since).ExecuteAnyAsync(token))
+ actions.Add(policy.ForNotFollowers);
+ }
+ if (policy.ForNewAccounts != NotificationAction.Accept || policy.ForLimitedAccounts != NotificationAction.Accept)
+ {
+ var (since, silenced) = await Sender(fromAccountId, fromActorUri, token);
+ if (since > DateTime.UtcNow - NewAccount)
+ actions.Add(policy.ForNewAccounts);
+ if (silenced)
+ actions.Add(policy.ForLimitedAccounts);
+ }
+ // a private mention it did not ask for: from someone it does not follow, answering nothing of the persona's
+ if (policy.ForPrivateMentions != NotificationAction.Accept && type == NotificationType.Mention && !following && !string.IsNullOrEmpty(postId)
+ && await DB.Default.Find().Match(p => p.ID == postId && p.Visibility == PostVisibility.Direct && p.InReplyToAccountId != avatarId)
+ .ExecuteAnyAsync(token))
+ actions.Add(policy.ForPrivateMentions);
+ return actions.Contains(NotificationAction.Drop) ? NotificationAction.Drop
+ : actions.Contains(NotificationAction.Filter) ? NotificationAction.Filter
+ : NotificationAction.Accept;
+ }
+
+ // since when the sender has been on the fediverse (its actor's published, else when this server first met it), and
+ // whether it is silenced here, itself or its server
+ static async Task<(DateTime Since, bool Silenced)> Sender(string accountId, string actorUri, CancellationToken token)
+ {
+ var host = Uri.TryCreate(actorUri, UriKind.Absolute, out var uri) ? uri.Host : default;
+ var silencedHere = host != default && (await DB.Default.Find().Match(b => b.Severity == DomainBlockSeverity.Silence).ExecuteAsync(token))
+ .Any(b => host.Equals(b.Domain, StringComparison.OrdinalIgnoreCase) || host.EndsWith("." + b.Domain, StringComparison.OrdinalIgnoreCase));
+ if (!string.IsNullOrEmpty(accountId) && await DB.Default.Find().Match(a => a.ID == accountId).ExecuteFirstAsync(token) is { } local)
+ return (local.CreatedAt, local.SilencedAt.HasValue);
+ var foreign = await DB.Default.Find().Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token);
+ return foreign == default ? (DateTime.UtcNow, silencedHere) : (foreign.Published ?? foreign.CreatedAt, silencedHere || foreign.SilencedAt.HasValue);
+ }
+
+ // a notification filtered from an account: its request gathers it
+ public static async Task Requested(Notification notification, CancellationToken token)
+ {
+ var now = DateTime.UtcNow;
+ var update = DB.Default.Update()
+ .Match(r => r.AvatarId == notification.AvatarId && r.FromActorURI == notification.FromActorURI)
+ .Modify(b => b.Inc(r => r.NotificationsCount, 1))
+ .Modify(r => r.FromAccountId, notification.FromAccountId)
+ .Modify(r => r.UpdatedAt, now)
+ .Modify(b => b.SetOnInsert(r => r.CreatedAt, now))
+ .Option(o => o.IsUpsert = true);
+ if (!string.IsNullOrEmpty(notification.PostId))
+ update.Modify(r => r.LastPostId, notification.PostId);
+ try
+ {
+ await update.ExecuteAsync(token);
+ }
+ catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
+ {
+ // (two filtered at once from one account: the other made the request)
+ await update.Option(o => o.IsUpsert = false).ExecuteAsync(token);
+ }
+ }
+
+ // the persona lets an account's notifications in, now and from then on
+ public static async Task Accept(NotificationRequest request, CancellationToken token)
+ {
+ try
+ {
+ await DB.Default.SaveAsync(new NotificationPermission { AvatarId = request.AvatarId, FromActorURI = request.FromActorURI }, token);
+ }
+ catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
+ {
+ }
+ await DB.Default.Update().Match(n => n.AvatarId == request.AvatarId && n.FromActorURI == request.FromActorURI && n.Filtered)
+ .Modify(n => n.Filtered, false).ExecuteAsync(token);
+ await DB.Default.DeleteAsync(request.ID);
+ }
+
+ // or dismisses them
+ public static async Task Dismiss(NotificationRequest request, CancellationToken token)
+ {
+ await DB.Default.DeleteAsync(n => n.AvatarId == request.AvatarId && n.FromActorURI == request.FromActorURI && n.Filtered);
+ await DB.Default.DeleteAsync(request.ID);
+ }
+ }
+}
diff --git a/PrivaPub/Domain/Social/Notifications.cs b/PrivaPub/Domain/Social/Notifications.cs
index 12ad1a8..52d35a9 100644
--- a/PrivaPub/Domain/Social/Notifications.cs
+++ b/PrivaPub/Domain/Social/Notifications.cs
@@ -14,6 +14,9 @@ namespace PrivaPub.Domain.Social
return;
if ((await Relationships.Hidden.AuthorsHiddenFrom(avatarId, new[] { fromActorUri }, forNotifications: true, token)).Count > 0)
return;
+ var action = await NotificationPolicies.Judge(avatarId, type, fromAccountId, fromActorUri, postId, token);
+ if (action == NotificationAction.Drop)
+ return;
try
{
var notification = new Notification
@@ -25,9 +28,16 @@ namespace PrivaPub.Domain.Social
PostId = postId,
Emoji = emoji,
EmojiURL = emojiUrl,
- DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}"
+ DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}",
+ Filtered = action == NotificationAction.Filter
};
await DB.Default.SaveAsync(notification, token);
+ // filtered: in its account's request, told to no stream
+ if (notification.Filtered)
+ {
+ await NotificationPolicies.Requested(notification, token);
+ return;
+ }
var told = new Timelines.Streams.Event("notification", notification.ID);
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.User), told);
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.UserNotification), told);
diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs
index 8b1e668..1ad3218 100644
--- a/PrivaPub/Infrastructure/Data/Indexes.cs
+++ b/PrivaPub/Infrastructure/Data/Indexes.cs
@@ -124,7 +124,10 @@ namespace PrivaPub.Infrastructure.Data
(() => DB.Default.Index().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "bookmark"),
(() => DB.Default.Index().Key(p => p.AvatarId, KeyType.Ascending).Key(p => p.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "pin"),
(() => DB.Default.Index().Key(r => r.Configured, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "relay"),
- (() => DB.Default.Index().Key(f => f.ActorURI, KeyType.Ascending).Key(f => f.ObjectURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "remote-featured")
+ (() => DB.Default.Index().Key(f => f.ActorURI, KeyType.Ascending).Key(f => f.ObjectURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "remote-featured"),
+ (() => DB.Default.Index().Key(p => p.AvatarId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "notification policy"),
+ (() => DB.Default.Index().Key(r => r.AvatarId, KeyType.Ascending).Key(r => r.FromActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "notification request"),
+ (() => DB.Default.Index().Key(p => p.AvatarId, KeyType.Ascending).Key(p => p.FromActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "notification permission")
})
await pair.Item1();
await Plain(token, b => b.TargetActorURI);
diff --git a/PrivaPub/Models/Social/Notification.cs b/PrivaPub/Models/Social/Notification.cs
index deedb6e..5600402 100644
--- a/PrivaPub/Models/Social/Notification.cs
+++ b/PrivaPub/Models/Social/Notification.cs
@@ -13,6 +13,7 @@ namespace PrivaPub.Models.Social
public string Emoji { get; set; }//a reaction's emoji
public string EmojiURL { get; set; }
public bool IsRead { get; set; }
+ public bool Filtered { get; set; }//held back by the persona's notification policy, in a NotificationRequest
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
diff --git a/PrivaPub/Models/Social/NotificationPolicy.cs b/PrivaPub/Models/Social/NotificationPolicy.cs
new file mode 100644
index 0000000..0d2939a
--- /dev/null
+++ b/PrivaPub/Models/Social/NotificationPolicy.cs
@@ -0,0 +1,45 @@
+using MongoDB.Entities;
+
+namespace PrivaPub.Models.Social
+{
+ // Mastodon's notification policy (4.3): what becomes of a notification from an account the persona does not follow, one
+ // that does not follow it (or only lately), one new to the fediverse, a private mention it did not ask for, or an
+ // account on a server this one silences. Anything not accepted is filtered (kept, out of the notifications, in a
+ // request per account) or dropped. Accepted by default, so nothing changes until a persona chooses
+ public class NotificationPolicy : Entity
+ {
+ public string AvatarId { get; set; }
+ public NotificationAction ForNotFollowing { get; set; } = NotificationAction.Accept;
+ public NotificationAction ForNotFollowers { get; set; } = NotificationAction.Accept;
+ public NotificationAction ForNewAccounts { get; set; } = NotificationAction.Accept;
+ public NotificationAction ForPrivateMentions { get; set; } = NotificationAction.Accept;
+ public NotificationAction ForLimitedAccounts { get; set; } = NotificationAction.Accept;
+ }
+
+ public enum NotificationAction
+ {
+ Accept,
+ Filter,
+ Drop
+ }
+
+ // the notifications filtered from one account, waiting for the persona to let them in or dismiss them
+ public class NotificationRequest : Entity
+ {
+ public string AvatarId { get; set; }
+ public string FromAccountId { get; set; }
+ public string FromActorURI { get; set; }
+ public string LastPostId { get; set; }
+ public int NotificationsCount { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
+ }
+
+ // an account whose notifications the persona let in: never filtered again
+ public class NotificationPermission : Entity
+ {
+ public string AvatarId { get; set; }
+ public string FromActorURI { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ }
+}