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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 15:48:33 +02:00
1 parent 9872b78677
commit 5d5f1d6909
10 files changed
+511 -29

No files matched your search

+5
View File
@@ -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
@@ -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<IEnumerable<string>> 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<int>());
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<int>(), summary["pending_notifications_count"]!.GetValue<int>()));
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);
}
}
}
@@ -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<object> Summary(CancellationToken token) => new
{
pending_requests_count = await DB.Default.CountAsync<NotificationRequest>(r => r.AvatarId == MyId, token),
pending_notifications_count = await DB.Default.CountAsync<Notification>(n => n.AvatarId == MyId && n.Filtered, token)
};
async Task<object> V2(NotificationPolicy policy, CancellationToken token) => new
{
for_not_following = Name(policy.ForNotFollowing),
for_not_followers = Name(policy.ForNotFollowers),
for_new_accounts = Name(policy.ForNewAccounts),
for_private_mentions = Name(policy.ForPrivateMentions),
for_limited_accounts = Name(policy.ForLimitedAccounts),
summary = await Summary(token)
};
// the first version spoke of filtering only: dropped is filtered for it
async Task<object> V1(NotificationPolicy policy, CancellationToken token) => new
{
filter_not_following = policy.ForNotFollowing != NotificationAction.Accept,
filter_not_followers = policy.ForNotFollowers != NotificationAction.Accept,
filter_new_accounts = policy.ForNewAccounts != NotificationAction.Accept,
filter_private_mentions = policy.ForPrivateMentions != NotificationAction.Accept,
summary = await Summary(token)
};
[HttpGet("/api/v2/notifications/policy"), Scope("read:notifications")]
public async Task<IActionResult> Policy(CancellationToken token) => Json(await V2(await NotificationPolicies.Of(MyId, token), token));
[HttpPatch("/api/v2/notifications/policy"), Scope("write:notifications")]
public async Task<IActionResult> SetPolicy(CancellationToken token)
{
var policy = await NotificationPolicies.Of(MyId, token);
policy.ForNotFollowing = Parse(Params.Get("for_not_following")) ?? policy.ForNotFollowing;
policy.ForNotFollowers = Parse(Params.Get("for_not_followers")) ?? policy.ForNotFollowers;
policy.ForNewAccounts = Parse(Params.Get("for_new_accounts")) ?? policy.ForNewAccounts;
policy.ForPrivateMentions = Parse(Params.Get("for_private_mentions")) ?? policy.ForPrivateMentions;
policy.ForLimitedAccounts = Parse(Params.Get("for_limited_accounts")) ?? policy.ForLimitedAccounts;
await NotificationPolicies.Save(policy, token);
return Json(await V2(policy, token));
}
[HttpGet("/api/v1/notifications/policy"), Scope("read:notifications")]
public async Task<IActionResult> PolicyV1(CancellationToken token) => Json(await V1(await NotificationPolicies.Of(MyId, token), token));
[HttpPut("/api/v1/notifications/policy"), HttpPatch("/api/v1/notifications/policy"), Scope("write:notifications")]
public async Task<IActionResult> SetPolicyV1(CancellationToken token)
{
var policy = await NotificationPolicies.Of(MyId, token);
static NotificationAction Filter(bool? filter, NotificationAction current) =>
filter switch { true => current == NotificationAction.Drop ? NotificationAction.Drop : NotificationAction.Filter, false => NotificationAction.Accept, _ => current };
policy.ForNotFollowing = Filter(Params.Bool("filter_not_following"), policy.ForNotFollowing);
policy.ForNotFollowers = Filter(Params.Bool("filter_not_followers"), policy.ForNotFollowers);
policy.ForNewAccounts = Filter(Params.Bool("filter_new_accounts"), policy.ForNewAccounts);
policy.ForPrivateMentions = Filter(Params.Bool("filter_private_mentions"), policy.ForPrivateMentions);
await NotificationPolicies.Save(policy, token);
return Json(await V1(policy, token));
}
async Task<object> Map(NotificationRequest request, CancellationToken token)
{
var account = await _mapper.Account(request.FromAccountId, token);
if (account == default)
return default;
var post = string.IsNullOrEmpty(request.LastPostId) ? default
: await _dbEntities.Posts.Match(p => p.ID == request.LastPostId && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
return new
{
id = request.ID,
created_at = MastodonJson.Time(request.CreatedAt),
updated_at = MastodonJson.Time(request.UpdatedAt),
notifications_count = request.NotificationsCount.ToString(System.Globalization.CultureInfo.InvariantCulture),
account,
last_status = post == default ? default : await _mapper.Status(post, MyId, token)
};
}
[HttpGet("/api/v1/notifications/requests"), Scope("read:notifications")]
public async Task<IActionResult> Requests(CancellationToken token)
{
var requests = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find<NotificationRequest>().Match(r => r.AvatarId == MyId), r => r.ID, token);
Link("/api/v1/notifications/requests", requests.LastOrDefault()?.ID, requests.FirstOrDefault()?.ID);
var mapped = new List<object>();
foreach (var request in requests)
if (await Map(request, token) is { } one)
mapped.Add(one);
return Json(mapped);
}
Task<NotificationRequest> Mine(string id, CancellationToken token) =>
DB.Default.Find<NotificationRequest>().Match(r => r.ID == id && r.AvatarId == MyId).ExecuteFirstAsync(token);
[HttpGet("/api/v1/notifications/requests/merged"), Scope("read:notifications")]
public IActionResult Merged() => Json(new { merged = true });
[HttpGet("/api/v1/notifications/requests/{id}"), Scope("read:notifications")]
public async Task<IActionResult> Request(string id, CancellationToken token) =>
await Mine(id, token) is { } request && await Map(request, token) is { } mapped ? Json(mapped) : NotFoundError();
[HttpPost("/api/v1/notifications/requests/{id}/accept"), Scope("write:notifications")]
public async Task<IActionResult> Accept(string id, CancellationToken token)
{
if (await Mine(id, token) is not { } request)
return NotFoundError();
await NotificationPolicies.Accept(request, token);
return Json(new { });
}
[HttpPost("/api/v1/notifications/requests/{id}/dismiss"), Scope("write:notifications")]
public async Task<IActionResult> Dismiss(string id, CancellationToken token)
{
if (await Mine(id, token) is not { } request)
return NotFoundError();
await NotificationPolicies.Dismiss(request, token);
return Json(new { });
}
[HttpPost("/api/v1/notifications/requests/accept"), Scope("write:notifications")]
public async Task<IActionResult> AcceptMany(CancellationToken token)
{
foreach (var id in Params.List("id").Take(100))
if (await Mine(id, token) is { } request)
await NotificationPolicies.Accept(request, token);
return Json(new { });
}
[HttpPost("/api/v1/notifications/requests/dismiss"), Scope("write:notifications")]
public async Task<IActionResult> DismissMany(CancellationToken token)
{
foreach (var id in Params.List("id").Take(100))
if (await Mine(id, token) is { } request)
await NotificationPolicies.Dismiss(request, token);
return Json(new { });
}
}
}
@@ -182,29 +182,5 @@ namespace PrivaPub.Api.Mastodon.Controllers
+ "together. What a persona posts is shared as its visibility says; a located post never leaves this server. "
+ "Uploads lose their metadata. Statistics name servers, never people: see /stargazing.</p>"
});
[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<object>());
}
}
@@ -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<NotificationEntity> NotFiltered = Builders<NotificationEntity>.Filter.Ne(n => n.Filtered, true);
[HttpGet("/api/v1/notifications"), Scope("read:notifications")]
public async Task<IActionResult> List(CancellationToken token)
{
@@ -283,7 +290,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
async Task<List<NotificationEntity>> 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<NotificationEntity>();
async Task<object> Groups(List<NotificationEntity> page, ISet<string> 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<NotificationEntity>.Filter.And(Builders<NotificationEntity>.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<IActionResult> UnreadCount(CancellationToken token)
{
var unread = await DB.Default.Find<NotificationEntity>().Match(n => n.AvatarId == MyId && !n.IsRead)
var unread = await DB.Default.Find<NotificationEntity>().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 });
}
@@ -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<NotificationPolicy> Of(string avatarId, CancellationToken token) =>
await DB.Default.Find<NotificationPolicy>().Match(p => p.AvatarId == avatarId).ExecuteFirstAsync(token)
?? new NotificationPolicy { AvatarId = avatarId };
public static async Task Save(NotificationPolicy policy, CancellationToken token) =>
await DB.Default.Update<NotificationPolicy>().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<NotificationAction> 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<NotificationPolicy>().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<NotificationPermission>().Match(p => p.AvatarId == avatarId && p.FromActorURI == fromActorUri).ExecuteAnyAsync(token))
return NotificationAction.Accept;
var actions = new List<NotificationAction>();
var following = await DB.Default.Find<Following>()
.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<Follower>()
.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<PostEntity>().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<DomainBlock>().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<Avatar>().Match(a => a.ID == accountId).ExecuteFirstAsync(token) is { } local)
return (local.CreatedAt, local.SilencedAt.HasValue);
var foreign = await DB.Default.Find<ForeignAvatar>().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<NotificationRequest>()
.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<Notification>().Match(n => n.AvatarId == request.AvatarId && n.FromActorURI == request.FromActorURI && n.Filtered)
.Modify(n => n.Filtered, false).ExecuteAsync(token);
await DB.Default.DeleteAsync<NotificationRequest>(request.ID);
}
// or dismisses them
public static async Task Dismiss(NotificationRequest request, CancellationToken token)
{
await DB.Default.DeleteAsync<Notification>(n => n.AvatarId == request.AvatarId && n.FromActorURI == request.FromActorURI && n.Filtered);
await DB.Default.DeleteAsync<NotificationRequest>(request.ID);
}
}
}
+11 -1
View File
@@ -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);
+4 -1
View File
@@ -124,7 +124,10 @@ namespace PrivaPub.Infrastructure.Data
(() => DB.Default.Index<Bookmark>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "bookmark"),
(() => DB.Default.Index<Pin>().Key(p => p.AvatarId, KeyType.Ascending).Key(p => p.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "pin"),
(() => DB.Default.Index<Models.Federation.RelaySubscription>().Key(r => r.Configured, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "relay"),
(() => DB.Default.Index<RemoteFeatured>().Key(f => f.ActorURI, KeyType.Ascending).Key(f => f.ObjectURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "remote-featured")
(() => DB.Default.Index<RemoteFeatured>().Key(f => f.ActorURI, KeyType.Ascending).Key(f => f.ObjectURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "remote-featured"),
(() => DB.Default.Index<NotificationPolicy>().Key(p => p.AvatarId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "notification policy"),
(() => DB.Default.Index<NotificationRequest>().Key(r => r.AvatarId, KeyType.Ascending).Key(r => r.FromActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "notification request"),
(() => DB.Default.Index<NotificationPermission>().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<Block>(token, b => b.TargetActorURI);
+1
View File
@@ -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;
}
@@ -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;
}
}