Pins across servers, both ways

An account elsewhere that pins or unpins one of its posts (Add or Remove on its `featured`) now shows those pins on its
profile here (`pinned=true`), in its order and its public posts only; a community's announce of a moderator's Add does
the same for the community. The `featured` collection itself is read with the account's counts, at most once a day, so
pins made before PrivaPub ever saw an account show too. Any other target (Smithereen's wall, a community's moderators)
is dropped. A persona's pin and unpin go to the post's audience as Add and Remove on /trophies, as Mastodon sends them.

Checked live against Mastodon (scenarios/pins.sh, 8 checks). The town's checker learnt three peer rules from the
village: Misskey and Sharkey keep a forwarded reply only with its author's LD signature (only Mastodon signs), they
count no renote by a bot, and Mastodon never sees a Lemmy vote on a post in a community. The village of 2026-10-05
checks clean, 2454 of 2454.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 22:48:57 +02:00
1 parent 5f150b7a59
commit 1c7d1ece9c
23 files changed
+510 -19

No files matched your search

@@ -20,6 +20,7 @@ namespace PrivaPub.Federation.Outbox
Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token);
Task PublishUpdate(LocalActor author, PostEntity post, string reason, CancellationToken token);
Task PublishProfile(LocalActor actor, CancellationToken token);
Task PublishFeatured(string personaId, PostEntity post, bool featured, CancellationToken token);
}
public class OutboxPublisher : IOutboxPublisher
@@ -110,6 +111,26 @@ namespace PrivaPub.Federation.Outbox
await _delivery.Enqueue(author, inboxes, activity, token);
}
// a persona pins its post or unpins it: Add or Remove on its featured collection (/trophies, which serves the same
// pins), told to the post's audience as Mastodon tells it
public async Task PublishFeatured(string personaId, PostEntity post, bool featured, CancellationToken token)
{
var author = await _localActors.FindById(LocalActorKind.Person, personaId, token);
if (author is not { IsFederated: true } || post.IsLocalOnly)
return;
await Publish(author, post, new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = author.ActivityUri($"{(featured ? "feature" : "unfeature")}-{post.ID}-{DateTime.UtcNow.Ticks}"),
["type"] = featured ? "Add" : "Remove",
["actor"] = author.Uri,
["object"] = author.PostUri(post.ID),
["target"] = author.Featured,
["to"] = new JsonArray(Objects.Addressing.Public),
["cc"] = new JsonArray(author.Followers)
}, token);
}
public async Task PublishUpdate(LocalActor author, PostEntity post, string reason, CancellationToken token)
{
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);