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;
+ }
+}