P7: feeds read as accounts, and PieFed's post Move
Build / Build (push) Successful in 7m50s

Lemmy 1.0's multi-communities and PieFed's feeds (`type: Feed`) resolve as accounts: the feed's `following` is kept
once a day with its counts, each community in it is read in turn, and `/api/v1/accounts/:id/following` lists them.
Lemmy's feed carries the instance's key and its text in `description`; both are taken as they are. Following a feed
answers 422 until the owner decides how a feed is followed.

A post moved to another community (PieFed's Move{object: post, origin, target}), relayed by the community it leaves,
goes there with its thread; a move into a community we host is not taken.

Checked in the pasture: a Lemmy 1.0 feed and a PieFed feed list their communities (ours among them); a PieFed post moved
from its web code arrives moved, with its comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 21:42:54 +02:00
1 parent 62189bf11d
commit 3b3f1f6b93
10 files changed
+226 -25

No files matched your search

+6 -1
View File
@@ -167,7 +167,12 @@ A group is either a **community** or a **circle**.
and from a community elsewhere once the post's origin answers it gone. A lock sent as it is (`Lock`, Mbin) is taken
from the post's own server only. A lock (`Announce{Lock}`, or `commentsEnabled: false` on the post) refuses replies,
ours included, until `Undo{Lock}`. A ban of a persona (`Announce{Block}` with the community as `target`) shows as
`blocked_by` on the community and refuses the persona's posts and replies there until the `Undo`.
`blocked_by` on the community and refuses the persona's posts and replies there until the `Undo`. A post moved to
another community (PieFed's `Announce{Move{object: post, origin, target}}`, relayed by the community it leaves) goes
there with its thread; a move into a community this server hosts is not taken.
- **Feeds** (Lemmy 1.0's multi-communities and PieFed's feeds, `type: Feed`) are read as accounts whose following is
their communities: the feed's `following` collection, read once a day with its counts, and each community in it read in
turn. A feed cannot be followed from here yet; its communities can.
## Activities
@@ -412,6 +412,48 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).FavouritesCount);
}
// PieFed moves a post to another community, and the community it leaves relays the Move: the post's thread goes with it
[Fact]
public async Task A_post_moved_to_another_community_takes_its_thread_along()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "cats", type: "Group");
var other = new RemoteActor(_harness.Peer, "dogs", type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
async Task<Post> Announced(JsonObject note)
{
note["audience"] = community.Id;
_harness.Peer.Serve(new Uri(IdOf(note)).AbsolutePath, note.ToJsonString());
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, note)));
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token);
}
var page = PublicNote(poster, "<p>misplaced</p>", community.Id);
page["type"] = "Page";
page["name"] = "misplaced";
var post = await Announced(page);
var comment = PublicNote(poster, "<p>a comment</p>", community.Id);
comment["inReplyTo"] = IdOf(page);
var answer = await Announced(comment);
var move = new JsonObject
{
["id"] = NewId(poster, "moves"), ["type"] = "Move", ["actor"] = poster.Id, ["object"] = post.ObjectURI, ["origin"] = community.Id, ["target"] = other.Id
};
// only the community it leaves speaks for it
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", new JsonObject
{
["id"] = NewId(poster, "moves"), ["type"] = "Move", ["actor"] = poster.Id, ["object"] = post.ObjectURI, ["origin"] = other.Id, ["target"] = community.Id
}));
Assert.Equal(("dropped", "unsupported"), (Processed("Announce").Outcome, Processed("Announce").Reason));
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", move));
Assert.Equal(("accepted", "moved"), (Processed("Announce").Outcome, Processed("Announce").Reason));
Assert.Equal(other.Id, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).AudienceURI);
Assert.Equal(other.Id, (await DB.Default.Find<Post>().OneAsync(answer.ID, token)).AudienceURI);
}
// Lemmy's moderators lock a community's post and ban members, and the community relays both inside its Announce
// (G-0006): a locked post takes no reply, a banned persona posts nothing there, until the Undo
[Fact]
+67
View File
@@ -0,0 +1,67 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Jobs;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Http
{
// Lemmy 1.0's multi-communities and PieFed's feeds are `Feed` actors whose `following` is their communities
[Trait("Category", "Integration")]
public sealed class MastodonFeedTests : IAsyncLifetime
{
PrivaPubHost _host;
Peer _peer;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
_peer = await Peer.Start();
}
public async ValueTask DisposeAsync()
{
if (_peer != default)
await _peer.DisposeAsync();
}
[Fact]
public async Task A_feed_shows_as_an_account_whose_following_is_its_communities()
{
var token = TestContext.Current.CancellationToken;
var reader = await _host.Mastodon("feeder");
var cats = new RemoteActor(_peer, "cats", type: "Group");
var dogs = new RemoteActor(_peer, "dogs", type: "Group");
var name = $"feed{Guid.NewGuid():N}"[..12];
var feed = $"{_peer.A}/m/{name}";
// as Lemmy serves one: the instance's inbox and key, its text in `description`
_peer.Serve($"/m/{name}", new JsonObject
{
["@context"] = new JsonArray("https://join-lemmy.org/context.json", "https://www.w3.org/ns/activitystreams"),
["type"] = "Feed", ["id"] = feed, ["inbox"] = $"{_peer.A}/inbox",
["publicKey"] = new JsonObject { ["id"] = $"{_peer.A}/#main-key", ["owner"] = $"{_peer.A}/", ["publicKeyPem"] = "-----BEGIN PUBLIC KEY-----\n-----END PUBLIC KEY-----\n" },
["following"] = $"{feed}/following", ["preferredUsername"] = name, ["name"] = "Pets", ["description"] = "every pet",
["attributedTo"] = $"{_peer.A}/users/owner"
}.ToJsonString());
_peer.Serve($"/m/{name}/following", new JsonObject
{
["type"] = "Collection", ["id"] = $"{feed}/following", ["totalItems"] = 2, ["items"] = new JsonArray(cats.Id, dogs.Id)
}.ToJsonString());
var account = await _host.Get<IRemoteActorService>().GetActor(feed, refresh: true, token);
Assert.NotNull(account);
await _host.Run(j => j.Kind == JobKind.CountAccount && j.Payload == feed, token);
var shown = (await reader.Client.Get($"/api/v1/accounts/{account.ID}")).Ok().Body;
Assert.Equal(("Pets", 2), (shown.Text("display_name"), shown.Number("following_count")));
Assert.Contains("every pet", shown.Text("note"));
var communities = (await reader.Client.Get($"/api/v1/accounts/{account.ID}/following")).Ok().Array;
Assert.Equal(new[] { cats.Id, dogs.Id }, communities.Select(c => c.Text("uri")));
Assert.All(communities, c => Assert.True(c!["group"]!.GetValue<bool>()));
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await reader.Client.Post($"/api/v1/accounts/{account.ID}/follow")).Status);
}
}
}
@@ -253,7 +253,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/accounts/{id}/following"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Following(string id, CancellationToken token)
{
var (local, _) = await Find(id, token);
var (local, remote) = await Find(id, token);
// a feed's following is what it is: its communities, as its server lists them
if (remote is { AvatarType: AvatarType.Feed })
return Json(await AccountsFor(remote.FeedCommunities.Take(Limit(40, 80)), token));
if (local == default || local.Id != MyId)
return Json(Array.Empty<Account>());
var following = await Page.From(Params, Limit(40, 80))
@@ -269,6 +272,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
var (local, remote) = await Find(id, token);
if (local == default && remote == default)
return NotFoundError();
if (remote is { AvatarType: AvatarType.Feed })
return Error(StatusCodes.Status422UnprocessableEntity, "A feed is followed through its communities");
var following = await _follows.FollowAs(Me, local?.Uri ?? remote.ActorURI, Params.Bool("reblogs") != false, token);
return following == default ? Error(StatusCodes.Status403Forbidden, "This action is not allowed") : Json(await Relationship(id, token));
}
+44 -15
View File
@@ -12,18 +12,23 @@ 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), 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.
// GoToSocial do the same. A feed's following is its communities: they are kept, and read in turn, so its list shows.
public class AccountCountsJob : IJobHandler
{
readonly IRemoteActorService _remoteActors;
readonly DbEntities _dbEntities;
readonly IFeaturedPosts _featured;
readonly ILocalActorService _localActors;
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities, IFeaturedPosts featured = default)
public const int MaxFeedCommunities = 50;
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities, IFeaturedPosts featured = default,
ILocalActorService localActors = default)
{
_remoteActors = remoteActors;
_dbEntities = dbEntities;
_featured = featured;
_localActors = localActors;
}
public JobKind Kind => JobKind.CountAccount;
@@ -38,28 +43,52 @@ namespace PrivaPub.Federation.Actors
var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == job.Payload && !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (actor == default)
return JobOutcome.Done;
await DB.Default.Update<ForeignAvatar>().MatchID(actor.ID)
.Modify(a => a.FollowersCount, await Total(actor, actor.FollowersURL, token))
.Modify(a => a.FollowingCount, await Total(actor, actor.FollowingURL, token))
.Modify(a => a.StatusesCount, await Total(actor, actor.OutboxURL, token))
.Modify(a => a.CountedAt, DateTime.UtcNow)
.ExecuteAsync(token);
var feed = actor.AvatarType == AvatarType.Feed;
var (following, communities) = await Read(actor, actor.FollowingURL, feed, token);
var update = DB.Default.Update<ForeignAvatar>().MatchID(actor.ID)
.Modify(a => a.FollowersCount, (await Read(actor, actor.FollowersURL, false, token)).Total)
.Modify(a => a.FollowingCount, following)
.Modify(a => a.StatusesCount, (await Read(actor, actor.OutboxURL, false, token)).Total)
.Modify(a => a.CountedAt, DateTime.UtcNow);
if (feed)
update = update.Modify(a => a.FeedCommunities, communities);
await update.ExecuteAsync(token);
if (feed)
foreach (var community in communities)
if (_localActors == default || !community.StartsWith(_localActors.BaseAddress + "/", StringComparison.Ordinal))
await _remoteActors.GetActor(community, refresh: false, token);
if (_featured != default)
await _featured.Sync(actor, token);
return JobOutcome.Done;
}
// only from the actor's own server, as everything we believe about it
async Task<int?> Total(ForeignAvatar actor, string collection, CancellationToken token)
// only from the actor's own server, as everything we believe about it: its total, and the ids it lists when asked
// (inline, or on its first page)
async Task<(int? Total, List<string> Items)> Read(ForeignAvatar actor, string collection, bool items, CancellationToken token)
{
var listed = new List<string>();
if (string.IsNullOrEmpty(collection) || !Origin.Same(collection, actor.ActorURI))
return default;
return (default, listed);
using var scope = HttpScope.For("collection");
using var fetched = await _remoteActors.FetchObject(collection, token);
return fetched != default && fetched.Root.ValueKind == System.Text.Json.JsonValueKind.Object
&& fetched.Root.TryGetProperty("totalItems", out var total) && total.TryGetInt32(out var count) && count >= 0
? count
: default;
if (fetched == default || fetched.Root.ValueKind != System.Text.Json.JsonValueKind.Object)
return (default, listed);
int? total = fetched.Root.TryGetProperty("totalItems", out var counted) && counted.TryGetInt32(out var count) && count >= 0 ? count : default;
if (!items)
return (total, listed);
var document = System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText());
var page = document["orderedItems"] ?? document["items"] ?? document["first"]?["orderedItems"] ?? document["first"]?["items"];
if (page == default && ActivityJson.Id(document["first"]) is { } first && Origin.Same(first, actor.ActorURI))
{
using var next = await _remoteActors.FetchObject(first, token);
var nextPage = next == default ? default : System.Text.Json.Nodes.JsonNode.Parse(next.Root.GetRawText());
page = nextPage?["orderedItems"] ?? nextPage?["items"];
}
if (page is System.Text.Json.Nodes.JsonArray array)
listed = array.Select(ActivityJson.Id)
.Where(id => Uri.TryCreate(id, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http")
.Distinct(StringComparer.Ordinal).Take(MaxFeedCommunities).ToList();
return (total, listed);
}
}
}
+3 -2
View File
@@ -11,7 +11,7 @@ namespace PrivaPub.Federation.Actors
public sealed class ActorDocument
{
static readonly string[] ActorTypes = { "Person", "Service", "Application", "Group", "Organization" };
static readonly string[] ActorTypes = { "Person", "Service", "Application", "Group", "Organization", "Feed" };
public string Id { get; init; }
public string Type { get; init; }
@@ -74,7 +74,8 @@ namespace PrivaPub.Federation.Actors
Features = ActorFeatures.Detect(root),
PreferredUsername = RemoteActorService.Text(root, "preferredUsername"),
Name = RemoteActorService.Text(root, "name"),
Summary = RemoteActorService.Text(root, "summary"),
// (Lemmy's feed keeps its text in `description`)
Summary = RemoteActorService.Text(root, "summary") ?? (type == "Feed" ? RemoteActorService.Text(root, "description") : default),
Url = RemoteActorService.Text(root, "url") ?? id,
Inbox = Origin.Same(inbox, id) ? inbox : default,
Outbox = RemoteActorService.Text(root, "outbox"),
@@ -59,7 +59,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var inner = activity["object"];
if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block" or "Lock")
if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block" or "Lock" or "Move")
{
await GroupActivity(inner, actor, token);
return;
@@ -242,6 +242,9 @@ namespace PrivaPub.Federation.Inbox.Handlers
case "Lock" when objectUri != default:
await Lock(objectUri, group, true, token);
break;
case "Move" when objectUri != default && Id(inner["origin"]) == group.ActorURI:
await Move(objectUri, Id(inner["target"]), group, token);
break;
case "Undo" when Value(inner["object"], "type") == "Lock":
await Lock(Id(inner["object"]?["object"]), group, false, token);
break;
@@ -305,6 +308,42 @@ namespace PrivaPub.Federation.Inbox.Handlers
Arrival.Accept(locked ? "locked" : "unlocked");
}
// A post moved to another community (PieFed's Move{object: post, origin, target}), relayed by the community it leaves,
// which vouches for what is done to its own posts as for a lock; its thread goes with it. A move into a community we
// host is not taken: it would be ours to announce.
async Task Move(string objectUri, string targetUri, ForeignAvatar group, CancellationToken token)
{
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI && !p.DeletedAt.HasValue)
.ExecuteFirstAsync(token);
if (post == default)
{
Arrival.Drop("unknown-object");
return;
}
Arrival.About(visibility: post.Visibility, created: post.CreationDate);
var target = targetUri == default || targetUri == group.ActorURI || await _localActors.FindByUri(targetUri, token) != default
? default
: await _remoteActors.GetActor(targetUri, refresh: false, token);
if (target is not { AvatarType: AvatarType.Group })
{
Arrival.Drop("unknown-target");
return;
}
var thread = new List<string> { post.ID };
var level = thread.ToList();
while (level.Count > 0 && thread.Count < MaxMoved)
{
var parents = level;
level = (await _dbEntities.Posts.Match(p => parents.Contains(p.AnsweringToPostId) && p.AudienceURI == group.ActorURI)
.Project(p => new PostEntity { ID = p.ID }).ExecuteAsync(token)).Select(p => p.ID).ToList();
thread.AddRange(level);
}
await DB.Default.Update<PostEntity>().Match(p => thread.Contains(p.ID)).Modify(p => p.AudienceURI, target.ActorURI).ExecuteAsync(token);
Arrival.Accept("moved");
}
const int MaxMoved = 1000;
// A community bans one of our personas (Lemmy's Block with the community as its target): kept as the community
// blocking the persona, so its relationship says blocked_by and nothing of the persona's is posted there until the
// Undo. A ban of someone from another server is no business of ours.
+3 -1
View File
@@ -78,6 +78,7 @@ namespace PrivaPub.Models.User
public string OutboxURL { get; set; }
public string FeaturedURL { get; set; }//featured: the posts it pins
public List<PrivaPub.Models.Post.PostFlair> Flairs { get; set; } = new();//a community's labels for its posts
public List<string> FeedCommunities { get; set; } = new();//a feed's communities, its `following` (Lemmy's multi-communities, PieFed's feeds)
public List<string> SearchableBy { get; set; }//FEP-268d, when the account states it: outranks IsIndexable
public string ModeratorsURL { get; set; }//a community's attributedTo (Lemmy, PieFed, Mbin): the collection of its moderators
public List<AssertionKey> AssertionKeys { get; set; } = new();//its Ed25519 keys (FEP-521a), which prove what it sends (FEP-8b32)
@@ -152,7 +153,8 @@ namespace PrivaPub.Models.User
Group,
Organization,
Person,
Service
Service,
Feed
}
public enum AvatarAccountState
+11 -3
View File
@@ -492,7 +492,11 @@ on a Page: pins live in `featured`, locks in `Lock`.
- **Private messages:** `ChatMessage` on 0.19; a single-recipient `Note` on 1.0.
- **Context:** 1.0's `context` collection is unpaged and **every comment's own context URL returns the whole post
thread**. Group threads by the root post, never by comparing `context` strings.
- **Feeds:** multi-communities are 1.0's `type: Feed` actors.
- **Feeds:** multi-communities are 1.0's `type: Feed` actors at `/m/<name>`, with no WebFinger handle: the instance's
shared inbox and the **instance's key** (`owner` is the instance, not the feed), text in `description` (not `summary`),
the creator in `attributedTo`, and `following`, an unpaged `Collection` of the community ids with `totalItems`.
Following one stays on the follower's server, which subscribes to each community itself (checked 2026-10-07 on
1.0.0-beta.2).
**Expects**
- **Fetched documents:** Content-Type exactly one of `activity+json`, `activity+json; charset=utf-8`, or `ld+json` with
@@ -595,8 +599,12 @@ What it showed:
- **polls in communities** (`Question` with `votersCount`), and Mobilizon-style `Event`s;
- flairs in two dialects;
- comments: `repliesEnabled` (comment lock), `answer`, plus custom `ChooseAnswer` and `PollVote` activities;
- `Move{object: post}` between communities;
- `Feed` actors;
- `Move{object: post, origin, target}` between communities, by the post's author or a moderator of the origin, relayed
as the origin community's `Announce` (only from the web UI; its API has no move);
- `Feed` actors at `/f/<name>` with their own key, inbox, `followers`, `moderators` (the owner) and `following` (the
communities, a `Collection` with `totalItems`); PieFed's handle is `~name@host`. Following one is a `Follow` to the
feed, after which its owner's server sends `Add`/`Remove` of communities, signed by the feed; each member also
subscribes to every community as themselves (`feed_auto_follow`). Its API creates feeds (`POST /api/alpha/feed`);
- emoji Likes and `EmojiReact` count as upvotes.
- **How PieFed formats what it sends us:**
- It **chooses the format by our NodeInfo software name**, so keep that accurate.
+4 -1
View File
@@ -696,7 +696,10 @@ it, raw where it doesn't.
server or its moderators collection, kept as `ForeignAvatar.ModeratorsURL`; a resolution is kept on our report);
- votes in and out; link posts; flairs (**done 2026-10-07**: Lemmy 1.0's `CommunityPostTag` and PieFed's
`lemmy:CommunityTag`, on posts and communities, described from the community's list, as `privapub.flairs`); `Feed`
actors; community polls; post `Move`;
actors (**read 2026-10-07**: Lemmy 1.0's and PieFed's feeds resolve as accounts whose `/following` is their
communities, kept from the feed's `following` once a day; following one is refused for now, **owner decision
pending** on how); community polls; post `Move` (**done 2026-10-07**: PieFed's, relayed by the community the post
leaves, moves the post and its thread; checked live);
- the outbound shape Lemmy requires;
- communities we host announce to the author's own instance too: **done 2026-10-06** (its server's shared inbox,
whether anyone there follows the community or not: Lemmy keeps its user's post pending until it is announced back);