Votes out. A persona's downvote is a Dislike (POST /api/v1/statuses/:id/downvote and /undownvote, the viewer's own as privapub.votes.downvoted). One vote a post: a changed vote is sent as the new vote alone, as Lemmy sends one, since an Undo of the old one beside it could arrive after it and take the new one away; Undo goes only when a vote is taken back. A vote on a post in a remote community goes to the community, which counts it, and to the author only when on another server: one copy a server, since PieFed drops a second copy of an activity it has just seen. Mbin keeps a favourite apart from a vote, so there a favourite stays after a switch to a downvote. Feeds followed (owner decision 2026-10-07: through the server). Following a feed (Lemmy's multi-community, PieFed's feed) keeps a FeedSubscription for the persona and nothing else; the new Service privapub_feeds (LocalActorKind.Reader, reserved by migration _017) follows every community of the feeds read here, reconciled when a persona follows or leaves one, when a feed's list is read again (kept as it was when it cannot be read) and every six hours. Its Following rows carry FollowerKind, so nobody's home gets what it brings in and its unanswered follows are sent again as its own. The persona reads GET /api/v1/timelines/feed/:id (the feed's threads, ours included) and lists its feeds at GET /api/v1/feeds. Checked in the pasture, every scenario: 873 pass. The 14 failures are Hubzilla's (identical on the previous commit: Hubzilla no longer answers a follow in this pasture since its restore) and two activities Smithereen never sent; followsync passes once the pasture's restore is older than the 14-day pause. Live: alice's downvotes count as downvotes on Lemmy 1.0 and PieFed, replacing her upvote; Lemmy 1.0 and PieFed take privapub_feeds' follows and their threads reach the feed timelines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
436 lines
21 KiB
C#
436 lines
21 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);
|
|
}
|
|
|
|
// PrivaPub's own: a feed the persona reads (Lemmy's multi-community, PieFed's feed), its communities' threads,
|
|
// newest first, as the server's reader of feeds brings them in (owner decision 2026-10-07)
|
|
[HttpGet("/api/v1/timelines/feed/{id}"), Scope("read:statuses")]
|
|
public async Task<IActionResult> Feed(string id, CancellationToken token)
|
|
{
|
|
var feed = await _dbEntities.ForeignAvatars.MatchID(id).ExecuteFirstAsync(token);
|
|
if (feed is not { AvatarType: Models.User.AvatarType.Feed }
|
|
|| !await DB.Default.Find<FeedSubscription>().Match(s => s.AvatarId == MyId && s.FeedActorURI == feed.ActorURI).ExecuteAnyAsync(token))
|
|
return NotFoundError();
|
|
var communities = feed.FeedCommunities;
|
|
var ours = Me.BaseAddress + "/peasants/";
|
|
var names = communities.Where(c => c.StartsWith(ours, StringComparison.Ordinal)).Select(c => c[ours.Length..]).ToList();
|
|
var groups = names.Count == 0
|
|
? new List<string>()
|
|
: (await _dbEntities.Groups.Match(g => names.Contains(g.UserName) && !g.DeletionAt.HasValue).ExecuteAsync(token)).Select(g => g.ID).ToList();
|
|
var query = _dbEntities.Posts.Match(p => (communities.Contains(p.AudienceURI) || groups.Contains(p.GroupId))
|
|
&& p.ReblogOfPostId == null && p.InReplyToURI == null).Match(VisibilityPolicy.IsPublic);
|
|
return await Respond(query, $"/api/v1/timelines/feed/{id}", 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;
|
|
}
|
|
}
|