Public threads name their replies and context
Owner decision of 2026-10-06: a persona's public or unlisted post outside any group names its replies
(…/scribbles/{id}/replies) and its conversation's context (FEP-7888), the root's …/context that a reply inherits from
its parent, ours or another server's. Both list only the public and unlisted posts PrivaPub holds; followers-only,
circle, direct and local-only posts name neither and their collections answer 404. Checked live: opening alice's
thread on Mastodon finds carol's reply, which nobody there follows (scenarios/threads.sh).
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
93e13e5563
commit
bf37223849
8 files changed
+152
-1
No files matched your search
@@ -559,6 +559,9 @@ 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.
|
||||
- **Threads (`scenarios/threads.sh`, needs mastodon):** mastouser follows alice, carol answers alice's public post,
|
||||
and opening the thread on Mastodon finds carol's reply through alice's `replies` (the scenario makes Mastodon's copy
|
||||
look ten minutes old: Mastodon reads a thread's replies only five minutes after it first holds the post). 5 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, a fresh account's earlier pin shows once PrivaPub resolves it, and following it brings
|
||||
its earlier posts (its outbox). 9 checks.
|
||||
|
||||
@@ -166,6 +166,11 @@ 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.
|
||||
- **Threads** (owner decision 2026-10-06). A persona's public or unlisted post outside any group names its `replies`
|
||||
(`…/scribbles/{id}/replies`) and its conversation, `context` (FEP-7888): the root post's `…/scribbles/{id}/context`,
|
||||
which a reply inherits from its parent, ours or another server's. Both list only the public and unlisted posts
|
||||
PrivaPub holds, the context every one under the root (at most 500); followers-only, circle, direct and local-only posts
|
||||
name neither, and their collections answer 404. Mastodon completes a thread from them.
|
||||
- **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
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using PrivaPub.Tests.Support;
|
||||
using PrivaPub.Tests.Support.Host;
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Http
|
||||
{
|
||||
// a public thread names its replies and its conversation (owner decision 2026-10-06), and they list only what is public
|
||||
// or unlisted; a followers-only post names neither
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class ThreadCollectionsTests : IAsyncLifetime
|
||||
{
|
||||
PrivaPubHost _host;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_host = await PrivaPubHost.Shared();
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
static string PathOf(JsonObject status) => new Uri(status.Text("uri")).AbsolutePath;
|
||||
|
||||
static string[] Items(JsonNode collection) => collection["orderedItems"]!.AsArray().Select(i => i!.GetValue<string>()).ToArray();
|
||||
|
||||
[Fact]
|
||||
public async Task A_public_post_names_its_replies_and_context_which_list_only_public_answers()
|
||||
{
|
||||
var alice = await _host.Mastodon("alice");
|
||||
var carol = await _host.Mastodon("carol");
|
||||
var root = await alice.Status("a public question");
|
||||
var answer = await carol.Status("a public answer", ("in_reply_to_id", root.Text("id")));
|
||||
var hidden = await carol.Status("for my followers", ("in_reply_to_id", root.Text("id")), ("visibility", "private"));
|
||||
var deeper = await alice.Status("and a deeper one", ("in_reply_to_id", answer.Text("id")), ("visibility", "unlisted"));
|
||||
|
||||
var note = (await _host.ActivityPub(PathOf(root))).Ok().Object;
|
||||
Assert.Equal(root.Text("uri") + "/replies", note.Text("replies"));
|
||||
Assert.Equal(root.Text("uri") + "/context", note.Text("context"));
|
||||
var answerNote = (await _host.ActivityPub(PathOf(deeper))).Ok().Object;
|
||||
Assert.Equal(root.Text("uri") + "/context", answerNote.Text("context"));
|
||||
|
||||
var replies = (await _host.ActivityPub(PathOf(root) + "/replies")).Ok().Object;
|
||||
Assert.Equal(new[] { answer.Text("uri") }, Items(replies));
|
||||
|
||||
var context = (await _host.ActivityPub(PathOf(root) + "/context")).Ok().Object;
|
||||
Assert.Equal(new[] { root.Text("uri"), answer.Text("uri"), deeper.Text("uri") }, Items(context));
|
||||
Assert.Equal(alice.Uri, context.Text("attributedTo"));
|
||||
Assert.DoesNotContain(hidden.Text("uri"), Items(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_followers_only_post_names_neither_and_serves_neither()
|
||||
{
|
||||
var alice = await _host.Mastodon("alice");
|
||||
var quiet = await alice.Status("for my followers", ("visibility", "private"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _host.ActivityPub(PathOf(quiet) + "/replies")).Status);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _host.ActivityPub(PathOf(quiet) + "/context")).Status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,6 +259,9 @@ namespace PrivaPub.Domain.Statuses
|
||||
}
|
||||
else if (!isLocalOnly)
|
||||
{
|
||||
// the conversation it belongs to (FEP-7888): its parent's, or its own when it starts one
|
||||
if (group == default && visibility is PostVisibility.Public or PostVisibility.Unlisted)
|
||||
post.ContextURI = parent == default ? post.ObjectURI + "/context" : parent.ContextURI;
|
||||
var note = ActivityPubRenderer.Note(post, author, group, post.InReplyToURI);
|
||||
create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
|
||||
post.To = Strings(note["to"]);
|
||||
|
||||
@@ -235,6 +235,42 @@ namespace PrivaPub.Federation.Controllers
|
||||
return Activity(note);
|
||||
}
|
||||
|
||||
// the public and unlisted replies PrivaPub holds to a public post (owner decision 2026-10-06), oldest first
|
||||
[HttpGet, Route("{actor}/scribbles/{postId}/replies")]
|
||||
public async Task<IActionResult> Replies(string actor, string postId, CancellationToken token)
|
||||
{
|
||||
var (local, post) = await PublicPost(actor, postId, token);
|
||||
if (post == default || post.IsLocalOnly || !string.IsNullOrEmpty(post.GroupId))
|
||||
return NotFound();
|
||||
var replies = await _dbEntities.Posts.Match(p => p.AnsweringToPostId == post.ID && !p.IsLocalOnly && p.ReblogOfPostId == null)
|
||||
.Match(VisibilityPolicy.IsPublic).Sort(p => p.CreationDate, Order.Ascending).Limit(MaxThread).ExecuteAsync(token);
|
||||
return Activity(ActivityPubRenderer.OrderedCollection(local.PostUri(post.ID) + "/replies", replies.Count,
|
||||
replies.Select(r => (JsonNode)r.ObjectURI)));
|
||||
}
|
||||
|
||||
// a public conversation this post starts (FEP-7888): it and every public or unlisted reply under it PrivaPub holds
|
||||
[HttpGet, Route("{actor}/scribbles/{postId}/context")]
|
||||
public async Task<IActionResult> Context(string actor, string postId, CancellationToken token)
|
||||
{
|
||||
var (local, root) = await PublicPost(actor, postId, token);
|
||||
if (root == default || root.IsLocalOnly || !string.IsNullOrEmpty(root.GroupId) || root.ContextURI != local.PostUri(root.ID) + "/context")
|
||||
return NotFound();
|
||||
var thread = new List<PostEntity> { root };
|
||||
var level = new List<string> { root.ID };
|
||||
for (var depth = 0; depth < PrivaPub.Federation.Inbox.RemotePosts.MaxDepth && level.Count > 0 && thread.Count < MaxThread; depth++)
|
||||
{
|
||||
var next = await _dbEntities.Posts.Match(p => level.Contains(p.AnsweringToPostId) && !p.IsLocalOnly && p.ReblogOfPostId == null)
|
||||
.Match(VisibilityPolicy.IsPublic).Sort(p => p.CreationDate, Order.Ascending).Limit(MaxThread - thread.Count).ExecuteAsync(token);
|
||||
thread.AddRange(next);
|
||||
level = next.Select(p => p.ID).ToList();
|
||||
}
|
||||
var collection = ActivityPubRenderer.OrderedCollection(root.ContextURI, thread.Count, thread.Select(p => (JsonNode)p.ObjectURI));
|
||||
collection["attributedTo"] = local.Uri;
|
||||
return Activity(collection);
|
||||
}
|
||||
|
||||
const int MaxThread = 500;
|
||||
|
||||
[HttpGet, Route("{actor}/parrot-licences/{licenceId}")]
|
||||
public async Task<IActionResult> ParrotLicence(string actor, string licenceId, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -162,6 +162,14 @@ namespace PrivaPub.Federation.Rendering
|
||||
cc.Add(post.InReplyToActorURI);
|
||||
|
||||
var note = NoteBody(post, author, to, cc, inReplyTo);
|
||||
// a public thread names where its replies and its whole conversation are read (owner decision 2026-10-06):
|
||||
// Mastodon, GoToSocial and Fedify complete threads from them
|
||||
if (!post.IsFederatedCopy && group == default && !post.IsLocalOnly && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted)
|
||||
{
|
||||
note["replies"] = author.PostUri(post.ID) + "/replies";
|
||||
if (!string.IsNullOrEmpty(post.ContextURI))
|
||||
note["context"] = post.ContextURI;
|
||||
}
|
||||
if (group != default)
|
||||
note["audience"] = group.Uri;
|
||||
else if (!string.IsNullOrEmpty(post.AudienceURI))
|
||||
|
||||
+2
-1
@@ -654,7 +654,8 @@ it, raw where it doesn't.
|
||||
reads at most 5 pages and 100 posts per job, signed by the instance actor, and stores what it lacks through
|
||||
`StoreContext` (public and unlisted only, the author checked against its origin);
|
||||
- group by the root post;
|
||||
- publish our own `context` and a paged `replies`.
|
||||
- publish our own `context` and `replies`: **done 2026-10-06** (owner decision; public and unlisted posts outside groups,
|
||||
public and unlisted replies only; Mastodon finds a thread's replies through them, checked live).
|
||||
- **Lemmy, PieFed and Mbin:**
|
||||
- the moderation set: removals, locks, bans (**done 2026-10-05**, relayed by the community: a lock refuses replies, a
|
||||
ban of a persona refuses its posts there and shows as `blocked_by`), featured, moderators, `Warn`, `Resolve`;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Public threads elsewhere (owner decision 2026-10-06): alice's public post names its `replies` and `context`, so a
|
||||
# Mastodon reader who follows alice, opening the thread, finds carol's reply although nobody on Mastodon follows carol
|
||||
# (Mastodon reads a thread's replies at most every fifteen minutes, five minutes after it first holds the post: the
|
||||
# scenario makes its copy look older). Needs the mastodon peer.
|
||||
M=https://mastodon.test:6443
|
||||
mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; }
|
||||
. "$here/peers/mastodon.sh"
|
||||
m_rails() { podman exec pasture-mastodon bin/rails runner "$1" 2>/dev/null | tail -1; }
|
||||
|
||||
echo "threads"
|
||||
AT=$(privapub_token alice_thread); CT=$(privapub_token carol_thread)
|
||||
[ -n "$AT" ] && [ -n "$CT" ] && ok "PrivaPub tokens for alice_thread and carol_thread" || { ko "PrivaPub tokens"; return 1; }
|
||||
MT=$(mastodon_token)
|
||||
MH="Authorization: Bearer $MT"
|
||||
run=$(date +%s)
|
||||
|
||||
alice_on_m=$(mcurl -H "$MH" "$M/api/v2/search?q=@alice_thread@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 '[ "$(mcurl -H "$MH" "$M/api/v1/accounts/relationships?id[]=$alice_on_m" | j "print(d[0][\"following\"])")" = "True" ]' \
|
||||
&& ok "mastouser follows alice" || ko "mastouser never followed alice"
|
||||
|
||||
root=$(curl -s -X POST -H "Authorization: Bearer $AT" "$P/api/v1/statuses" -d "status=a question for the thread $run&visibility=public")
|
||||
root_uri=$(echo "$root" | j "print(d['uri'])")
|
||||
reply_uri=$(curl -s -X POST -H "Authorization: Bearer $CT" "$P/api/v1/statuses" -d "status=an answer from carol $run&visibility=public&in_reply_to_id=$(echo "$root" | j "print(d['id'])")" | j "print(d['uri'])")
|
||||
until_true 45 '[ "$(m_rails "puts Status.exists?(uri: \"$root_uri\")")" = "true" ]' && ok "alice's post reaches Mastodon" || ko "alice's post never reached Mastodon"
|
||||
[ "$(m_rails "puts Status.exists?(uri: \"$reply_uri\")")" = "false" ] && ok "carol's reply is not delivered there" || ko "carol's reply reached Mastodon unasked"
|
||||
|
||||
m_rails "Status.find_by(uri: \"$root_uri\").update_columns(created_at: 10.minutes.ago, fetched_replies_at: nil)" >/dev/null
|
||||
root_on_m=$(m_rails "puts Status.find_by(uri: \"$root_uri\").id")
|
||||
mcurl -o /dev/null -H "$MH" "$M/api/v1/statuses/$root_on_m/context"
|
||||
until_true 60 '[ "$(m_rails "puts Status.exists?(uri: \"$reply_uri\")")" = "true" ]' \
|
||||
&& ok "opening the thread on Mastodon finds carol's reply through its replies" || ko "Mastodon never found carol's reply"
|
||||
Reference in new issue
Block a user