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

@@ -13,6 +13,7 @@ namespace PrivaPub.Federation.Actors
// followers, following and outbox collections), and the posts it features. Read when the account is fetched, at most
// once a day, never when someone looks at it; a hidden or missing collection leaves its count unknown. Mastodon and
// GoToSocial do the same. A feed's following is its communities: they are kept, and read in turn, so its list shows.
// Its featured hashtags (its featuredTags collection, R8) are read with them.
public class AccountCountsJob : IJobHandler
{
readonly IRemoteActorService _remoteActors;
@@ -20,12 +21,14 @@ namespace PrivaPub.Federation.Actors
readonly IFeaturedPosts _featured;
readonly ILocalActorService _localActors;
readonly IJobQueue _jobs;
readonly Domain.Social.IFeaturedTags _featuredTags;
public const int MaxFeedCommunities = 50;
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities, IFeaturedPosts featured = default,
ILocalActorService localActors = default, IJobQueue jobs = default)
ILocalActorService localActors = default, IJobQueue jobs = default, Domain.Social.IFeaturedTags featuredTags = default)
{
_featuredTags = featuredTags;
_remoteActors = remoteActors;
_dbEntities = dbEntities;
_featured = featured;
@@ -66,6 +69,8 @@ namespace PrivaPub.Federation.Actors
}
if (_featured != default)
await _featured.Sync(actor, token);
if (_featuredTags != default)
await _featuredTags.Sync(actor, token);
return JobOutcome.Done;
}
@@ -24,6 +24,7 @@ namespace PrivaPub.Federation.Actors
public string Followers { get; init; }
public string Following { get; init; }
public string Featured { get; init; }
public string FeaturedTags { get; init; }
public string Wall { get; init; }
public string WebFinger { get; init; }//FEP-2c59: its handle, user@domain, where the domain is not its host's
public string SharedInbox { get; init; }
@@ -82,6 +83,7 @@ namespace PrivaPub.Federation.Actors
Followers = RemoteActorService.Text(root, "followers"),
Following = RemoteActorService.Text(root, "following"),
Featured = RemoteActorService.Text(root, "featured"),
FeaturedTags = RemoteActorService.Text(root, "featuredTags"),
Wall = RemoteActorService.Text(root, "wall") ?? RemoteActorService.Text(root, "sm:wall"),
WebFinger = RemoteActorService.Text(root, "webfinger")?.Replace("acct:", "", StringComparison.OrdinalIgnoreCase).TrimStart('@'),
SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default,
@@ -228,6 +228,7 @@ namespace PrivaPub.Federation.Actors
.Modify(a => a.FollowersURL, actor.Followers)
.Modify(a => a.FollowingURL, actor.Following)
.Modify(a => a.FeaturedURL, Origin.Same(actor.Featured, actor.Id) ? actor.Featured : default)
.Modify(a => a.FeaturedTagsURL, Origin.Same(actor.FeaturedTags, actor.Id) ? actor.FeaturedTags : default)
.Modify(a => a.WallURL, Origin.Same(actor.Wall, actor.Id) ? actor.Wall : default)
.Modify(a => a.AssertionKeys, actor.AssertionKeys)
.Modify(a => a.SharedInboxURL, actor.SharedInbox)
@@ -244,13 +244,21 @@ namespace PrivaPub.Federation.Controllers
return Activity(ActivityPubRenderer.OrderedCollection(local.Featured, notes.Count, notes));
}
// the hashtags a persona features (R8), as Hashtag objects in the order it featured them, as Mastodon's featured tags
// collection lists them (Mastodon reads Collection and OrderedCollection alike); a group features none
[HttpGet, Route("{actor}/tattoos")]
public async Task<IActionResult> FeaturedTags(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
return local is not { IsFederated: true }
? NotFound()
: Activity(ActivityPubRenderer.OrderedCollection(local.FeaturedTags, 0, Enumerable.Empty<JsonNode>()));
if (local is not { IsFederated: true })
return NotFound();
var tags = local.Kind == LocalActorKind.Person
? await DB.Default.Find<FeaturedTag>().Match(t => t.AvatarId == local.Id).Sort(t => t.ID, Order.Ascending).ExecuteAsync(token)
: new List<FeaturedTag>();
var collection = ActivityPubRenderer.OrderedCollection(local.FeaturedTags, tags.Count,
tags.Select(t => (JsonNode)Domain.Social.FeaturedTags.Hashtag(local.BaseAddress, t)));
collection["@context"] = ActivityPubRenderer.Context();//(Hashtag is Mastodon's term, defined there)
return Activity(collection);
}
[HttpGet, Route("{actor}/scribbles/{postId}")]
@@ -256,7 +256,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
await _fanout.Distribute(post, token);
await ReplyRelay.Pass(post, _dbEntities, _localActors, _delivery, token);
if (conversation != default)
{
await Domain.Statuses.ConversationStates.Posted(conversation.ID, post.ID, default, token);
await Domain.Timelines.Fanout.Conversation(conversation.ID, token);//the `direct` streams (R8), once it is the newest
}
if (group is { IsCircle: false })
await _groups.Announce(group, activity.AsObject(), note.Id, isNewPost: true, token);
if (wall != default)
@@ -6,20 +6,23 @@ using System.Text.Json.Nodes;
namespace PrivaPub.Federation.Inbox.Handlers
{
// An account pins one of its posts (Add to its `featured` collection) or unpins it (Remove), as Mastodon and Akkoma
// send it; inside a community's announce, the community features a post made in it. On its wall (Smithereen's, FEP-400e)
// it tells of a post someone wrote there, or takes it off. Any other target (a community's moderators) is not ours to
// keep.
// send it; inside a community's announce, the community features a post made in it. A Hashtag added to or removed from
// its `featured` collection (as Mastodon sends it) or its `featuredTags` is a featured tag (R8). On its wall
// (Smithereen's, FEP-400e) it tells of a post someone wrote there, or takes it off. Any other target (a community's
// moderators) is not ours to keep.
public class FeaturedHandler : IActivityHandler
{
readonly IFeaturedPosts _featured;
readonly IWalls _walls;
readonly IRemoteActorService _remoteActors;
readonly Infrastructure.Jobs.IJobQueue _queue;
readonly Domain.Social.IFeaturedTags _tags;
readonly bool _add;
public FeaturedHandler(IFeaturedPosts featured, bool add, IWalls walls = default, IRemoteActorService remoteActors = default,
Infrastructure.Jobs.IJobQueue queue = default)
Infrastructure.Jobs.IJobQueue queue = default, Domain.Social.IFeaturedTags tags = default)
{
_tags = tags;
_featured = featured;
_walls = walls;
_remoteActors = remoteActors;
@@ -36,9 +39,18 @@ namespace PrivaPub.Federation.Inbox.Handlers
var target = Objects.ActivityJson.Id(activity["target"]);
// a collection of its own we do not know of: its document read again, at most hourly, in case it was kept before
// we read that collection (a wall)
if (_remoteActors != default && target != default && target != actor.FeaturedURL && target != actor.WallURL
if (_remoteActors != default && target != default && target != actor.FeaturedURL && target != actor.WallURL && target != actor.FeaturedTagsURL
&& Objects.Origin.Same(target, actor.ActorURI) && actor.UpdatedAt < DateTime.UtcNow.AddHours(-1))
actor = await _remoteActors.GetActor(actor.ActorURI, refresh: true, token) ?? actor;
// a featured hashtag: an object of its own, with no id (its href is no post), so before anything reads it as one
if (Objects.ActivityJson.Value(activity["object"], "type") == "Hashtag")
{
if (_tags != default && target != default && (target == actor.FeaturedURL || target == actor.FeaturedTagsURL))
await _tags.Receive(activity, actor, _add, token);
else
Arrival.Drop("unknown-target");
return;
}
if (_walls != default && !string.IsNullOrEmpty(actor.WallURL) && target == actor.WallURL)
await _walls.Receive(activity, actor, _add, token);
else
@@ -73,10 +73,19 @@ namespace PrivaPub.Federation.Inbox.Handlers
if (persona == default)
continue;
var lists = await DB.Default.Find<PersonaListMember>().Match(m => m.AvatarId == persona.Id && m.AccountId == old.ID).ExecuteAsync(token);
var followed = await _follows.FollowAs(persona, moved.ActorURI, following.ShowReblogs, token);
var endorsed = await DB.Default.Find<Endorsement>().Match(e => e.AvatarId == persona.Id && e.TargetActorURI == old.ActorURI).ExecuteAnyAsync(token);
var followed = await _follows.FollowAs(persona, moved.ActorURI, following.ShowReblogs, token, following.Notify, following.Languages ?? new List<string>());
await _follows.UnfollowAs(persona, old.ActorURI, token);
if (followed == default)
continue;
// the persona's endorsement and private note go with the follow, as Mastodon carries them over (R8)
if (endorsed)
await DB.Default.Update<Endorsement>().Match(e => e.AvatarId == persona.Id && e.TargetAccountId == moved.ID)
.Modify(e => e.TargetActorURI, moved.ActorURI).Modify(b => b.SetOnInsert(e => e.CreatedAt, DateTime.UtcNow))
.Option(o => o.IsUpsert = true).ExecuteAsync(token);
if (await DB.Default.Find<PersonaNote>().Match(n => n.AvatarId == persona.Id && n.TargetAccountId == old.ID).ExecuteFirstAsync(token) is { } note
&& !await DB.Default.Find<PersonaNote>().Match(n => n.AvatarId == persona.Id && n.TargetAccountId == moved.ID).ExecuteAnyAsync(token))
await DB.Default.SaveAsync(new PersonaNote { AvatarId = persona.Id, TargetAccountId = moved.ID, Text = note.Text }, token);
foreach (var member in lists)
try
{