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:
@@ -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
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
uses: ./.github/workflows/build-submit-ios.yml
|
||||
with:
|
||||
profile: testflight
|
||||
assignTestFlightGroup: true
|
||||
testFlightGroup: "QA Team"
|
||||
secrets: inherit
|
||||
|
||||
android:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]}>
|
||||
|
||||
@@ -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`,
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1145,7 +1145,7 @@ msgid "Add image"
|
||||
msgstr ""
|
||||
|
||||
#. Accessibility label for button in composer to add images, a video, or a GIF to a post
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:505
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:511
|
||||
msgid "Add media to post"
|
||||
msgstr ""
|
||||
|
||||
@@ -8053,11 +8053,11 @@ msgstr ""
|
||||
msgid "One or more images is missing alt text."
|
||||
msgstr ""
|
||||
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:417
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:423
|
||||
msgid "One or more of your selected files are not supported."
|
||||
msgstr ""
|
||||
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:440
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:446
|
||||
msgid "One or more of your selected files are too large. Maximum size is {VIDEO_MAX_SIZE_MB} MB."
|
||||
msgstr "One or more of your selected files are too large. Maximum size is {VIDEO_MAX_SIZE_MB} MB."
|
||||
|
||||
@@ -8308,7 +8308,7 @@ msgid "Opens device camera"
|
||||
msgstr ""
|
||||
|
||||
#. Accessibility hint for button in composer to add images, a video, or a GIF to a post.
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:511
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:517
|
||||
msgid "Opens device gallery to select up to {MAX_GALLERY_IMAGES, plural, other {# images}}, or a single video or GIF."
|
||||
msgstr "Opens device gallery to select up to {MAX_GALLERY_IMAGES, plural, other {# images}}, or a single video or GIF."
|
||||
|
||||
@@ -10455,7 +10455,7 @@ msgstr ""
|
||||
msgid "Select your preferred notification channels"
|
||||
msgstr ""
|
||||
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:420
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:426
|
||||
msgid "Selecting multiple media types is not supported."
|
||||
msgstr ""
|
||||
|
||||
@@ -13092,7 +13092,7 @@ msgstr ""
|
||||
msgid "Videos"
|
||||
msgstr ""
|
||||
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:434
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:440
|
||||
msgid "Videos must be less than 3 minutes long."
|
||||
msgstr ""
|
||||
|
||||
@@ -13816,11 +13816,11 @@ msgstr ""
|
||||
msgid "You can only save drafts up to 1000 characters. Would you like to discard this post before viewing your drafts?"
|
||||
msgstr ""
|
||||
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:437
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:443
|
||||
msgid "You can only select one GIF at a time."
|
||||
msgstr ""
|
||||
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:431
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:437
|
||||
msgid "You can only select one video at a time."
|
||||
msgstr ""
|
||||
|
||||
@@ -13833,7 +13833,7 @@ msgid "You can read chat history but can’t send new messages."
|
||||
msgstr "You can read chat history but can’t send new messages."
|
||||
|
||||
#. Error message for maximum number of images that can be selected to add to a post.
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:423
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:429
|
||||
msgid "You can select up to {MAX_GALLERY_IMAGES, plural, other {# images}} in total."
|
||||
msgstr "You can select up to {MAX_GALLERY_IMAGES, plural, other {# images}} in total."
|
||||
|
||||
@@ -14028,7 +14028,7 @@ msgstr ""
|
||||
msgid "You must grant access to your photo library to save a QR code"
|
||||
msgstr ""
|
||||
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:466
|
||||
#: src/view/com/composer/SelectMediaButton.tsx:472
|
||||
msgid "You need to allow access to your media library."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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]})
|
||||
|
||||
Reference in New Issue
Block a user