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

+2
View File
@@ -559,6 +559,8 @@ tools/pasture/run.sh down # removes e
`connection`) and its API is v3 (`/api/v3`, `sort=New`, `resolve_object` answering views). It takes private messages `connection`) and its API is v3 (`/api/v3`, `sort=New`, `resolve_object` answering views). It takes private messages
only as `ChatMessage`, which PrivaPub sends it, a first message too (invariant 17). `scenarios/lemmy19.sh`, 30 only as `ChatMessage`, which PrivaPub sends it, a first message too (invariant 17). `scenarios/lemmy19.sh`, 30
checks. checks.
- **Pins (`scenarios/pins.sh`, needs mastodon):** a Mastodon account pins and unpins while alice follows it, alice pins
and unpins while it follows her, and a fresh account's earlier pin shows once PrivaPub resolves it. 8 checks.
- **Smithereen (1.0.3):** its image on the shared MySQL (database `smithereen`, its schema from the image's commit), - **Smithereen (1.0.3):** its image on the shared MySQL (database `smithereen`, its schema from the image's commit),
with imgproxy and a file server behind Caddy as `smithereen.test` (`/i` and `/s`), trusting the CA through a JDK with imgproxy and a file server behind Caddy as `smithereen.test` (`/i` and `/s`), trusting the CA through a JDK
store with it added (`JAVA_TOOL_OPTIONS`). MySQL takes its stored functions only with store with it added (`JAVA_TOOL_OPTIONS`). MySQL takes its stored functions only with
+6 -3
View File
@@ -149,6 +149,7 @@ Received:
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back | | `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back |
| `Flag` | becomes a report for this server's moderators | | `Flag` | becomes a report for this server's moderators |
| `Move` | an account moving: believed as Mastodon believes it, when the account sends it about itself and the new account, read again from its server, names it in `alsoKnownAs`. The old account then shows where it went (`moved`), and, as Mastodon does it (owner decision 2026-10-05), the personas following it follow the new one instead (a Follow to its server, an Undo to the old), in the same lists; a mute or a block of the old account carries over | | `Move` | an account moving: believed as Mastodon believes it, when the account sends it about itself and the new account, read again from its server, names it in `alsoKnownAs`. The old account then shows where it went (`moved`), and, as Mastodon does it (owner decision 2026-10-05), the personas following it follow the new one instead (a Follow to its server, an Undo to the old), in the same lists; a mute or a block of the old account carries over |
| `Add`/`Remove` on the actor's `featured` | the account pins or unpins one of its own posts (fetched from its server when not held); inside a community's announce, the community features a post made in it. Its profile shows them first (`pinned=true`). The collection itself is read with the account's counts, at most once a day. Any other target (Smithereen's wall, a community's moderators) is dropped |
| `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it | | `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it |
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`, Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
@@ -157,7 +158,8 @@ persona's posts passed on to its followers. A deleted post answers 410 with a `T
- **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`. - **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`.
Uploaded files have all metadata removed. Uploaded files have all metadata removed.
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`. - **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`. A pin or an unpin
is told to the post's audience as `Add` or `Remove` on `featured`, as Mastodon tells it.
- **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it - **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
followed); an unblock sends `Undo{Block}`. followed); an unblock sends `Undo{Block}`.
- **Reports** are sent as `Flag` by the instance actor, never by the reporting account. - **Reports** are sent as `Flag` by the instance actor, never by the reporting account.
@@ -169,8 +171,9 @@ persona's posts passed on to its followers. A deleted post answers 410 with a `T
to a persona's public, unlisted or followers-only post goes on to the persona's followers as its author's server sent to a persona's public, unlisted or followers-only post goes on to the persona's followers as its author's server sent
and signed it, the way Mastodon forwards it: to every follower's server but the replier's own. Its `Update` and and signed it, the way Mastodon forwards it: to every follower's server but the replier's own. Its `Update` and
`Delete` follow the same way. Nothing is passed on for a local-only post, a group's post or a reply that is not public `Delete` follow the same way. Nothing is passed on for a local-only post, a group's post or a reply that is not public
or unlisted. A server that receives it checks it as it checks any forwarded activity (Mastodon by the LD signature or unlisted. A server that receives it checks it as it checks any forwarded activity: Mastodon, Misskey and Sharkey
or by reading the reply from its origin). keep it only when its author LD-signed it (Mastodon does; Misskey 2026.10 and Akkoma do not), others read the reply
from its origin.
- **Events** (owner decision 2026-10-05). A persona joins another server's event with a `Join` and leaves it with a - **Events** (owner decision 2026-10-05). A persona joins another server's event with a `Join` and leaves it with a
`Leave`, both sent to the event's organiser only (`…/grunts/join-<id>`, `…/grunts/leave-<id>`). The organiser's `Leave`, both sent to the event's organiser only (`…/grunts/join-<id>`, `…/grunts/leave-<id>`). The organiser's
server answers `Accept` (Mobilizon at once for an open event) or `Reject`, which the persona sees as server answers `Accept` (Mobilizon at once for an open event) or `Reject`, which the persona sees as
+143
View File
@@ -0,0 +1,143 @@
using MongoDB.Entities;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// pins across servers: an account elsewhere features its own posts (Add and Remove on its `featured` collection, and
// the collection itself), a community features posts made in it, and a persona's pin is told to its followers
[Trait("Category", "Integration")]
public sealed class FeaturedTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
// a public note of the actor's, served where its id points
string Note(RemoteActor author, string text, string audience = default)
{
var path = $"/notes/{Guid.NewGuid():N}";
var note = new JsonObject
{
["id"] = Origin(author) + path, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"<p>{text}</p>",
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), ["published"] = DateTime.UtcNow.ToString("O")
};
if (audience != default)
note["audience"] = audience;
_harness.Peer.Serve(path, note.ToJsonString());
return Origin(author) + path;
}
JsonObject Featuring(RemoteActor actor, string type, string objectUri, string target = default) => new()
{
["id"] = $"{actor.Id}#{type}-{Guid.NewGuid():N}", ["type"] = type, ["actor"] = actor.Id, ["object"] = objectUri,
["target"] = target ?? actor.Id + "/featured"
};
Task<List<RemoteFeatured>> FeaturedBy(RemoteActor actor) =>
DB.Default.Find<RemoteFeatured>().Match(f => f.ActorURI == actor.Id).Sort(f => f.Position, Order.Ascending).ExecuteAsync(TestContext.Current.CancellationToken);
[Fact]
public async Task An_account_features_its_own_post_and_takes_it_back_but_nothing_else()
{
var bob = new RemoteActor(_harness.Peer, "bob");
var carol = new RemoteActor(_harness.Peer, "carol", _harness.Peer.B);
var mine = Note(bob, "pinned");
var theirs = Note(carol, "not bob's");
var older = Note(bob, "pinned before");
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", older));
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", mine));
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", theirs));
// Smithereen's wall is a collection of the actor's too, but no pin
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", Note(bob, "on the wall"), bob.Id + "/wall"));
Assert.Equal(new[] { mine, older }, (await FeaturedBy(bob)).Select(f => f.ObjectURI));
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == mine).ExecuteSingleAsync(TestContext.Current.CancellationToken);
Assert.Equal(post.ID, (await FeaturedBy(bob))[0].PostId);
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Remove", mine));
Assert.Equal(new[] { older }, (await FeaturedBy(bob)).Select(f => f.ObjectURI));
}
[Fact]
public async Task The_featured_collection_is_read_in_its_order_from_the_accounts_own_server()
{
var token = TestContext.Current.CancellationToken;
var bob = new RemoteActor(_harness.Peer, "bob");
var first = Note(bob, "first pin");
var second = Note(bob, "second pin");
_harness.Peer.Serve(new Uri(bob.Id).AbsolutePath + "/featured", new JsonObject
{
["id"] = bob.Id + "/featured", ["type"] = "OrderedCollection", ["totalItems"] = 2,
["orderedItems"] = new JsonArray(new JsonObject { ["id"] = first, ["type"] = "Note" }, second)
}.ToJsonString());
var account = await _harness.Remote.GetActor(bob.Id, refresh: false, token);
await _harness.Featured.Sync(account, token);
Assert.Equal(new[] { first, second }, (await FeaturedBy(bob)).Select(f => f.ObjectURI));
}
[Fact]
public async Task A_personas_pin_and_unpin_reach_its_followers_as_add_and_remove()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var follower = new RemoteActor(_harness.Peer, "fan");
await _harness.FollowedBy(alice, follower);
await _harness.Posts.InsertPost(root, new PrivaPub.ClientModels.Post.InsertPostForm { AvatarId = alice.Id, Text = "worth a pin" }, token);
var post = await DB.Default.Find<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(token);
await _harness.Outbox.PublishFeatured(alice.Id, post, featured: true, token);
await _harness.Outbox.PublishFeatured(alice.Id, post, featured: false, token);
var sent = (await _harness.Outgoing(follower.SharedInbox)).Concat(await _harness.Outgoing(follower.Id + "/inbox"))
.Where(a => a["type"]!.GetValue<string>() is "Add" or "Remove").ToList();
Assert.Equal(new[] { "Add", "Remove" }, sent.Select(a => a["type"]!.GetValue<string>()));
Assert.All(sent, a => Assert.Equal((alice.PostUri(post.ID), alice.Featured), (a["object"]!.GetValue<string>(), a["target"]!.GetValue<string>())));
}
[Fact]
public async Task A_community_features_a_post_made_in_it()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "books", type: "Group");
var member = new RemoteActor(_harness.Peer, "reader", _harness.Peer.B);
var account = await _harness.Remote.GetActor(community.Id, refresh: false, token);
await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = community.Id, TargetAccountId = account.ID, State = FollowState.Accepted }, token);
var post = Note(member, "a thread in the community", community.Id);
await _harness.Deliver(community, "/human-centipede", new JsonObject
{
["id"] = $"{community.Id}#announce-{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = community.Id,
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"),
["object"] = new JsonObject
{
["id"] = $"{Origin(member)}/activities/{Guid.NewGuid():N}", ["type"] = "Add", ["actor"] = member.Id, ["object"] = post,
["target"] = community.Id + "/featured"
}
});
Assert.Equal(new[] { post }, (await FeaturedBy(community)).Select(f => f.ObjectURI));
}
}
}
@@ -183,6 +183,33 @@ namespace PrivaPub.Tests.Http
Assert.Contains(alice.Id, (await alice.Client.Get($"/api/v1/accounts/search?q={alice.UserName}")).Ok().Ids); Assert.Contains(alice.Id, (await alice.Client.Get($"/api/v1/accounts/search?q={alice.UserName}")).Ok().Ids);
} }
// an account elsewhere: what it features, in its order, its public posts only
[Fact]
public async Task A_remote_accounts_pinned_statuses_are_what_it_features()
{
var alice = await _host.Mastodon("alice");
var (bob, bobId) = await Remote();
Post Held(string text, PostVisibility visibility) => new()
{
ObjectURI = $"{bob.Id}/statuses/{Guid.NewGuid():N}", ActorURI = bob.Id, AuthorAccountId = bobId, IsFederatedCopy = true,
Visibility = visibility, ContentHtml = $"<p>{text}</p>", CreationDate = DateTime.UtcNow, UpdateDate = DateTime.UtcNow
};
var first = Held("first pin", PostVisibility.Public);
var second = Held("second pin", PostVisibility.Unlisted);
var hidden = Held("for followers", PostVisibility.FollowersOnly);
await DB.Default.SaveAsync(new[] { first, second, hidden }, Token);
await DB.Default.SaveAsync(new[]
{
new RemoteFeatured { ActorURI = bob.Id, ObjectURI = second.ObjectURI, PostId = second.ID, Position = 1 },
new RemoteFeatured { ActorURI = bob.Id, ObjectURI = hidden.ObjectURI, PostId = hidden.ID, Position = 2 },
new RemoteFeatured { ActorURI = bob.Id, ObjectURI = first.ObjectURI, PostId = first.ID, Position = 0 }
}, Token);
var pinned = (await alice.Client.Get($"/api/v1/accounts/{bobId}/statuses?pinned=true")).Ok();
Assert.Equal(new[] { first.ID, second.ID }, pinned.Ids);
}
[Fact] [Fact]
public async Task Account_statuses_filter_pins_replies_reblogs_media_and_tags_and_page_both_ways() public async Task Account_statuses_filter_pins_replies_reblogs_media_and_tags_and_page_both_ways()
{ {
+5 -1
View File
@@ -56,6 +56,7 @@ namespace PrivaPub.Tests.Support
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance, Ledger); Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance, Ledger);
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance); Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
Relationships = new RelationshipService(Db, Follows, Delivery); Relationships = new RelationshipService(Db, Follows, Delivery);
Featured = new FeaturedPosts(Db, Remote, RemotePosts);
Handlers = new IActivityHandler[] Handlers = new IActivityHandler[]
{ {
new FollowHandler(Db, Local, Remote, Delivery), new FollowHandler(Db, Local, Remote, Delivery),
@@ -74,7 +75,9 @@ namespace PrivaPub.Tests.Support
new FlagHandler(Db, Local), new FlagHandler(Db, Local),
new BlockHandler(Db, Local), new BlockHandler(Db, Local),
new LockHandler(Db), new LockHandler(Db),
new MoveHandler(Remote, Db, Local, Follows, Relationships) new MoveHandler(Remote, Db, Local, Follows, Relationships),
new FeaturedHandler(Featured, add: true),
new FeaturedHandler(Featured, add: false)
}; };
((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers; ((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers;
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger, Local); Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger, Local);
@@ -114,6 +117,7 @@ namespace PrivaPub.Tests.Support
public Participations Participations { get; } public Participations Participations { get; }
public TimelineService Timelines { get; } public TimelineService Timelines { get; }
public RelationshipService Relationships { get; } public RelationshipService Relationships { get; }
public FeaturedPosts Featured { get; }
public ReportService Reports { get; } public ReportService Reports { get; }
public async Task FollowedBy(LocalActor local, RemoteActor follower) => public async Task FollowedBy(LocalActor local, RemoteActor follower) =>
+1
View File
@@ -36,6 +36,7 @@ namespace PrivaPub.Tests.Support
["preferredUsername"] = Name, ["preferredUsername"] = Name,
["inbox"] = Id + "/inbox", ["inbox"] = Id + "/inbox",
["followers"] = Id + "/followers", ["followers"] = Id + "/followers",
["featured"] = Id + "/featured",
["publicKey"] = new JsonObject ["publicKey"] = new JsonObject
{ {
["id"] = KeyId, ["id"] = KeyId,
@@ -154,7 +154,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (Params.Bool("pinned") == true) if (Params.Bool("pinned") == true)
{ {
if (local == default) if (local == default)
return Json(Array.Empty<Status>()); {
var featured = (await DB.Default.Find<RemoteFeatured>().Match(f => f.ActorURI == remote.ActorURI).Sort(f => f.Position, Order.Ascending)
.Limit(Federation.Actors.FeaturedPosts.MaxFeatured).ExecuteAsync(token)).Select(f => f.PostId).ToList();
var featuredPosts = await _dbEntities.Posts.Match(p => featured.Contains(p.ID) && !p.DeletedAt.HasValue).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
return Json(await _mapper.Statuses(featured.Select(id => featuredPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
}
var pinIds = (await DB.Default.Find<Pin>().Match(p => p.AvatarId == local.Id).Sort(p => p.ID, Order.Descending).ExecuteAsync(token)).Select(p => p.PostId).ToList(); var pinIds = (await DB.Default.Find<Pin>().Match(p => p.AvatarId == local.Id).Sort(p => p.ID, Order.Descending).ExecuteAsync(token)).Select(p => p.PostId).ToList();
var pinnedPosts = await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID)).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token); var pinnedPosts = await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID)).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
return Json(await _mapper.Statuses(pinIds.Select(id => pinnedPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token)); return Json(await _mapper.Statuses(pinIds.Select(id => pinnedPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
@@ -405,6 +405,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
try try
{ {
await DB.Default.SaveAsync(new Models.Social.Pin { AvatarId = MyId, PostId = post.ID }, token); await DB.Default.SaveAsync(new Models.Social.Pin { AvatarId = MyId, PostId = post.ID }, token);
await _outbox.PublishFeatured(MyId, post, featured: true, token);
} }
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey) catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
{ {
@@ -415,7 +416,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")] [HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")]
public async Task<IActionResult> Unpin(string id, CancellationToken token) public async Task<IActionResult> Unpin(string id, CancellationToken token)
{ {
await DB.Default.DeleteAsync<Models.Social.Pin>(p => p.AvatarId == MyId && p.PostId == id); var unpinned = await DB.Default.DeleteAsync<Models.Social.Pin>(p => p.AvatarId == MyId && p.PostId == id);
if (unpinned is { IsAcknowledged: true, DeletedCount: > 0 } && await _dbEntities.Posts.MatchID(id).ExecuteFirstAsync(token) is { } post)
await _outbox.PublishFeatured(MyId, post, featured: false, token);
return await Unchanged(id, token); return await Unchanged(id, token);
} }
@@ -10,17 +10,20 @@ using PrivaPub.StaticServices;
namespace PrivaPub.Federation.Actors namespace PrivaPub.Federation.Actors
{ {
// A remote account's follower, following and post counts, as its own server publishes them (the totalItems of its // 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 // followers, following and outbox collections), and the posts it features. Read when the account is fetched, at most
// someone looks at it; a hidden or missing collection leaves its count unknown. Mastodon and GoToSocial do the same. // 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 public class AccountCountsJob : IJobHandler
{ {
readonly IRemoteActorService _remoteActors; readonly IRemoteActorService _remoteActors;
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly IFeaturedPosts _featured;
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities) public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities, IFeaturedPosts featured = default)
{ {
_remoteActors = remoteActors; _remoteActors = remoteActors;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_featured = featured;
} }
public JobKind Kind => JobKind.CountAccount; 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.StatusesCount, await Total(actor, actor.OutboxURL, token))
.Modify(a => a.CountedAt, DateTime.UtcNow) .Modify(a => a.CountedAt, DateTime.UtcNow)
.ExecuteAsync(token); .ExecuteAsync(token);
if (_featured != default)
await _featured.Sync(actor, token);
return JobOutcome.Done; return JobOutcome.Done;
} }
@@ -23,6 +23,7 @@ namespace PrivaPub.Federation.Actors
public string Outbox { get; init; } public string Outbox { get; init; }
public string Followers { get; init; } public string Followers { get; init; }
public string Following { get; init; } public string Following { get; init; }
public string Featured { get; init; }
public string SharedInbox { get; init; } public string SharedInbox { get; init; }
public string Icon { get; init; } public string Icon { get; init; }
public bool Discoverable { get; init; } = true; public bool Discoverable { get; init; } = true;
@@ -73,6 +74,7 @@ namespace PrivaPub.Federation.Actors
Outbox = RemoteActorService.Text(root, "outbox"), Outbox = RemoteActorService.Text(root, "outbox"),
Followers = RemoteActorService.Text(root, "followers"), Followers = RemoteActorService.Text(root, "followers"),
Following = RemoteActorService.Text(root, "following"), Following = RemoteActorService.Text(root, "following"),
Featured = RemoteActorService.Text(root, "featured"),
SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default, SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default,
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default, Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False, 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.OutboxURL, actor.Outbox)
.Modify(a => a.FollowersURL, actor.Followers) .Modify(a => a.FollowersURL, actor.Followers)
.Modify(a => a.FollowingURL, actor.Following) .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.SharedInboxURL, actor.SharedInbox)
.Modify(a => a.PictureURL, actor.Icon) .Modify(a => a.PictureURL, actor.Icon)
.Modify(a => a.ThumbnailURL, actor.Header) .Modify(a => a.ThumbnailURL, actor.Header)
@@ -228,6 +228,15 @@ namespace PrivaPub.Federation.Inbox.Handlers
case "Like" or "Dislike" or "Undo": case "Like" or "Dislike" or "Undo":
await Relayed(inner, group, token); await Relayed(inner, group, token);
break; break;
// a moderator features a post in the community, or no longer: the community's own pins
case "Add" or "Remove" when Id(inner["target"]) is { } target && target == group.FeaturedURL:
var featured = (Relays ?? _services?.GetService(typeof(IEnumerable<IActivityHandler>)) as IEnumerable<IActivityHandler>)
?.FirstOrDefault(h => h.Type == Value(inner, "type"));
if (featured == default)
Arrival.Drop("unsupported");
else
await featured.Handle(inner, group, token);
break;
default: default:
Arrival.Drop("unsupported"); Arrival.Drop("unsupported");
break; break;
@@ -0,0 +1,26 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Models.User;
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. Any other target (Smithereen's
// wall, a community's moderators) is not ours to keep.
public class FeaturedHandler : IActivityHandler
{
readonly IFeaturedPosts _featured;
readonly bool _add;
public FeaturedHandler(IFeaturedPosts featured, bool add)
{
_featured = featured;
_add = add;
}
public string Type => _add ? "Add" : "Remove";
public Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) => _featured.Receive(activity, actor, _add, token);
}
}
@@ -20,6 +20,7 @@ namespace PrivaPub.Federation.Outbox
Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token); Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token);
Task PublishUpdate(LocalActor author, PostEntity post, string reason, CancellationToken token); Task PublishUpdate(LocalActor author, PostEntity post, string reason, CancellationToken token);
Task PublishProfile(LocalActor actor, CancellationToken token); Task PublishProfile(LocalActor actor, CancellationToken token);
Task PublishFeatured(string personaId, PostEntity post, bool featured, CancellationToken token);
} }
public class OutboxPublisher : IOutboxPublisher public class OutboxPublisher : IOutboxPublisher
@@ -110,6 +111,26 @@ namespace PrivaPub.Federation.Outbox
await _delivery.Enqueue(author, inboxes, activity, token); 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) 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); var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
+2 -1
View File
@@ -121,7 +121,8 @@ namespace PrivaPub.Infrastructure.Data
(() => DB.Default.Index<Mute>().Key(m => m.AvatarId, KeyType.Ascending).Key(m => m.TargetActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "mute"), (() => DB.Default.Index<Mute>().Key(m => m.AvatarId, KeyType.Ascending).Key(m => m.TargetActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "mute"),
(() => DB.Default.Index<AccountDomainBlock>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.Domain, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "domain block"), (() => DB.Default.Index<AccountDomainBlock>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.Domain, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "domain block"),
(() => DB.Default.Index<Bookmark>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "bookmark"), (() => DB.Default.Index<Bookmark>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "bookmark"),
(() => DB.Default.Index<Pin>().Key(p => p.AvatarId, KeyType.Ascending).Key(p => p.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "pin") (() => DB.Default.Index<Pin>().Key(p => p.AvatarId, KeyType.Ascending).Key(p => p.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "pin"),
(() => DB.Default.Index<RemoteFeatured>().Key(f => f.ActorURI, KeyType.Ascending).Key(f => f.ObjectURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "remote-featured")
}) })
await pair.Item1(); await pair.Item1();
await Plain<Block>(token, b => b.TargetActorURI); await Plain<Block>(token, b => b.TargetActorURI);
@@ -94,6 +94,9 @@ namespace PrivaPub.Middleware
.AddSingleton<IActivityHandler, FlagHandler>() .AddSingleton<IActivityHandler, FlagHandler>()
.AddSingleton<IActivityHandler, BlockHandler>() .AddSingleton<IActivityHandler, BlockHandler>()
.AddSingleton<IActivityHandler, LockHandler>() .AddSingleton<IActivityHandler, LockHandler>()
.AddSingleton<Federation.Actors.IFeaturedPosts, Federation.Actors.FeaturedPosts>()
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: true))
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: false))
.AddSingleton<IActivityHandler, MoveHandler>() .AddSingleton<IActivityHandler, MoveHandler>()
.AddSingleton<IActivityHandler, CreateHandler>() .AddSingleton<IActivityHandler, CreateHandler>()
.AddSingleton<IActivityHandler, DeleteHandler>() .AddSingleton<IActivityHandler, DeleteHandler>()
+11
View File
@@ -21,6 +21,17 @@ namespace PrivaPub.Models.Social
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
} }
//a post an account elsewhere features (its `featured` collection, Add and Remove), shown first on its profile here;
//the lowest Position first
public class RemoteFeatured : Entity
{
public string ActorURI { get; set; }
public string ObjectURI { get; set; }
public string PostId { get; set; }
public int Position { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class Mute : Entity public class Mute : Entity
{ {
public string AvatarId { get; set; } public string AvatarId { get; set; }
+1
View File
@@ -75,6 +75,7 @@ namespace PrivaPub.Models.User
public string SharedInboxURL { get; set; } public string SharedInboxURL { get; set; }
public string InboxURL { get; set; } public string InboxURL { get; set; }
public string OutboxURL { get; set; } public string OutboxURL { get; set; }
public string FeaturedURL { get; set; }//featured: the posts it pins
public string MovedToURL { get; set; } public string MovedToURL { get; set; }
public List<string> AlsoKnownAs { get; set; } = new();//alsoKnownAs: the accounts it says it also is public List<string> AlsoKnownAs { get; set; } = new();//alsoKnownAs: the accounts it says it also is
public string PictureURL { get; set; }//icon public string PictureURL { get; set; }//icon
+1 -1
View File
@@ -145,7 +145,7 @@ Priorities, used throughout:
| Inbound `Move` with Mastodon's checks (`target` re-fetched, its `alsoKnownAs` lists the old account); move each persona's follow, lists, mutes and blocks (done 2026-10-05) | P1 | `Account.moved` | | Inbound `Move` with Mastodon's checks (`target` re-fetched, its `alsoKnownAs` lists the old account); move each persona's follow, lists, mutes and blocks (done 2026-10-05) | P1 | `Account.moved` |
| Re-run WebFinger when `preferredUsername` changes; key accounts on the actor id | P2 | `Account.acct` | | Re-run WebFinger when `preferredUsername` changes; key accounts on the actor id | P2 | `Account.acct` |
| Inbound `Block`: stop delivering, hide (**done** 2026-10-04) | P2 | `relationship.blocked_by` | | Inbound `Block`: stop delivering, hide (**done** 2026-10-04) | P2 | `relationship.blocked_by` |
| `Add`/`Remove` featured (pins, tags) | P2 | `GET /accounts/:id/statuses?pinned=true` | | `Add`/`Remove` featured (pins, tags): pins both ways and the collection read daily, done 2026-10-05; featured tags open | P2 | `GET /accounts/:id/statuses?pinned=true` |
| Remote like and boost totals | P2 | counts | | Remote like and boost totals | P2 | counts |
| Read the thread's `context` collection to complete a thread (**done** 2026-10-05, `FetchReplies`) | P2 | `/statuses/:id/context` | | Read the thread's `context` collection to complete a thread (**done** 2026-10-05, `FetchReplies`) | P2 | `/statuses/:id/context` |
| Publish `context` and a paged `replies` | P2 | — | | Publish `context` and a paged `replies` | P2 | — |
+2 -1
View File
@@ -664,7 +664,8 @@ it, raw where it doesn't.
- inbound `Move` with Mastodon's checks: **done 2026-10-05** (the old account shows `moved`, the personas' follows, - inbound `Move` with Mastodon's checks: **done 2026-10-05** (the old account shows `moved`, the personas' follows,
lists, mutes and blocks move to the new one, owner decision; checked live against GoToSocial); lists, mutes and blocks move to the new one, owner decision; checked live against GoToSocial);
- re-run WebFinger on a rename; - re-run WebFinger on a rename;
- inbound `Block`, plus `Add`/`Remove` of pins; - inbound `Block`; `Add`/`Remove` of pins: **done 2026-10-05** (both ways, a community's pins too, the `featured`
collection read with an account's counts; checked live against Mastodon);
- FEP-8fcf followers sync; - FEP-8fcf followers sync;
- `indexable`/`discoverable`/`searchableBy`; - `indexable`/`discoverable`/`searchableBy`;
- edit history from `formerRepresentations`; - edit history from `formerRepresentations`;
+54
View File
@@ -0,0 +1,54 @@
# Pinned posts across servers (featured, FEP-e232's Add and Remove): a Mastodon account pins and unpins while alice
# follows it; alice pins and unpins while it follows her; an account that pinned before PrivaPub ever saw it shows its
# pins once resolved (its featured collection, read with its counts). Needs the mastodon peer.
M=https://mastodon.test:6443
mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; }
. "$here/peers/mastodon.sh"
p_pinned() { curl -s -H "$PH" "$P/api/v1/accounts/$1/statuses?pinned=true" | j "print(' '.join(s['uri'] for s in d))"; }
m_pinned() { mcurl -H "$MH" "$M/api/v1/accounts/$1/statuses?pinned=true" | j "print(' '.join(s['uri'] for s in d))"; }
echo "pins"
PT=$(privapub_token alice_pins)
PH="Authorization: Bearer $PT"
[ -n "$PT" ] && ok "PrivaPub token for alice_pins" || { ko "PrivaPub token for alice_pins"; return 1; }
run=$(date +%s)
mastodon_user pinner
MT=$(mastodon_token pinner)
MH="Authorization: Bearer $MT"
[ -n "$MT" ] && ok "Mastodon token for pinner" || { ko "Mastodon token for pinner"; return 1; }
echo " following each other"
pinner_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=pinner@mastodon.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$pinner_on_p/follow"
alice_on_m=$(mcurl -H "$MH" "$M/api/v2/search?q=@alice_pins@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/accounts/$alice_on_m/follow"
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$pinner_on_p" | j "print(d[0][\"following\"] and d[0][\"followed_by\"])")" = "True" ]' \
&& ok "alice and pinner follow each other" || ko "the follows between alice and pinner never took"
echo " pinner pins"
m_post=$(mcurl -X POST -H "$MH" "$M/api/v1/statuses" -d "status=a Mastodon post worth pinning $run&visibility=public")
m_post_id=$(echo "$m_post" | j "print(d['id'])"); m_post_uri=$(echo "$m_post" | j "print(d['uri'])")
mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/statuses/$m_post_id/pin"
until_true 45 '[ "$(p_pinned $pinner_on_p)" = "$m_post_uri" ]' && ok "pinner's pin shows on its profile here" || ko "pinner's pin never arrived ($(p_pinned $pinner_on_p))"
mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/statuses/$m_post_id/unpin"
until_true 45 '[ -z "$(p_pinned $pinner_on_p)" ]' && ok "and its unpin takes it off" || ko "pinner's unpin never arrived"
echo " alice pins"
p_post=$(curl -s -X POST -H "$PH" "$P/api/v1/statuses" -d "status=a PrivaPub post worth pinning $run&visibility=public")
p_post_id=$(echo "$p_post" | j "print(d['id'])"); p_post_uri=$(echo "$p_post" | j "print(d['uri'])")
until_true 45 '[ "$(mcurl -H "$MH" "$M/api/v1/accounts/$alice_on_m/statuses?limit=5" | j "print(any(s[\"uri\"] == \"$p_post_uri\" for s in d))")" = "True" ]'
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$p_post_id/pin"
until_true 45 '[ "$(m_pinned $alice_on_m)" = "$p_post_uri" ]' && ok "alice's pin shows on her profile on Mastodon" || ko "alice's pin never reached Mastodon ($(m_pinned $alice_on_m))"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$p_post_id/unpin"
until_true 45 '[ -z "$(m_pinned $alice_on_m)" ]' && ok "and her unpin takes it off" || ko "alice's unpin never reached Mastodon"
echo " pins made before"
# a fresh account pins first; PrivaPub reads its featured collection when it first fetches it
early="early$run"
mastodon_user "$early"
ET=$(mastodon_token "$early")
e_post=$(mcurl -X POST -H "Authorization: Bearer $ET" "$M/api/v1/statuses" -d "status=pinned before anyone looked $run&visibility=public")
mcurl -o /dev/null -X POST -H "Authorization: Bearer $ET" "$M/api/v1/statuses/$(echo "$e_post" | j "print(d['id'])")/pin"
early_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=$early@mastodon.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
until_true 45 '[ "$(p_pinned $early_on_p)" = "$(echo "$e_post" | j "print(d[\"uri\"])")" ]' \
&& ok "an account's earlier pin shows once it is resolved" || ko "the earlier pin never showed ($(p_pinned $early_on_p))"
+19 -7
View File
@@ -37,10 +37,17 @@ NEEDS_PICTURE = {"pixelfed"}
# others there (nobody relays a followers-only reply), nor anything under it # others there (nobody relays a followers-only reply), nor anything under it
THREAD_BOUND = {"friendica"} THREAD_BOUND = {"friendica"}
# Mastodon takes an activity another server passes on only with its author's LD signature (or a FEP-8b32 proof) and # Mastodon takes an activity another server passes on only with its author's LD signature (or a FEP-8b32 proof) and
# drops it otherwise (ActivityPub::ProcessActivityService); of the platforms here only Mastodon and Misskey and its forks # drops it otherwise (ActivityPub::ProcessActivityService); Misskey and Sharkey skip it the same way ("http-signature
# sign their Creates that way, so another's reply PrivaPub passes on never lands there # verification failed and no LD-Signature"). Of the platforms here only Mastodon signs its Creates that way: Misskey
NEEDS_LD_SIGNATURE = {"mastodon"} # 2026.10's carry no signature (seen in the village of 2026-10-05), so another's reply PrivaPub passes on lands there
LD_SIGNS = {"mastodon", "misskey", "sharkey"} # only when Mastodon wrote it
NEEDS_LD_SIGNATURE = {"mastodon", "misskey", "sharkey"}
LD_SIGNS = {"mastodon"}
# Misskey and Sharkey count no renote by a bot (NoteCreateService: `!user.isBot` before incRenoteCount)
BOT_RENOTES_UNCOUNTED = {"misskey", "sharkey"}
# a Lemmy account's vote goes to the community alone, which announces it; Mastodon takes no Announce of a Like, so a
# Lemmy vote on a Mastodon post in a community never counts there
GROUP_VOTES_UNSEEN = {"mastodon"}
class World: class World:
@@ -256,6 +263,7 @@ class Sweep:
# -- counts on the object's own server, and PrivaPub's exact counts # -- counts on the object's own server, and PrivaPub's exact counts
def counts(self, stored): def counts(self, stored):
likes, reacts, boosts, votes = defaultdict(int), defaultdict(lambda: defaultdict(int)), defaultdict(int), defaultdict(lambda: defaultdict(int)) likes, reacts, boosts, votes = defaultdict(int), defaultdict(lambda: defaultdict(int)), defaultdict(int), defaultdict(lambda: defaultdict(int))
bot_boosts = defaultdict(int)
for f in self.w.interactions: for f in self.w.interactions:
ref = f["ref"] ref = f["ref"]
if f["verb"] == "like": if f["verb"] == "like":
@@ -269,6 +277,8 @@ class Sweep:
reacts[ref]["❤️" if f["emoji"] in HEARTS else f["emoji"]] += 1 reacts[ref]["❤️" if f["emoji"] in HEARTS else f["emoji"]] += 1
elif f["verb"] == "boost": elif f["verb"] == "boost":
boosts[ref] += 1 boosts[ref] += 1
if self.w.planner.accounts[f["actor"]]["bot"]:
bot_boosts[ref] += 1
elif f["verb"] == "vote": elif f["verb"] == "vote":
for c in f["choices"]: for c in f["choices"]:
votes[ref][c] += 1 votes[ref][c] += 1
@@ -293,12 +303,14 @@ class Sweep:
self.add(f"count.vote.{v}", "privapub", "privapub", row.votes == want, ref, want, row.votes) self.add(f"count.vote.{v}", "privapub", "privapub", row.votes == want, ref, want, row.votes)
elif origin != "lemmy": elif origin != "lemmy":
# elsewhere: at least the plain likes that every platform sends as Like # elsewhere: at least the plain likes that every platform sends as Like
plain = sum(1 for f in self.w.interactions if f["ref"] == ref and f["verb"] == "like") plain = sum(1 for f in self.w.interactions if f["ref"] == ref and f["verb"] == "like"
and not (origin in GROUP_VOTES_UNSEEN and o.get("group") and self.w.platform(f["actor"]) == "lemmy"))
if plain: if plain:
total = (row.likes or 0) total = (row.likes or 0)
self.add(f"count.like.{v}", "*", origin, total >= plain, ref, f">={plain}", total) self.add(f"count.like.{v}", "*", origin, total >= plain, ref, f">={plain}", total)
if boosts[ref]: counted = boosts[ref] - (bot_boosts[ref] if origin in BOT_RENOTES_UNCOUNTED else 0)
self.add(f"count.boost.{v}", "*", origin, (row.boosts or 0) >= boosts[ref], ref, f">={boosts[ref]}", row.boosts) if counted:
self.add(f"count.boost.{v}", "*", origin, (row.boosts or 0) >= counted, ref, f">={counted}", row.boosts)
if votes[ref]: if votes[ref]:
want = sum(votes[ref].values()) want = sum(votes[ref].values())
got = sum(row.votes or []) got = sum(row.votes or [])