diff --git a/bskyweb/cmd/bskyweb/jsonld.go b/bskyweb/cmd/bskyweb/jsonld.go
index 4178250493..a5ab849c35 100644
--- a/bskyweb/cmd/bskyweb/jsonld.go
+++ b/bskyweb/cmd/bskyweb/jsonld.go
@@ -65,6 +65,7 @@ type discussionForumPosting struct {
CommentCount *int64 `json:"commentCount,omitempty"`
Comment []comment `json:"comment,omitempty"`
IsBasedOn string `json:"isBasedOn,omitempty"`
+ IsPartOf string `json:"isPartOf,omitempty"`
SharedContent *sharedContent `json:"sharedContent,omitempty"`
}
@@ -344,6 +345,43 @@ func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string {
return ""
}
+// threadRootURI returns the AT-URI of the root post of the thread a reply
+// belongs to, or "" if the post is not a reply or the record is malformed.
+func threadRootURI(pv *appbsky.FeedDefs_PostView) string {
+ if pv == nil || pv.Record == nil {
+ return ""
+ }
+ rec, ok := pv.Record.Val.(*appbsky.FeedPost)
+ if !ok || rec.Reply == nil || rec.Reply.Root == nil {
+ return ""
+ }
+ return rec.Reply.Root.Uri
+}
+
+// findRootPostInParents walks tv's parent chain upward and returns the
+// PostView whose URI matches rootURI, or nil if the root is not present.
+// The root is absent when the chain is truncated by parentHeight (reply
+// deeper than the fetched height) or broken by a blocked/not-found parent.
+// Avoids a separate FeedGetPosts call when the thread response already
+// contains the root.
+func findRootPostInParents(tv *appbsky.FeedDefs_ThreadViewPost, rootURI string) *appbsky.FeedDefs_PostView {
+ if rootURI == "" {
+ return nil
+ }
+ for node := tv; node != nil; {
+ if node.Post != nil && node.Post.Uri == rootURI {
+ return node.Post
+ }
+ if node.Parent == nil {
+ return nil
+ }
+ // Only threadViewPost parents continue the chain; a blocked or
+ // not-found parent breaks it before reaching the root.
+ node = node.Parent.FeedDefs_ThreadViewPost
+ }
+ return nil
+}
+
// buildAuthor constructs a Person. Organization classification for
// custom-domain accounts is a future enhancement.
func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
@@ -584,13 +622,21 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) c
// buildPostJSONLD marshals the WebPage envelope wrapping a
// DiscussionForumPosting. canonicalURL is used for both envelope.url and
-// (as a fallback) mainEntity.url so they always agree.
-func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
+// (as a fallback) mainEntity.url so they always agree. isPartOfURL, when
+// non-empty, is the handle-form canonical URL of the thread root the handler
+// resolved for a reply; pass "" to omit isPartOf (non-reply, or the root could
+// not be resolved). We never emit a DID-form isPartOf because it would not
+// match the root page's handle-form canonical.
+func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, isPartOfURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
if pv == nil || pv.Author == nil {
return "", fmt.Errorf("nil post view or author")
}
node := buildPostNode(pv, replies, hideLabels, hideReplyLabels)
+ if isPartOfURL != "" {
+ node.IsPartOf = isPartOfURL
+ }
+
// mainEntity.url is empty when the author handle is unusable; fall back
// to canonicalURL so it agrees with envelope.url.
if node.URL == "" {
diff --git a/bskyweb/cmd/bskyweb/jsonld_test.go b/bskyweb/cmd/bskyweb/jsonld_test.go
index ee44008030..d3da1a0570 100644
--- a/bskyweb/cmd/bskyweb/jsonld_test.go
+++ b/bskyweb/cmd/bskyweb/jsonld_test.go
@@ -183,6 +183,19 @@ func withQuotePostBlocked() func(*appbsky.FeedDefs_PostView) {
}
}
+// withReplyRoot marks the post as a reply by setting its record's Reply.Root
+// strong-ref to the given thread-root post.
+func withReplyRoot(rootDid, rootRkey string) func(*appbsky.FeedDefs_PostView) {
+ return func(pv *appbsky.FeedDefs_PostView) {
+ rec, _ := pv.Record.Val.(*appbsky.FeedPost)
+ uri := "at://" + rootDid + "/app.bsky.feed.post/" + rootRkey
+ rec.Reply = &appbsky.FeedPost_ReplyRef{
+ Root: &comatprototypes.RepoStrongRef{Uri: uri, Cid: "bafy-root"},
+ Parent: &comatprototypes.RepoStrongRef{Uri: uri, Cid: "bafy-root"},
+ }
+ }
+}
+
// withSelfLabel adds a self-label that should hide embeds.
func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
@@ -263,7 +276,7 @@ func unmarshalLD(t *testing.T, s string) map[string]any {
func TestBuildPostJSONLD_Bare(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
- out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
+ out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -333,7 +346,7 @@ func TestBuildPostJSONLD_WithImages(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
- out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
+ out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -358,7 +371,7 @@ func TestBuildPostJSONLD_WithGallery(t *testing.T) {
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
thumb3 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g3@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2, thumb3))
- out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
+ out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -385,7 +398,7 @@ func TestBuildPostJSONLD_GalleryInRecordWithMedia(t *testing.T) {
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery",
withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb))
- out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -438,7 +451,7 @@ func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) {
func TestBuildPostJSONLD_WithVideo(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
- out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["thumbnailUrl"] != thumb {
t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"])
@@ -451,7 +464,7 @@ func TestBuildPostJSONLD_WithVideo(t *testing.T) {
func TestBuildPostJSONLD_QuotePost(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting!", withQuotePost("bob.example.com", "did:plc:bob", "xyz"))
- out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
t.Errorf("isBasedOn wrong: %v", main["isBasedOn"])
@@ -460,16 +473,122 @@ func TestBuildPostJSONLD_QuotePost(t *testing.T) {
func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting blocked", withQuotePostBlocked())
- out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["isBasedOn"]; present {
t.Errorf("blocked quote should not produce isBasedOn")
}
}
+func TestBuildPostJSONLD_IsPartOf(t *testing.T) {
+ // isPartOf is sourced solely from the handler-resolved URL. When supplied,
+ // it is emitted on the main post; when empty, no isPartOf is present.
+ pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply")
+ isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
+ out, _ := buildPostJSONLD(pv, nil, "u", isPartOf, hideEmbedLabels, hideReplyLabels)
+ main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
+ if main["isPartOf"] != isPartOf {
+ t.Errorf("isPartOf = %v, want %v", main["isPartOf"], isPartOf)
+ }
+
+ out, _ = buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
+ main = unmarshalLD(t, out)["mainEntity"].(map[string]any)
+ if _, present := main["isPartOf"]; present {
+ t.Errorf("empty isPartOfURL should omit isPartOf, got %v", main["isPartOf"])
+ }
+}
+
+func TestThreadRootURI(t *testing.T) {
+ // A reply returns its root AT-URI; a non-reply returns "".
+ reply := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply",
+ withReplyRoot("did:plc:root", "rootrkey"))
+ want := "at://did:plc:root/app.bsky.feed.post/rootrkey"
+ if got := threadRootURI(reply); got != want {
+ t.Errorf("threadRootURI = %q, want %q", got, want)
+ }
+
+ post := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "not a reply")
+ if got := threadRootURI(post); got != "" {
+ t.Errorf("threadRootURI on non-reply = %q, want empty", got)
+ }
+}
+
+func TestFindRootPostInParents(t *testing.T) {
+ rootPost := makePostView("root.bsky.social", "did:plc:root", "rootrkey", "root")
+ rootURI := rootPost.Uri
+
+ // tvp wraps a PostView, optionally chaining to a parent thread node.
+ tvp := func(pv *appbsky.FeedDefs_PostView, parent *appbsky.FeedDefs_ThreadViewPost) *appbsky.FeedDefs_ThreadViewPost {
+ node := &appbsky.FeedDefs_ThreadViewPost{Post: pv}
+ if parent != nil {
+ node.Parent = &appbsky.FeedDefs_ThreadViewPost_Parent{FeedDefs_ThreadViewPost: parent}
+ }
+ return node
+ }
+
+ t.Run("direct reply, parent is root", func(t *testing.T) {
+ leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
+ if got := findRootPostInParents(leaf, rootURI); got != rootPost {
+ t.Errorf("expected root post, got %v", got)
+ }
+ })
+
+ t.Run("multi-level chain", func(t *testing.T) {
+ mid := tvp(makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid"), tvp(rootPost, nil))
+ leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), mid)
+ if got := findRootPostInParents(leaf, rootURI); got != rootPost {
+ t.Errorf("expected root post in chain, got %v", got)
+ }
+ })
+
+ t.Run("root absent, chain truncated", func(t *testing.T) {
+ // Topmost parent is not the root (e.g. parentHeight cut off the chain).
+ topmost := makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid")
+ leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(topmost, nil))
+ if got := findRootPostInParents(leaf, rootURI); got != nil {
+ t.Errorf("expected nil when root absent, got %v", got)
+ }
+ })
+
+ t.Run("chain broken by blocked parent", func(t *testing.T) {
+ // A blocked/not-found parent yields a nil FeedDefs_ThreadViewPost,
+ // breaking the walk before the root.
+ leaf := &appbsky.FeedDefs_ThreadViewPost{
+ Post: makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"),
+ Parent: &appbsky.FeedDefs_ThreadViewPost_Parent{
+ FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: rootURI},
+ },
+ }
+ if got := findRootPostInParents(leaf, rootURI); got != nil {
+ t.Errorf("expected nil when chain broken by blocked parent, got %v", got)
+ }
+ })
+
+ t.Run("empty rootURI", func(t *testing.T) {
+ leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
+ if got := findRootPostInParents(leaf, ""); got != nil {
+ t.Errorf("expected nil for empty rootURI, got %v", got)
+ }
+ })
+}
+
+func TestBuildPostJSONLD_ReplyCommentsNoIsPartOf(t *testing.T) {
+ // Replies surfaced under the main post as comment[] are Comment nodes and
+ // never carry isPartOf, even when the main post has one.
+ pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
+ reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "a reply")
+ isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
+ out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", isPartOf, hideEmbedLabels, hideReplyLabels)
+ main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
+ c := main["comment"].([]any)[0].(map[string]any)
+ if _, present := c["isPartOf"]; present {
+ t.Errorf("comment entries should not carry isPartOf, got %v", c["isPartOf"])
+ }
+}
+
func TestBuildPostJSONLD_ExternalEmbed(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "check this out", withExternalEmbed("https://www.spiegel.de/article", "Title"))
- out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
sc, ok := main["sharedContent"].(map[string]any)
if !ok {
@@ -487,7 +606,7 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
thumb := "https://cdn.bsky.app/img/x@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withImages(thumb), withSelfLabel("porn"))
- out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["image"]; present {
t.Errorf("hidden-embed post should not emit image")
@@ -506,7 +625,7 @@ func TestBuildPostJSONLD_HiddenEmbed_Gallery(t *testing.T) {
thumb := "https://cdn.bsky.app/img/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withGallery(thumb), withSelfLabel("porn"))
- out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["image"]; present {
t.Errorf("hidden-embed gallery post should not emit image")
@@ -520,7 +639,7 @@ func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
// Includes ", \, newline, , and a unicode char.
tricky := "hello \"world\" \\ <\\>\n 🎉"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", tricky)
- out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -556,7 +675,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: "at://x/y/z"},
})
- out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if cc := main["commentCount"].(float64); int64(cc) != 14 {
@@ -598,7 +717,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) {
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello")
fallback := "https://bsky.app/profile/did:plc:alice/post/abc123"
- out, _ := buildPostJSONLD(pv, nil, fallback, hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, nil, fallback, "", hideEmbedLabels, hideReplyLabels)
envelope := unmarshalLD(t, out)
main := envelope["mainEntity"].(map[string]any)
// mainEntity.url falls back to the caller's canonical URL so envelope
@@ -647,7 +766,7 @@ func TestBuildPostJSONLD_EnvelopeURLMatchesMainEntity(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
pv := makePostView(tc.handle, tc.did, tc.rkey, "hi")
- out, _ := buildPostJSONLD(pv, nil, tc.canonical, hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, nil, tc.canonical, "", hideEmbedLabels, hideReplyLabels)
env := unmarshalLD(t, out)
main := env["mainEntity"].(map[string]any)
if env["url"] != tc.canonical {
@@ -664,7 +783,7 @@ func TestBuildPostJSONLD_NilAuthor(t *testing.T) {
// Defensive: don't panic if Author is nil.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
pv.Author = nil
- if _, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels); err == nil {
+ if _, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels); err == nil {
t.Errorf("expected error for nil-author post, got nil")
}
}
@@ -682,7 +801,7 @@ func TestBuildPostJSONLD_NilAuthorReply(t *testing.T) {
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: goodReply}},
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: badReply}},
}
- out, err := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
+ out, err := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -703,7 +822,7 @@ func TestBuildPostJSONLD_CommentMedia(t *testing.T) {
replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply}},
}
- out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
imgs, ok := c["image"].([]any)
@@ -910,7 +1029,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_PostViewLabel(t *testing.T) {
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
withPostLabel("!hide", false))
- out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected only the unlabeled reply to remain, got %v", ids)
@@ -923,7 +1042,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_SelfLabel(t *testing.T) {
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
withSelfLabel("spam"))
- out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected self-labeled reply dropped, got %v", ids)
@@ -939,7 +1058,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_EmbedLabel(t *testing.T) {
// union behavior.
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "concerning reply",
withPostLabel("self-harm", false))
- out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected embed-labeled reply dropped, got %v", ids)
@@ -951,7 +1070,7 @@ func TestBuildPostJSONLD_NegatedHideLabelKept(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "fine reply",
withPostLabel("!hide", true))
- out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != reply.Uri {
t.Errorf("expected negated-label reply to be kept, got %v", ids)
@@ -962,7 +1081,7 @@ func TestBuildPostJSONLD_ReplyAuthorHasIdentifier(t *testing.T) {
// Reply author should also carry a DID identifier.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi")
- out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
auth, ok := c["author"].(map[string]any)
@@ -1103,7 +1222,7 @@ func TestBuildPostJSONLD_AuthorReviewedBy(t *testing.T) {
})
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi",
withVerifications(state))
- out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -1143,7 +1262,7 @@ func TestBuildPostJSONLD_ReplyAuthorNoReviewedBy(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi",
withVerifications(state))
- out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
auth := c["author"].(map[string]any)
@@ -1267,7 +1386,7 @@ func TestBuildPostJSONLD_WithVideoObject(t *testing.T) {
hasAspect: true, width: 16, height: 9,
}))
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
- out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
+ out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -1315,7 +1434,7 @@ func TestBuildPostJSONLD_VideoNameFallback(t *testing.T) {
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8",
}))
- out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
@@ -1332,7 +1451,7 @@ func TestBuildPostJSONLD_VideoNameFallbackHandleInvalid(t *testing.T) {
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8",
}))
- out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any)
if video["name"] != "Video on Bluesky" {
@@ -1347,7 +1466,7 @@ func TestBuildPostJSONLD_VideoDescriptionFallback(t *testing.T) {
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", alt: "scenic clip",
}))
- out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any)
if video["description"] != "scenic clip" {
@@ -1360,7 +1479,7 @@ func TestBuildPostJSONLD_VideoNoAspectRatio(t *testing.T) {
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", alt: "alt",
}))
- out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any)
if _, present := video["width"]; present {
@@ -1377,7 +1496,7 @@ func TestBuildPostJSONLD_VideoMissingPlaylist(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
withVideo(thumb))
- out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present {
t.Errorf("video without playlist should not produce VideoObject")
@@ -1396,7 +1515,7 @@ func TestBuildPostJSONLD_VideoHiddenEmbed(t *testing.T) {
alt: "should be dropped",
}),
withSelfLabel("porn"))
- out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present {
t.Errorf("hidden-embed post should not emit video")
@@ -1411,7 +1530,7 @@ func TestBuildPostJSONLD_VideoInRecordWithMedia(t *testing.T) {
thumbnail: thumb, playlist: playlist, alt: "alt", recordMedia: true,
hasAspect: true, width: 4, height: 3,
}))
- out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
@@ -1438,7 +1557,7 @@ func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
withVideoFull(videoEmbedOpts{
thumbnail: thumb, playlist: playlist, alt: "bob's clip",
}))
- out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
video, ok := c["video"].(map[string]any)
@@ -1461,7 +1580,7 @@ func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
func TestBuildPostJSONLD_NoVideoNoField(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no embed")
- out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present {
t.Errorf("post without video should not include video field")
@@ -1505,7 +1624,7 @@ func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL(t *testing.T) {
playlist: playlist, alt: "scenic clip",
}))
canonical := "https://bsky.app/profile/did:plc:alice/post/abc123"
- out, _ := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
@@ -1528,7 +1647,7 @@ func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL_Reply(t *testing.T) {
withVideoFull(videoEmbedOpts{
playlist: playlist, alt: "bob's clip",
}))
- out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
+ out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
video, ok := c["video"].(map[string]any)
diff --git a/bskyweb/cmd/bskyweb/labels.go b/bskyweb/cmd/bskyweb/labels.go
index 4055d4b9c8..2a1c77e3e0 100644
--- a/bskyweb/cmd/bskyweb/labels.go
+++ b/bskyweb/cmd/bskyweb/labels.go
@@ -20,3 +20,20 @@ func profileRequiresAuth(pv *appbsky.ActorDefs_ProfileViewDetailed) bool {
}
return false
}
+
+// postAuthorRequiresAuth reports whether the post author self-applied the
+// !no-unauthenticated label, read from the author view embedded in a
+// getPostThread response. The appview surfaces the account's profile-record
+// self-labels on the post author (src == author DID), so this mirrors
+// profileRequiresAuth without a separate ActorGetProfile call.
+func postAuthorRequiresAuth(pv *appbsky.FeedDefs_PostView) bool {
+ if pv == nil || pv.Author == nil {
+ return false
+ }
+ for _, label := range pv.Author.Labels {
+ if label.Src == pv.Author.Did && label.Val == "!no-unauthenticated" {
+ return true
+ }
+ }
+ return false
+}
diff --git a/bskyweb/cmd/bskyweb/labels_test.go b/bskyweb/cmd/bskyweb/labels_test.go
index d55e31b207..5d385b7ad2 100644
--- a/bskyweb/cmd/bskyweb/labels_test.go
+++ b/bskyweb/cmd/bskyweb/labels_test.go
@@ -77,3 +77,76 @@ func TestProfileRequiresAuth(t *testing.T) {
})
}
}
+
+func TestPostAuthorRequiresAuth(t *testing.T) {
+ negTrue := true
+
+ authorPV := func(labels []*comatprototypes.LabelDefs_Label) *appbsky.FeedDefs_PostView {
+ return &appbsky.FeedDefs_PostView{
+ Author: &appbsky.ActorDefs_ProfileViewBasic{
+ Did: "did:plc:alice",
+ Handle: "alice.bsky.social",
+ Labels: labels,
+ },
+ }
+ }
+
+ tests := []struct {
+ name string
+ pv *appbsky.FeedDefs_PostView
+ want bool
+ }{
+ {
+ name: "nil post view",
+ pv: nil,
+ want: false,
+ },
+ {
+ name: "nil author",
+ pv: &appbsky.FeedDefs_PostView{},
+ want: false,
+ },
+ {
+ name: "no labels",
+ pv: authorPV(nil),
+ want: false,
+ },
+ {
+ name: "self-applied !no-unauthenticated",
+ pv: authorPV([]*comatprototypes.LabelDefs_Label{
+ {Src: "did:plc:alice", Val: "!no-unauthenticated"},
+ }),
+ want: true,
+ },
+ {
+ name: "label from a different src does not gate",
+ pv: authorPV([]*comatprototypes.LabelDefs_Label{
+ {Src: "did:plc:labeler", Val: "!no-unauthenticated"},
+ }),
+ want: false,
+ },
+ {
+ name: "different label value does not gate",
+ pv: authorPV([]*comatprototypes.LabelDefs_Label{
+ {Src: "did:plc:alice", Val: "spam"},
+ }),
+ want: false,
+ },
+ {
+ // Negation isn't honored - matches profileRequiresAuth behavior.
+ name: "negated label still triggers (matches profile behavior)",
+ pv: authorPV([]*comatprototypes.LabelDefs_Label{
+ {Src: "did:plc:alice", Val: "!no-unauthenticated", Neg: &negTrue},
+ }),
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := postAuthorRequiresAuth(tt.pv); got != tt.want {
+ t.Errorf("got %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/bskyweb/cmd/bskyweb/render_test.go b/bskyweb/cmd/bskyweb/render_test.go
index 902476709f..c4ba22090a 100644
--- a/bskyweb/cmd/bskyweb/render_test.go
+++ b/bskyweb/cmd/bskyweb/render_test.go
@@ -52,7 +52,7 @@ func TestRenderBase_NoindexMeta(t *testing.T) {
func TestRenderPost_EmitsJSONLD(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
- ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
+ ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -81,7 +81,7 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
- ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
+ ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
@@ -113,7 +113,7 @@ func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
thumbs := extractPostMedia(pv, false)
- ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
+ ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
@@ -142,7 +142,7 @@ func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
- ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo",
@@ -208,7 +208,7 @@ func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) {
// og:url and must emit the same URL.
func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
- ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
@@ -233,7 +233,7 @@ func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
// video without a thumbnail dropped og:video entirely.
func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch")
- ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
videoURL := "https://video.bsky.app/v.m3u8"
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
@@ -286,7 +286,7 @@ func TestRenderProfile_AuthRequiredNoindex(t *testing.T) {
// flip of the noindex flag for indexable pages.
func TestRenderPost_PublicNoNoindex(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
- ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
+ ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 6f6a5c0804..7373dc5cdc 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -620,23 +620,39 @@ func (srv *Server) WebPost(c echo.Context) error {
identifier := handleOrDID.Normalize().String()
- // requires two fetches: first fetch profile (!)
- pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, identifier)
- if err != nil {
- log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
- return c.Render(http.StatusOK, "post.html", data)
- }
- unauthedViewingOkay := !profileRequiresAuth(pv)
-
req := c.Request()
requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
+ // Fetch the post thread directly. The AT-URI authority accepts either a
+ // handle or a DID (the appview resolves it), so we skip the separate
+ // ActorGetProfile call and source identity, the canonical URL, and the
+ // auth gate from the thread response's author view instead.
+ // parentHeight=80 (the lexicon default) pulls the reply's ancestor chain
+ // up to the root in nearly all threads, letting isPartOf resolve from this
+ // response without a separate FeedGetPosts call.
+ uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", identifier, rkey)
+ tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 80, uri)
+ if err != nil {
+ log.Warnf("failed to fetch post: %s\t%v", uri, err)
+ return c.Render(http.StatusOK, "post.html", data)
+ }
+
+ threadView := tpv.Thread.FeedDefs_ThreadViewPost
+ if threadView == nil || threadView.Post == nil || threadView.Post.Author == nil {
+ return c.Render(http.StatusOK, "post.html", data)
+ }
+ postView := threadView.Post
+
// Always prefer the handle-form URL so JSON-LD `url` and
// match. Falls back to requestURI when the
// handle is unusable (template strips query/fragment).
- canonicalURL := bskyPostURL(pv.Handle, rkey.String())
+ canonicalURL := bskyPostURL(postView.Author.Handle, rkey.String())
- if !unauthedViewingOkay {
+ // Gate before populating any post content into the template so that
+ // !no-unauthenticated posts never leak text/media. The appview returns the
+ // post (with the author self-label) to unauthed callers, so we detect the
+ // label here rather than via a profile fetch.
+ if postAuthorRequiresAuth(postView) {
// Provide minimal OpenGraph data for auth-required posts
data["requestURI"] = requestURI
if canonicalURL != "" {
@@ -645,26 +661,13 @@ func (srv *Server) WebPost(c echo.Context) error {
data["requiresAuth"] = true
data["noindex"] = true
data["nofollow"] = true
- data["profileHandle"] = pv.Handle
- if pv.DisplayName != nil {
- data["profileDisplayName"] = *pv.DisplayName
+ data["profileHandle"] = postView.Author.Handle
+ if postView.Author.DisplayName != nil {
+ data["profileDisplayName"] = *postView.Author.DisplayName
}
return c.Render(http.StatusOK, "post.html", data)
}
- // then fetch the post thread (with extra context)
- uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", pv.Did, rkey)
- tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 0, uri)
- if err != nil {
- log.Warnf("failed to fetch post: %s\t%v", uri, err)
- return c.Render(http.StatusOK, "post.html", data)
- }
-
- threadView := tpv.Thread.FeedDefs_ThreadViewPost
- if threadView == nil || threadView.Post == nil {
- return c.Render(http.StatusOK, "post.html", data)
- }
- postView := threadView.Post
data["postView"] = postView
data["requestURI"] = requestURI
if canonicalURL != "" {
@@ -694,7 +697,32 @@ func (srv *Server) WebPost(c echo.Context) error {
if jsonldURL == "" {
jsonldURL = requestURI
}
- if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, hideEmbedLabels, hideReplyLabels); err == nil {
+
+ // Best-effort: resolve a reply's thread root to its handle-form canonical
+ // URL for isPartOf. Prefer the root already present in the thread response
+ // (parentHeight=80); fall back to a bounded FeedGetPosts only when the
+ // chain is truncated (very deep thread) or broken by a blocked/not-found
+ // ancestor. On timeout, error, or an unresolvable root we omit isPartOf
+ // rather than point at a non-indexable page.
+ isPartOfURL := ""
+ if rootURI := threadRootURI(postView); rootURI != "" {
+ if rootPost := findRootPostInParents(threadView, rootURI); rootPost != nil && rootPost.Author != nil {
+ isPartOfURL = bskyPostURLFromATURI(rootPost.Author.Handle, rootURI)
+ } else {
+ pctx, cancel := context.WithTimeout(ctx, 1*time.Second)
+ if posts, perr := appbsky.FeedGetPosts(pctx, srv.xrpcc, []string{rootURI}); perr != nil {
+ log.Warnf("failed to resolve thread root post for isPartOf: %s\t%v", rootURI, perr)
+ } else if len(posts.Posts) > 0 && posts.Posts[0].Author != nil {
+ // Handle-form only (no DID fallback): isPartOf must match the
+ // root page's handle-form canonical, so an unusable handle omits
+ // isPartOf rather than point at a non-canonical DID-form URL.
+ isPartOfURL = bskyPostURLFromATURI(posts.Posts[0].Author.Handle, rootURI)
+ }
+ cancel()
+ }
+ }
+
+ if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, isPartOfURL, hideEmbedLabels, hideReplyLabels); err == nil {
data["postJSONLD"] = jsonld
} else {
log.Warnf("failed to build post JSON-LD for %s: %v", uri, err)