diff --git a/PrivaPub.Tests/Federation/GroupTests.cs b/PrivaPub.Tests/Federation/GroupTests.cs index ca46aa4..31ff394 100644 --- a/PrivaPub.Tests/Federation/GroupTests.cs +++ b/PrivaPub.Tests/Federation/GroupTests.cs @@ -90,6 +90,28 @@ namespace PrivaPub.Tests.Federation Assert.Contains(announces, a => a["object"] is JsonValue); } + [Fact] + public async Task A_comment_in_a_community_is_announced_activity_and_object_too() + { + var (_, owner) = await _harness.Persona("owner"); + var (_, community) = await Group(GroupKind.Community, PostingPolicy.Followers, owner.Id); + var lemmy = new RemoteActor(_harness.Peer, "lemmy"); + var akkoma = new RemoteActor(_harness.Peer, "akkoma"); + await _harness.FollowedBy(community, lemmy); + await _harness.FollowedBy(community, akkoma); + var thread = Create(lemmy, new[] { community.Uri, Addressing.Public }, community.Uri); + await _harness.Deliver(lemmy, "/human-centipede", thread); + var comment = Create(lemmy, new[] { community.Uri, Addressing.Public }, community.Uri); + comment["object"]!["inReplyTo"] = thread["object"]!["id"]!.GetValue(); + + await _harness.Deliver(lemmy, "/human-centipede", comment); + + var commentId = comment["object"]!["id"]!.GetValue(); + var announces = (await _harness.Outgoing(akkoma.SharedInbox)).Where(a => a["actor"]!.GetValue() == community.Uri).ToList(); + Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["object"]?["id"]?.GetValue() == commentId); + Assert.Contains(announces, a => a["object"] is JsonValue bare && bare.GetValue() == commentId); + } + [Fact] public async Task An_open_community_takes_posts_from_anyone() { diff --git a/PrivaPub/Domain/Statuses/StatusService.cs b/PrivaPub/Domain/Statuses/StatusService.cs index 433b5df..ef6d93d 100644 --- a/PrivaPub/Domain/Statuses/StatusService.cs +++ b/PrivaPub/Domain/Statuses/StatusService.cs @@ -262,7 +262,7 @@ namespace PrivaPub.Domain.Statuses if (create != default) await _outbox.Publish(author, post, create, token); if (create != default && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted) - await _groups.Announce(group, create, post.ObjectURI, isNewPost: string.IsNullOrEmpty(post.InReplyToURI), token); + await _groups.Announce(group, create, post.ObjectURI, isNewPost: true, token); return new StatusOutcome(post); } diff --git a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs index 0a3f7d9..c08b63b 100644 --- a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs @@ -188,7 +188,7 @@ namespace PrivaPub.Federation.Inbox.Handlers if (conversation != default) await DB.Default.Update().MatchID(conversation.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token); if (group is { IsCircle: false }) - await _groups.Announce(group, activity.AsObject(), note.Id, isNewPost: string.IsNullOrEmpty(note.InReplyTo), token); + await _groups.Announce(group, activity.AsObject(), note.Id, isNewPost: true, token); } async Task IsCircleMember(LocalActor circle, string actorUri, CancellationToken token) => diff --git a/PrivaPub/Federation/Outbox/GroupDistributor.cs b/PrivaPub/Federation/Outbox/GroupDistributor.cs index 67c1a00..484265f 100644 --- a/PrivaPub/Federation/Outbox/GroupDistributor.cs +++ b/PrivaPub/Federation/Outbox/GroupDistributor.cs @@ -67,6 +67,8 @@ namespace PrivaPub.Federation.Outbox // announces it back, and clears that before it answers the echo 400 ("Object is not remote"). await _delivery.EnqueueToFollowers(group, announce, token); await Keep(group, announce, objectUri, token); + // a new post or comment is also boosted as itself: microblogs (Mastodon, Akkoma, Misskey) drop an Announce of an + // activity, so without it their members never see what is said in the community; Lemmy answers it 400, harmlessly if (isNewPost && !string.IsNullOrEmpty(objectUri)) { var boost = ActivityPubRenderer.Announce(group, objectUri, $"announce-object-{key}"); diff --git a/docs/INTEROP.md b/docs/INTEROP.md index d4bae29..42659bc 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -297,7 +297,8 @@ Firefish is dead (its site has answered 410 since February 2025). - **Visibility:** Misskey recognises followers-only by the author's own `followers` URL, matched exactly. Otherwise: - **A "specified" (direct) note with no resolvable recipients that Misskey fetches by URL is stored as public.** Circle objects must therefore never be served to an unauthorised fetcher. They aren't: 404. -- **Groups:** vanilla Misskey drops `Announce{Create}`, so groups should `Announce` the Note itself. We send both. +- **Groups:** vanilla Misskey drops `Announce{Create}`, so groups should `Announce` the Note itself. We send both, for + comments as for posts (Akkoma and Mastodon drop the activity form too; the town found Akkoma members missing them). - **Reactions:** must be `:name:` with no host, plus an Emoji tag, or they fall back to ❤. - **Article/Page titles** are never shown in Misskey's web UI. - **Iceshrimp.NET:** diff --git a/tools/pasture/town/check.py b/tools/pasture/town/check.py index 02c240f..1347260 100644 --- a/tools/pasture/town/check.py +++ b/tools/pasture/town/check.py @@ -114,7 +114,8 @@ class World: who |= self.follows.get(o["author"], set()) if o.get("parent") and o["parent"] in self.objects: who.add(self.objects[o["parent"]]["author"]) - if o.get("quote") and o["quote"] in self.objects: + # a quote reaches its quoted author when it is public or unlisted; a followers-only quote stays with the followers + if o.get("quote") and o["quote"] in self.objects and v in ("public", "unlisted"): who.add(self.objects[o["quote"]]["author"]) elif v == "circle": who |= self.members[o["group"]] diff --git a/tools/pasture/town/gaps.json b/tools/pasture/town/gaps.json index ab84ca2..b4b5696 100644 --- a/tools/pasture/town/gaps.json +++ b/tools/pasture/town/gaps.json @@ -36,13 +36,15 @@ "title": "Votes and moderation that a Lemmy community relays inside Announce (Like, Dislike, Undo, Delete, Block) are dropped", "match": { "feature": "count\\.like\\.community", - "observer": "privapub" + "observer": "privapub", + "origin": "lemmy" }, "kind": "server", "phase": "P7", "code": "PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs:203", "opened": "2026-10-01", - "status": "open" + "status": "open", + "note": "Relayed likes count since ed08f80; what stays open is moderation relayed by a Lemmy community (Delete, Block, Lock). Communities hosted here count their likes as any post does." }, { "id": "G-0004",