Build / Build (push) Successful in 8m37s
- A Warn from a community's moderator about a persona's own post there becomes that persona's moderation_warning
notification (Mastodon's AccountWarning, with the reason and the post), believed from the community's own server or
from an account the community's moderators collection lists. Remote communities keep that collection's address
(attributedTo) as ForeignAvatar.ModeratorsURL; it is read only when a warning needs it.
- A Resolve{Flag} for one of our reports (`/grunts/flag-<report id>`) is kept as the report's remote resolution when it
comes from the server holding what was reported, or the community it was reported to; our own moderators'
resolution is never replaced. The moderators' report list shows both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
416 lines
20 KiB
C#
416 lines
20 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
|
|
using MongoDB.Driver;
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Api.Mastodon.Entities;
|
|
using PrivaPub.Api.Mastodon.Infrastructure;
|
|
using PrivaPub.Api.Mastodon.Mappers;
|
|
using PrivaPub.Domain.Privacy;
|
|
using PrivaPub.Domain.Statuses;
|
|
using PrivaPub.Models.Group;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.Social;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
using NotificationEntity = PrivaPub.Models.Social.Notification;
|
|
using MarkerEntity = PrivaPub.Models.Social.Marker;
|
|
|
|
namespace PrivaPub.Api.Mastodon.Controllers
|
|
{
|
|
public class TimelinesController : MastodonController
|
|
{
|
|
readonly MastodonMapper _mapper;
|
|
readonly DbEntities _dbEntities;
|
|
|
|
public TimelinesController(MastodonMapper mapper, DbEntities dbEntities)
|
|
{
|
|
_mapper = mapper;
|
|
_dbEntities = dbEntities;
|
|
}
|
|
|
|
[HttpGet("/api/v1/timelines/home"), Scope("read:statuses")]
|
|
public async Task<IActionResult> Home(CancellationToken token)
|
|
{
|
|
//an exclusive list's members are read in that list, not at home
|
|
var exclusive = (await DB.Default.Find<PersonaList>().Match(l => l.AvatarId == MyId && l.Exclusive).ExecuteAsync(token)).Select(l => l.ID).ToList();
|
|
var apart = exclusive.Count == 0
|
|
? new List<string>()
|
|
: (await DB.Default.Find<PersonaListMember>().Match(m => exclusive.Contains(m.ListId)).ExecuteAsync(token)).Select(m => m.AccountId).Distinct().ToList();
|
|
var home = _dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId);
|
|
if (apart.Count > 0)
|
|
home.Match(e => !apart.Contains(e.AuthorAccountId));
|
|
var entries = await Page.From(Params, Limit()).Fetch(home, e => e.PostId, token);
|
|
var ids = entries.Select(e => e.PostId).ToList();
|
|
// (located posts once reached their author's home: the entries left from then are passed over)
|
|
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && p.Visibility != PostVisibility.LocalGeo).Match(VisibilityPolicy.IsShown)
|
|
.ExecuteAsync(token)).ToDictionary(p => p.ID);
|
|
var statuses = await _mapper.Statuses(ids.Where(posts.ContainsKey).Select(id => posts[id]).ToList(), MyId, token);
|
|
Link("/api/v1/timelines/home", entries.LastOrDefault()?.PostId, entries.FirstOrDefault()?.PostId);
|
|
return Json(statuses);
|
|
}
|
|
|
|
[HttpGet("/api/v1/timelines/public"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
public async Task<IActionResult> Public(CancellationToken token)
|
|
{
|
|
var query = _dbEntities.Posts.Match(p => p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic);
|
|
if (Params.Bool("local") == true)
|
|
query.Match(p => !p.IsFederatedCopy);
|
|
if (Params.Bool("remote") == true)
|
|
query.Match(p => p.IsFederatedCopy);
|
|
if (Params.Bool("only_media") == true)
|
|
query.Match(p => p.Media.Count > 0);
|
|
return await Respond(query, "/api/v1/timelines/public", token);
|
|
}
|
|
|
|
[HttpGet("/api/v1/timelines/tag/{hashtag}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
public async Task<IActionResult> Tag(string hashtag, CancellationToken token)
|
|
{
|
|
var tag = hashtag.TrimStart('#').ToLowerInvariant();
|
|
var query = _dbEntities.Posts.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic);
|
|
if (Params.Bool("local") == true)
|
|
query.Match(p => !p.IsFederatedCopy);
|
|
return await Respond(query, $"/api/v1/timelines/tag/{hashtag}", token);
|
|
}
|
|
|
|
[HttpGet("/api/v1/favourites"), Scope("read:favourites")]
|
|
public async Task<IActionResult> Favourites(CancellationToken token)
|
|
{
|
|
var favourites = await Page.From(Params, Limit()).Fetch(_dbEntities.Favourites.Match(f => f.AccountId == MyId), f => f.ID, token);
|
|
var ids = favourites.Select(f => f.PostId).ToList();
|
|
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
|
var visible = new List<PostEntity>();
|
|
foreach (var id in ids.Where(posts.ContainsKey))
|
|
if (await VisibilityPolicy.CanSee(posts[id], MyId, token))
|
|
visible.Add(posts[id]);
|
|
Link("/api/v1/favourites", favourites.LastOrDefault()?.ID, favourites.FirstOrDefault()?.ID);
|
|
return Json(await _mapper.Statuses(visible, MyId, token));
|
|
}
|
|
|
|
// the persona's direct conversations, by their newest post (paged like Mastodon's by that post's id), each with its
|
|
// other members and whether its newest post, someone else's, is newer than what the persona read; the ones it took
|
|
// off its list stay off until a newer message
|
|
[HttpGet("/api/v1/conversations"), Scope("read:statuses")]
|
|
public async Task<IActionResult> Conversations(CancellationToken token)
|
|
{
|
|
var groups = await Page.From(Params, Limit(20, 40)).Fetch(
|
|
_dbEntities.DmGroups.Match(g => !g.DeletionAt.HasValue && g.LastPostId != null && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)),
|
|
g => g.LastPostId, token);
|
|
Link("/api/v1/conversations", groups.LastOrDefault()?.LastPostId, groups.FirstOrDefault()?.LastPostId);
|
|
return Json(await ConversationsOf(groups, token));
|
|
}
|
|
|
|
async Task<List<Conversation>> ConversationsOf(List<DmGroup> groups, CancellationToken token)
|
|
{
|
|
var ids = groups.Select(g => g.ID).ToList();
|
|
var states = (await DB.Default.Find<ConversationState>().Match(s => s.AvatarId == MyId && ids.Contains(s.ConversationId)).ExecuteAsync(token))
|
|
.ToDictionary(s => s.ConversationId);
|
|
groups = groups.Where(g => !ConversationStates.IsHidden(states.GetValueOrDefault(g.ID), g.LastPostId)).ToList();
|
|
|
|
// the newest post each conversation still shows: usually its LastPostId, else (deleted) the one before
|
|
var newestIds = groups.Select(g => g.LastPostId).ToList();
|
|
var newest = (await _dbEntities.Posts.Match(p => newestIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token))
|
|
.ToDictionary(p => p.ConversationId);
|
|
foreach (var group in groups.Where(g => !newest.ContainsKey(g.ID)))
|
|
if (await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending)
|
|
.ExecuteFirstAsync(token) is { } shown)
|
|
newest[group.ID] = shown;
|
|
|
|
var foreignUris = groups.SelectMany(g => g.Members).Where(m => m.IsForeign).Select(m => m.AvatarId).Distinct().ToList();
|
|
var foreignIds = (await _dbEntities.ForeignAvatars.Match(f => foreignUris.Contains(f.ActorURI)).ExecuteAsync(token))
|
|
.GroupBy(f => f.ActorURI).ToDictionary(g => g.Key, g => g.First().ID);
|
|
List<string> Others(DmGroup group) => group.Members.Where(m => !(m.AvatarId == MyId && !m.IsForeign))
|
|
.Select(m => m.IsForeign ? foreignIds.GetValueOrDefault(m.AvatarId) : m.AvatarId).Where(id => id != default).ToList();
|
|
var accounts = await _mapper.Accounts(groups.SelectMany(Others).Distinct(), token);
|
|
var statuses = (await _mapper.Statuses(newest.Values.ToList(), MyId, token)).ToDictionary(s => s.Id);
|
|
|
|
return groups.Where(g => newest.ContainsKey(g.ID) && statuses.ContainsKey(newest[g.ID].ID))
|
|
.Select(g => new Conversation
|
|
{
|
|
Id = g.ID,
|
|
Unread = ConversationStates.IsUnread(states.GetValueOrDefault(g.ID), newest[g.ID].ID, newest[g.ID].AuthorAccountId, MyId),
|
|
Accounts = Others(g).Select(id => accounts.GetValueOrDefault(id)).Where(a => a != default).ToList(),
|
|
LastStatus = statuses[newest[g.ID].ID]
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
async Task<DmGroup> MyConversation(string id, CancellationToken token) =>
|
|
await _dbEntities.DmGroups.Match(g => g.ID == id && !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId))
|
|
.ExecuteFirstAsync(token);
|
|
|
|
[HttpPost("/api/v1/conversations/{id}/read"), Scope("write:conversations")]
|
|
public async Task<IActionResult> ReadConversation(string id, CancellationToken token)
|
|
{
|
|
var group = await MyConversation(id, token);
|
|
if (group == default)
|
|
return NotFoundError();
|
|
await ConversationStates.Read(MyId, group.ID, group.LastPostId, token);
|
|
var conversation = (await ConversationsOf(new List<DmGroup> { group }, token)).FirstOrDefault();
|
|
return Json(conversation ?? new Conversation { Id = group.ID });
|
|
}
|
|
|
|
// off the persona's list until a newer message; the messages themselves stay
|
|
[HttpDelete("/api/v1/conversations/{id}"), Scope("write:conversations")]
|
|
public async Task<IActionResult> RemoveConversation(string id, CancellationToken token)
|
|
{
|
|
var group = await MyConversation(id, token);
|
|
if (group == default)
|
|
return NotFoundError();
|
|
await ConversationStates.Hide(MyId, group.ID, group.LastPostId, token);
|
|
await ConversationStates.Read(MyId, group.ID, group.LastPostId, token);
|
|
return Json(new { });
|
|
}
|
|
|
|
[HttpGet("/api/v1/markers"), Scope("read:statuses")]
|
|
public async Task<IActionResult> Markers(CancellationToken token)
|
|
{
|
|
var wanted = Params.List("timeline");
|
|
var markers = await DB.Default.Find<MarkerEntity>().Match(m => m.AvatarId == MyId).ExecuteAsync(token);
|
|
return Json(markers.Where(m => wanted.Count == 0 || wanted.Contains(m.Timeline))
|
|
.ToDictionary(m => m.Timeline, m => new Entities.Marker { LastReadId = m.LastReadId, Version = m.Version, UpdatedAt = MastodonJson.Time(m.UpdatedAt) }));
|
|
}
|
|
|
|
[HttpPost("/api/v1/markers"), Scope("write:statuses")]
|
|
public async Task<IActionResult> SaveMarkers(CancellationToken token)
|
|
{
|
|
var result = new Dictionary<string, Entities.Marker>();
|
|
foreach (var timeline in new[] { "home", "notifications" })
|
|
{
|
|
var lastRead = Params.Get($"{timeline}[last_read_id]");
|
|
if (string.IsNullOrEmpty(lastRead))
|
|
continue;
|
|
var marker = await DB.Default.UpdateAndGet<MarkerEntity>()
|
|
.Match(m => m.AvatarId == MyId && m.Timeline == timeline)
|
|
.Modify(m => m.LastReadId, lastRead)
|
|
.Modify(m => m.UpdatedAt, DateTime.UtcNow)
|
|
.Modify(b => b.Inc(m => m.Version, 1))
|
|
.Option(o => o.IsUpsert = true)
|
|
.ExecuteAsync(token);
|
|
result[timeline] = new Entities.Marker { LastReadId = marker.LastReadId, Version = marker.Version, UpdatedAt = MastodonJson.Time(marker.UpdatedAt) };
|
|
// what the marker passes is read: the unread count (v1 and v2) counts only what is newer
|
|
if (timeline == "notifications")
|
|
await DB.Default.Update<NotificationEntity>().Match(n => n.AvatarId == MyId && !n.IsRead)
|
|
.Match(f => f.Lte(n => n.ID, lastRead)).Modify(n => n.IsRead, true).ExecuteAsync(token);
|
|
}
|
|
return Json(result);
|
|
}
|
|
|
|
async Task<IActionResult> Respond(Find<PostEntity, PostEntity> query, string path, CancellationToken token)
|
|
{
|
|
var posts = await Page.From(Params, Limit()).Fetch(query, p => p.ID, token);
|
|
Link(path, posts.LastOrDefault()?.ID, posts.FirstOrDefault()?.ID);
|
|
return Json(await _mapper.Statuses(posts, MyId, token));
|
|
}
|
|
}
|
|
|
|
public class NotificationsController : MastodonController
|
|
{
|
|
internal static readonly Dictionary<NotificationType, string> Names = new()
|
|
{
|
|
[NotificationType.Mention] = "mention",
|
|
[NotificationType.Follow] = "follow",
|
|
[NotificationType.FollowRequest] = "follow_request",
|
|
[NotificationType.Favourite] = "favourite",
|
|
[NotificationType.Reblog] = "reblog",
|
|
[NotificationType.Update] = "update",
|
|
[NotificationType.Poll] = "poll",
|
|
[NotificationType.Reaction] = "pleroma:emoji_reaction",
|
|
[NotificationType.Quote] = "quote",
|
|
[NotificationType.ModerationWarning] = "moderation_warning"
|
|
};
|
|
|
|
readonly MastodonMapper _mapper;
|
|
readonly DbEntities _dbEntities;
|
|
|
|
public NotificationsController(MastodonMapper mapper, DbEntities dbEntities)
|
|
{
|
|
_mapper = mapper;
|
|
_dbEntities = dbEntities;
|
|
}
|
|
|
|
Find<NotificationEntity, NotificationEntity> Filtered()
|
|
{
|
|
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)
|
|
query.Match(n => !excluded.Contains(n.Type));
|
|
if (Params.Get("account_id") is { } accountId)
|
|
query.Match(n => n.FromAccountId == accountId);
|
|
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)
|
|
{
|
|
var notifications = await Page.From(Params, Limit(15, 30)).Fetch(Filtered(), n => n.ID, token);
|
|
Link("/api/v1/notifications", notifications.LastOrDefault()?.ID, notifications.FirstOrDefault()?.ID);
|
|
return Json(await Map(notifications, token));
|
|
}
|
|
|
|
// Grouped notifications (Mastodon 4.3): api_versions.mastodon says we speak that API, so clients call it. Likes and
|
|
// boosts of one post group together, as do follows within an hour; everything else stands alone.
|
|
internal static readonly HashSet<string> DefaultGrouped = new() { "favourite", "reblog", "follow" };
|
|
|
|
HashSet<string> Grouped() => Params.List("grouped_types") is { Count: > 0 } chosen ? chosen.ToHashSet() : DefaultGrouped;
|
|
|
|
internal static string GroupKey(NotificationEntity n, ISet<string> grouped) =>
|
|
!grouped.Contains(Names[n.Type]) ? $"ungrouped-{n.ID}"
|
|
: n.Type == NotificationType.Follow ? $"grouped-follow-{n.CreatedAt:yyyyMMddHH}"
|
|
: string.IsNullOrEmpty(n.PostId) ? $"ungrouped-{n.ID}"
|
|
: $"grouped-{Names[n.Type]}-{n.PostId}";
|
|
|
|
// the notifications a group key stands for
|
|
System.Linq.Expressions.Expression<Func<NotificationEntity, bool>> InGroup(string key)
|
|
{
|
|
var me = MyId;
|
|
if (key.StartsWith("ungrouped-", StringComparison.Ordinal))
|
|
{
|
|
var id = key["ungrouped-".Length..];
|
|
return n => n.AvatarId == me && n.ID == id;
|
|
}
|
|
if (key.StartsWith("grouped-follow-", StringComparison.Ordinal)
|
|
&& DateTime.TryParseExact(key["grouped-follow-".Length..], "yyyyMMddHH", System.Globalization.CultureInfo.InvariantCulture,
|
|
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var hour))
|
|
{
|
|
var end = hour.AddHours(1);
|
|
return n => n.AvatarId == me && n.Type == NotificationType.Follow && n.CreatedAt >= hour && n.CreatedAt < end;
|
|
}
|
|
if (key.Split('-') is ["grouped", var name, var postId] && Parse(name) is { } type)
|
|
return n => n.AvatarId == me && n.Type == type && n.PostId == postId;
|
|
return default;
|
|
}
|
|
|
|
async Task<List<NotificationEntity>> Members(string key, CancellationToken token) =>
|
|
InGroup(key) is { } filter
|
|
? 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)
|
|
{
|
|
var mapped = (await Map(page, token)).ToDictionary(n => n.Id);
|
|
var shown = page.Where(n => mapped.ContainsKey(n.ID)).ToList();
|
|
var groups = new List<object>();
|
|
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(Builders<NotificationEntity>.Filter.And(Builders<NotificationEntity>.Filter.Where(InGroup(group.Key)), NotFiltered), token);
|
|
groups.Add(new
|
|
{
|
|
group_key = group.Key,
|
|
notifications_count = Math.Max(total, group.Count()),
|
|
type = newest.Type,
|
|
most_recent_notification_id = newest.Id,
|
|
page_min_id = group.Last().ID,
|
|
page_max_id = group.First().ID,
|
|
latest_page_notification_at = newest.CreatedAt,
|
|
sample_account_ids = group.Select(n => mapped[n.ID].Account.Id).Distinct().Take(8).ToList(),
|
|
status_id = newest.Status?.Id,
|
|
emoji = newest.Emoji,
|
|
emoji_url = newest.EmojiUrl,
|
|
moderation_warning = newest.ModerationWarning
|
|
});
|
|
}
|
|
return new
|
|
{
|
|
accounts = mapped.Values.Select(n => n.Account).DistinctBy(a => a.Id).ToList(),
|
|
statuses = mapped.Values.Where(n => n.Status != default).Select(n => n.Status).DistinctBy(s => s.Id).ToList(),
|
|
notification_groups = groups
|
|
};
|
|
}
|
|
|
|
[HttpGet("/api/v2/notifications"), Scope("read:notifications")]
|
|
public async Task<IActionResult> GroupedList(CancellationToken token)
|
|
{
|
|
var notifications = await Page.From(Params, Limit(40, 80)).Fetch(Filtered(), n => n.ID, token);
|
|
Link("/api/v2/notifications", notifications.LastOrDefault()?.ID, notifications.FirstOrDefault()?.ID);
|
|
return Json(await Groups(notifications, Grouped(), token));
|
|
}
|
|
|
|
[HttpGet("/api/v2/notifications/unread_count"), Scope("read:notifications")]
|
|
public async Task<IActionResult> GroupedUnreadCount(CancellationToken token)
|
|
{
|
|
var unread = await Filtered().Match(n => !n.IsRead).Sort(n => n.ID, Order.Descending)
|
|
.Limit(Math.Clamp(Params.Int("limit") ?? 100, 1, 1000)).ExecuteAsync(token);
|
|
var shown = (await Map(unread, token)).Select(n => n.Id).ToHashSet();
|
|
var grouped = Grouped();
|
|
return Json(new { count = unread.Where(n => shown.Contains(n.ID)).Select(n => GroupKey(n, grouped)).Distinct().Count() });
|
|
}
|
|
|
|
[HttpGet("/api/v2/notifications/{groupKey}"), Scope("read:notifications")]
|
|
public async Task<IActionResult> Group(string groupKey, CancellationToken token)
|
|
{
|
|
var members = await Members(groupKey, token);
|
|
if (members.Count == 0)
|
|
return NotFoundError();
|
|
return Json(await Groups(members, new HashSet<string>(DefaultGrouped.Append(Names[members[0].Type])), token));
|
|
}
|
|
|
|
[HttpGet("/api/v2/notifications/{groupKey}/accounts"), Scope("read:notifications")]
|
|
public async Task<IActionResult> GroupAccounts(string groupKey, CancellationToken token)
|
|
{
|
|
var members = await Members(groupKey, token);
|
|
if (members.Count == 0)
|
|
return NotFoundError();
|
|
return Json((await Map(members, token)).Select(n => n.Account).DistinctBy(a => a.Id).ToList());
|
|
}
|
|
|
|
[HttpPost("/api/v2/notifications/{groupKey}/dismiss"), Scope("write:notifications")]
|
|
public async Task<IActionResult> DismissGroup(string groupKey, CancellationToken token)
|
|
{
|
|
if (InGroup(groupKey) is not { } filter)
|
|
return NotFoundError();
|
|
await DB.Default.DeleteAsync(filter);
|
|
return Json(new { });
|
|
}
|
|
|
|
[HttpGet("/api/v1/notifications/{id}"), Scope("read:notifications")]
|
|
public async Task<IActionResult> Get(string id, CancellationToken token)
|
|
{
|
|
var notification = await _dbEntities.Notifications.Match(n => n.ID == id && n.AvatarId == MyId).ExecuteFirstAsync(token);
|
|
var mapped = notification == default ? default : (await Map(new List<NotificationEntity> { notification }, token)).FirstOrDefault();
|
|
return mapped == default ? NotFoundError() : Json(mapped);
|
|
}
|
|
|
|
[HttpPost("/api/v1/notifications/clear"), Scope("write:notifications")]
|
|
public async Task<IActionResult> Clear(CancellationToken token)
|
|
{
|
|
await DB.Default.DeleteAsync<NotificationEntity>(n => n.AvatarId == MyId);
|
|
return Json(new { });
|
|
}
|
|
|
|
[HttpPost("/api/v1/notifications/{id}/dismiss"), Scope("write:notifications")]
|
|
public async Task<IActionResult> Dismiss(string id, CancellationToken token)
|
|
{
|
|
await DB.Default.DeleteAsync<NotificationEntity>(n => n.ID == id && n.AvatarId == MyId);
|
|
return Json(new { });
|
|
}
|
|
|
|
// counts what the list would show (Map drops notifications from gone accounts and about hidden posts), at most
|
|
// `limit` of them, as Mastodon does
|
|
[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).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 });
|
|
}
|
|
|
|
Task<List<Entities.Notification>> Map(List<NotificationEntity> notifications, CancellationToken token) =>
|
|
_mapper.Notifications(notifications, MyId, token);
|
|
|
|
static NotificationType? Parse(string name) => Names.FirstOrDefault(n => n.Value == name) is { Value: not null } pair ? pair.Key : (NotificationType?)null;
|
|
}
|
|
}
|