A community's vote on our reply counts, followed or not

Lemmy sends a vote to the community alone, which relays it to the post's
server. A persona's reply in a thread of a community nobody here follows
lost its Lemmy upvotes: the relay was dropped as "not followed". Such a
relay is now taken when the vote (or its undo) is on one of our posts in a
thread rooted in that community; from any other unfollowed group it is
still dropped. For that, a post keeps the community its `audience` names
(FEP-1b12) however it arrived, fetched for a thread as well as announced.

Found by the town's village (p191: 1 like counted of 2).

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 07:29:12 +02:00
1 parent 8686275b69
commit 7e37f56db6
4 files changed
+62 -2

No files matched your search

+3 -1
View File
@@ -104,7 +104,9 @@ A group is either a **community** or a **circle**.
of a member's server, and that copy names the member (or the members on that server); anyone else gets 404. A
Mastodon member's replies reach only the people they mention.
- Announces from **remote** groups (Lemmy communities) are followed through to the activity: the object is fetched
from its own origin, never taken from the announce.
from its own origin, never taken from the announce. They are taken from a group someone here follows, and also when
they relay a vote on one of our posts in a thread of that group (Lemmy sends a vote to the community alone). A post
keeps the group its `audience` names, however it arrived.
- A remote community's **moderation** reaches the posts it holds. A removal (`Announce{Delete}`) is believed once the
post's origin answers it gone. 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
@@ -209,6 +209,36 @@ namespace PrivaPub.Tests.Federation
Assert.False(await DB.Default.Find<Favourite>().Match(f => f.PostId == post.ID).ExecuteAnyAsync(token));
}
// Lemmy sends a vote to the community alone: a vote on our reply in a thread of a community nobody here follows still
// reaches us through it, and only through that community (found by the town's village)
[Fact]
public async Task A_vote_on_our_reply_in_an_unfollowed_communitys_thread_counts_through_that_community_only()
{
var token = TestContext.Current.CancellationToken;
var (_, 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");
var voter = new RemoteActor(_harness.Peer, "voter");
var page = PublicNote(poster, "<p>a thread</p>", community.Id);
page["type"] = "Page";
page["name"] = "a thread";
page["audience"] = community.Id;
_harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString());
var root = await _harness.RemotePosts.StoreContext(IdOf(page), 0, token);
Assert.Equal(community.Id, root.AudienceURI);
var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "my answer", InReplyTo = root.ID }, token)).Post;
var like = new JsonObject { ["id"] = NewId(voter, "likes"), ["type"] = "Like", ["actor"] = voter.Id, ["object"] = reply.ObjectURI };
await _harness.Deliver(other, "/human-centipede", Activity(other, "Announce", like));
Assert.Equal(("dropped", "not-followed"), (Processed("Announce").Outcome, Processed("Announce").Reason));
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like));
Assert.Equal("accepted", Processed("Announce").Outcome);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).FavouritesCount);
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(voter, "Undo", like)));
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).FavouritesCount);
}
// 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]
@@ -143,7 +143,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
{
Arrival.About(Value(inner, "type"));
if (group.AvatarType != AvatarType.Group
|| !await _dbEntities.Followings.Match(f => f.TargetActorURI == group.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token))
|| !await _dbEntities.Followings.Match(f => f.TargetActorURI == group.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token)
&& !await OurPostInItsThread(inner, group, token))
{
Arrival.Drop("not-followed");
return;
@@ -230,6 +231,28 @@ namespace PrivaPub.Federation.Inbox.Handlers
}
}
// A vote (or its undoing) on one of our posts in a thread the community holds: the community relays it to the post's
// server whether or not anyone here follows the community (Lemmy sends a vote to the community alone)
async Task<bool> OurPostInItsThread(JsonNode inner, ForeignAvatar group, CancellationToken token)
{
var target = Value(inner, "type") switch
{
"Like" or "Dislike" => Id(inner["object"]),
"Undo" when (inner["object"] as JsonObject)?["type"]?.GetValue<string>() is "Like" or "Dislike" => Id(inner["object"]?["object"]),
_ => default
};
var post = target == default
? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == target && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
for (var depth = 0; post != default && depth < RemotePosts.MaxDepth; depth++)
{
if (post.AudienceURI == group.ActorURI)
return true;
post = string.IsNullOrEmpty(post.AnsweringToPostId) ? default : await _dbEntities.Posts.MatchID(post.AnsweringToPostId).ExecuteFirstAsync(token);
}
return false;
}
// A community's moderators lock one of its posts, or unlock it: no more replies, ours included. The community vouches
// for what is done to its own posts.
async Task Lock(string objectUri, ForeignAvatar group, bool locked, CancellationToken token)
+5
View File
@@ -94,6 +94,11 @@ namespace PrivaPub.Federation.Inbox
Cc = cc.ToList(),
Url = note.Url,
ContextURI = note.Context,
// the community it lives in (FEP-1b12), as the post itself names it: a post fetched for a thread knows its
// community as well as one its community announced
AudienceURI = note.Audience != default && note.Audience != author.ActorURI && !note.Audience.EndsWith("/followers", StringComparison.Ordinal)
? note.Audience
: default,
QuoteURI = note.QuoteUri,
ObjectType = note.Type,
Title = note.Title,