Merge remote-tracking branch 'origin/main' into native-video-compress

* origin/main:
  Focus composer input when editable (#10982)
  Fix issue with stale unread message counts (#10953)
  [APP-2429] Add Sunlight and Twilight color options to the Invite Friends QR code (#10921)
  signup: align "Contact support" to the right on larger displays (#10978)
  Set a min height on native Menus (#10956)
  Tweak strings and context (#10928)
  Fix internal repo sync broken by actions/checkout v6 bump (#10933)
  Fix setState-in-render warning from convo cache subscription (#10934)
  Add light haptics to Edit Profile Button for labellers (#10948)
  Remove old message composer (#10951)
  Fix embeds overlapping each other in chat (#10949)
  Add mark all as read to chat settings (#10973)
  Add TestFlight group selection to iOS build workflow (#10979)
  Nightly source-language update
  bskyweb: add isPartOf jsonld post attr (#10945)
This commit is contained in:
Eric Bailey
2026-06-24 18:17:04 -05:00
37 changed files with 756 additions and 753 deletions
+15 -10
View File
@@ -10,20 +10,24 @@ on:
options: options:
- testflight - testflight
- production - production
assignTestFlightGroup: testFlightGroup:
type: boolean type: choice
description: Assign the build to the "QA Team" TestFlight group after submitting description: TestFlight group to assign the build to after submitting
default: false options:
- none
- QA Team
- Software Mansion
default: none
workflow_call: workflow_call:
inputs: inputs:
profile: profile:
type: string type: string
description: Build profile to use description: Build profile to use
required: true required: true
assignTestFlightGroup: testFlightGroup:
type: boolean type: string
description: Assign the build to the "QA Team" TestFlight group after submitting description: TestFlight group to assign the build to after submitting ("none" to skip)
default: false default: none
outputs: outputs:
package-version: package-version:
description: Version from package.json description: Version from package.json
@@ -212,8 +216,9 @@ jobs:
# TestFlight group. fastlane's distribute_only mode skips the upload and assigns the # 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. # already-submitted build to the group, polling until Apple finishes processing it.
- name: 🧪 Assign build to TestFlight group - name: 🧪 Assign build to TestFlight group
if: ${{ inputs.assignTestFlightGroup }} if: ${{ inputs.testFlightGroup != 'none' }}
env: env:
TESTFLIGHT_GROUP: ${{ inputs.testFlightGroup }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }} ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
@@ -241,7 +246,7 @@ jobs:
app_identifier:"xyz.blueskyweb.app" \ app_identifier:"xyz.blueskyweb.app" \
app_version:"$APP_VERSION" \ app_version:"$APP_VERSION" \
build_number:"$BUILD_NUMBER" \ build_number:"$BUILD_NUMBER" \
groups:"QA Team" \ groups:"$TESTFLIGHT_GROUP" \
notify_external_testers:true notify_external_testers:true
- name: 🔔 Notify Slack of Production Build - name: 🔔 Notify Slack of Production Build
+1 -1
View File
@@ -77,7 +77,7 @@ jobs:
uses: ./.github/workflows/build-submit-ios.yml uses: ./.github/workflows/build-submit-ios.yml
with: with:
profile: testflight profile: testflight
assignTestFlightGroup: true testFlightGroup: "QA Team"
secrets: inherit secrets: inherit
android: android:
+3 -1
View File
@@ -17,6 +17,9 @@ jobs:
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 0 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 - name: Generate GitHub App Token
id: app-token id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
@@ -33,6 +36,5 @@ jobs:
run: | run: |
git config user.name "github-actions" git config user.name "github-actions"
git config user.email "test@users.noreply.github.com" 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 remote add internal https://x-access-token:${TOKEN}@github.com/bluesky-social/social-app-internal.git
git push internal main --force git push internal main --force
+48 -2
View File
@@ -65,6 +65,7 @@ type discussionForumPosting struct {
CommentCount *int64 `json:"commentCount,omitempty"` CommentCount *int64 `json:"commentCount,omitempty"`
Comment []comment `json:"comment,omitempty"` Comment []comment `json:"comment,omitempty"`
IsBasedOn string `json:"isBasedOn,omitempty"` IsBasedOn string `json:"isBasedOn,omitempty"`
IsPartOf string `json:"isPartOf,omitempty"`
SharedContent *sharedContent `json:"sharedContent,omitempty"` SharedContent *sharedContent `json:"sharedContent,omitempty"`
} }
@@ -344,6 +345,43 @@ func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string {
return "" 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 // buildAuthor constructs a Person. Organization classification for
// custom-domain accounts is a future enhancement. // custom-domain accounts is a future enhancement.
func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg { 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 // buildPostJSONLD marshals the WebPage envelope wrapping a
// DiscussionForumPosting. canonicalURL is used for both envelope.url and // DiscussionForumPosting. canonicalURL is used for both envelope.url and
// (as a fallback) mainEntity.url so they always agree. // (as a fallback) mainEntity.url so they always agree. isPartOfURL, when
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) { // 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 { if pv == nil || pv.Author == nil {
return "", fmt.Errorf("nil post view or author") return "", fmt.Errorf("nil post view or author")
} }
node := buildPostNode(pv, replies, hideLabels, hideReplyLabels) node := buildPostNode(pv, replies, hideLabels, hideReplyLabels)
if isPartOfURL != "" {
node.IsPartOf = isPartOfURL
}
// mainEntity.url is empty when the author handle is unusable; fall back // mainEntity.url is empty when the author handle is unusable; fall back
// to canonicalURL so it agrees with envelope.url. // to canonicalURL so it agrees with envelope.url.
if node.URL == "" { if node.URL == "" {
+155 -36
View File
@@ -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. // withSelfLabel adds a self-label that should hide embeds.
func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) { func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) {
return func(pv *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) { func TestBuildPostJSONLD_Bare(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123" 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 { if err != nil {
t.Fatal(err) 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" 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" 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)) 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 { if err != nil {
t.Fatal(err) 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" 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" 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)) 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 { if err != nil {
t.Fatal(err) 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" 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", pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery",
withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb)) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -438,7 +451,7 @@ func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) {
func TestBuildPostJSONLD_WithVideo(t *testing.T) { func TestBuildPostJSONLD_WithVideo(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg" 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)) 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["thumbnailUrl"] != thumb { if main["thumbnailUrl"] != thumb {
t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"]) t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"])
@@ -451,7 +464,7 @@ func TestBuildPostJSONLD_WithVideo(t *testing.T) {
func TestBuildPostJSONLD_QuotePost(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")) 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" { if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
t.Errorf("isBasedOn wrong: %v", main["isBasedOn"]) t.Errorf("isBasedOn wrong: %v", main["isBasedOn"])
@@ -460,16 +473,122 @@ func TestBuildPostJSONLD_QuotePost(t *testing.T) {
func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) { func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting blocked", withQuotePostBlocked()) 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["isBasedOn"]; present { if _, present := main["isBasedOn"]; present {
t.Errorf("blocked quote should not produce isBasedOn") 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) { 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")) 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
sc, ok := main["sharedContent"].(map[string]any) sc, ok := main["sharedContent"].(map[string]any)
if !ok { if !ok {
@@ -487,7 +606,7 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
thumb := "https://cdn.bsky.app/img/x@jpeg" thumb := "https://cdn.bsky.app/img/x@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw", pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withImages(thumb), withSelfLabel("porn")) 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["image"]; present { if _, present := main["image"]; present {
t.Errorf("hidden-embed post should not emit image") 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" thumb := "https://cdn.bsky.app/img/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw", pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withGallery(thumb), withSelfLabel("porn")) 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["image"]; present { if _, present := main["image"]; present {
t.Errorf("hidden-embed gallery post should not emit image") t.Errorf("hidden-embed gallery post should not emit image")
@@ -520,7 +639,7 @@ func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
// Includes ", \, newline, </script>, and a unicode char. // Includes ", \, newline, </script>, and a unicode char.
tricky := "hello \"world\" \\ <\\>\n</script> 🎉" tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", tricky) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -556,7 +675,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: "at://x/y/z"}, 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if cc := main["commentCount"].(float64); int64(cc) != 14 { if cc := main["commentCount"].(float64); int64(cc) != 14 {
@@ -598,7 +717,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) { func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) {
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello") pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello")
fallback := "https://bsky.app/profile/did:plc:alice/post/abc123" 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) envelope := unmarshalLD(t, out)
main := envelope["mainEntity"].(map[string]any) main := envelope["mainEntity"].(map[string]any)
// mainEntity.url falls back to the caller's canonical URL so envelope // 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 { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
pv := makePostView(tc.handle, tc.did, tc.rkey, "hi") 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) env := unmarshalLD(t, out)
main := env["mainEntity"].(map[string]any) main := env["mainEntity"].(map[string]any)
if env["url"] != tc.canonical { if env["url"] != tc.canonical {
@@ -664,7 +783,7 @@ func TestBuildPostJSONLD_NilAuthor(t *testing.T) {
// Defensive: don't panic if Author is nil. // Defensive: don't panic if Author is nil.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
pv.Author = nil 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") 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: goodReply}},
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: badReply}}, {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 { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@@ -703,7 +822,7 @@ func TestBuildPostJSONLD_CommentMedia(t *testing.T) {
replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{ replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply}}, {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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any) c := main["comment"].([]any)[0].(map[string]any)
imgs, ok := c["image"].([]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") good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply", bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
withPostLabel("!hide", false)) 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) ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri { if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected only the unlabeled reply to remain, got %v", ids) 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") good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply", bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
withSelfLabel("spam")) withSelfLabel("spam"))
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out) ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri { if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected self-labeled reply dropped, got %v", ids) t.Errorf("expected self-labeled reply dropped, got %v", ids)
@@ -939,7 +1058,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_EmbedLabel(t *testing.T) {
// union behavior. // union behavior.
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "concerning reply", bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "concerning reply",
withPostLabel("self-harm", false)) 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) ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri { if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected embed-labeled reply dropped, got %v", ids) 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") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "fine reply", reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "fine reply",
withPostLabel("!hide", true)) withPostLabel("!hide", true))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out) ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != reply.Uri { if len(ids) != 1 || ids[0] != reply.Uri {
t.Errorf("expected negated-label reply to be kept, got %v", ids) 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. // Reply author should also carry a DID identifier.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi") 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any) c := main["comment"].([]any)[0].(map[string]any)
auth, ok := c["author"].(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", pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi",
withVerifications(state)) withVerifications(state))
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1143,7 +1262,7 @@ func TestBuildPostJSONLD_ReplyAuthorNoReviewedBy(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi", reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi",
withVerifications(state)) 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any) c := main["comment"].([]any)[0].(map[string]any)
auth := c["author"].(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, hasAspect: true, width: 16, height: 9,
})) }))
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123" 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1315,7 +1434,7 @@ func TestBuildPostJSONLD_VideoNameFallback(t *testing.T) {
withVideoFull(videoEmbedOpts{ withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any) video, ok := main["video"].(map[string]any)
if !ok { if !ok {
@@ -1332,7 +1451,7 @@ func TestBuildPostJSONLD_VideoNameFallbackHandleInvalid(t *testing.T) {
withVideoFull(videoEmbedOpts{ withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any) video := main["video"].(map[string]any)
if video["name"] != "Video on Bluesky" { if video["name"] != "Video on Bluesky" {
@@ -1347,7 +1466,7 @@ func TestBuildPostJSONLD_VideoDescriptionFallback(t *testing.T) {
withVideoFull(videoEmbedOpts{ withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", alt: "scenic clip", 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any) video := main["video"].(map[string]any)
if video["description"] != "scenic clip" { if video["description"] != "scenic clip" {
@@ -1360,7 +1479,7 @@ func TestBuildPostJSONLD_VideoNoAspectRatio(t *testing.T) {
withVideoFull(videoEmbedOpts{ withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", alt: "alt", 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any) video := main["video"].(map[string]any)
if _, present := video["width"]; present { 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" thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x", pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
withVideo(thumb)) withVideo(thumb))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present { if _, present := main["video"]; present {
t.Errorf("video without playlist should not produce VideoObject") t.Errorf("video without playlist should not produce VideoObject")
@@ -1396,7 +1515,7 @@ func TestBuildPostJSONLD_VideoHiddenEmbed(t *testing.T) {
alt: "should be dropped", alt: "should be dropped",
}), }),
withSelfLabel("porn")) withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present { if _, present := main["video"]; present {
t.Errorf("hidden-embed post should not emit video") 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, thumbnail: thumb, playlist: playlist, alt: "alt", recordMedia: true,
hasAspect: true, width: 4, height: 3, 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any) video, ok := main["video"].(map[string]any)
if !ok { if !ok {
@@ -1438,7 +1557,7 @@ func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
withVideoFull(videoEmbedOpts{ withVideoFull(videoEmbedOpts{
thumbnail: thumb, playlist: playlist, alt: "bob's clip", 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any) c := main["comment"].([]any)[0].(map[string]any)
video, ok := c["video"].(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) { func TestBuildPostJSONLD_NoVideoNoField(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no embed") 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present { if _, present := main["video"]; present {
t.Errorf("post without video should not include video field") 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", playlist: playlist, alt: "scenic clip",
})) }))
canonical := "https://bsky.app/profile/did:plc:alice/post/abc123" 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any) video, ok := main["video"].(map[string]any)
if !ok { if !ok {
@@ -1528,7 +1647,7 @@ func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL_Reply(t *testing.T) {
withVideoFull(videoEmbedOpts{ withVideoFull(videoEmbedOpts{
playlist: playlist, alt: "bob's clip", 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any) c := main["comment"].([]any)[0].(map[string]any)
video, ok := c["video"].(map[string]any) video, ok := c["video"].(map[string]any)
+17
View File
@@ -20,3 +20,20 @@ func profileRequiresAuth(pv *appbsky.ActorDefs_ProfileViewDetailed) bool {
} }
return false 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
}
+73
View File
@@ -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)
}
})
}
}
+7 -7
View File
@@ -52,7 +52,7 @@ func TestRenderBase_NoindexMeta(t *testing.T) {
func TestRenderPost_EmitsJSONLD(t *testing.T) { func TestRenderPost_EmitsJSONLD(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello") 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 { if err != nil {
t.Fatal(err) 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" 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" 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)) 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{ html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv, "postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123", "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" 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)) pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
thumbs := extractPostMedia(pv, false) 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{ html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv, "postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123", "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) { func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
// Without canonicalURL, the template falls back to requestURI|canonicalize_url. // Without canonicalURL, the template falls back to requestURI|canonicalize_url.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi") 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{ html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv, "postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo", "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 <link rel="canonical"> must emit the same URL. // og:url and <link rel="canonical"> must emit the same URL.
func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) { func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi") 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" canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
html := renderTemplate(t, "post.html", pongo2.Context{ html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv, "postView": pv,
@@ -233,7 +233,7 @@ func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
// video without a thumbnail dropped og:video entirely. // video without a thumbnail dropped og:video entirely.
func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) { func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch") 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" videoURL := "https://video.bsky.app/v.m3u8"
html := renderTemplate(t, "post.html", pongo2.Context{ html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv, "postView": pv,
@@ -286,7 +286,7 @@ func TestRenderProfile_AuthRequiredNoindex(t *testing.T) {
// flip of the noindex flag for indexable pages. // flip of the noindex flag for indexable pages.
func TestRenderPost_PublicNoNoindex(t *testing.T) { func TestRenderPost_PublicNoNoindex(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello") 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{ html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv, "postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123", "requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
+55 -27
View File
@@ -620,23 +620,39 @@ func (srv *Server) WebPost(c echo.Context) error {
identifier := handleOrDID.Normalize().String() 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() req := c.Request()
requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path) 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 // Always prefer the handle-form URL so JSON-LD `url` and
// <link rel="canonical"> match. Falls back to requestURI when the // <link rel="canonical"> match. Falls back to requestURI when the
// handle is unusable (template strips query/fragment). // 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 // Provide minimal OpenGraph data for auth-required posts
data["requestURI"] = requestURI data["requestURI"] = requestURI
if canonicalURL != "" { if canonicalURL != "" {
@@ -645,26 +661,13 @@ func (srv *Server) WebPost(c echo.Context) error {
data["requiresAuth"] = true data["requiresAuth"] = true
data["noindex"] = true data["noindex"] = true
data["nofollow"] = true data["nofollow"] = true
data["profileHandle"] = pv.Handle data["profileHandle"] = postView.Author.Handle
if pv.DisplayName != nil { if postView.Author.DisplayName != nil {
data["profileDisplayName"] = *pv.DisplayName data["profileDisplayName"] = *postView.Author.DisplayName
} }
return c.Render(http.StatusOK, "post.html", data) 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["postView"] = postView
data["requestURI"] = requestURI data["requestURI"] = requestURI
if canonicalURL != "" { if canonicalURL != "" {
@@ -694,7 +697,32 @@ func (srv *Server) WebPost(c echo.Context) error {
if jsonldURL == "" { if jsonldURL == "" {
jsonldURL = requestURI 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 data["postJSONLD"] = jsonld
} else { } else {
log.Warnf("failed to build post JSON-LD for %s: %v", uri, err) log.Warnf("failed to build post JSON-LD for %s: %v", uri, err)
@@ -399,7 +399,7 @@ function AccessSection() {
locationControl.open() locationControl.open()
})}> })}>
Tap here to update your location with GPS. Tap here to update your location with GPS.
</SimpleInlineLinkText>{' '} </SimpleInlineLinkText>
</Trans> </Trans>
</Admonition> </Admonition>
-1
View File
@@ -10,7 +10,6 @@ export enum Features {
ImportContactsSettingsDisable = 'import_contacts:settings:disable', ImportContactsSettingsDisable = 'import_contacts:settings:disable',
LiveNowBetaDisable = 'live_now_beta:disable', LiveNowBetaDisable = 'live_now_beta:disable',
GroupChatsDisable = 'group_chats:disable', GroupChatsDisable = 'group_chats:disable',
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
ComposerLanguageDetectionEnable = 'composer:language_detection:enable', ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable', PostGalleryEmbedEnable = 'post_gallery_embed:enable',
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable', NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
+1 -1
View File
@@ -1319,7 +1319,7 @@ export type Events = {
'invite:action:scan': {} 'invite:action:scan': {}
// user changed the QR card color theme // user changed the QR card color theme
'invite:theme:change': { '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 // QR scanner decoded a code; result indicates whether it resolved to a profile
'invite:scanner:scanned': { 'invite:scanner:scanned': {
+2
View File
@@ -615,6 +615,8 @@ export function Outer({
label?: string label?: string
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
align?: 'left' | 'right' align?: 'left' | 'right'
/** Web only. Native restores focus differently. */
onCloseAutoFocus?: (event: Event) => void
}) { }) {
const t = useTheme() const t = useTheme()
const context = useContextMenuContext() const context = useContextMenuContext()
+3 -1
View File
@@ -34,14 +34,16 @@ export function Outer({
children, children,
label, label,
style, style,
onCloseAutoFocus,
}: { }: {
children: React.ReactNode children: React.ReactNode
label?: string label?: string
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
onCloseAutoFocus?: (event: Event) => void
}) { }) {
const t = useTheme() const t = useTheme()
return ( return (
<Menu.Outer style={style}> <Menu.Outer style={style} onCloseAutoFocus={onCloseAutoFocus}>
{label ? ( {label ? (
<Text <Text
numberOfLines={1} numberOfLines={1}
+14 -8
View File
@@ -6,9 +6,7 @@ import {
View, View,
type ViewStyle, type ViewStyle,
} from 'react-native' } from 'react-native'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import flattenReactChildren from 'react-keyed-flatten-children' import flattenReactChildren from 'react-keyed-flatten-children'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
@@ -32,6 +30,11 @@ import {
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env' import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env'
// iOS 26's floaty sheet presentation subtracts the bottom safe-area inset from
// the requested detent, which eats the visible bottom padding of short (e.g.
// single-item) menus. Flooring the native sheet height restores that padding.
const IOS_MENU_MIN_HEIGHT = 128
export { export {
type DialogControlProps as MenuControlProps, type DialogControlProps as MenuControlProps,
useDialogControl as useMenuControl, useDialogControl as useMenuControl,
@@ -101,16 +104,19 @@ export function Outer({
onCloseAutoFocus?: (event: Event) => void onCloseAutoFocus?: (event: Event) => void
}>) { }>) {
const context = useMenuContext() const context = useMenuContext()
const {_} = useLingui() const {t: l} = useLingui()
return ( return (
<Dialog.Outer <Dialog.Outer
control={context.control} control={context.control}
nativeOptions={{preventExpansion: true}}> nativeOptions={{
preventExpansion: true,
minHeight: IS_IOS ? IOS_MENU_MIN_HEIGHT : undefined,
}}>
<Dialog.Handle /> <Dialog.Handle />
{/* Re-wrap with context since Dialogs are portal-ed to root */} {/* Re-wrap with context since Dialogs are portal-ed to root */}
<Context.Provider value={context}> <Context.Provider value={context}>
<Dialog.ScrollableInner label={_(msg`Menu`)}> <Dialog.ScrollableInner label={l`Menu`}>
<View style={[a.gap_lg]}> <View style={[a.gap_lg]}>
{children} {children}
{IS_NATIVE && showCancel && <Cancel />} {IS_NATIVE && showCancel && <Cancel />}
@@ -353,12 +359,12 @@ export function Group({children, style}: GroupProps) {
} }
function Cancel() { function Cancel() {
const {_} = useLingui() const {t: l} = useLingui()
const context = useMenuContext() const context = useMenuContext()
return ( return (
<Button <Button
label={_(msg`Close this dialog`)} label={l`Close this dialog`}
size="small" size="small"
variant="ghost" variant="ghost"
color="secondary" color="secondary"
+2 -6
View File
@@ -40,11 +40,7 @@ import {ModeratedFeedEmbed} from './FeedEmbed'
import {ImageEmbed} from './ImageEmbed' import {ImageEmbed} from './ImageEmbed'
import {ModeratedListEmbed} from './ListEmbed' import {ModeratedListEmbed} from './ListEmbed'
import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder' import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder'
import { import {type CommonProps, type EmbedProps, PostEmbedViewContext} from './types'
type CommonProps,
type EmbedProps,
type PostEmbedViewContext,
} from './types'
import {VideoEmbed} from './VideoEmbed' import {VideoEmbed} from './VideoEmbed'
export {PostEmbedViewContext} from './types' export {PostEmbedViewContext} from './types'
@@ -356,7 +352,7 @@ export function QuoteEmbed({
return ( return (
<GalleryBleed> <GalleryBleed>
<View <View
style={[a.mt_sm]} style={[viewContext !== PostEmbedViewContext.ChatMessage && a.mt_sm]}
onPointerEnter={linkDisabled ? undefined : onPointerEnter} onPointerEnter={linkDisabled ? undefined : onPointerEnter}
onPointerLeave={linkDisabled ? undefined : onPointerLeave}> onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
<ContentHider <ContentHider
@@ -168,7 +168,7 @@ export function GroupChatsAnnouncement() {
a.font_medium, a.font_medium,
{color: t.palette.primary_500}, {color: t.palette.primary_500},
]}> ]}>
<Trans>New</Trans> <Trans context="nux-description">New</Trans>
</Text> </Text>
</View> </View>
<Text <Text
+17 -2
View File
@@ -60,6 +60,20 @@ export let MessageContextMenu = ({
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const translate = useGoogleTranslate() const translate = useGoogleTranslate()
const onReply = useCallback(() => {
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 isFromSelf = message.sender?.did === currentAccount?.did
const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable) 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), { label={l`Sent at ${i18n.date(new Date(message.sentAt), {
timeStyle: 'short', timeStyle: 'short',
})}`} })}`}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}> style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}
onCloseAutoFocus={onCloseAutoFocus}>
<ContextMenu.Item <ContextMenu.Item
testID="messageDropdownReplyBtn" testID="messageDropdownReplyBtn"
label={l`Reply`} label={l`Reply`}
onPress={() => setReply(message)}> onPress={onReply}>
<ContextMenu.ItemIcon icon={ReplyIcon} position="left" /> <ContextMenu.ItemIcon icon={ReplyIcon} position="left" />
<ContextMenu.ItemText>{l`Reply`}</ContextMenu.ItemText> <ContextMenu.ItemText>{l`Reply`}</ContextMenu.ItemText>
</ContextMenu.Item> </ContextMenu.Item>
-3
View File
@@ -77,9 +77,6 @@ let MessageItemEmbed = ({
minWidth: 280, minWidth: 280,
maxWidth: 360, 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},
]}> ]}>
<Animated.View <Animated.View
style={[a.rounded_xl, a.overflow_hidden, radiiStyle, highlightStyle]}> style={[a.rounded_xl, a.overflow_hidden, radiiStyle, highlightStyle]}>
+2 -2
View File
@@ -362,14 +362,14 @@ function MutualGroupChat({
<Button <Button
color="negative_subtle" color="negative_subtle"
disabled={isRemovePending} disabled={isRemovePending}
label={l`Kick member`} label={l`Remove member`}
size="small" size="small"
onPress={() => { onPress={() => {
onOptimisticallyRemoveConvo(view.id) onOptimisticallyRemoveConvo(view.id)
removeMembers({members: [profileDid]}) removeMembers({members: [profileDid]})
}}> }}>
<ButtonText> <ButtonText>
<Trans>Kick member</Trans> <Trans>Remove member</Trans>
</ButtonText> </ButtonText>
{isRemovePending ? <ButtonIcon icon={Loader} /> : null} {isRemovePending ? <ButtonIcon icon={Loader} /> : null}
</Button> </Button>
@@ -26,8 +26,10 @@ export function ThemePicker({
const labels: Record<InviteThemeKey, string> = { const labels: Record<InviteThemeKey, string> = {
dawn: l`Dawn`, dawn: l`Dawn`,
sunlight: l`Sunlight`,
day: l`Day`, day: l`Day`,
dusk: l`Dusk`, dusk: l`Dusk`,
twilight: l`Twilight`,
night: l`Night`, night: l`Night`,
} }
+9 -2
View File
@@ -3,8 +3,15 @@ import {describe, expect, it} from '@jest/globals'
import {getInviteTheme, INVITE_THEME_KEYS, INVITE_THEMES} from './themes' import {getInviteTheme, INVITE_THEME_KEYS, INVITE_THEMES} from './themes'
describe('invite themes', () => { describe('invite themes', () => {
it('exposes four themes in canonical order', () => { it('exposes six themes in canonical order', () => {
expect(INVITE_THEME_KEYS).toEqual(['dawn', 'day', 'dusk', 'night']) expect(INVITE_THEME_KEYS).toEqual([
'dawn',
'sunlight',
'day',
'dusk',
'twilight',
'night',
])
}) })
it('has light and dark variants for every theme', () => { it('has light and dark variants for every theme', () => {
+45 -1
View File
@@ -5,7 +5,13 @@
* matching primary color; the QR data uses qrPrimary; the avatar handle text * matching primary color; the QR data uses qrPrimary; the avatar handle text
* follows handleColor. * follows handleColor.
*/ */
export type InviteThemeKey = 'dawn' | 'day' | 'dusk' | 'night' export type InviteThemeKey =
| 'dawn'
| 'sunlight'
| 'day'
| 'dusk'
| 'twilight'
| 'night'
export type InviteThemeVariant = { export type InviteThemeVariant = {
/** QR data + eye color */ /** QR data + eye color */
@@ -30,8 +36,10 @@ export type InviteTheme = {
export const INVITE_THEME_KEYS: readonly InviteThemeKey[] = [ export const INVITE_THEME_KEYS: readonly InviteThemeKey[] = [
'dawn', 'dawn',
'sunlight',
'day', 'day',
'dusk', 'dusk',
'twilight',
'night', 'night',
] as const ] as const
@@ -54,6 +62,24 @@ export const INVITE_THEMES: Record<InviteThemeKey, InviteTheme> = {
handleColor: '#ffffff', 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: { day: {
key: 'day', key: 'day',
swatch: '#006aff', swatch: '#006aff',
@@ -90,6 +116,24 @@ export const INVITE_THEMES: Record<InviteThemeKey, InviteTheme> = {
handleColor: '#ffffff', 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: { night: {
key: 'night', key: 'night',
swatch: '#0048ad', swatch: '#0048ad',
+10 -10
View File
@@ -1145,7 +1145,7 @@ msgid "Add image"
msgstr "" msgstr ""
#. Accessibility label for button in composer to add images, a video, or a GIF to a post #. 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" msgid "Add media to post"
msgstr "" msgstr ""
@@ -8053,11 +8053,11 @@ msgstr ""
msgid "One or more images is missing alt text." msgid "One or more images is missing alt text."
msgstr "" 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." msgid "One or more of your selected files are not supported."
msgstr "" 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." 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." 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 "" msgstr ""
#. Accessibility hint for button in composer to add images, a video, or a GIF to a post. #. 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." 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." 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" msgid "Select your preferred notification channels"
msgstr "" msgstr ""
#: src/view/com/composer/SelectMediaButton.tsx:420 #: src/view/com/composer/SelectMediaButton.tsx:426
msgid "Selecting multiple media types is not supported." msgid "Selecting multiple media types is not supported."
msgstr "" msgstr ""
@@ -13092,7 +13092,7 @@ msgstr ""
msgid "Videos" msgid "Videos"
msgstr "" msgstr ""
#: src/view/com/composer/SelectMediaButton.tsx:434 #: src/view/com/composer/SelectMediaButton.tsx:440
msgid "Videos must be less than 3 minutes long." msgid "Videos must be less than 3 minutes long."
msgstr "" 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?" msgid "You can only save drafts up to 1000 characters. Would you like to discard this post before viewing your drafts?"
msgstr "" 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." msgid "You can only select one GIF at a time."
msgstr "" 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." msgid "You can only select one video at a time."
msgstr "" msgstr ""
@@ -13833,7 +13833,7 @@ msgid "You can read chat history but cant send new messages."
msgstr "You can read chat history but cant send new messages." msgstr "You can read chat history but cant send new messages."
#. Error message for maximum number of images that can be selected to add to a post. #. 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." 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." 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" msgid "You must grant access to your photo library to save a QR code"
msgstr "" 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." msgid "You need to allow access to your media library."
msgstr "" msgstr ""
+96 -23
View File
@@ -3,14 +3,21 @@ import {View} from 'react-native'
import {useAnimatedRef} from 'react-native-reanimated' import {useAnimatedRef} from 'react-native-reanimated'
import {type ChatBskyActorGetStatus, type ChatBskyConvoDefs} from '@atproto/api' import {type ChatBskyActorGetStatus, type ChatBskyConvoDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro' 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 {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useAppState} from '#/lib/appState' import {useAppState} from '#/lib/appState'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 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 {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {listenSoftReset} from '#/state/events' import {listenSoftReset} from '#/state/events'
@@ -19,6 +26,7 @@ import {useMessagesEventBus} from '#/state/messages/events'
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status' import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
import {useUnreadCountsQuery} from '#/state/queries/messages/get-unread-counts' import {useUnreadCountsQuery} from '#/state/queries/messages/get-unread-counts'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations' 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 {EmptyState} from '#/view/com/util/EmptyState'
import {List, type ListRef} from '#/view/com/util/List' import {List, type ListRef} from '#/view/com/util/List'
import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 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 {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import {BubbleSmile_Stroke2_Corner2_Rounded_Large as BubbleSmileIcon} from '#/components/icons/Bubble' 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 {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {Inbox_Stroke2_Corner2_Rounded_Large as InboxLargeIcon} from '#/components/icons/Inbox' import {Inbox_Stroke2_Corner2_Rounded_Large as InboxLargeIcon} from '#/components/icons/Inbox'
import { import {
@@ -41,6 +50,8 @@ import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/component
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
import {ListFooter} from '#/components/Lists' import {ListFooter} from '#/components/Lists'
import * as Menu from '#/components/Menu'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance'
import {IS_NATIVE, IS_WEB} from '#/env' import {IS_NATIVE, IS_WEB} from '#/env'
@@ -289,7 +300,10 @@ export function ChatList({
isError, isError,
error, error,
refetch, refetch,
} = useListConvosQuery({status: 'accepted'}) } = useListConvosQuery({
status: 'accepted',
kind: aa.flags.groupChatDisabled ? 'direct' : 'all',
})
const {refetch: refetchInbox} = useListConvosQuery({ const {refetch: refetchInbox} = useListConvosQuery({
status: 'request', status: 'request',
@@ -546,16 +560,19 @@ export function Header({
variant="solid" variant="solid"
action={action} action={action}
/> />
<Link <ChatSettingsMenu action={action}>
to="/messages/settings" {({props}) => (
action={action} <Button
label={l`Chat settings`} {...props}
size="small" label={l`Chat options`}
color="secondary" size="small"
shape="round" color="secondary"
style={[a.justify_center]}> shape="round"
<ButtonIcon icon={SettingsIcon} /> style={[a.justify_center]}>
</Link> <ButtonIcon icon={SettingsIcon} />
</Button>
)}
</ChatSettingsMenu>
{!chatStatus?.chatDisabled && ( {!chatStatus?.chatDisabled && (
<Button <Button
label={l`New chat`} label={l`New chat`}
@@ -578,19 +595,75 @@ export function Header({
</Layout.Header.Content> </Layout.Header.Content>
<InboxRequests count={requestCount} variant="ghost" /> <InboxRequests count={requestCount} variant="ghost" />
<Layout.Header.Slot> <Layout.Header.Slot>
<Link <ChatSettingsMenu action={action}>
to="/messages/settings" {({props}) => (
label={l`Chat settings`} <Button
size="small" {...props}
variant="ghost" label={l`Chat options`}
color="secondary" size="small"
shape="round" variant="ghost"
style={[a.justify_center]}> color="secondary"
<ButtonIcon icon={SettingsIcon} size="lg" /> shape="round"
</Link> style={[a.justify_center]}>
<ButtonIcon icon={SettingsIcon} size="lg" />
</Button>
)}
</ChatSettingsMenu>
</Layout.Header.Slot> </Layout.Header.Slot>
</> </>
)} )}
</Layout.Header.Outer> </Layout.Header.Outer>
) )
} }
function ChatSettingsMenu({
action,
children,
}: {
action: 'navigate' | 'push'
children: React.ComponentProps<typeof Menu.Trigger>['children']
}) {
const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
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 (
<Menu.Root>
<Menu.Trigger label={l`Chat options`}>{children}</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={l`Mark all chats as read`}
onPress={() => markAllChatsRead()}>
<Menu.ItemIcon icon={CircleCheckIcon} />
<Menu.ItemText>
<Trans>Mark all chats as read</Trans>
</Menu.ItemText>
</Menu.Item>
<Menu.Item
label={l`Chat settings`}
onPress={() => {
if (action === 'navigate') {
navigation.navigate('MessagesSettings')
} else {
navigation.push('MessagesSettings')
}
}}>
<Menu.ItemIcon icon={SettingsIcon} />
<Menu.ItemText>
<Trans>Chat settings</Trans>
</Menu.ItemText>
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
)
}
+3 -3
View File
@@ -212,7 +212,7 @@ function JoinRequestsList({
useJoinRequestMutation('reject', convoId, { useJoinRequestMutation('reject', convoId, {
onSuccess: () => { onSuccess: () => {
ax.metric('groupchat:owner:joinRequest:reject', {convoId}) ax.metric('groupchat:owner:joinRequest:reject', {convoId})
Toast.show(l`Request ignored.`) Toast.show(l`Request rejected.`)
if (getRemainingRequestCount() < 1) { if (getRemainingRequestCount() < 1) {
navigation.replace('MessagesConversationSettings', { navigation.replace('MessagesConversationSettings', {
conversation: convoId, conversation: convoId,
@@ -220,7 +220,7 @@ function JoinRequestsList({
} }
}, },
onError: error => { onError: error => {
let errorMessage = l`Failed to ignore join request` let errorMessage = l`Failed to reject join request`
if (isNetworkError(error)) { if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.` errorMessage = l`A network error occurred. Please check your internet connection.`
} else if ( } else if (
@@ -230,7 +230,7 @@ function JoinRequestsList({
} else if ( } else if (
error instanceof ChatBskyGroupRejectJoinRequest.InsufficientRoleError 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'}) Toast.show(errorMessage, {type: 'error'})
}, },
@@ -77,6 +77,15 @@ export function MessageComposer({
composerInternalApiRef.current?.input?.focus() composerInternalApiRef.current?.input?.focus()
}, [replyTo, composerInternalApiRef]) }, [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 // Android interactive dismiss sometimes doesn't blur the input
const blur = useNonReactiveCallback(() => { const blur = useNonReactiveCallback(() => {
composerInternalApiRef.current?.input?.blur() composerInternalApiRef.current?.input?.blur()
@@ -243,13 +252,12 @@ export function MessageComposer({
placeholder={ placeholder={
loading loading
? l({message: 'Loading chat…', context: 'placeholder'}) ? l({message: 'Loading chat…', context: 'placeholder'})
: l({message: 'Message', context: 'action'}) : l({message: 'Message', context: 'description'})
} }
autocompletePlacement="top-start" autocompletePlacement="top-start"
internalApiRef={composerInternalApiRef} internalApiRef={composerInternalApiRef}
defaultValue={text} defaultValue={text}
editable={editable} editable={editable}
autoFocus={IS_WEB}
maxRows={12} maxRows={12}
outerStyle={[a.flex_1]} outerStyle={[a.flex_1]}
contentTextStyle={[a.text_md, a.leading_snug]} contentTextStyle={[a.text_md, a.leading_snug]}
@@ -334,8 +342,7 @@ function SubmitButton({
) )
} }
// TODO: remove export when MessageInput is deleted function ComposerContainer({children}: {children: React.ReactNode}) {
export function ComposerContainer({children}: {children: React.ReactNode}) {
const {bottom: bottomInset} = useSafeAreaInsets() const {bottom: bottomInset} = useSafeAreaInsets()
const {progress} = useReanimatedKeyboardAnimation() const {progress} = useReanimatedKeyboardAnimation()
const t = useTheme() const t = useTheme()
@@ -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<ChatBskyConvoDefs.MessageView>,
) => Promise<void> | 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 | number>(undefined)
const isInputScrollable = useSharedValue(false)
const [message, setMessage] = useState(getDraft)
const inputRef = useAnimatedRef<TextInput>()
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 (
<ComposerContainer>
<GlassContainer
style={[a.flex_row, a.align_end, a.gap_sm]}
spacing={tokens.space.xs}>
<GlassView
isInteractive
glassEffectStyle="regular"
style={[a.flex_1, a.rounded_xl, {minHeight: MIN_HEIGHT}]}
tintColor={t.palette.contrast_50}
fallbackStyle={[t.atoms.bg_contrast_50]}>
{children}
<AnimatedTextInput
nativeID={textInputId}
accessibilityLabel={l`Message input field`}
accessibilityHint={l`Type your message here`}
placeholder={l`Message`}
placeholderTextColor={t.palette.contrast_500}
value={message}
onChange={evt => {
// 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}
/>
</GlassView>
<GlassView
isInteractive
glassEffectStyle="regular"
style={[a.rounded_full]}
tintColor={
submitDisabled ? t.palette.contrast_100 : t.palette.primary_500
}
fallbackStyle={{
backgroundColor: submitDisabled
? t.palette.contrast_100
: t.palette.primary_500,
}}>
<Pressable
accessibilityRole="button"
accessibilityLabel={
loading
? l({message: 'Loading chat…', context: 'placeholder'})
: l({message: 'Message', context: 'action'})
}
accessibilityHint=""
hitSlop={HITSLOP_10}
style={[
a.rounded_full,
a.align_center,
a.justify_center,
{
height: MIN_HEIGHT,
width: MIN_HEIGHT,
},
]}
onPress={onSubmit}
disabled={submitDisabled}>
{loading ? (
<Loader size="md" fill={t.palette.white} style={[a.mb_2xs]} />
) : (
<PaperPlaneIcon
size="md"
fill={t.palette.white}
style={[a.mb_2xs]}
/>
)}
</Pressable>
</GlassView>
</GlassContainer>
</ComposerContainer>
)
}
@@ -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<ChatBskyConvoDefs.MessageView>,
) => 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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
// 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<HTMLTextAreaElement>) => {
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 (
<View style={a.p_sm}>
{children}
<View
style={[
a.flex_row,
t.atoms.bg_contrast_25,
{
paddingRight: a.p_sm.padding - 2,
paddingLeft: a.p_sm.padding - 2,
borderWidth: 1,
borderRadius: 23,
borderColor: 'transparent',
height: textAreaHeight + 23,
},
isHovered && inputStyles.chromeHover,
isFocused && inputStyles.chromeFocus,
]}
// @ts-expect-error web only
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}>
{loading ? null : (
<EmojiPicker.Root
onEmojiSelect={onEmojiInserted}
nextFocusRef={textAreaRef}>
<EmojiPicker.Trigger label={l`Open emoji picker`}>
{({props, state}) => (
<Button
style={[
a.rounded_full,
a.overflow_hidden,
a.align_center,
a.justify_center,
{
marginTop: 5,
height: 30,
width: 30,
},
]}
label={props.accessibilityLabel}
{...props}>
<View
style={[
a.absolute,
a.inset_0,
a.align_center,
a.justify_center,
{
backgroundColor:
state.hovered || state.focused || state.pressed
? t.atoms.bg.backgroundColor
: undefined,
},
]}>
<EmojiSmile size="lg" />
</View>
</Button>
)}
</EmojiPicker.Trigger>
<EmojiPicker.Picker />
</EmojiPicker.Root>
)}
<TextareaAutosize
ref={textAreaRef}
disabled={loading}
style={flatten([
a.flex_1,
a.px_sm,
a.border_0,
t.atoms.text,
{
paddingTop: 10,
backgroundColor: 'transparent',
resize: 'none',
},
])}
maxRows={12}
placeholder={
loading
? l({message: 'Loading chat…', context: 'placeholder'})
: l({message: 'Message', context: 'action'})
}
defaultValue=""
value={message}
dirName="ltr"
autoFocus={true}
onFocus={() => 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}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Send message`}
accessibilityHint=""
disabled={loading}
style={[
a.rounded_full,
a.align_center,
a.justify_center,
{
height: 30,
width: 30,
marginTop: 5,
backgroundColor: t.palette.primary_500,
},
]}
onPress={onSubmit}>
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
</Pressable>
</View>
</View>
)
}
@@ -57,7 +57,6 @@ import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {List, type ListMethods} from '#/view/com/util/List' import {List, type ListMethods} from '#/view/com/util/List'
import {MessageComposer} from '#/screens/Messages/components/MessageComposer' import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
import {MessageInput} from '#/screens/Messages/components/MessageInput'
import {MessageListError} from '#/screens/Messages/components/MessageListError' import {MessageListError} from '#/screens/Messages/components/MessageListError'
import {atoms as a, platform, tokens, useTheme, web} from '#/alf' import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {DateDivider} from '#/components/dms/DateDivider' import {DateDivider} from '#/components/dms/DateDivider'
@@ -704,9 +703,6 @@ export function MessagesList({
messageEmbed={messageEmbed} messageEmbed={messageEmbed}
setEmbed={setEmbed} setEmbed={setEmbed}
loading={loading} loading={loading}
useNewComposer={ax.features.enabled(
ax.features.DmsNewMessageComposerEnable,
)}
/> />
)} )}
</ConversationFooter> </ConversationFooter>
@@ -735,7 +731,6 @@ function Composer({
messageEmbed, messageEmbed,
setEmbed, setEmbed,
loading, loading,
useNewComposer,
}: { }: {
textInputId: string textInputId: string
onSendMessage: ( onSendMessage: (
@@ -746,7 +741,6 @@ function Composer({
messageEmbed: MessageEmbedState | undefined messageEmbed: MessageEmbedState | undefined
setEmbed: (embedUrl: string | undefined) => void setEmbed: (embedUrl: string | undefined) => void
loading?: boolean loading?: boolean
useNewComposer: boolean
}) { }) {
const handleSendMessage = useNonReactiveCallback( const handleSendMessage = useNonReactiveCallback(
( (
@@ -758,31 +752,16 @@ function Composer({
}, },
) )
const previews = ( return (
<>
<MessageInputReply />
<MessageInputEmbed embed={messageEmbed} setEmbed={setEmbed} />
</>
)
return useNewComposer ? (
<MessageComposer <MessageComposer
textInputId={textInputId} textInputId={textInputId}
onSendMessage={handleSendMessage} onSendMessage={handleSendMessage}
messageEmbed={messageEmbed} messageEmbed={messageEmbed}
setEmbed={setEmbed} setEmbed={setEmbed}
loading={loading}> loading={loading}>
{previews} <MessageInputReply />
<MessageInputEmbed embed={messageEmbed} setEmbed={setEmbed} />
</MessageComposer> </MessageComposer>
) : (
<MessageInput
textInputId={textInputId}
onSendMessage={handleSendMessage}
messageEmbed={messageEmbed}
setEmbed={setEmbed}
loading={loading}>
{previews}
</MessageInput>
) )
} }
@@ -292,7 +292,10 @@ export function HeaderLabelerButtons({
testID="profileHeaderEditProfileButton" testID="profileHeaderEditProfileButton"
size="small" size="small"
color="secondary" color="secondary"
onPress={editProfileControl.open} onPress={() => {
playHaptic('Light')
editProfileControl.open()
}}
label={_(msg`Edit profile`)} label={_(msg`Edit profile`)}
style={a.rounded_full}> style={a.rounded_full}>
<ButtonText> <ButtonText>
+22 -14
View File
@@ -3,6 +3,7 @@ import {AppState, type AppStateStatus, View} from 'react-native'
import ReactNativeDeviceAttest from 'react-native-device-attest' import ReactNativeDeviceAttest from 'react-native-device-attest'
import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated' import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'
import {AppBskyGraphStarterpack} from '@atproto/api' import {AppBskyGraphStarterpack} from '@atproto/api'
import {tokens} from '@bsky.app/alf'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {FEEDBACK_FORM_URL} from '#/lib/constants' import {FEEDBACK_FORM_URL} from '#/lib/constants'
@@ -222,20 +223,27 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
a.align_center, a.align_center,
]}> ]}>
<AppLanguageDropdown /> <AppLanguageDropdown />
<Text <View
style={[ style={
a.flex_1, gtMobile
t.atoms.text_contrast_medium, ? [a.flex_1, a.flex, a.flex_row, a.justify_end]
!gtMobile && a.text_md, : []
]}> }>
<Trans>Having trouble?</Trans>{' '} <Text
<InlineLinkText style={[
label={l`Contact support`} t.atoms.text_contrast_medium,
to={FEEDBACK_FORM_URL({email: state.email})} !gtMobile && a.text_md,
style={[!gtMobile && a.text_md]}> {paddingInline: tokens.space.sm},
<Trans>Contact support</Trans> ]}>
</InlineLinkText> <Trans>Having trouble?</Trans>{' '}
</Text> <InlineLinkText
label={l`Contact support`}
to={FEEDBACK_FORM_URL({email: state.email})}
style={[!gtMobile && a.text_md]}>
<Trans>Contact support</Trans>
</InlineLinkText>
</Text>
</View>
</View> </View>
</View> </View>
</ScreenTransition> </ScreenTransition>
+6
View File
@@ -132,6 +132,12 @@ export function ConvoProvider({
useEffect(() => { useEffect(() => {
const [root, id] = getConvoKey(convoId) const [root, id] = getConvoKey(convoId)
return queryClient.getQueryCache().subscribe(event => { 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[] const queryKey = event.query.queryKey as string[]
if (queryKey[0] === root && queryKey[1] === id) { if (queryKey[0] === root && queryKey[1] === id) {
const data = event.query.state.data as const data = event.query.state.data as
@@ -2,6 +2,7 @@ import {
type ChatBskyActorDefs, type ChatBskyActorDefs,
type ChatBskyConvoDefs, type ChatBskyConvoDefs,
type ChatBskyConvoGetConvo, type ChatBskyConvoGetConvo,
type ChatBskyConvoGetUnreadCounts,
} from '@atproto/api' } from '@atproto/api'
import { import {
type QueryClient, type QueryClient,
@@ -14,6 +15,11 @@ import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {useOnMarkAsRead} from '#/state/queries/messages/list-conversations' import {useOnMarkAsRead} from '#/state/queries/messages/list-conversations'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {
RQKEY_PARTIAL as UNREAD_COUNTS_PARTIAL_KEY,
UNREAD_ACCEPTED_CAP,
UNREAD_REQUEST_CAP,
} from './get-unread-counts'
import { import {
type ConvoListQueryData, type ConvoListQueryData,
getConvoFromQueryData, getConvoFromQueryData,
@@ -74,11 +80,81 @@ export function useMarkAsReadMutation() {
}, },
onMutate({convoId}) { onMutate({convoId}) {
if (!convoId) throw new Error('No convoId provided') 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<ConvoListQueryData>({
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) 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<ChatBskyConvoGetUnreadCounts.OutputSchema>({
queryKey: UNREAD_COUNTS_PARTIAL_KEY,
})
if (unreadStatus) {
queryClient.setQueriesData<ChatBskyConvoGetUnreadCounts.OutputSchema>(
{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}) { onSuccess(_, {convoId}) {
if (!convoId) return 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( queryClient.setQueriesData(
{queryKey: [LIST_CONVOS_KEY]}, {queryKey: [LIST_CONVOS_KEY]},
(old?: ConvoListQueryData) => { (old?: ConvoListQueryData) => {
@@ -10,6 +10,14 @@ export const RQKEY = (includeGroupChats: boolean) =>
[RQKEY_ROOT, includeGroupChats] as const [RQKEY_ROOT, includeGroupChats] as const
export const RQKEY_PARTIAL = [RQKEY_ROOT] 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() { export function useUnreadCountsQuery() {
const agent = useAgent() const agent = useAgent()
const {hasSession} = useSession() const {hasSession} = useSession()
@@ -23,6 +23,7 @@ import * as bsky from '#/types/bsky'
import {RQKEY as CONVO_KEY} from './conversation' import {RQKEY as CONVO_KEY} from './conversation'
import { import {
RQKEY_PARTIAL as UNREAD_COUNTS_RQKEY_PARTIAL, RQKEY_PARTIAL as UNREAD_COUNTS_RQKEY_PARTIAL,
UNREAD_ACCEPTED_CAP,
useUnreadCountsQuery, useUnreadCountsQuery,
} from './get-unread-counts' } from './get-unread-counts'
import { import {
@@ -858,7 +859,15 @@ export function useUnreadMessageCount(): {
const total = accepted + Math.min(request, 1) const total = accepted + Math.min(request, 1)
return { return {
count: total, 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 // only needed when numUnread is undefined
hasNew: false, hasNew: false,
} }
+34 -1
View File
@@ -1,8 +1,10 @@
import {type ChatBskyConvoGetUnreadCounts} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query' import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants' import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {RQKEY_PARTIAL as UNREAD_COUNTS_PARTIAL_KEY} from './get-unread-counts'
import { import {
type ConvoRequestListQueryData, type ConvoRequestListQueryData,
markAllRead as markAllRequestsRead, markAllRead as markAllRequestsRead,
@@ -94,10 +96,36 @@ export function useUpdateAllRead(
markAllRequestsRead, 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<ChatBskyConvoGetUnreadCounts.OutputSchema>({
queryKey: UNREAD_COUNTS_PARTIAL_KEY,
})
queryClient.setQueriesData<ChatBskyConvoGetUnreadCounts.OutputSchema>(
{queryKey: UNREAD_COUNTS_PARTIAL_KEY},
old => {
if (!old) return old
return {
...old,
...(status === 'accepted'
? {unreadAcceptedConvos: 0}
: {unreadRequestConvos: 0}),
}
},
)
onMutate?.() onMutate?.()
return {prevConvoListQueries, prevRequestsQueries} return {
prevConvoListQueries,
prevRequestsQueries,
prevUnreadCountsQueries,
}
}, },
onSuccess: () => { 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({ void queryClient.invalidateQueries({
queryKey: CONVO_LIST_PARTIAL_KEY(status), queryKey: CONVO_LIST_PARTIAL_KEY(status),
}) })
@@ -121,6 +149,11 @@ export function useUpdateAllRead(
queryClient.setQueryData(queryKey, prevData) 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]}) void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]})
if (status === 'request') { if (status === 'request') {
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]}) void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})