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:
1 parent
9872b78677
commit
5d5f1d6909
10 files changed
+511
-29
No files matched your search
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user