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

+34
View File
@@ -35,6 +35,8 @@ namespace PrivaPub.Domain.Timelines
var recipients = new HashSet<string>(StringComparer.Ordinal);
if (!post.IsFederatedCopy && !string.IsNullOrEmpty(post.GroupUserId))
recipients.Add(post.GroupUserId);
// those following the author with notify (R8), told of the post once it reached their home
var notifying = new List<string>();
switch (post.Visibility)
{
@@ -44,7 +46,11 @@ namespace PrivaPub.Domain.Timelines
: await _dbEntities.Followings.Match(f => f.TargetAccountId == post.GroupUserId && f.TargetIsLocal && f.State == FollowState.Accepted).ExecuteAsync(token);
foreach (var following in followers)
if (await Shows(following, post, token))
{
recipients.Add(following.AvatarId);
if (following.Notify && Notifies(post))
notifying.Add(following.AvatarId);
}
if (!string.IsNullOrEmpty(post.AudienceURI))
// (the server's reader of feeds follows communities for nobody's home)
foreach (var member in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.AudienceURI && f.State == FollowState.Accepted && f.FollowerKind == null).ExecuteAsync(token))
@@ -100,6 +106,8 @@ namespace PrivaPub.Domain.Timelines
if (string.IsNullOrEmpty(post.ReblogOfPostId))
foreach (var mention in post.Mentions.Where(m => m.IsLocal && !string.IsNullOrEmpty(m.AccountId)))
await Notifications.Add(mention.AccountId, NotificationType.Mention, post.AuthorAccountId ?? post.GroupUserId, post.ActorURI, post.ID, token);
foreach (var avatarId in notifying.Where(recipients.Contains).Distinct())
await Notifications.Add(avatarId, NotificationType.Status, post.AuthorAccountId ?? post.GroupUserId, post.ActorURI, post.ID, token);
if (Streams.Anyone)
await Stream(post, recipients, token);
}
@@ -186,10 +194,36 @@ namespace PrivaPub.Domain.Timelines
}
}
// Mastodon's notify: the account's own posts and its replies to itself, never its boosts (FeedInsertWorker)
static bool Notifies(Post post) =>
string.IsNullOrEmpty(post.ReblogOfPostId) && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly
&& (string.IsNullOrEmpty(post.InReplyToURI) || post.InReplyToAccountId == (post.AuthorAccountId ?? post.GroupUserId));
// a follow's languages (R8): a post in another language stays out of that home, as on Mastodon; one whose language
// is not known comes in
static bool InLanguages(Following following, Post post) =>
following.Languages is not { Count: > 0 } || string.IsNullOrEmpty(post.Language)
|| following.Languages.Contains(post.Language.Replace('_', '-').Split('-')[0].ToLowerInvariant());
// a direct conversation that has a new post, to the `direct` streams of the personas taking part in it (R8), as
// Mastodon's `conversation` event: each connection maps it for its own persona when it sends it
public static async Task Conversation(string conversationId, CancellationToken token)
{
if (!Streams.Anyone || string.IsNullOrEmpty(conversationId))
return;
var conversation = await DB.Default.Find<DmGroup>().MatchID(conversationId).ExecuteFirstAsync(token);
var told = new Streams.Event("conversation", conversationId);
foreach (var member in conversation?.Members.Where(m => !m.IsForeign).Select(m => m.AvatarId).Distinct() ?? Enumerable.Empty<string>())
if (Streams.Listening(member))
Streams.ToPersona(member, new Streams.Key(Streams.Direct), told);
}
async Task<bool> Shows(Following following, Post post, CancellationToken token)
{
if (!string.IsNullOrEmpty(post.ReblogOfPostId))
return following.ShowReblogs;
if (!InLanguages(following, post))
return false;
var repliedTo = post.InReplyToAccountId;
var author = post.AuthorAccountId ?? post.GroupUserId;
if (string.IsNullOrEmpty(post.InReplyToURI) || repliedTo == author || repliedTo == following.AvatarId)