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

+31 -2
View File
@@ -26,7 +26,10 @@ namespace PrivaPub.Domain.Social
Task<WebResult> Follow(string rootUserId, FollowForm form, CancellationToken token);
Task<WebResult> Unfollow(string rootUserId, FollowForm form, CancellationToken token);
Task<WebResult> Followings(string rootUserId, string avatarId, CancellationToken token);
Task<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token);
/// <summary>Follows an account, or changes how a follow already made shows it: notify and languages (R8) change only
/// when given (languages empty: every language).</summary>
Task<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token, bool? notify = default,
IReadOnlyList<string> languages = default);
Task UnfollowAs(LocalActor follower, string target, CancellationToken token);
Task<bool> Decide(LocalActor me, string followerAccountId, bool accept, CancellationToken token);
Task<int> ResendPending(DateTime now, CancellationToken token);
@@ -93,7 +96,18 @@ namespace PrivaPub.Domain.Social
}
}
public async Task<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token)
// a follow's languages as kept: their primary subtags, lower case; none, every language (null)
public static List<string> Languages(IReadOnlyList<string> languages)
{
var kept = (languages ?? Array.Empty<string>())
.Select(l => l?.Trim().Replace('_', '-').Split('-')[0].ToLowerInvariant())
.Where(l => l is { Length: >= 2 and <= 3 } && l.All(char.IsAsciiLetterLower))
.Distinct().Take(20).ToList();
return kept.Count == 0 ? null : kept;
}
public async Task<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token, bool? notify = default,
IReadOnlyList<string> languages = default)
{
var (local, remote) = await ResolveTarget(target, token);
if (local == default && remote == default)
@@ -108,6 +122,17 @@ namespace PrivaPub.Domain.Social
if (existing.ShowReblogs != showReblogs)
await DB.Default.Update<Following>().MatchID(existing.ID).Modify(f => f.ShowReblogs, showReblogs).ExecuteAsync(token);
existing.ShowReblogs = showReblogs;
if (notify is { } notifying && existing.Notify != notifying)
{
await DB.Default.Update<Following>().MatchID(existing.ID).Modify(f => f.Notify, notifying).ExecuteAsync(token);
existing.Notify = notifying;
}
if (languages != default)
{
var kept = Languages(languages);
await DB.Default.Update<Following>().MatchID(existing.ID).Modify(f => f.Languages, kept).ExecuteAsync(token);
existing.Languages = kept;
}
// a request still unanswered is sent again, at most once an hour: a server can take a Follow (202) and
// drop it later, as one that cannot yet read our actor does; one that holds it already ignores the copy
if (existing.State == FollowState.Requested && remote != default)
@@ -125,6 +150,8 @@ namespace PrivaPub.Domain.Social
TargetIsLocal = local != default,
TargetInboxURL = local?.Inbox ?? remote.InboxURL,
ShowReblogs = showReblogs,
Notify = notify == true,
Languages = Languages(languages),
State = local is { ManuallyApprovesFollowers: false } ? FollowState.Accepted : FollowState.Requested
};
following.ID = (string)following.GenerateNewID();
@@ -157,6 +184,8 @@ namespace PrivaPub.Domain.Social
await DB.Default.DeleteAsync<Following>(following.ID);
await DB.Default.DeleteAsync<PersonaListMember>(m => m.AvatarId == follower.Id && m.AccountId == following.TargetAccountId);
// an endorsement holds only while the follow does, as on Mastodon (R8)
await DB.Default.DeleteAsync<Endorsement>(e => e.AvatarId == follower.Id && e.TargetActorURI == targetUri);
if (following.TargetIsLocal)
{
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == following.TargetAccountId && f.ActorURI == follower.Uri);