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

@@ -10,17 +10,20 @@ using PrivaPub.StaticServices;
namespace PrivaPub.Federation.Actors
{
// A remote account's follower, following and post counts, as its own server publishes them (the totalItems of its
// followers, following and outbox collections). 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.
// 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.
public class AccountCountsJob : IJobHandler
{
readonly IRemoteActorService _remoteActors;
readonly DbEntities _dbEntities;
readonly IFeaturedPosts _featured;
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities)
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities, IFeaturedPosts featured = default)
{
_remoteActors = remoteActors;
_dbEntities = dbEntities;
_featured = featured;
}
public JobKind Kind => JobKind.CountAccount;
@@ -41,6 +44,8 @@ namespace PrivaPub.Federation.Actors
.Modify(a => a.StatusesCount, await Total(actor, actor.OutboxURL, token))
.Modify(a => a.CountedAt, DateTime.UtcNow)
.ExecuteAsync(token);
if (_featured != default)
await _featured.Sync(actor, token);
return JobOutcome.Done;
}
@@ -23,6 +23,7 @@ namespace PrivaPub.Federation.Actors
public string Outbox { get; init; }
public string Followers { get; init; }
public string Following { get; init; }
public string Featured { get; init; }
public string SharedInbox { get; init; }
public string Icon { get; init; }
public bool Discoverable { get; init; } = true;
@@ -73,6 +74,7 @@ namespace PrivaPub.Federation.Actors
Outbox = RemoteActorService.Text(root, "outbox"),
Followers = RemoteActorService.Text(root, "followers"),
Following = RemoteActorService.Text(root, "following"),
Featured = RemoteActorService.Text(root, "featured"),
SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default,
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False,
+156
View File
@@ -0,0 +1,156 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Objects;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Actors
{
public interface IFeaturedPosts
{
Task Receive(JsonNode activity, ForeignAvatar owner, bool add, CancellationToken token);
Task Sync(ForeignAvatar owner, CancellationToken token);
}
// What an account elsewhere features (pins): its `featured` collection, read with its counts at most once a day, and
// the Add and Remove it sends when it pins or unpins. An account features only its own posts; a community (Lemmy's,
// inside its announce) features posts made in it. Shown first on its profile here, as Mastodon shows them.
public class FeaturedPosts : IFeaturedPosts
{
public const int MaxFeatured = 20;
readonly DbEntities _dbEntities;
readonly IRemoteActorService _remoteActors;
readonly IRemotePosts _remotePosts;
public FeaturedPosts(DbEntities dbEntities, IRemoteActorService remoteActors, IRemotePosts remotePosts)
{
_dbEntities = dbEntities;
_remoteActors = remoteActors;
_remotePosts = remotePosts;
}
public async Task Receive(JsonNode activity, ForeignAvatar owner, bool add, CancellationToken token)
{
if (string.IsNullOrEmpty(owner.FeaturedURL) || Id(activity["target"]) != owner.FeaturedURL)
{
Arrival.Drop("unknown-target");
return;
}
var objectUri = Id(activity["object"]);
if (objectUri == default)
{
Arrival.Drop("unparseable");
return;
}
if (!add)
{
var removed = await DB.Default.DeleteAsync<RemoteFeatured>(f => f.ActorURI == owner.ActorURI && f.ObjectURI == objectUri);
if (removed is { IsAcknowledged: true, DeletedCount: > 0 })
Arrival.Accept("unfeatured");
else
Arrival.Drop("unknown-object");
return;
}
var post = await Featurable(owner, objectUri, token);
if (post == default)
{
Arrival.Drop("unknown-object");
return;
}
Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate);
var first = await DB.Default.Find<RemoteFeatured>().Match(f => f.ActorURI == owner.ActorURI).Sort(f => f.Position, Order.Ascending).ExecuteFirstAsync(token);
try
{
await DB.Default.SaveAsync(new RemoteFeatured
{
ActorURI = owner.ActorURI,
ObjectURI = objectUri,
PostId = post.ID,
Position = (first?.Position ?? 0) - 1
}, token);
Arrival.Accept("featured");
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
Arrival.Drop("duplicate");
}
}
public async Task Sync(ForeignAvatar owner, CancellationToken token)
{
if (string.IsNullOrEmpty(owner.FeaturedURL) || !Origin.Same(owner.FeaturedURL, owner.ActorURI))
return;
var uris = await Items(owner, token);
if (uris == default)
return;
var featured = new List<RemoteFeatured>();
foreach (var uri in uris.Distinct(StringComparer.Ordinal).Take(MaxFeatured))
if (await Featurable(owner, uri, token) is { } post)
featured.Add(new RemoteFeatured { ActorURI = owner.ActorURI, ObjectURI = uri, PostId = post.ID, Position = featured.Count });
await DB.Default.DeleteAsync<RemoteFeatured>(f => f.ActorURI == owner.ActorURI);
if (featured.Count > 0)
await DB.Default.SaveAsync(featured, token);
}
// the post, held or fetched from its own server, when the owner may feature it
async Task<PostEntity> Featurable(ForeignAvatar owner, string objectUri, CancellationToken token)
{
var isGroup = owner.AvatarType is AvatarType.Group;
if (!isGroup && !Origin.Same(objectUri, owner.ActorURI))
return default;
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue).ExecuteFirstAsync(token)
?? await _remotePosts.StoreContext(objectUri, 0, token);
return post switch
{
null => default,
{ ReblogOfPostId: not null } => default,
_ when isGroup => post.AudienceURI == owner.ActorURI ? post : default,
_ => post.ActorURI == owner.ActorURI ? post : default
};
}
// the collection's items, in its order, from its first page when it pages: links or embedded objects alike
async Task<List<string>> Items(ForeignAvatar owner, CancellationToken token)
{
using var scope = HttpScope.For("collection");
using var collection = await _remoteActors.FetchObject(owner.FeaturedURL, token);
if (collection == default || collection.Root.ValueKind != JsonValueKind.Object)
return default;
var items = ItemsOf(collection.Root);
if (items.Count > 0 || !collection.Root.TryGetProperty("first", out var first))
return items;
if (first.ValueKind == JsonValueKind.Object)
return ItemsOf(first);
var page = first.ValueKind == JsonValueKind.String ? first.GetString() : default;
if (page == default || !Origin.Same(page, owner.ActorURI))
return items;
using var fetched = await _remoteActors.FetchObject(page, token);
return fetched == default || fetched.Root.ValueKind != JsonValueKind.Object ? items : ItemsOf(fetched.Root);
}
static List<string> ItemsOf(JsonElement collection)
{
var items = new List<string>();
foreach (var name in new[] { "orderedItems", "items" })
if (collection.TryGetProperty(name, out var list) && list.ValueKind == JsonValueKind.Array)
foreach (var item in list.EnumerateArray())
if (item.ValueKind == JsonValueKind.String)
items.Add(item.GetString());
else if (item.ValueKind == JsonValueKind.Object && item.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String)
items.Add(id.GetString());
return items;
}
}
}
@@ -204,6 +204,7 @@ namespace PrivaPub.Federation.Actors
.Modify(a => a.OutboxURL, actor.Outbox)
.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.SharedInboxURL, actor.SharedInbox)
.Modify(a => a.PictureURL, actor.Icon)
.Modify(a => a.ThumbnailURL, actor.Header)