R8 the Mastodon API made real: featured tags, endorsements, notes, notify and languages, suggestions, the direct stream

Featured tags per persona (/api/v1/featured_tags, suggestions, tags/:name/feature, accounts' featured tags, max 10),
listed at /tattoos as Hashtags and sent as Add/Remove{Hashtag} to followers on the featured collection, as Mastodon
sends them; remote accounts' tags received and read from featuredTags. Endorsements (pin/endorse) and private notes
(PersonaNote) kept per persona and never federated; the relationship's notifying, languages, endorsed and note, with
follow's notify (status notifications) and languages (home filter). Reading preferences set on /clientapi. Suggestions
v1/v2 with dismissals and staff picks, never the persona or a sibling. conversation events on the direct stream.
translate, annual reports and the admin API answer. New collections backed up and removed with their persona.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-18 08:38:11 +02:00
1 parent d08fbabaa5
commit 87529bca49
42 files changed
+1505 -77

No files matched your search

@@ -123,35 +123,39 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Json(await ConversationsOf(groups, token));
}
async Task<List<Conversation>> ConversationsOf(List<DmGroup> groups, CancellationToken token)
Task<List<Conversation>> ConversationsOf(List<DmGroup> groups, CancellationToken token) => ConversationsOf(_mapper, _dbEntities, MyId, groups, token);
// (also the `direct` stream's conversation event, StreamingController)
internal static async Task<List<Conversation>> ConversationsOf(MastodonMapper mapper, DbEntities dbEntities, string viewerId, 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))
var states = (await DB.Default.Find<ConversationState>().Match(s => s.AvatarId == viewerId && 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))
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)
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))
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))
List<string> Others(DmGroup group) => group.Members.Where(m => !(m.AvatarId == viewerId && !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);
var accounts = await mapper.Accounts(groups.SelectMany(Others).Distinct(), token);
var statuses = (await mapper.Statuses(newest.Values.ToList(), viewerId, 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),
Unread = ConversationStates.IsUnread(states.GetValueOrDefault(g.ID), newest[g.ID].ID, newest[g.ID].AuthorAccountId, viewerId),
Accounts = Others(g).Select(id => accounts.GetValueOrDefault(id)).Where(a => a != default).ToList(),
LastStatus = statuses[newest[g.ID].ID]
})
@@ -240,7 +244,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
[NotificationType.Poll] = "poll",
[NotificationType.Reaction] = "pleroma:emoji_reaction",
[NotificationType.Quote] = "quote",
[NotificationType.ModerationWarning] = "moderation_warning"
[NotificationType.ModerationWarning] = "moderation_warning",
[NotificationType.Status] = "status"//an account followed with notify posted (R8)
};
readonly MastodonMapper _mapper;