diff --git a/.github/workflows/build-submit-ios.yml b/.github/workflows/build-submit-ios.yml
index 9b16a2d12c..30d75598cb 100644
--- a/.github/workflows/build-submit-ios.yml
+++ b/.github/workflows/build-submit-ios.yml
@@ -10,20 +10,24 @@ on:
options:
- testflight
- production
- assignTestFlightGroup:
- type: boolean
- description: Assign the build to the "QA Team" TestFlight group after submitting
- default: false
+ testFlightGroup:
+ type: choice
+ description: TestFlight group to assign the build to after submitting
+ options:
+ - none
+ - QA Team
+ - Software Mansion
+ default: none
workflow_call:
inputs:
profile:
type: string
description: Build profile to use
required: true
- assignTestFlightGroup:
- type: boolean
- description: Assign the build to the "QA Team" TestFlight group after submitting
- default: false
+ testFlightGroup:
+ type: string
+ description: TestFlight group to assign the build to after submitting ("none" to skip)
+ default: none
outputs:
package-version:
description: Version from package.json
@@ -212,8 +216,9 @@ jobs:
# TestFlight group. fastlane's distribute_only mode skips the upload and assigns the
# already-submitted build to the group, polling until Apple finishes processing it.
- name: 🧪 Assign build to TestFlight group
- if: ${{ inputs.assignTestFlightGroup }}
+ if: ${{ inputs.testFlightGroup != 'none' }}
env:
+ TESTFLIGHT_GROUP: ${{ inputs.testFlightGroup }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
@@ -241,7 +246,7 @@ jobs:
app_identifier:"xyz.blueskyweb.app" \
app_version:"$APP_VERSION" \
build_number:"$BUILD_NUMBER" \
- groups:"QA Team" \
+ groups:"$TESTFLIGHT_GROUP" \
notify_external_testers:true
- name: 🔔 Notify Slack of Production Build
diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml
index f3ec4ac3d1..8e50a4beff 100644
--- a/.github/workflows/nightly-build.yml
+++ b/.github/workflows/nightly-build.yml
@@ -77,7 +77,7 @@ jobs:
uses: ./.github/workflows/build-submit-ios.yml
with:
profile: testflight
- assignTestFlightGroup: true
+ testFlightGroup: "QA Team"
secrets: inherit
android:
diff --git a/.github/workflows/sync-internal.yaml b/.github/workflows/sync-internal.yaml
index 3b07ae2b90..fd7fb89893 100644
--- a/.github/workflows/sync-internal.yaml
+++ b/.github/workflows/sync-internal.yaml
@@ -17,6 +17,9 @@ jobs:
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
+ # Don't persist the checkout auth header; the push below authenticates
+ # with the app token embedded in the remote URL instead
+ persist-credentials: false
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
@@ -33,6 +36,5 @@ jobs:
run: |
git config user.name "github-actions"
git config user.email "test@users.noreply.github.com"
- git config --unset-all http.https://github.com/.extraheader
git remote add internal https://x-access-token:${TOKEN}@github.com/bluesky-social/social-app-internal.git
git push internal main --force
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)
diff --git a/src/ageAssurance/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx
index ac88e59f7a..44870dcffd 100644
--- a/src/ageAssurance/components/NoAccessScreen.tsx
+++ b/src/ageAssurance/components/NoAccessScreen.tsx
@@ -399,7 +399,7 @@ function AccessSection() {
locationControl.open()
})}>
Tap here to update your location with GPS.
- {' '}
+
diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts
index bca196c706..1526fb426a 100644
--- a/src/analytics/features/types.ts
+++ b/src/analytics/features/types.ts
@@ -10,7 +10,6 @@ export enum Features {
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
LiveNowBetaDisable = 'live_now_beta:disable',
GroupChatsDisable = 'group_chats:disable',
- DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts
index 7fba1b9555..a9d7f7bb17 100644
--- a/src/analytics/metrics/types.ts
+++ b/src/analytics/metrics/types.ts
@@ -1319,7 +1319,7 @@ export type Events = {
'invite:action:scan': {}
// user changed the QR card color theme
'invite:theme:change': {
- themeKey: 'dawn' | 'day' | 'dusk' | 'night'
+ themeKey: 'dawn' | 'sunlight' | 'day' | 'dusk' | 'twilight' | 'night'
}
// QR scanner decoded a code; result indicates whether it resolved to a profile
'invite:scanner:scanned': {
diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx
index 258e2898e4..4a6f453a8a 100644
--- a/src/components/ContextMenu/index.tsx
+++ b/src/components/ContextMenu/index.tsx
@@ -615,6 +615,8 @@ export function Outer({
label?: string
style?: StyleProp
align?: 'left' | 'right'
+ /** Web only. Native restores focus differently. */
+ onCloseAutoFocus?: (event: Event) => void
}) {
const t = useTheme()
const context = useContextMenuContext()
diff --git a/src/components/ContextMenu/index.web.tsx b/src/components/ContextMenu/index.web.tsx
index 9554e7e1ae..3dfc39ffaa 100644
--- a/src/components/ContextMenu/index.web.tsx
+++ b/src/components/ContextMenu/index.web.tsx
@@ -34,14 +34,16 @@ export function Outer({
children,
label,
style,
+ onCloseAutoFocus,
}: {
children: React.ReactNode
label?: string
style?: StyleProp
+ onCloseAutoFocus?: (event: Event) => void
}) {
const t = useTheme()
return (
-
+
{label ? (
void
}>) {
const context = useMenuContext()
- const {_} = useLingui()
+ const {t: l} = useLingui()
return (
+ nativeOptions={{
+ preventExpansion: true,
+ minHeight: IS_IOS ? IOS_MENU_MIN_HEIGHT : undefined,
+ }}>
{/* Re-wrap with context since Dialogs are portal-ed to root */}
-
+
{children}
{IS_NATIVE && showCancel && }
@@ -353,12 +359,12 @@ export function Group({children, style}: GroupProps) {
}
function Cancel() {
- const {_} = useLingui()
+ const {t: l} = useLingui()
const context = useMenuContext()
return (
{
+ setReply(message)
+ }, [setReply, message])
+ // On web, the menu is a Radix dropdown that restores focus to the trigger on
+ // close. When Reply moves focus to the composer, don't let Radix steal it
+ // back. Checking activeElement (rather than tracking reply intent) also
+ // handles re-replying to the same message, where the composer's focus effect
+ // bails on an unchanged reply target and focus should stay on the trigger.
+ const onCloseAutoFocus = useCallback((event: Event) => {
+ if (document.activeElement && document.activeElement !== document.body) {
+ event.preventDefault()
+ }
+ }, [])
+
const isFromSelf = message.sender?.did === currentAccount?.did
const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable)
@@ -161,11 +175,12 @@ export let MessageContextMenu = ({
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
timeStyle: 'short',
})}`}
- style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
+ style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}
+ onCloseAutoFocus={onCloseAutoFocus}>
setReply(message)}>
+ onPress={onReply}>
{l`Reply`}
diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx
index f65330159e..5014a9ff11 100644
--- a/src/components/dms/MessageItemEmbed.tsx
+++ b/src/components/dms/MessageItemEmbed.tsx
@@ -77,9 +77,6 @@ let MessageItemEmbed = ({
minWidth: 280,
maxWidth: 360,
}),
- // Cancel out the embed's internal a.mt_sm so the container's
- // CLUSTERED_MESSAGE_GAP (2px) is the only spacing applied
- {marginTop: -a.mt_sm.marginTop},
]}>
diff --git a/src/components/moderation/BlockDialog.tsx b/src/components/moderation/BlockDialog.tsx
index 540f1603f0..dcca625163 100644
--- a/src/components/moderation/BlockDialog.tsx
+++ b/src/components/moderation/BlockDialog.tsx
@@ -362,14 +362,14 @@ function MutualGroupChat({
diff --git a/src/features/inviteFriends/components/ThemePicker.tsx b/src/features/inviteFriends/components/ThemePicker.tsx
index 179c80d815..064fcf0ab3 100644
--- a/src/features/inviteFriends/components/ThemePicker.tsx
+++ b/src/features/inviteFriends/components/ThemePicker.tsx
@@ -26,8 +26,10 @@ export function ThemePicker({
const labels: Record = {
dawn: l`Dawn`,
+ sunlight: l`Sunlight`,
day: l`Day`,
dusk: l`Dusk`,
+ twilight: l`Twilight`,
night: l`Night`,
}
diff --git a/src/features/inviteFriends/themes.test.ts b/src/features/inviteFriends/themes.test.ts
index 6dbdfb2e9c..607217dc5a 100644
--- a/src/features/inviteFriends/themes.test.ts
+++ b/src/features/inviteFriends/themes.test.ts
@@ -3,8 +3,15 @@ import {describe, expect, it} from '@jest/globals'
import {getInviteTheme, INVITE_THEME_KEYS, INVITE_THEMES} from './themes'
describe('invite themes', () => {
- it('exposes four themes in canonical order', () => {
- expect(INVITE_THEME_KEYS).toEqual(['dawn', 'day', 'dusk', 'night'])
+ it('exposes six themes in canonical order', () => {
+ expect(INVITE_THEME_KEYS).toEqual([
+ 'dawn',
+ 'sunlight',
+ 'day',
+ 'dusk',
+ 'twilight',
+ 'night',
+ ])
})
it('has light and dark variants for every theme', () => {
diff --git a/src/features/inviteFriends/themes.ts b/src/features/inviteFriends/themes.ts
index 6b71217372..7cb64131e1 100644
--- a/src/features/inviteFriends/themes.ts
+++ b/src/features/inviteFriends/themes.ts
@@ -5,7 +5,13 @@
* matching primary color; the QR data uses qrPrimary; the avatar handle text
* follows handleColor.
*/
-export type InviteThemeKey = 'dawn' | 'day' | 'dusk' | 'night'
+export type InviteThemeKey =
+ | 'dawn'
+ | 'sunlight'
+ | 'day'
+ | 'dusk'
+ | 'twilight'
+ | 'night'
export type InviteThemeVariant = {
/** QR data + eye color */
@@ -30,8 +36,10 @@ export type InviteTheme = {
export const INVITE_THEME_KEYS: readonly InviteThemeKey[] = [
'dawn',
+ 'sunlight',
'day',
'dusk',
+ 'twilight',
'night',
] as const
@@ -54,6 +62,24 @@ export const INVITE_THEMES: Record = {
handleColor: '#ffffff',
},
},
+ sunlight: {
+ key: 'sunlight',
+ swatch: '#ff8159',
+ light: {
+ qrPrimary: '#ff8159',
+ gradientFrom: '#ffc785',
+ gradientTo: '#ff8159',
+ shadowColor: '#ff8159',
+ handleColor: '#ffffff',
+ },
+ dark: {
+ qrPrimary: '#ff8159',
+ gradientFrom: '#ffc785',
+ gradientTo: '#ff8159',
+ shadowColor: '#ff8159',
+ handleColor: '#ffffff',
+ },
+ },
day: {
key: 'day',
swatch: '#006aff',
@@ -90,6 +116,24 @@ export const INVITE_THEMES: Record = {
handleColor: '#ffffff',
},
},
+ twilight: {
+ key: 'twilight',
+ swatch: '#8b60f7',
+ light: {
+ qrPrimary: '#8b60f7',
+ gradientFrom: '#8ec1ff',
+ gradientTo: '#8b60f7',
+ shadowColor: '#8b60f7',
+ handleColor: '#ffffff',
+ },
+ dark: {
+ qrPrimary: '#8b60f7',
+ gradientFrom: '#8ec1ff',
+ gradientTo: '#8b60f7',
+ shadowColor: '#8b60f7',
+ handleColor: '#ffffff',
+ },
+ },
night: {
key: 'night',
swatch: '#0048ad',
diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po
index 38b347aa63..78455288dd 100644
--- a/src/locale/locales/en/messages.po
+++ b/src/locale/locales/en/messages.po
@@ -1145,7 +1145,7 @@ msgid "Add image"
msgstr ""
#. Accessibility label for button in composer to add images, a video, or a GIF to a post
-#: src/view/com/composer/SelectMediaButton.tsx:505
+#: src/view/com/composer/SelectMediaButton.tsx:511
msgid "Add media to post"
msgstr ""
@@ -8053,11 +8053,11 @@ msgstr ""
msgid "One or more images is missing alt text."
msgstr ""
-#: src/view/com/composer/SelectMediaButton.tsx:417
+#: src/view/com/composer/SelectMediaButton.tsx:423
msgid "One or more of your selected files are not supported."
msgstr ""
-#: src/view/com/composer/SelectMediaButton.tsx:440
+#: src/view/com/composer/SelectMediaButton.tsx:446
msgid "One or more of your selected files are too large. Maximum size is {VIDEO_MAX_SIZE_MB}Â MB."
msgstr "One or more of your selected files are too large. Maximum size is {VIDEO_MAX_SIZE_MB}Â MB."
@@ -8308,7 +8308,7 @@ msgid "Opens device camera"
msgstr ""
#. Accessibility hint for button in composer to add images, a video, or a GIF to a post.
-#: src/view/com/composer/SelectMediaButton.tsx:511
+#: src/view/com/composer/SelectMediaButton.tsx:517
msgid "Opens device gallery to select up to {MAX_GALLERY_IMAGES, plural, other {# images}}, or a single video or GIF."
msgstr "Opens device gallery to select up to {MAX_GALLERY_IMAGES, plural, other {# images}}, or a single video or GIF."
@@ -10455,7 +10455,7 @@ msgstr ""
msgid "Select your preferred notification channels"
msgstr ""
-#: src/view/com/composer/SelectMediaButton.tsx:420
+#: src/view/com/composer/SelectMediaButton.tsx:426
msgid "Selecting multiple media types is not supported."
msgstr ""
@@ -13092,7 +13092,7 @@ msgstr ""
msgid "Videos"
msgstr ""
-#: src/view/com/composer/SelectMediaButton.tsx:434
+#: src/view/com/composer/SelectMediaButton.tsx:440
msgid "Videos must be less than 3 minutes long."
msgstr ""
@@ -13816,11 +13816,11 @@ msgstr ""
msgid "You can only save drafts up to 1000 characters. Would you like to discard this post before viewing your drafts?"
msgstr ""
-#: src/view/com/composer/SelectMediaButton.tsx:437
+#: src/view/com/composer/SelectMediaButton.tsx:443
msgid "You can only select one GIF at a time."
msgstr ""
-#: src/view/com/composer/SelectMediaButton.tsx:431
+#: src/view/com/composer/SelectMediaButton.tsx:437
msgid "You can only select one video at a time."
msgstr ""
@@ -13833,7 +13833,7 @@ msgid "You can read chat history but can’t send new messages."
msgstr "You can read chat history but can’t send new messages."
#. Error message for maximum number of images that can be selected to add to a post.
-#: src/view/com/composer/SelectMediaButton.tsx:423
+#: src/view/com/composer/SelectMediaButton.tsx:429
msgid "You can select up to {MAX_GALLERY_IMAGES, plural, other {# images}} in total."
msgstr "You can select up to {MAX_GALLERY_IMAGES, plural, other {# images}} in total."
@@ -14028,7 +14028,7 @@ msgstr ""
msgid "You must grant access to your photo library to save a QR code"
msgstr ""
-#: src/view/com/composer/SelectMediaButton.tsx:466
+#: src/view/com/composer/SelectMediaButton.tsx:472
msgid "You need to allow access to your media library."
msgstr ""
diff --git a/src/screens/Messages/ChatList.tsx b/src/screens/Messages/ChatList.tsx
index f7c44ec2d2..53c342a799 100644
--- a/src/screens/Messages/ChatList.tsx
+++ b/src/screens/Messages/ChatList.tsx
@@ -3,14 +3,21 @@ import {View} from 'react-native'
import {useAnimatedRef} from 'react-native-reanimated'
import {type ChatBskyActorGetStatus, type ChatBskyConvoDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
-import {useFocusEffect, useIsFocused} from '@react-navigation/native'
+import {
+ useFocusEffect,
+ useIsFocused,
+ useNavigation,
+} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useAppState} from '#/lib/appState'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
-import {type MessagesTabNavigatorParams} from '#/lib/routes/types'
+import {
+ type MessagesTabNavigatorParams,
+ type NavigationProp,
+} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {listenSoftReset} from '#/state/events'
@@ -19,6 +26,7 @@ import {useMessagesEventBus} from '#/state/messages/events'
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
import {useUnreadCountsQuery} from '#/state/queries/messages/get-unread-counts'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
+import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
import {EmptyState} from '#/view/com/util/EmptyState'
import {List, type ListRef} from '#/view/com/util/List'
import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
@@ -31,6 +39,7 @@ import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import {BubbleSmile_Stroke2_Corner2_Rounded_Large as BubbleSmileIcon} from '#/components/icons/Bubble'
+import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {Inbox_Stroke2_Corner2_Rounded_Large as InboxLargeIcon} from '#/components/icons/Inbox'
import {
@@ -41,6 +50,8 @@ import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/component
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
+import * as Menu from '#/components/Menu'
+import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
import {IS_NATIVE, IS_WEB} from '#/env'
@@ -289,7 +300,10 @@ export function ChatList({
isError,
error,
refetch,
- } = useListConvosQuery({status: 'accepted'})
+ } = useListConvosQuery({
+ status: 'accepted',
+ kind: aa.flags.groupChatDisabled ? 'direct' : 'all',
+ })
const {refetch: refetchInbox} = useListConvosQuery({
status: 'request',
@@ -546,16 +560,19 @@ export function Header({
variant="solid"
action={action}
/>
-
-
-
+
+ {({props}) => (
+
+ )}
+
{!chatStatus?.chatDisabled && (