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:
1 parent
5f150b7a59
commit
1c7d1ece9c
23 files changed
+510
-19
No files matched your search
@@ -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
|
||||
only as `ChatMessage`, which PrivaPub sends it, a first message too (invariant 17). `scenarios/lemmy19.sh`, 30
|
||||
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),
|
||||
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
|
||||
|
||||
+6
-3
@@ -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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
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`.
|
||||
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
|
||||
followed); an unblock sends `Undo{Block}`.
|
||||
- **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
|
||||
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
|
||||
or unlisted. A server that receives it checks it as it checks any forwarded activity (Mastodon by the LD signature
|
||||
or by reading the reply from its origin).
|
||||
or unlisted. A server that receives it checks it as it checks any forwarded activity: Mastodon, Misskey and Sharkey
|
||||
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
|
||||
`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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 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]
|
||||
public async Task Account_statuses_filter_pins_replies_reblogs_media_and_tags_and_page_both_ways()
|
||||
{
|
||||
|
||||
@@ -56,6 +56,7 @@ namespace PrivaPub.Tests.Support
|
||||
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);
|
||||
Relationships = new RelationshipService(Db, Follows, Delivery);
|
||||
Featured = new FeaturedPosts(Db, Remote, RemotePosts);
|
||||
Handlers = new IActivityHandler[]
|
||||
{
|
||||
new FollowHandler(Db, Local, Remote, Delivery),
|
||||
@@ -74,7 +75,9 @@ namespace PrivaPub.Tests.Support
|
||||
new FlagHandler(Db, Local),
|
||||
new BlockHandler(Db, Local),
|
||||
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;
|
||||
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger, Local);
|
||||
@@ -114,6 +117,7 @@ namespace PrivaPub.Tests.Support
|
||||
public Participations Participations { get; }
|
||||
public TimelineService Timelines { get; }
|
||||
public RelationshipService Relationships { get; }
|
||||
public FeaturedPosts Featured { get; }
|
||||
public ReportService Reports { get; }
|
||||
|
||||
public async Task FollowedBy(LocalActor local, RemoteActor follower) =>
|
||||
|
||||
@@ -36,6 +36,7 @@ namespace PrivaPub.Tests.Support
|
||||
["preferredUsername"] = Name,
|
||||
["inbox"] = Id + "/inbox",
|
||||
["followers"] = Id + "/followers",
|
||||
["featured"] = Id + "/featured",
|
||||
["publicKey"] = new JsonObject
|
||||
{
|
||||
["id"] = KeyId,
|
||||
|
||||
@@ -154,7 +154,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
if (Params.Bool("pinned") == true)
|
||||
{
|
||||
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 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));
|
||||
|
||||
@@ -405,6 +405,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -415,7 +416,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")]
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -228,6 +228,15 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
case "Like" or "Dislike" or "Undo":
|
||||
await Relayed(inner, group, token);
|
||||
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:
|
||||
Arrival.Drop("unsupported");
|
||||
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 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);
|
||||
|
||||
@@ -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<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<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 Plain<Block>(token, b => b.TargetActorURI);
|
||||
|
||||
@@ -94,6 +94,9 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<IActivityHandler, FlagHandler>()
|
||||
.AddSingleton<IActivityHandler, BlockHandler>()
|
||||
.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, CreateHandler>()
|
||||
.AddSingleton<IActivityHandler, DeleteHandler>()
|
||||
|
||||
@@ -21,6 +21,17 @@ namespace PrivaPub.Models.Social
|
||||
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 string AvatarId { get; set; }
|
||||
|
||||
@@ -75,6 +75,7 @@ namespace PrivaPub.Models.User
|
||||
public string SharedInboxURL { get; set; }
|
||||
public string InboxURL { get; set; }
|
||||
public string OutboxURL { get; set; }
|
||||
public string FeaturedURL { get; set; }//featured: the posts it pins
|
||||
public string MovedToURL { get; set; }
|
||||
public List<string> AlsoKnownAs { get; set; } = new();//alsoKnownAs: the accounts it says it also is
|
||||
public string PictureURL { get; set; }//icon
|
||||
|
||||
+1
-1
@@ -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` |
|
||||
| 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` |
|
||||
| `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 |
|
||||
| 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 | — |
|
||||
|
||||
+2
-1
@@ -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,
|
||||
lists, mutes and blocks move to the new one, owner decision; checked live against GoToSocial);
|
||||
- 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;
|
||||
- `indexable`/`discoverable`/`searchableBy`;
|
||||
- edit history from `formerRepresentations`;
|
||||
|
||||
@@ -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))"
|
||||
@@ -37,10 +37,17 @@ NEEDS_PICTURE = {"pixelfed"}
|
||||
# others there (nobody relays a followers-only reply), nor anything under it
|
||||
THREAD_BOUND = {"friendica"}
|
||||
# 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
|
||||
# sign their Creates that way, so another's reply PrivaPub passes on never lands there
|
||||
NEEDS_LD_SIGNATURE = {"mastodon"}
|
||||
LD_SIGNS = {"mastodon", "misskey", "sharkey"}
|
||||
# drops it otherwise (ActivityPub::ProcessActivityService); Misskey and Sharkey skip it the same way ("http-signature
|
||||
# verification failed and no LD-Signature"). Of the platforms here only Mastodon signs its Creates that way: Misskey
|
||||
# 2026.10's carry no signature (seen in the village of 2026-10-05), so another's reply PrivaPub passes on lands there
|
||||
# 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:
|
||||
@@ -256,6 +263,7 @@ class Sweep:
|
||||
# -- counts on the object's own server, and PrivaPub's exact counts
|
||||
def counts(self, stored):
|
||||
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:
|
||||
ref = f["ref"]
|
||||
if f["verb"] == "like":
|
||||
@@ -269,6 +277,8 @@ class Sweep:
|
||||
reacts[ref]["❤️" if f["emoji"] in HEARTS else f["emoji"]] += 1
|
||||
elif f["verb"] == "boost":
|
||||
boosts[ref] += 1
|
||||
if self.w.planner.accounts[f["actor"]]["bot"]:
|
||||
bot_boosts[ref] += 1
|
||||
elif f["verb"] == "vote":
|
||||
for c in f["choices"]:
|
||||
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)
|
||||
elif origin != "lemmy":
|
||||
# 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:
|
||||
total = (row.likes or 0)
|
||||
self.add(f"count.like.{v}", "*", origin, total >= plain, ref, f">={plain}", total)
|
||||
if boosts[ref]:
|
||||
self.add(f"count.boost.{v}", "*", origin, (row.boosts or 0) >= boosts[ref], ref, f">={boosts[ref]}", row.boosts)
|
||||
counted = boosts[ref] - (bot_boosts[ref] if origin in BOT_RENOTES_UNCOUNTED else 0)
|
||||
if counted:
|
||||
self.add(f"count.boost.{v}", "*", origin, (row.boosts or 0) >= counted, ref, f">={counted}", row.boosts)
|
||||
if votes[ref]:
|
||||
want = sum(votes[ref].values())
|
||||
got = sum(row.votes or [])
|
||||
|
||||
Reference in new issue
Block a user