using Microsoft.AspNetCore.Mvc; using MongoDB.Entities; using PrivaPub.Api.Mastodon.Entities; using PrivaPub.Api.Mastodon.Infrastructure; using PrivaPub.Api.Mastodon.Mappers; using PrivaPub.Domain.Privacy; 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 Home(CancellationToken token) { var entries = await Page.From(Params, Limit()).Fetch(_dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId), e => e.PostId, token); var ids = entries.Select(e => e.PostId).ToList(); var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID)).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 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 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/timelines/list/{id}"), Scope("read:lists")] public IActionResult List(string id) => Json(Array.Empty()); [HttpGet("/api/v1/favourites"), Scope("read:favourites")] public async Task 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(); 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)); } [HttpGet("/api/v1/conversations"), Scope("read:statuses")] public async Task Conversations(CancellationToken token) { var groups = await _dbEntities.DmGroups.Match(g => !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)) .Sort(g => g.UpdatedAt, Order.Descending).Limit(Limit()).ExecuteAsync(token); var conversations = new List(); foreach (var group in groups) { var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token); if (last == default) continue; var others = new List(); foreach (var member in group.Members.Where(m => !(m.AvatarId == MyId && !m.IsForeign))) others.Add(member.IsForeign ? (await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == member.AvatarId).ExecuteFirstAsync(token))?.ID : member.AvatarId); var accounts = await _mapper.Accounts(others, token); conversations.Add(new Conversation { Id = group.ID, Accounts = accounts.Values.ToList(), LastStatus = await _mapper.Status(last, MyId, token) }); } return Json(conversations); } [HttpPost("/api/v1/conversations/{id}/read"), Scope("write:conversations")] public async Task ReadConversation(string id, CancellationToken token) { var group = await _dbEntities.DmGroups.Match(g => g.ID == id && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)).ExecuteFirstAsync(token); if (group == default) return NotFoundError(); var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token); return Json(new Conversation { Id = group.ID, LastStatus = last == default ? default : await _mapper.Status(last, MyId, token) }); } [HttpGet("/api/v1/markers"), Scope("read:statuses")] public async Task Markers(CancellationToken token) { var wanted = Params.List("timeline"); var markers = await DB.Default.Find().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 SaveMarkers(CancellationToken token) { var result = new Dictionary(); 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() .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) }; } return Json(result); } async Task Respond(Find 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 { static readonly Dictionary 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" }; readonly MastodonMapper _mapper; readonly DbEntities _dbEntities; public NotificationsController(MastodonMapper mapper, DbEntities dbEntities) { _mapper = mapper; _dbEntities = dbEntities; } Find 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); 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; } [HttpGet("/api/v1/notifications"), Scope("read:notifications")] public async Task 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. static readonly HashSet DefaultGrouped = new() { "favourite", "reblog", "follow" }; HashSet Grouped() => Params.List("grouped_types") is { Count: > 0 } chosen ? chosen.ToHashSet() : DefaultGrouped; static string GroupKey(NotificationEntity n, ISet 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> 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> Members(string key, CancellationToken token) => InGroup(key) is { } filter ? await _dbEntities.Notifications.Match(filter).Sort(n => n.ID, Order.Descending).Limit(80).ExecuteAsync(token) : new List(); async Task Groups(List page, ISet 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(); 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); 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 }); } 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 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 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 Group(string groupKey, CancellationToken token) { var members = await Members(groupKey, token); if (members.Count == 0) return NotFoundError(); return Json(await Groups(members, new HashSet(DefaultGrouped.Append(Names[members[0].Type])), token)); } [HttpGet("/api/v2/notifications/{groupKey}/accounts"), Scope("read:notifications")] public async Task 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 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 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 { notification }, token)).FirstOrDefault(); return mapped == default ? NotFoundError() : Json(mapped); } [HttpPost("/api/v1/notifications/clear"), Scope("write:notifications")] public async Task Clear(CancellationToken token) { await DB.Default.DeleteAsync(n => n.AvatarId == MyId); return Json(new { }); } [HttpPost("/api/v1/notifications/{id}/dismiss"), Scope("write:notifications")] public async Task Dismiss(string id, CancellationToken token) { await DB.Default.DeleteAsync(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 UnreadCount(CancellationToken token) { var unread = await DB.Default.Find().Match(n => n.AvatarId == MyId && !n.IsRead) .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 }); } async Task> Map(List notifications, CancellationToken token) { var accounts = await _mapper.Accounts(notifications.Select(n => n.FromAccountId), token); var postIds = notifications.Where(n => n.PostId != default).Select(n => n.PostId).Distinct().ToList(); var posts = postIds.Count == 0 ? new List() : await _dbEntities.Posts.Match(p => postIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token); var statuses = (await _mapper.Statuses(posts, MyId, token)).ToDictionary(s => s.Id); return notifications .Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId))) .Select(n => new Entities.Notification { Id = n.ID, Type = Names[n.Type], CreatedAt = MastodonJson.Time(n.CreatedAt), GroupKey = $"ungrouped-{n.ID}", Account = accounts[n.FromAccountId], Status = n.PostId == default ? default : statuses[n.PostId], Emoji = n.Emoji == default ? default : n.EmojiURL == default ? n.Emoji : $":{n.Emoji}:", EmojiUrl = _mapper.ProxiedUrl(n.EmojiURL) }) .ToList(); } static NotificationType? Parse(string name) => Names.FirstOrDefault(n => n.Value == name) is { Value: not null } pair ? pair.Key : (NotificationType?)null; } }