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 ( 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 && ( + )} + )} ) } + +function ChatSettingsMenu({ + action, + children, +}: { + action: 'navigate' | 'push' + children: React.ComponentProps['children'] +}) { + const {t: l} = useLingui() + const navigation = useNavigation() + + const {mutate: markAllChatsRead} = useUpdateAllRead('accepted', { + onMutate: () => { + Toast.show(l`Marked all chats as read`, {type: 'success'}) + }, + onError: () => { + Toast.show(l`Failed to mark all chats as read`, {type: 'error'}) + }, + }) + + return ( + + {children} + + + markAllChatsRead()}> + + + Mark all chats as read + + + { + if (action === 'navigate') { + navigation.navigate('MessagesSettings') + } else { + navigation.push('MessagesSettings') + } + }}> + + + Chat settings + + + + + + ) +} diff --git a/src/screens/Messages/JoinRequests.tsx b/src/screens/Messages/JoinRequests.tsx index ecb40ef9c3..ffe3fb219e 100644 --- a/src/screens/Messages/JoinRequests.tsx +++ b/src/screens/Messages/JoinRequests.tsx @@ -212,7 +212,7 @@ function JoinRequestsList({ useJoinRequestMutation('reject', convoId, { onSuccess: () => { ax.metric('groupchat:owner:joinRequest:reject', {convoId}) - Toast.show(l`Request ignored.`) + Toast.show(l`Request rejected.`) if (getRemainingRequestCount() < 1) { navigation.replace('MessagesConversationSettings', { conversation: convoId, @@ -220,7 +220,7 @@ function JoinRequestsList({ } }, onError: error => { - let errorMessage = l`Failed to ignore join request` + let errorMessage = l`Failed to reject join request` if (isNetworkError(error)) { errorMessage = l`A network error occurred. Please check your internet connection.` } else if ( @@ -230,7 +230,7 @@ function JoinRequestsList({ } else if ( error instanceof ChatBskyGroupRejectJoinRequest.InsufficientRoleError ) { - errorMessage = l`Only admins can ignore join requests.` + errorMessage = l`Only admins can reject join requests.` } Toast.show(errorMessage, {type: 'error'}) }, diff --git a/src/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx index 6cdc756bb3..dd2197b247 100644 --- a/src/screens/Messages/components/MessageComposer.tsx +++ b/src/screens/Messages/components/MessageComposer.tsx @@ -77,6 +77,15 @@ export function MessageComposer({ composerInternalApiRef.current?.input?.focus() }, [replyTo, composerInternalApiRef]) + // On web, focus the input once the conversation is ready. The composer also + // mounts during the loading state (when it isn't editable), so a mount-time + // autoFocus would fire too early to land focus. + useEffect(() => { + if (IS_WEB && editable) { + composerInternalApiRef.current?.input?.focus() + } + }, [editable, composerInternalApiRef]) + // Android interactive dismiss sometimes doesn't blur the input const blur = useNonReactiveCallback(() => { composerInternalApiRef.current?.input?.blur() @@ -243,13 +252,12 @@ export function MessageComposer({ placeholder={ loading ? l({message: 'Loading chat…', context: 'placeholder'}) - : l({message: 'Message', context: 'action'}) + : l({message: 'Message', context: 'description'}) } autocompletePlacement="top-start" internalApiRef={composerInternalApiRef} defaultValue={text} editable={editable} - autoFocus={IS_WEB} maxRows={12} outerStyle={[a.flex_1]} contentTextStyle={[a.text_md, a.leading_snug]} @@ -334,8 +342,7 @@ function SubmitButton({ ) } -// TODO: remove export when MessageInput is deleted -export function ComposerContainer({children}: {children: React.ReactNode}) { +function ComposerContainer({children}: {children: React.ReactNode}) { const {bottom: bottomInset} = useSafeAreaInsets() const {progress} = useReanimatedKeyboardAnimation() const t = useTheme() diff --git a/src/screens/Messages/components/MessageInput.tsx b/src/screens/Messages/components/MessageInput.tsx deleted file mode 100644 index 441fd75fa4..0000000000 --- a/src/screens/Messages/components/MessageInput.tsx +++ /dev/null @@ -1,288 +0,0 @@ -import {useCallback, useState} from 'react' -import {Pressable, TextInput, useWindowDimensions} from 'react-native' -import { - useFocusedInputHandler, - useKeyboardHandler, - useReanimatedKeyboardAnimation, -} from 'react-native-keyboard-controller' -import Animated, { - measure, - runOnJS, - useAnimatedProps, - useAnimatedRef, - useAnimatedStyle, - useSharedValue, -} from 'react-native-reanimated' -import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {GlassContainer} from 'expo-glass-effect' -import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api' -import {useLingui} from '@lingui/react/macro' -import {countGraphemes} from 'unicode-segmenter/grapheme' - -import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' -import {useHaptics} from '#/lib/haptics' -import {useEmail} from '#/state/email-verification' -import { - useMessageDraft, - useSaveMessageDraft, -} from '#/state/messages/message-drafts' -import {atoms as a, platform, tokens, useTheme} from '#/alf' -import {useMessageReplies} from '#/components/dms/MessageReplies' -import {GlassView} from '#/components/GlassView' -import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' -import {Loader} from '#/components/Loader' -import * as Toast from '#/components/Toast' -import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env' -import {ComposerContainer} from './MessageComposer' -import { - type MessageEmbedState, - useExtractEmbedFromFacets, -} from './MessageInputEmbed' - -const AnimatedTextInput = Animated.createAnimatedComponent(TextInput) - -const MIN_HEIGHT = 40 - -export function MessageInput({ - textInputId, - onSendMessage, - messageEmbed, - setEmbed, - children, - loading = false, -}: { - textInputId?: string - onSendMessage: ( - message: string, - embed?: MessageEmbedState, - replyTo?: $Typed, - ) => Promise | void - messageEmbed: MessageEmbedState | undefined - setEmbed: (embedUrl: string | undefined) => void - children?: React.ReactNode - loading?: boolean -}) { - const {t: l} = useLingui() - const t = useTheme() - const playHaptic = useHaptics() - const {getDraft, clearDraft} = useMessageDraft() - const {replyTo, clearReply} = useMessageReplies() - - // Input layout - const {top: topInset} = useSafeAreaInsets() - const {height: windowHeight} = useWindowDimensions() - const {height: keyboardHeight} = useReanimatedKeyboardAnimation() - const maxHeight = useSharedValue(undefined) - const isInputScrollable = useSharedValue(false) - - const [message, setMessage] = useState(getDraft) - const inputRef = useAnimatedRef() - const [shouldEnforceClear, setShouldEnforceClear] = useState(false) - - const {needsEmailVerification} = useEmail() - const editable = !needsEmailVerification && !loading - - useSaveMessageDraft(message) - useExtractEmbedFromFacets(message, setEmbed) - - const onSubmit = useCallback(() => { - if (!editable) { - return - } - if (!messageEmbed && message.trim() === '') { - return - } - if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { - Toast.show(l`Message is too long`, { - type: 'error', - }) - return - } - clearDraft() - playHaptic() - // Capture the embed before clearing - the deferred send below reads it. - const embed = messageEmbed - setEmbed(undefined) - setMessage('') - // Capture the reply before clearing - the deferred send below reads it. - const reply = replyTo - clearReply() - if (IS_IOS) { - setShouldEnforceClear(true) - } - if (IS_WEB) { - // Pressing the send button causes the text input to lose focus, so we need to - // re-focus it after sending - setTimeout(() => { - inputRef.current?.focus() - }, 100) - } - - requestAnimationFrame(() => { - void onSendMessage( - message, - embed, - reply - ? {...reply, $type: 'chat.bsky.convo.defs#messageView'} - : undefined, - ) - }) - }, [ - editable, - messageEmbed, - message, - clearDraft, - onSendMessage, - playHaptic, - setEmbed, - inputRef, - l, - replyTo, - clearReply, - ]) - - useFocusedInputHandler( - { - onChangeText: () => { - 'worklet' - const measurement = measure(inputRef) - if (!measurement) return - - const max = windowHeight - -keyboardHeight.get() - topInset - 150 - const availableSpace = max - measurement.height - - maxHeight.set(max) - isInputScrollable.set(availableSpace < 30) - }, - }, - [windowHeight, topInset], - ) - - const animatedStyle = useAnimatedStyle(() => ({ - maxHeight: maxHeight.get(), - })) - - const animatedProps = useAnimatedProps(() => ({ - scrollEnabled: isInputScrollable.get(), - })) - - const submitDisabled = - !editable || (!messageEmbed && message.trim().length === 0) - - const blur = useCallback(() => { - inputRef.current?.blur() - }, [inputRef]) - - useKeyboardHandler({ - onEnd: evt => { - 'worklet' - // small hack: interactive dismiss on Android sometimes doesn't blur the input - if (IS_ANDROID && evt.progress === 0) { - runOnJS(blur)() - } - }, - }) - - return ( - - - - {children} - { - // bit of a hack: iOS automatically accepts autocomplete suggestions when you tap anywhere on the screen - // including the button we just pressed - and this overrides clearing the input! so we watch for the - // next change and double make sure the input is cleared. It should *always* send an onChange event after - // clearing via setMessage('') that happens in onSubmit() - // -sfn - if (IS_IOS && shouldEnforceClear) { - setShouldEnforceClear(false) - setMessage('') - return - } - const text = evt.nativeEvent.text - setMessage(text) - }} - multiline={true} - style={[ - {flexBasis: 'auto', minHeight: MIN_HEIGHT}, - a.flex_shrink_0, - a.flex_grow, - a.text_md, - a.px_lg, - t.atoms.text, - platform({ - android: {paddingTop: 2, paddingBottom: 3}, - ios: {paddingTop: 10, paddingBottom: 5}, - }), - animatedStyle, - ]} - verticalAlign="middle" - keyboardAppearance={t.scheme} - submitBehavior="newline" - ref={inputRef} - hitSlop={HITSLOP_10} - animatedProps={animatedProps} - editable={editable} - /> - - - - {loading ? ( - - ) : ( - - )} - - - - - ) -} diff --git a/src/screens/Messages/components/MessageInput.web.tsx b/src/screens/Messages/components/MessageInput.web.tsx deleted file mode 100644 index 6142528f85..0000000000 --- a/src/screens/Messages/components/MessageInput.web.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import {useCallback, useRef, useState} from 'react' -import {Pressable, View} from 'react-native' -import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api' -import {useLingui} from '@lingui/react/macro' -import {flushSync} from 'react-dom' -import TextareaAutosize from 'react-textarea-autosize' -import {countGraphemes} from 'unicode-segmenter/grapheme' - -import {MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import { - useMessageDraft, - useSaveMessageDraft, -} from '#/state/messages/message-drafts' -import {atoms as a, flatten, useTheme} from '#/alf' -import {Button} from '#/components/Button' -import {useMessageReplies} from '#/components/dms/MessageReplies' -import * as EmojiPicker from '#/components/EmojiPicker' -import {useSharedInputStyles} from '#/components/forms/TextField' -import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' -import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' -import * as Toast from '#/components/Toast' -import {IS_WEB_SAFARI, IS_WEB_TOUCH_DEVICE} from '#/env' -import { - type MessageEmbedState, - useExtractEmbedFromFacets, -} from './MessageInputEmbed' - -export function MessageInput({ - onSendMessage, - messageEmbed, - setEmbed, - children, - loading = false, -}: { - onSendMessage: ( - message: string, - embed?: MessageEmbedState, - replyTo?: $Typed, - ) => void - messageEmbed: MessageEmbedState | undefined - setEmbed: (embedUrl: string | undefined) => void - children?: React.ReactNode - loading?: boolean -}) { - const {isMobile} = useWebMediaQueries() - const {t: l} = useLingui() - const t = useTheme() - const {getDraft, clearDraft} = useMessageDraft() - const {replyTo, clearReply} = useMessageReplies() - const [message, setMessage] = useState(getDraft) - - const inputStyles = useSharedInputStyles() - const isComposing = useRef(false) - const [isFocused, setIsFocused] = useState(false) - const [isHovered, setIsHovered] = useState(false) - const [textAreaHeight, setTextAreaHeight] = useState(38) - const textAreaRef = useRef(null) - - const onSubmit = useCallback(() => { - if (!messageEmbed && message.trim() === '') { - return - } - if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { - Toast.show(l`Message is too long`, { - type: 'error', - }) - return - } - clearDraft() - onSendMessage( - message, - messageEmbed, - replyTo - ? {...replyTo, $type: 'chat.bsky.convo.defs#messageView'} - : undefined, - ) - clearReply() - setMessage('') - setEmbed(undefined) - }, [ - message, - onSendMessage, - l, - clearDraft, - messageEmbed, - setEmbed, - replyTo, - clearReply, - ]) - - const onKeyDown = useCallback( - (e: React.KeyboardEvent) => { - // Don't submit the form when the Japanese or any other IME is composing - if (isComposing.current) return - - // see https://github.com/bluesky-social/social-app/issues/4178 - // see https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/ - // see https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html - // - // On Safari, the final keydown event to dismiss the IME - which is the enter key - is also "Enter" below. - // Obviously, this causes problems because the final dismissal should _not_ submit the text, but should just - // stop the IME editing. This is the behavior of Chrome and Firefox, but not Safari. - // - // Keycode is deprecated, however the alternative seems to only be to compare the timestamp from the - // onCompositionEnd event to the timestamp of the keydown event, which is not reliable. For example, this hack - // uses that method: https://github.com/ProseMirror/prosemirror-view/pull/44. However, from my 500ms resulted in - // far too long of a delay, and a subsequent enter press would often just end up doing nothing. A shorter time - // frame was also not great, since it was too short to be reliable (i.e. an older system might have a larger - // time gap between the two events firing. - if (IS_WEB_SAFARI && e.key === 'Enter' && e.keyCode === 229) { - return - } - - if (e.key === 'Enter') { - if (e.shiftKey) return - e.preventDefault() - onSubmit() - } - }, - [onSubmit], - ) - - const onChange = useCallback((e: React.ChangeEvent) => { - setMessage(e.target.value) - }, []) - - const onEmojiInserted = useCallback( - (emoji: EmojiPicker.Emoji) => { - if (!textAreaRef.current) { - return - } - const position = textAreaRef.current.selectionStart ?? 0 - flushSync(() => { - setMessage( - message => - message.slice(0, position) + emoji.native + message.slice(position), - ) - }) - textAreaRef.current.selectionStart = position + emoji.native.length - textAreaRef.current.selectionEnd = position + emoji.native.length - }, - [setMessage], - ) - - useSaveMessageDraft(message) - useExtractEmbedFromFacets(message, setEmbed) - - return ( - - {children} - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)}> - {loading ? null : ( - - - {({props, state}) => ( - - )} - - - - )} - setIsFocused(true)} - onBlur={() => setIsFocused(false)} - onCompositionStart={() => { - isComposing.current = true - }} - onCompositionEnd={() => { - isComposing.current = false - }} - onHeightChange={height => setTextAreaHeight(height)} - onChange={onChange} - // On mobile web phones, we want to keep the same behavior as the native app. Do not submit the message - // in these cases. - onKeyDown={IS_WEB_TOUCH_DEVICE && isMobile ? undefined : onKeyDown} - /> - - - - - - ) -} diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index 8fbfa88e04..d549ffb904 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -57,7 +57,6 @@ import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util' import {useAgent, useSession} from '#/state/session' import {List, type ListMethods} from '#/view/com/util/List' import {MessageComposer} from '#/screens/Messages/components/MessageComposer' -import {MessageInput} from '#/screens/Messages/components/MessageInput' import {MessageListError} from '#/screens/Messages/components/MessageListError' import {atoms as a, platform, tokens, useTheme, web} from '#/alf' import {DateDivider} from '#/components/dms/DateDivider' @@ -704,9 +703,6 @@ export function MessagesList({ messageEmbed={messageEmbed} setEmbed={setEmbed} loading={loading} - useNewComposer={ax.features.enabled( - ax.features.DmsNewMessageComposerEnable, - )} /> )} @@ -735,7 +731,6 @@ function Composer({ messageEmbed, setEmbed, loading, - useNewComposer, }: { textInputId: string onSendMessage: ( @@ -746,7 +741,6 @@ function Composer({ messageEmbed: MessageEmbedState | undefined setEmbed: (embedUrl: string | undefined) => void loading?: boolean - useNewComposer: boolean }) { const handleSendMessage = useNonReactiveCallback( ( @@ -758,31 +752,16 @@ function Composer({ }, ) - const previews = ( - <> - - - - ) - - return useNewComposer ? ( + return ( - {previews} + + - ) : ( - - {previews} - ) } diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx index 565bf2edc4..5ce90205c9 100644 --- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx +++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx @@ -292,7 +292,10 @@ export function HeaderLabelerButtons({ testID="profileHeaderEditProfileButton" size="small" color="secondary" - onPress={editProfileControl.open} + onPress={() => { + playHaptic('Light') + editProfileControl.open() + }} label={_(msg`Edit profile`)} style={a.rounded_full}> diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx index d98b6b4fea..236217a5f0 100644 --- a/src/screens/Signup/index.tsx +++ b/src/screens/Signup/index.tsx @@ -3,6 +3,7 @@ import {AppState, type AppStateStatus, View} from 'react-native' import ReactNativeDeviceAttest from 'react-native-device-attest' import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated' import {AppBskyGraphStarterpack} from '@atproto/api' +import {tokens} from '@bsky.app/alf' import {Trans, useLingui} from '@lingui/react/macro' import {FEEDBACK_FORM_URL} from '#/lib/constants' @@ -222,20 +223,27 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { a.align_center, ]}> - - Having trouble?{' '} - - Contact support - - + + + Having trouble?{' '} + + Contact support + + + diff --git a/src/state/messages/convo/index.tsx b/src/state/messages/convo/index.tsx index a78ea1a038..69ad1e237c 100644 --- a/src/state/messages/convo/index.tsx +++ b/src/state/messages/convo/index.tsx @@ -132,6 +132,12 @@ export function ConvoProvider({ useEffect(() => { const [root, id] = getConvoKey(convoId) return queryClient.getQueryCache().subscribe(event => { + // Only react to data updates. Other event types (e.g. `added`) can be + // emitted synchronously while another component reads this same query + // during its render (React Query builds the query in `getOptimisticResult`), + // and committing to the convo store then would set state on this provider + // mid-render of that component. + if (event.type !== 'updated') return const queryKey = event.query.queryKey as string[] if (queryKey[0] === root && queryKey[1] === id) { const data = event.query.state.data as diff --git a/src/state/queries/messages/conversation.ts b/src/state/queries/messages/conversation.ts index 2ce6c06e51..1b7699c0ef 100644 --- a/src/state/queries/messages/conversation.ts +++ b/src/state/queries/messages/conversation.ts @@ -2,6 +2,7 @@ import { type ChatBskyActorDefs, type ChatBskyConvoDefs, type ChatBskyConvoGetConvo, + type ChatBskyConvoGetUnreadCounts, } from '@atproto/api' import { type QueryClient, @@ -14,6 +15,11 @@ import {DM_SERVICE_HEADERS} from '#/lib/constants' import {STALE} from '#/state/queries' import {useOnMarkAsRead} from '#/state/queries/messages/list-conversations' import {useAgent} from '#/state/session' +import { + RQKEY_PARTIAL as UNREAD_COUNTS_PARTIAL_KEY, + UNREAD_ACCEPTED_CAP, + UNREAD_REQUEST_CAP, +} from './get-unread-counts' import { type ConvoListQueryData, getConvoFromQueryData, @@ -74,11 +80,81 @@ export function useMarkAsReadMutation() { }, onMutate({convoId}) { if (!convoId) throw new Error('No convoId provided') + + // snapshot the list caches before the optimistic update so onError can + // restore the convo rows alongside the badge count + const prevListQueries = queryClient.getQueriesData({ + queryKey: [LIST_CONVOS_KEY], + }) + + // find the convo so we know which badge counter (if any) to decrement. + // keep scanning past a stale unreadCount === 0 cache so another cache + // holding the true unread state still drives the decrement + let unreadStatus: ChatBskyConvoDefs.ConvoView['status'] | undefined + for (const [, data] of prevListQueries) { + if (!data) continue + const convo = getConvoFromQueryData(convoId, data) + if (convo?.unreadCount) { + unreadStatus = convo.status + break + } + } + optimisticUpdate(convoId) + + // the badge count query is a separate server query that the list caches + // don't feed, so decrement it here to keep the badge in sync + const prevUnreadCountsQueries = + queryClient.getQueriesData({ + queryKey: UNREAD_COUNTS_PARTIAL_KEY, + }) + if (unreadStatus) { + queryClient.setQueriesData( + {queryKey: UNREAD_COUNTS_PARTIAL_KEY}, + old => { + if (!old) return old + return { + ...old, + ...(unreadStatus === 'request' + ? { + unreadRequestConvos: + old.unreadRequestConvos >= UNREAD_REQUEST_CAP + ? old.unreadRequestConvos + : Math.max(0, old.unreadRequestConvos - 1), + } + : { + unreadAcceptedConvos: + old.unreadAcceptedConvos >= UNREAD_ACCEPTED_CAP + ? old.unreadAcceptedConvos + : Math.max(0, old.unreadAcceptedConvos - 1), + }), + } + }, + ) + } + return {prevListQueries, prevUnreadCountsQueries} + }, + onError(_, __, context) { + if (context?.prevListQueries) { + for (const [queryKey, prevData] of context.prevListQueries) { + queryClient.setQueryData(queryKey, prevData) + } + } + if (context?.prevUnreadCountsQueries) { + for (const [queryKey, prevData] of context.prevUnreadCountsQueries) { + queryClient.setQueryData(queryKey, prevData) + } + } }, onSuccess(_, {convoId}) { if (!convoId) return + // the optimistic badge arithmetic can drift from the server (e.g. a convo + // whose status differs between caches, or a sentinel-capped count). invalidate + // so the 15s-stale count query self-corrects on next access rather than + // waiting for a log event + void queryClient.invalidateQueries({queryKey: UNREAD_COUNTS_PARTIAL_KEY}) + queryClient.setQueriesData( {queryKey: [LIST_CONVOS_KEY]}, (old?: ConvoListQueryData) => { diff --git a/src/state/queries/messages/get-unread-counts.ts b/src/state/queries/messages/get-unread-counts.ts index 1073d569e5..57b565fa17 100644 --- a/src/state/queries/messages/get-unread-counts.ts +++ b/src/state/queries/messages/get-unread-counts.ts @@ -10,6 +10,14 @@ export const RQKEY = (includeGroupChats: boolean) => [RQKEY_ROOT, includeGroupChats] as const export const RQKEY_PARTIAL = [RQKEY_ROOT] as const +// the server sentinel-caps the badge counts: unreadAcceptedConvos maxes at 31 +// (meaning "more than 30") and unreadRequestConvos at 11 (meaning "more than +// 10"). at the cap the value is no longer an exact count, so consumers must not +// treat it as one - both the optimistic decrement and the badge display ceiling +// key off these. +export const UNREAD_ACCEPTED_CAP = 31 +export const UNREAD_REQUEST_CAP = 11 + export function useUnreadCountsQuery() { const agent = useAgent() const {hasSession} = useSession() diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx index 6a326ca14e..3a3183bad1 100644 --- a/src/state/queries/messages/list-conversations.tsx +++ b/src/state/queries/messages/list-conversations.tsx @@ -23,6 +23,7 @@ import * as bsky from '#/types/bsky' import {RQKEY as CONVO_KEY} from './conversation' import { RQKEY_PARTIAL as UNREAD_COUNTS_RQKEY_PARTIAL, + UNREAD_ACCEPTED_CAP, useUnreadCountsQuery, } from './get-unread-counts' import { @@ -858,7 +859,15 @@ export function useUnreadMessageCount(): { const total = accepted + Math.min(request, 1) return { count: total, - numUnread: total > 10 ? '10+' : String(total), + // accepted is sentinel-capped at UNREAD_ACCEPTED_CAP (meaning "more than + // cap - 1"). show the "+" overflow label only when accepted is actually + // capped - the +1 request nudge must not trip it at exactly cap - 1 + // accepted convos. otherwise clamp the number to cap - 1 so the nudge + // never surfaces the sentinel value (31) itself + numUnread: + accepted >= UNREAD_ACCEPTED_CAP + ? `${UNREAD_ACCEPTED_CAP - 1}+` + : String(Math.min(total, UNREAD_ACCEPTED_CAP - 1)), // only needed when numUnread is undefined hasNew: false, } diff --git a/src/state/queries/messages/update-all-read.ts b/src/state/queries/messages/update-all-read.ts index a6a7f45741..6fbffc58cd 100644 --- a/src/state/queries/messages/update-all-read.ts +++ b/src/state/queries/messages/update-all-read.ts @@ -1,8 +1,10 @@ +import {type ChatBskyConvoGetUnreadCounts} from '@atproto/api' import {useMutation, useQueryClient} from '@tanstack/react-query' import {DM_SERVICE_HEADERS} from '#/lib/constants' import {logger} from '#/logger' import {useAgent} from '#/state/session' +import {RQKEY_PARTIAL as UNREAD_COUNTS_PARTIAL_KEY} from './get-unread-counts' import { type ConvoRequestListQueryData, markAllRead as markAllRequestsRead, @@ -94,10 +96,36 @@ export function useUpdateAllRead( markAllRequestsRead, ) } + // zero out the badge count query that actually drives the unread badge, + // since it's a separate server query that the list caches don't feed + const prevUnreadCountsQueries = + queryClient.getQueriesData({ + queryKey: UNREAD_COUNTS_PARTIAL_KEY, + }) + queryClient.setQueriesData( + {queryKey: UNREAD_COUNTS_PARTIAL_KEY}, + old => { + if (!old) return old + return { + ...old, + ...(status === 'accepted' + ? {unreadAcceptedConvos: 0} + : {unreadRequestConvos: 0}), + } + }, + ) onMutate?.() - return {prevConvoListQueries, prevRequestsQueries} + return { + prevConvoListQueries, + prevRequestsQueries, + prevUnreadCountsQueries, + } }, onSuccess: () => { + // the optimistic badge zeroing can drift from the server, so invalidate + // the count query to let it self-correct on next access rather than + // waiting for a log event + void queryClient.invalidateQueries({queryKey: UNREAD_COUNTS_PARTIAL_KEY}) void queryClient.invalidateQueries({ queryKey: CONVO_LIST_PARTIAL_KEY(status), }) @@ -121,6 +149,11 @@ export function useUpdateAllRead( queryClient.setQueryData(queryKey, prevData) } } + if (context?.prevUnreadCountsQueries) { + for (const [queryKey, prevData] of context.prevUnreadCountsQueries) { + queryClient.setQueryData(queryKey, prevData) + } + } void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]}) if (status === 'request') { void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})