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:
- testflight
- production
assignTestFlightGroup:
type: boolean
description: Assign the build to the "QA Team" TestFlight group after submitting
default: false
testFlightGroup:
type: choice
description: TestFlight group to assign the build to after submitting
options:
- none
- QA Team
- Software Mansion
default: none
workflow_call:
inputs:
profile:
type: string
description: Build profile to use
required: true
assignTestFlightGroup:
type: boolean
description: Assign the build to the "QA Team" TestFlight group after submitting
default: false
testFlightGroup:
type: string
description: TestFlight group to assign the build to after submitting ("none" to skip)
default: none
outputs:
package-version:
description: Version from package.json
@@ -212,8 +216,9 @@ jobs:
# TestFlight group. fastlane's distribute_only mode skips the upload and assigns the
# already-submitted build to the group, polling until Apple finishes processing it.
- name: 🧪 Assign build to TestFlight group
if: ${{ inputs.assignTestFlightGroup }}
if: ${{ inputs.testFlightGroup != 'none' }}
env:
TESTFLIGHT_GROUP: ${{ inputs.testFlightGroup }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
@@ -241,7 +246,7 @@ jobs:
app_identifier:"xyz.blueskyweb.app" \
app_version:"$APP_VERSION" \
build_number:"$BUILD_NUMBER" \
groups:"QA Team" \
groups:"$TESTFLIGHT_GROUP" \
notify_external_testers:true
- name: 🔔 Notify Slack of Production Build
+1 -1
View File
@@ -77,7 +77,7 @@ jobs:
uses: ./.github/workflows/build-submit-ios.yml
with:
profile: testflight
assignTestFlightGroup: true
testFlightGroup: "QA Team"
secrets: inherit
android:
+3 -1
View File
@@ -17,6 +17,9 @@ jobs:
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
# Don't persist the checkout auth header; the push below authenticates
# with the app token embedded in the remote URL instead
persist-credentials: false
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
@@ -33,6 +36,5 @@ jobs:
run: |
git config user.name "github-actions"
git config user.email "test@users.noreply.github.com"
git config --unset-all http.https://github.com/.extraheader
git remote add internal https://x-access-token:${TOKEN}@github.com/bluesky-social/social-app-internal.git
git push internal main --force
+48 -2
View File
@@ -65,6 +65,7 @@ type discussionForumPosting struct {
CommentCount *int64 `json:"commentCount,omitempty"`
Comment []comment `json:"comment,omitempty"`
IsBasedOn string `json:"isBasedOn,omitempty"`
IsPartOf string `json:"isPartOf,omitempty"`
SharedContent *sharedContent `json:"sharedContent,omitempty"`
}
@@ -344,6 +345,43 @@ func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string {
return ""
}
// threadRootURI returns the AT-URI of the root post of the thread a reply
// belongs to, or "" if the post is not a reply or the record is malformed.
func threadRootURI(pv *appbsky.FeedDefs_PostView) string {
if pv == nil || pv.Record == nil {
return ""
}
rec, ok := pv.Record.Val.(*appbsky.FeedPost)
if !ok || rec.Reply == nil || rec.Reply.Root == nil {
return ""
}
return rec.Reply.Root.Uri
}
// findRootPostInParents walks tv's parent chain upward and returns the
// PostView whose URI matches rootURI, or nil if the root is not present.
// The root is absent when the chain is truncated by parentHeight (reply
// deeper than the fetched height) or broken by a blocked/not-found parent.
// Avoids a separate FeedGetPosts call when the thread response already
// contains the root.
func findRootPostInParents(tv *appbsky.FeedDefs_ThreadViewPost, rootURI string) *appbsky.FeedDefs_PostView {
if rootURI == "" {
return nil
}
for node := tv; node != nil; {
if node.Post != nil && node.Post.Uri == rootURI {
return node.Post
}
if node.Parent == nil {
return nil
}
// Only threadViewPost parents continue the chain; a blocked or
// not-found parent breaks it before reaching the root.
node = node.Parent.FeedDefs_ThreadViewPost
}
return nil
}
// buildAuthor constructs a Person. Organization classification for
// custom-domain accounts is a future enhancement.
func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
@@ -584,13 +622,21 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) c
// buildPostJSONLD marshals the WebPage envelope wrapping a
// DiscussionForumPosting. canonicalURL is used for both envelope.url and
// (as a fallback) mainEntity.url so they always agree.
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
// (as a fallback) mainEntity.url so they always agree. isPartOfURL, when
// non-empty, is the handle-form canonical URL of the thread root the handler
// resolved for a reply; pass "" to omit isPartOf (non-reply, or the root could
// not be resolved). We never emit a DID-form isPartOf because it would not
// match the root page's handle-form canonical.
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, isPartOfURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
if pv == nil || pv.Author == nil {
return "", fmt.Errorf("nil post view or author")
}
node := buildPostNode(pv, replies, hideLabels, hideReplyLabels)
if isPartOfURL != "" {
node.IsPartOf = isPartOfURL
}
// mainEntity.url is empty when the author handle is unusable; fall back
// to canonicalURL so it agrees with envelope.url.
if node.URL == "" {
+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.
func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
@@ -263,7 +276,7 @@ func unmarshalLD(t *testing.T, s string) map[string]any {
func TestBuildPostJSONLD_Bare(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -333,7 +346,7 @@ func TestBuildPostJSONLD_WithImages(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -358,7 +371,7 @@ func TestBuildPostJSONLD_WithGallery(t *testing.T) {
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
thumb3 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g3@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2, thumb3))
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -385,7 +398,7 @@ func TestBuildPostJSONLD_GalleryInRecordWithMedia(t *testing.T) {
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery",
withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb))
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -438,7 +451,7 @@ func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) {
func TestBuildPostJSONLD_WithVideo(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["thumbnailUrl"] != thumb {
t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"])
@@ -451,7 +464,7 @@ func TestBuildPostJSONLD_WithVideo(t *testing.T) {
func TestBuildPostJSONLD_QuotePost(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting!", withQuotePost("bob.example.com", "did:plc:bob", "xyz"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
t.Errorf("isBasedOn wrong: %v", main["isBasedOn"])
@@ -460,16 +473,122 @@ func TestBuildPostJSONLD_QuotePost(t *testing.T) {
func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting blocked", withQuotePostBlocked())
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["isBasedOn"]; present {
t.Errorf("blocked quote should not produce isBasedOn")
}
}
func TestBuildPostJSONLD_IsPartOf(t *testing.T) {
// isPartOf is sourced solely from the handler-resolved URL. When supplied,
// it is emitted on the main post; when empty, no isPartOf is present.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply")
isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
out, _ := buildPostJSONLD(pv, nil, "u", isPartOf, hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["isPartOf"] != isPartOf {
t.Errorf("isPartOf = %v, want %v", main["isPartOf"], isPartOf)
}
out, _ = buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main = unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["isPartOf"]; present {
t.Errorf("empty isPartOfURL should omit isPartOf, got %v", main["isPartOf"])
}
}
func TestThreadRootURI(t *testing.T) {
// A reply returns its root AT-URI; a non-reply returns "".
reply := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply",
withReplyRoot("did:plc:root", "rootrkey"))
want := "at://did:plc:root/app.bsky.feed.post/rootrkey"
if got := threadRootURI(reply); got != want {
t.Errorf("threadRootURI = %q, want %q", got, want)
}
post := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "not a reply")
if got := threadRootURI(post); got != "" {
t.Errorf("threadRootURI on non-reply = %q, want empty", got)
}
}
func TestFindRootPostInParents(t *testing.T) {
rootPost := makePostView("root.bsky.social", "did:plc:root", "rootrkey", "root")
rootURI := rootPost.Uri
// tvp wraps a PostView, optionally chaining to a parent thread node.
tvp := func(pv *appbsky.FeedDefs_PostView, parent *appbsky.FeedDefs_ThreadViewPost) *appbsky.FeedDefs_ThreadViewPost {
node := &appbsky.FeedDefs_ThreadViewPost{Post: pv}
if parent != nil {
node.Parent = &appbsky.FeedDefs_ThreadViewPost_Parent{FeedDefs_ThreadViewPost: parent}
}
return node
}
t.Run("direct reply, parent is root", func(t *testing.T) {
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
if got := findRootPostInParents(leaf, rootURI); got != rootPost {
t.Errorf("expected root post, got %v", got)
}
})
t.Run("multi-level chain", func(t *testing.T) {
mid := tvp(makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid"), tvp(rootPost, nil))
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), mid)
if got := findRootPostInParents(leaf, rootURI); got != rootPost {
t.Errorf("expected root post in chain, got %v", got)
}
})
t.Run("root absent, chain truncated", func(t *testing.T) {
// Topmost parent is not the root (e.g. parentHeight cut off the chain).
topmost := makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid")
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(topmost, nil))
if got := findRootPostInParents(leaf, rootURI); got != nil {
t.Errorf("expected nil when root absent, got %v", got)
}
})
t.Run("chain broken by blocked parent", func(t *testing.T) {
// A blocked/not-found parent yields a nil FeedDefs_ThreadViewPost,
// breaking the walk before the root.
leaf := &appbsky.FeedDefs_ThreadViewPost{
Post: makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"),
Parent: &appbsky.FeedDefs_ThreadViewPost_Parent{
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: rootURI},
},
}
if got := findRootPostInParents(leaf, rootURI); got != nil {
t.Errorf("expected nil when chain broken by blocked parent, got %v", got)
}
})
t.Run("empty rootURI", func(t *testing.T) {
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
if got := findRootPostInParents(leaf, ""); got != nil {
t.Errorf("expected nil for empty rootURI, got %v", got)
}
})
}
func TestBuildPostJSONLD_ReplyCommentsNoIsPartOf(t *testing.T) {
// Replies surfaced under the main post as comment[] are Comment nodes and
// never carry isPartOf, even when the main post has one.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "a reply")
isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", isPartOf, hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
if _, present := c["isPartOf"]; present {
t.Errorf("comment entries should not carry isPartOf, got %v", c["isPartOf"])
}
}
func TestBuildPostJSONLD_ExternalEmbed(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "check this out", withExternalEmbed("https://www.spiegel.de/article", "Title"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
sc, ok := main["sharedContent"].(map[string]any)
if !ok {
@@ -487,7 +606,7 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
thumb := "https://cdn.bsky.app/img/x@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withImages(thumb), withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["image"]; present {
t.Errorf("hidden-embed post should not emit image")
@@ -506,7 +625,7 @@ func TestBuildPostJSONLD_HiddenEmbed_Gallery(t *testing.T) {
thumb := "https://cdn.bsky.app/img/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withGallery(thumb), withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["image"]; present {
t.Errorf("hidden-embed gallery post should not emit image")
@@ -520,7 +639,7 @@ func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
// Includes ", \, newline, </script>, and a unicode char.
tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", tricky)
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -556,7 +675,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: "at://x/y/z"},
})
out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if cc := main["commentCount"].(float64); int64(cc) != 14 {
@@ -598,7 +717,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) {
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello")
fallback := "https://bsky.app/profile/did:plc:alice/post/abc123"
out, _ := buildPostJSONLD(pv, nil, fallback, hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, fallback, "", hideEmbedLabels, hideReplyLabels)
envelope := unmarshalLD(t, out)
main := envelope["mainEntity"].(map[string]any)
// mainEntity.url falls back to the caller's canonical URL so envelope
@@ -647,7 +766,7 @@ func TestBuildPostJSONLD_EnvelopeURLMatchesMainEntity(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
pv := makePostView(tc.handle, tc.did, tc.rkey, "hi")
out, _ := buildPostJSONLD(pv, nil, tc.canonical, hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, tc.canonical, "", hideEmbedLabels, hideReplyLabels)
env := unmarshalLD(t, out)
main := env["mainEntity"].(map[string]any)
if env["url"] != tc.canonical {
@@ -664,7 +783,7 @@ func TestBuildPostJSONLD_NilAuthor(t *testing.T) {
// Defensive: don't panic if Author is nil.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
pv.Author = nil
if _, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels); err == nil {
if _, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels); err == nil {
t.Errorf("expected error for nil-author post, got nil")
}
}
@@ -682,7 +801,7 @@ func TestBuildPostJSONLD_NilAuthorReply(t *testing.T) {
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: goodReply}},
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: badReply}},
}
out, err := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -703,7 +822,7 @@ func TestBuildPostJSONLD_CommentMedia(t *testing.T) {
replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply}},
}
out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
imgs, ok := c["image"].([]any)
@@ -910,7 +1029,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_PostViewLabel(t *testing.T) {
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
withPostLabel("!hide", false))
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected only the unlabeled reply to remain, got %v", ids)
@@ -923,7 +1042,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_SelfLabel(t *testing.T) {
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
withSelfLabel("spam"))
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected self-labeled reply dropped, got %v", ids)
@@ -939,7 +1058,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_EmbedLabel(t *testing.T) {
// union behavior.
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "concerning reply",
withPostLabel("self-harm", false))
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected embed-labeled reply dropped, got %v", ids)
@@ -951,7 +1070,7 @@ func TestBuildPostJSONLD_NegatedHideLabelKept(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "fine reply",
withPostLabel("!hide", true))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != reply.Uri {
t.Errorf("expected negated-label reply to be kept, got %v", ids)
@@ -962,7 +1081,7 @@ func TestBuildPostJSONLD_ReplyAuthorHasIdentifier(t *testing.T) {
// Reply author should also carry a DID identifier.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi")
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
auth, ok := c["author"].(map[string]any)
@@ -1103,7 +1222,7 @@ func TestBuildPostJSONLD_AuthorReviewedBy(t *testing.T) {
})
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi",
withVerifications(state))
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -1143,7 +1262,7 @@ func TestBuildPostJSONLD_ReplyAuthorNoReviewedBy(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi",
withVerifications(state))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
auth := c["author"].(map[string]any)
@@ -1267,7 +1386,7 @@ func TestBuildPostJSONLD_WithVideoObject(t *testing.T) {
hasAspect: true, width: 16, height: 9,
}))
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -1315,7 +1434,7 @@ func TestBuildPostJSONLD_VideoNameFallback(t *testing.T) {
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8",
}))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
@@ -1332,7 +1451,7 @@ func TestBuildPostJSONLD_VideoNameFallbackHandleInvalid(t *testing.T) {
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8",
}))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any)
if video["name"] != "Video on Bluesky" {
@@ -1347,7 +1466,7 @@ func TestBuildPostJSONLD_VideoDescriptionFallback(t *testing.T) {
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", alt: "scenic clip",
}))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any)
if video["description"] != "scenic clip" {
@@ -1360,7 +1479,7 @@ func TestBuildPostJSONLD_VideoNoAspectRatio(t *testing.T) {
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", alt: "alt",
}))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any)
if _, present := video["width"]; present {
@@ -1377,7 +1496,7 @@ func TestBuildPostJSONLD_VideoMissingPlaylist(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
withVideo(thumb))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present {
t.Errorf("video without playlist should not produce VideoObject")
@@ -1396,7 +1515,7 @@ func TestBuildPostJSONLD_VideoHiddenEmbed(t *testing.T) {
alt: "should be dropped",
}),
withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present {
t.Errorf("hidden-embed post should not emit video")
@@ -1411,7 +1530,7 @@ func TestBuildPostJSONLD_VideoInRecordWithMedia(t *testing.T) {
thumbnail: thumb, playlist: playlist, alt: "alt", recordMedia: true,
hasAspect: true, width: 4, height: 3,
}))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
@@ -1438,7 +1557,7 @@ func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
withVideoFull(videoEmbedOpts{
thumbnail: thumb, playlist: playlist, alt: "bob's clip",
}))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
video, ok := c["video"].(map[string]any)
@@ -1461,7 +1580,7 @@ func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
func TestBuildPostJSONLD_NoVideoNoField(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no embed")
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present {
t.Errorf("post without video should not include video field")
@@ -1505,7 +1624,7 @@ func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL(t *testing.T) {
playlist: playlist, alt: "scenic clip",
}))
canonical := "https://bsky.app/profile/did:plc:alice/post/abc123"
out, _ := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
@@ -1528,7 +1647,7 @@ func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL_Reply(t *testing.T) {
withVideoFull(videoEmbedOpts{
playlist: playlist, alt: "bob's clip",
}))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
video, ok := c["video"].(map[string]any)
+17
View File
@@ -20,3 +20,20 @@ func profileRequiresAuth(pv *appbsky.ActorDefs_ProfileViewDetailed) bool {
}
return false
}
// postAuthorRequiresAuth reports whether the post author self-applied the
// !no-unauthenticated label, read from the author view embedded in a
// getPostThread response. The appview surfaces the account's profile-record
// self-labels on the post author (src == author DID), so this mirrors
// profileRequiresAuth without a separate ActorGetProfile call.
func postAuthorRequiresAuth(pv *appbsky.FeedDefs_PostView) bool {
if pv == nil || pv.Author == nil {
return false
}
for _, label := range pv.Author.Labels {
if label.Src == pv.Author.Did && label.Val == "!no-unauthenticated" {
return true
}
}
return false
}
+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) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -81,7 +81,7 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
@@ -113,7 +113,7 @@ func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
thumbs := extractPostMedia(pv, false)
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
@@ -142,7 +142,7 @@ func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo",
@@ -208,7 +208,7 @@ func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) {
// og:url and <link rel="canonical"> must emit the same URL.
func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
@@ -233,7 +233,7 @@ func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
// video without a thumbnail dropped og:video entirely.
func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch")
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
videoURL := "https://video.bsky.app/v.m3u8"
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
@@ -286,7 +286,7 @@ func TestRenderProfile_AuthRequiredNoindex(t *testing.T) {
// flip of the noindex flag for indexable pages.
func TestRenderPost_PublicNoNoindex(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
+55 -27
View File
@@ -620,23 +620,39 @@ func (srv *Server) WebPost(c echo.Context) error {
identifier := handleOrDID.Normalize().String()
// requires two fetches: first fetch profile (!)
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, identifier)
if err != nil {
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
return c.Render(http.StatusOK, "post.html", data)
}
unauthedViewingOkay := !profileRequiresAuth(pv)
req := c.Request()
requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
// Fetch the post thread directly. The AT-URI authority accepts either a
// handle or a DID (the appview resolves it), so we skip the separate
// ActorGetProfile call and source identity, the canonical URL, and the
// auth gate from the thread response's author view instead.
// parentHeight=80 (the lexicon default) pulls the reply's ancestor chain
// up to the root in nearly all threads, letting isPartOf resolve from this
// response without a separate FeedGetPosts call.
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", identifier, rkey)
tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 80, uri)
if err != nil {
log.Warnf("failed to fetch post: %s\t%v", uri, err)
return c.Render(http.StatusOK, "post.html", data)
}
threadView := tpv.Thread.FeedDefs_ThreadViewPost
if threadView == nil || threadView.Post == nil || threadView.Post.Author == nil {
return c.Render(http.StatusOK, "post.html", data)
}
postView := threadView.Post
// Always prefer the handle-form URL so JSON-LD `url` and
// <link rel="canonical"> match. Falls back to requestURI when the
// handle is unusable (template strips query/fragment).
canonicalURL := bskyPostURL(pv.Handle, rkey.String())
canonicalURL := bskyPostURL(postView.Author.Handle, rkey.String())
if !unauthedViewingOkay {
// Gate before populating any post content into the template so that
// !no-unauthenticated posts never leak text/media. The appview returns the
// post (with the author self-label) to unauthed callers, so we detect the
// label here rather than via a profile fetch.
if postAuthorRequiresAuth(postView) {
// Provide minimal OpenGraph data for auth-required posts
data["requestURI"] = requestURI
if canonicalURL != "" {
@@ -645,26 +661,13 @@ func (srv *Server) WebPost(c echo.Context) error {
data["requiresAuth"] = true
data["noindex"] = true
data["nofollow"] = true
data["profileHandle"] = pv.Handle
if pv.DisplayName != nil {
data["profileDisplayName"] = *pv.DisplayName
data["profileHandle"] = postView.Author.Handle
if postView.Author.DisplayName != nil {
data["profileDisplayName"] = *postView.Author.DisplayName
}
return c.Render(http.StatusOK, "post.html", data)
}
// then fetch the post thread (with extra context)
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", pv.Did, rkey)
tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 0, uri)
if err != nil {
log.Warnf("failed to fetch post: %s\t%v", uri, err)
return c.Render(http.StatusOK, "post.html", data)
}
threadView := tpv.Thread.FeedDefs_ThreadViewPost
if threadView == nil || threadView.Post == nil {
return c.Render(http.StatusOK, "post.html", data)
}
postView := threadView.Post
data["postView"] = postView
data["requestURI"] = requestURI
if canonicalURL != "" {
@@ -694,7 +697,32 @@ func (srv *Server) WebPost(c echo.Context) error {
if jsonldURL == "" {
jsonldURL = requestURI
}
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, hideEmbedLabels, hideReplyLabels); err == nil {
// Best-effort: resolve a reply's thread root to its handle-form canonical
// URL for isPartOf. Prefer the root already present in the thread response
// (parentHeight=80); fall back to a bounded FeedGetPosts only when the
// chain is truncated (very deep thread) or broken by a blocked/not-found
// ancestor. On timeout, error, or an unresolvable root we omit isPartOf
// rather than point at a non-indexable page.
isPartOfURL := ""
if rootURI := threadRootURI(postView); rootURI != "" {
if rootPost := findRootPostInParents(threadView, rootURI); rootPost != nil && rootPost.Author != nil {
isPartOfURL = bskyPostURLFromATURI(rootPost.Author.Handle, rootURI)
} else {
pctx, cancel := context.WithTimeout(ctx, 1*time.Second)
if posts, perr := appbsky.FeedGetPosts(pctx, srv.xrpcc, []string{rootURI}); perr != nil {
log.Warnf("failed to resolve thread root post for isPartOf: %s\t%v", rootURI, perr)
} else if len(posts.Posts) > 0 && posts.Posts[0].Author != nil {
// Handle-form only (no DID fallback): isPartOf must match the
// root page's handle-form canonical, so an unusable handle omits
// isPartOf rather than point at a non-canonical DID-form URL.
isPartOfURL = bskyPostURLFromATURI(posts.Posts[0].Author.Handle, rootURI)
}
cancel()
}
}
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, isPartOfURL, hideEmbedLabels, hideReplyLabels); err == nil {
data["postJSONLD"] = jsonld
} else {
log.Warnf("failed to build post JSON-LD for %s: %v", uri, err)
@@ -399,7 +399,7 @@ function AccessSection() {
locationControl.open()
})}>
Tap here to update your location with GPS.
</SimpleInlineLinkText>{' '}
</SimpleInlineLinkText>
</Trans>
</Admonition>
-1
View File
@@ -10,7 +10,6 @@ export enum Features {
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
LiveNowBetaDisable = 'live_now_beta:disable',
GroupChatsDisable = 'group_chats:disable',
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
+1 -1
View File
@@ -1319,7 +1319,7 @@ export type Events = {
'invite:action:scan': {}
// user changed the QR card color theme
'invite:theme:change': {
themeKey: 'dawn' | 'day' | 'dusk' | 'night'
themeKey: 'dawn' | 'sunlight' | 'day' | 'dusk' | 'twilight' | 'night'
}
// QR scanner decoded a code; result indicates whether it resolved to a profile
'invite:scanner:scanned': {
+2
View File
@@ -615,6 +615,8 @@ export function Outer({
label?: string
style?: StyleProp<ViewStyle>
align?: 'left' | 'right'
/** Web only. Native restores focus differently. */
onCloseAutoFocus?: (event: Event) => void
}) {
const t = useTheme()
const context = useContextMenuContext()
+3 -1
View File
@@ -34,14 +34,16 @@ export function Outer({
children,
label,
style,
onCloseAutoFocus,
}: {
children: React.ReactNode
label?: string
style?: StyleProp<ViewStyle>
onCloseAutoFocus?: (event: Event) => void
}) {
const t = useTheme()
return (
<Menu.Outer style={style}>
<Menu.Outer style={style} onCloseAutoFocus={onCloseAutoFocus}>
{label ? (
<Text
numberOfLines={1}
+14 -8
View File
@@ -6,9 +6,7 @@ import {
View,
type ViewStyle,
} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import flattenReactChildren from 'react-keyed-flatten-children'
import {atoms as a, useTheme} from '#/alf'
@@ -32,6 +30,11 @@ import {
import {Text} from '#/components/Typography'
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 {
type DialogControlProps as MenuControlProps,
useDialogControl as useMenuControl,
@@ -101,16 +104,19 @@ export function Outer({
onCloseAutoFocus?: (event: Event) => void
}>) {
const context = useMenuContext()
const {_} = useLingui()
const {t: l} = useLingui()
return (
<Dialog.Outer
control={context.control}
nativeOptions={{preventExpansion: true}}>
nativeOptions={{
preventExpansion: true,
minHeight: IS_IOS ? IOS_MENU_MIN_HEIGHT : undefined,
}}>
<Dialog.Handle />
{/* Re-wrap with context since Dialogs are portal-ed to root */}
<Context.Provider value={context}>
<Dialog.ScrollableInner label={_(msg`Menu`)}>
<Dialog.ScrollableInner label={l`Menu`}>
<View style={[a.gap_lg]}>
{children}
{IS_NATIVE && showCancel && <Cancel />}
@@ -353,12 +359,12 @@ export function Group({children, style}: GroupProps) {
}
function Cancel() {
const {_} = useLingui()
const {t: l} = useLingui()
const context = useMenuContext()
return (
<Button
label={_(msg`Close this dialog`)}
label={l`Close this dialog`}
size="small"
variant="ghost"
color="secondary"
+2 -6
View File
@@ -40,11 +40,7 @@ import {ModeratedFeedEmbed} from './FeedEmbed'
import {ImageEmbed} from './ImageEmbed'
import {ModeratedListEmbed} from './ListEmbed'
import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder'
import {
type CommonProps,
type EmbedProps,
type PostEmbedViewContext,
} from './types'
import {type CommonProps, type EmbedProps, PostEmbedViewContext} from './types'
import {VideoEmbed} from './VideoEmbed'
export {PostEmbedViewContext} from './types'
@@ -356,7 +352,7 @@ export function QuoteEmbed({
return (
<GalleryBleed>
<View
style={[a.mt_sm]}
style={[viewContext !== PostEmbedViewContext.ChatMessage && a.mt_sm]}
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
<ContentHider
@@ -168,7 +168,7 @@ export function GroupChatsAnnouncement() {
a.font_medium,
{color: t.palette.primary_500},
]}>
<Trans>New</Trans>
<Trans context="nux-description">New</Trans>
</Text>
</View>
<Text
+17 -2
View File
@@ -60,6 +60,20 @@ export let MessageContextMenu = ({
const langPrefs = useLanguagePrefs()
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 isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable)
@@ -161,11 +175,12 @@ export let MessageContextMenu = ({
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
timeStyle: 'short',
})}`}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}
onCloseAutoFocus={onCloseAutoFocus}>
<ContextMenu.Item
testID="messageDropdownReplyBtn"
label={l`Reply`}
onPress={() => setReply(message)}>
onPress={onReply}>
<ContextMenu.ItemIcon icon={ReplyIcon} position="left" />
<ContextMenu.ItemText>{l`Reply`}</ContextMenu.ItemText>
</ContextMenu.Item>
-3
View File
@@ -77,9 +77,6 @@ let MessageItemEmbed = ({
minWidth: 280,
maxWidth: 360,
}),
// Cancel out the embed's internal a.mt_sm so the container's
// CLUSTERED_MESSAGE_GAP (2px) is the only spacing applied
{marginTop: -a.mt_sm.marginTop},
]}>
<Animated.View
style={[a.rounded_xl, a.overflow_hidden, radiiStyle, highlightStyle]}>
+2 -2
View File
@@ -362,14 +362,14 @@ function MutualGroupChat({
<Button
color="negative_subtle"
disabled={isRemovePending}
label={l`Kick member`}
label={l`Remove member`}
size="small"
onPress={() => {
onOptimisticallyRemoveConvo(view.id)
removeMembers({members: [profileDid]})
}}>
<ButtonText>
<Trans>Kick member</Trans>
<Trans>Remove member</Trans>
</ButtonText>
{isRemovePending ? <ButtonIcon icon={Loader} /> : null}
</Button>
@@ -26,8 +26,10 @@ export function ThemePicker({
const labels: Record<InviteThemeKey, string> = {
dawn: l`Dawn`,
sunlight: l`Sunlight`,
day: l`Day`,
dusk: l`Dusk`,
twilight: l`Twilight`,
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'
describe('invite themes', () => {
it('exposes four themes in canonical order', () => {
expect(INVITE_THEME_KEYS).toEqual(['dawn', 'day', 'dusk', 'night'])
it('exposes six themes in canonical order', () => {
expect(INVITE_THEME_KEYS).toEqual([
'dawn',
'sunlight',
'day',
'dusk',
'twilight',
'night',
])
})
it('has light and dark variants for every theme', () => {
+45 -1
View File
@@ -5,7 +5,13 @@
* matching primary color; the QR data uses qrPrimary; the avatar handle text
* follows handleColor.
*/
export type InviteThemeKey = 'dawn' | 'day' | 'dusk' | 'night'
export type InviteThemeKey =
| 'dawn'
| 'sunlight'
| 'day'
| 'dusk'
| 'twilight'
| 'night'
export type InviteThemeVariant = {
/** QR data + eye color */
@@ -30,8 +36,10 @@ export type InviteTheme = {
export const INVITE_THEME_KEYS: readonly InviteThemeKey[] = [
'dawn',
'sunlight',
'day',
'dusk',
'twilight',
'night',
] as const
@@ -54,6 +62,24 @@ export const INVITE_THEMES: Record<InviteThemeKey, InviteTheme> = {
handleColor: '#ffffff',
},
},
sunlight: {
key: 'sunlight',
swatch: '#ff8159',
light: {
qrPrimary: '#ff8159',
gradientFrom: '#ffc785',
gradientTo: '#ff8159',
shadowColor: '#ff8159',
handleColor: '#ffffff',
},
dark: {
qrPrimary: '#ff8159',
gradientFrom: '#ffc785',
gradientTo: '#ff8159',
shadowColor: '#ff8159',
handleColor: '#ffffff',
},
},
day: {
key: 'day',
swatch: '#006aff',
@@ -90,6 +116,24 @@ export const INVITE_THEMES: Record<InviteThemeKey, InviteTheme> = {
handleColor: '#ffffff',
},
},
twilight: {
key: 'twilight',
swatch: '#8b60f7',
light: {
qrPrimary: '#8b60f7',
gradientFrom: '#8ec1ff',
gradientTo: '#8b60f7',
shadowColor: '#8b60f7',
handleColor: '#ffffff',
},
dark: {
qrPrimary: '#8b60f7',
gradientFrom: '#8ec1ff',
gradientTo: '#8b60f7',
shadowColor: '#8b60f7',
handleColor: '#ffffff',
},
},
night: {
key: 'night',
swatch: '#0048ad',
+10 -10
View File
@@ -1145,7 +1145,7 @@ msgid "Add image"
msgstr ""
#. Accessibility label for button in composer to add images, a video, or a GIF to a post
#: src/view/com/composer/SelectMediaButton.tsx:505
#: src/view/com/composer/SelectMediaButton.tsx:511
msgid "Add media to post"
msgstr ""
@@ -8053,11 +8053,11 @@ msgstr ""
msgid "One or more images is missing alt text."
msgstr ""
#: src/view/com/composer/SelectMediaButton.tsx:417
#: src/view/com/composer/SelectMediaButton.tsx:423
msgid "One or more of your selected files are not supported."
msgstr ""
#: src/view/com/composer/SelectMediaButton.tsx:440
#: src/view/com/composer/SelectMediaButton.tsx:446
msgid "One or more of your selected files are too large. Maximum size is {VIDEO_MAX_SIZE_MB} MB."
msgstr "One or more of your selected files are too large. Maximum size is {VIDEO_MAX_SIZE_MB} MB."
@@ -8308,7 +8308,7 @@ msgid "Opens device camera"
msgstr ""
#. Accessibility hint for button in composer to add images, a video, or a GIF to a post.
#: src/view/com/composer/SelectMediaButton.tsx:511
#: src/view/com/composer/SelectMediaButton.tsx:517
msgid "Opens device gallery to select up to {MAX_GALLERY_IMAGES, plural, other {# images}}, or a single video or GIF."
msgstr "Opens device gallery to select up to {MAX_GALLERY_IMAGES, plural, other {# images}}, or a single video or GIF."
@@ -10455,7 +10455,7 @@ msgstr ""
msgid "Select your preferred notification channels"
msgstr ""
#: src/view/com/composer/SelectMediaButton.tsx:420
#: src/view/com/composer/SelectMediaButton.tsx:426
msgid "Selecting multiple media types is not supported."
msgstr ""
@@ -13092,7 +13092,7 @@ msgstr ""
msgid "Videos"
msgstr ""
#: src/view/com/composer/SelectMediaButton.tsx:434
#: src/view/com/composer/SelectMediaButton.tsx:440
msgid "Videos must be less than 3 minutes long."
msgstr ""
@@ -13816,11 +13816,11 @@ msgstr ""
msgid "You can only save drafts up to 1000 characters. Would you like to discard this post before viewing your drafts?"
msgstr ""
#: src/view/com/composer/SelectMediaButton.tsx:437
#: src/view/com/composer/SelectMediaButton.tsx:443
msgid "You can only select one GIF at a time."
msgstr ""
#: src/view/com/composer/SelectMediaButton.tsx:431
#: src/view/com/composer/SelectMediaButton.tsx:437
msgid "You can only select one video at a time."
msgstr ""
@@ -13833,7 +13833,7 @@ msgid "You can read chat history but 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.
#: src/view/com/composer/SelectMediaButton.tsx:423
#: src/view/com/composer/SelectMediaButton.tsx:429
msgid "You can select up to {MAX_GALLERY_IMAGES, plural, other {# images}} in total."
msgstr "You can select up to {MAX_GALLERY_IMAGES, plural, other {# images}} in total."
@@ -14028,7 +14028,7 @@ msgstr ""
msgid "You must grant access to your photo library to save a QR code"
msgstr ""
#: src/view/com/composer/SelectMediaButton.tsx:466
#: src/view/com/composer/SelectMediaButton.tsx:472
msgid "You need to allow access to your media library."
msgstr ""
+96 -23
View File
@@ -3,14 +3,21 @@ import {View} from 'react-native'
import {useAnimatedRef} from 'react-native-reanimated'
import {type ChatBskyActorGetStatus, type ChatBskyConvoDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {
useFocusEffect,
useIsFocused,
useNavigation,
} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useAppState} from '#/lib/appState'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {type MessagesTabNavigatorParams} from '#/lib/routes/types'
import {
type MessagesTabNavigatorParams,
type NavigationProp,
} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {listenSoftReset} from '#/state/events'
@@ -19,6 +26,7 @@ import {useMessagesEventBus} from '#/state/messages/events'
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
import {useUnreadCountsQuery} from '#/state/queries/messages/get-unread-counts'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
import {EmptyState} from '#/view/com/util/EmptyState'
import {List, type ListRef} from '#/view/com/util/List'
import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
@@ -31,6 +39,7 @@ import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import {BubbleSmile_Stroke2_Corner2_Rounded_Large as BubbleSmileIcon} from '#/components/icons/Bubble'
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {Inbox_Stroke2_Corner2_Rounded_Large as InboxLargeIcon} from '#/components/icons/Inbox'
import {
@@ -41,6 +50,8 @@ import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/component
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import * as Menu from '#/components/Menu'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
import {IS_NATIVE, IS_WEB} from '#/env'
@@ -289,7 +300,10 @@ export function ChatList({
isError,
error,
refetch,
} = useListConvosQuery({status: 'accepted'})
} = useListConvosQuery({
status: 'accepted',
kind: aa.flags.groupChatDisabled ? 'direct' : 'all',
})
const {refetch: refetchInbox} = useListConvosQuery({
status: 'request',
@@ -546,16 +560,19 @@ export function Header({
variant="solid"
action={action}
/>
<Link
to="/messages/settings"
action={action}
label={l`Chat settings`}
size="small"
color="secondary"
shape="round"
style={[a.justify_center]}>
<ButtonIcon icon={SettingsIcon} />
</Link>
<ChatSettingsMenu action={action}>
{({props}) => (
<Button
{...props}
label={l`Chat options`}
size="small"
color="secondary"
shape="round"
style={[a.justify_center]}>
<ButtonIcon icon={SettingsIcon} />
</Button>
)}
</ChatSettingsMenu>
{!chatStatus?.chatDisabled && (
<Button
label={l`New chat`}
@@ -578,19 +595,75 @@ export function Header({
</Layout.Header.Content>
<InboxRequests count={requestCount} variant="ghost" />
<Layout.Header.Slot>
<Link
to="/messages/settings"
label={l`Chat settings`}
size="small"
variant="ghost"
color="secondary"
shape="round"
style={[a.justify_center]}>
<ButtonIcon icon={SettingsIcon} size="lg" />
</Link>
<ChatSettingsMenu action={action}>
{({props}) => (
<Button
{...props}
label={l`Chat options`}
size="small"
variant="ghost"
color="secondary"
shape="round"
style={[a.justify_center]}>
<ButtonIcon icon={SettingsIcon} size="lg" />
</Button>
)}
</ChatSettingsMenu>
</Layout.Header.Slot>
</>
)}
</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, {
onSuccess: () => {
ax.metric('groupchat:owner:joinRequest:reject', {convoId})
Toast.show(l`Request ignored.`)
Toast.show(l`Request rejected.`)
if (getRemainingRequestCount() < 1) {
navigation.replace('MessagesConversationSettings', {
conversation: convoId,
@@ -220,7 +220,7 @@ function JoinRequestsList({
}
},
onError: error => {
let errorMessage = l`Failed to ignore join request`
let errorMessage = l`Failed to reject join request`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (
@@ -230,7 +230,7 @@ function JoinRequestsList({
} else if (
error instanceof ChatBskyGroupRejectJoinRequest.InsufficientRoleError
) {
errorMessage = l`Only admins can ignore join requests.`
errorMessage = l`Only admins can reject join requests.`
}
Toast.show(errorMessage, {type: 'error'})
},
@@ -77,6 +77,15 @@ export function MessageComposer({
composerInternalApiRef.current?.input?.focus()
}, [replyTo, composerInternalApiRef])
// On web, focus the input once the conversation is ready. The composer also
// mounts during the loading state (when it isn't editable), so a mount-time
// autoFocus would fire too early to land focus.
useEffect(() => {
if (IS_WEB && editable) {
composerInternalApiRef.current?.input?.focus()
}
}, [editable, composerInternalApiRef])
// Android interactive dismiss sometimes doesn't blur the input
const blur = useNonReactiveCallback(() => {
composerInternalApiRef.current?.input?.blur()
@@ -243,13 +252,12 @@ export function MessageComposer({
placeholder={
loading
? l({message: 'Loading chat…', context: 'placeholder'})
: l({message: 'Message', context: 'action'})
: l({message: 'Message', context: 'description'})
}
autocompletePlacement="top-start"
internalApiRef={composerInternalApiRef}
defaultValue={text}
editable={editable}
autoFocus={IS_WEB}
maxRows={12}
outerStyle={[a.flex_1]}
contentTextStyle={[a.text_md, a.leading_snug]}
@@ -334,8 +342,7 @@ function SubmitButton({
)
}
// TODO: remove export when MessageInput is deleted
export function ComposerContainer({children}: {children: React.ReactNode}) {
function ComposerContainer({children}: {children: React.ReactNode}) {
const {bottom: bottomInset} = useSafeAreaInsets()
const {progress} = useReanimatedKeyboardAnimation()
const t = useTheme()
@@ -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 {List, type ListMethods} from '#/view/com/util/List'
import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
import {MessageInput} from '#/screens/Messages/components/MessageInput'
import {MessageListError} from '#/screens/Messages/components/MessageListError'
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {DateDivider} from '#/components/dms/DateDivider'
@@ -704,9 +703,6 @@ export function MessagesList({
messageEmbed={messageEmbed}
setEmbed={setEmbed}
loading={loading}
useNewComposer={ax.features.enabled(
ax.features.DmsNewMessageComposerEnable,
)}
/>
)}
</ConversationFooter>
@@ -735,7 +731,6 @@ function Composer({
messageEmbed,
setEmbed,
loading,
useNewComposer,
}: {
textInputId: string
onSendMessage: (
@@ -746,7 +741,6 @@ function Composer({
messageEmbed: MessageEmbedState | undefined
setEmbed: (embedUrl: string | undefined) => void
loading?: boolean
useNewComposer: boolean
}) {
const handleSendMessage = useNonReactiveCallback(
(
@@ -758,31 +752,16 @@ function Composer({
},
)
const previews = (
<>
<MessageInputReply />
<MessageInputEmbed embed={messageEmbed} setEmbed={setEmbed} />
</>
)
return useNewComposer ? (
return (
<MessageComposer
textInputId={textInputId}
onSendMessage={handleSendMessage}
messageEmbed={messageEmbed}
setEmbed={setEmbed}
loading={loading}>
{previews}
<MessageInputReply />
<MessageInputEmbed embed={messageEmbed} setEmbed={setEmbed} />
</MessageComposer>
) : (
<MessageInput
textInputId={textInputId}
onSendMessage={handleSendMessage}
messageEmbed={messageEmbed}
setEmbed={setEmbed}
loading={loading}>
{previews}
</MessageInput>
)
}
@@ -292,7 +292,10 @@ export function HeaderLabelerButtons({
testID="profileHeaderEditProfileButton"
size="small"
color="secondary"
onPress={editProfileControl.open}
onPress={() => {
playHaptic('Light')
editProfileControl.open()
}}
label={_(msg`Edit profile`)}
style={a.rounded_full}>
<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 Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'
import {AppBskyGraphStarterpack} from '@atproto/api'
import {tokens} from '@bsky.app/alf'
import {Trans, useLingui} from '@lingui/react/macro'
import {FEEDBACK_FORM_URL} from '#/lib/constants'
@@ -222,20 +223,27 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
a.align_center,
]}>
<AppLanguageDropdown />
<Text
style={[
a.flex_1,
t.atoms.text_contrast_medium,
!gtMobile && a.text_md,
]}>
<Trans>Having trouble?</Trans>{' '}
<InlineLinkText
label={l`Contact support`}
to={FEEDBACK_FORM_URL({email: state.email})}
style={[!gtMobile && a.text_md]}>
<Trans>Contact support</Trans>
</InlineLinkText>
</Text>
<View
style={
gtMobile
? [a.flex_1, a.flex, a.flex_row, a.justify_end]
: []
}>
<Text
style={[
t.atoms.text_contrast_medium,
!gtMobile && a.text_md,
{paddingInline: tokens.space.sm},
]}>
<Trans>Having trouble?</Trans>{' '}
<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>
</ScreenTransition>
+6
View File
@@ -132,6 +132,12 @@ export function ConvoProvider({
useEffect(() => {
const [root, id] = getConvoKey(convoId)
return queryClient.getQueryCache().subscribe(event => {
// Only react to data updates. Other event types (e.g. `added`) can be
// emitted synchronously while another component reads this same query
// during its render (React Query builds the query in `getOptimisticResult`),
// and committing to the convo store then would set state on this provider
// mid-render of that component.
if (event.type !== 'updated') return
const queryKey = event.query.queryKey as string[]
if (queryKey[0] === root && queryKey[1] === id) {
const data = event.query.state.data as
@@ -2,6 +2,7 @@ import {
type ChatBskyActorDefs,
type ChatBskyConvoDefs,
type ChatBskyConvoGetConvo,
type ChatBskyConvoGetUnreadCounts,
} from '@atproto/api'
import {
type QueryClient,
@@ -14,6 +15,11 @@ import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {STALE} from '#/state/queries'
import {useOnMarkAsRead} from '#/state/queries/messages/list-conversations'
import {useAgent} from '#/state/session'
import {
RQKEY_PARTIAL as UNREAD_COUNTS_PARTIAL_KEY,
UNREAD_ACCEPTED_CAP,
UNREAD_REQUEST_CAP,
} from './get-unread-counts'
import {
type ConvoListQueryData,
getConvoFromQueryData,
@@ -74,11 +80,81 @@ export function useMarkAsReadMutation() {
},
onMutate({convoId}) {
if (!convoId) throw new Error('No convoId provided')
// snapshot the list caches before the optimistic update so onError can
// restore the convo rows alongside the badge count
const prevListQueries = queryClient.getQueriesData<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)
// 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}) {
if (!convoId) return
// the optimistic badge arithmetic can drift from the server (e.g. a convo
// whose status differs between caches, or a sentinel-capped count). invalidate
// so the 15s-stale count query self-corrects on next access rather than
// waiting for a log event
void queryClient.invalidateQueries({queryKey: UNREAD_COUNTS_PARTIAL_KEY})
queryClient.setQueriesData(
{queryKey: [LIST_CONVOS_KEY]},
(old?: ConvoListQueryData) => {
@@ -10,6 +10,14 @@ export const RQKEY = (includeGroupChats: boolean) =>
[RQKEY_ROOT, includeGroupChats] as const
export const RQKEY_PARTIAL = [RQKEY_ROOT] as const
// the server sentinel-caps the badge counts: unreadAcceptedConvos maxes at 31
// (meaning "more than 30") and unreadRequestConvos at 11 (meaning "more than
// 10"). at the cap the value is no longer an exact count, so consumers must not
// treat it as one - both the optimistic decrement and the badge display ceiling
// key off these.
export const UNREAD_ACCEPTED_CAP = 31
export const UNREAD_REQUEST_CAP = 11
export function useUnreadCountsQuery() {
const agent = useAgent()
const {hasSession} = useSession()
@@ -23,6 +23,7 @@ import * as bsky from '#/types/bsky'
import {RQKEY as CONVO_KEY} from './conversation'
import {
RQKEY_PARTIAL as UNREAD_COUNTS_RQKEY_PARTIAL,
UNREAD_ACCEPTED_CAP,
useUnreadCountsQuery,
} from './get-unread-counts'
import {
@@ -858,7 +859,15 @@ export function useUnreadMessageCount(): {
const total = accepted + Math.min(request, 1)
return {
count: total,
numUnread: total > 10 ? '10+' : String(total),
// accepted is sentinel-capped at UNREAD_ACCEPTED_CAP (meaning "more than
// cap - 1"). show the "+" overflow label only when accepted is actually
// capped - the +1 request nudge must not trip it at exactly cap - 1
// accepted convos. otherwise clamp the number to cap - 1 so the nudge
// never surfaces the sentinel value (31) itself
numUnread:
accepted >= UNREAD_ACCEPTED_CAP
? `${UNREAD_ACCEPTED_CAP - 1}+`
: String(Math.min(total, UNREAD_ACCEPTED_CAP - 1)),
// only needed when numUnread is undefined
hasNew: false,
}
+34 -1
View File
@@ -1,8 +1,10 @@
import {type ChatBskyConvoGetUnreadCounts} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {RQKEY_PARTIAL as UNREAD_COUNTS_PARTIAL_KEY} from './get-unread-counts'
import {
type ConvoRequestListQueryData,
markAllRead as markAllRequestsRead,
@@ -94,10 +96,36 @@ export function useUpdateAllRead(
markAllRequestsRead,
)
}
// zero out the badge count query that actually drives the unread badge,
// since it's a separate server query that the list caches don't feed
const prevUnreadCountsQueries =
queryClient.getQueriesData<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?.()
return {prevConvoListQueries, prevRequestsQueries}
return {
prevConvoListQueries,
prevRequestsQueries,
prevUnreadCountsQueries,
}
},
onSuccess: () => {
// the optimistic badge zeroing can drift from the server, so invalidate
// the count query to let it self-correct on next access rather than
// waiting for a log event
void queryClient.invalidateQueries({queryKey: UNREAD_COUNTS_PARTIAL_KEY})
void queryClient.invalidateQueries({
queryKey: CONVO_LIST_PARTIAL_KEY(status),
})
@@ -121,6 +149,11 @@ export function useUpdateAllRead(
queryClient.setQueryData(queryKey, prevData)
}
}
if (context?.prevUnreadCountsQueries) {
for (const [queryKey, prevData] of context.prevUnreadCountsQueries) {
queryClient.setQueryData(queryKey, prevData)
}
}
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]})
if (status === 'request') {
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})