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:
|
options:
|
||||||
- testflight
|
- testflight
|
||||||
- production
|
- production
|
||||||
assignTestFlightGroup:
|
testFlightGroup:
|
||||||
type: boolean
|
type: choice
|
||||||
description: Assign the build to the "QA Team" TestFlight group after submitting
|
description: TestFlight group to assign the build to after submitting
|
||||||
default: false
|
options:
|
||||||
|
- none
|
||||||
|
- QA Team
|
||||||
|
- Software Mansion
|
||||||
|
default: none
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
profile:
|
profile:
|
||||||
type: string
|
type: string
|
||||||
description: Build profile to use
|
description: Build profile to use
|
||||||
required: true
|
required: true
|
||||||
assignTestFlightGroup:
|
testFlightGroup:
|
||||||
type: boolean
|
type: string
|
||||||
description: Assign the build to the "QA Team" TestFlight group after submitting
|
description: TestFlight group to assign the build to after submitting ("none" to skip)
|
||||||
default: false
|
default: none
|
||||||
outputs:
|
outputs:
|
||||||
package-version:
|
package-version:
|
||||||
description: Version from package.json
|
description: Version from package.json
|
||||||
@@ -212,8 +216,9 @@ jobs:
|
|||||||
# TestFlight group. fastlane's distribute_only mode skips the upload and assigns the
|
# TestFlight group. fastlane's distribute_only mode skips the upload and assigns the
|
||||||
# already-submitted build to the group, polling until Apple finishes processing it.
|
# already-submitted build to the group, polling until Apple finishes processing it.
|
||||||
- name: 🧪 Assign build to TestFlight group
|
- name: 🧪 Assign build to TestFlight group
|
||||||
if: ${{ inputs.assignTestFlightGroup }}
|
if: ${{ inputs.testFlightGroup != 'none' }}
|
||||||
env:
|
env:
|
||||||
|
TESTFLIGHT_GROUP: ${{ inputs.testFlightGroup }}
|
||||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||||
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
|
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
|
||||||
@@ -241,7 +246,7 @@ jobs:
|
|||||||
app_identifier:"xyz.blueskyweb.app" \
|
app_identifier:"xyz.blueskyweb.app" \
|
||||||
app_version:"$APP_VERSION" \
|
app_version:"$APP_VERSION" \
|
||||||
build_number:"$BUILD_NUMBER" \
|
build_number:"$BUILD_NUMBER" \
|
||||||
groups:"QA Team" \
|
groups:"$TESTFLIGHT_GROUP" \
|
||||||
notify_external_testers:true
|
notify_external_testers:true
|
||||||
|
|
||||||
- name: 🔔 Notify Slack of Production Build
|
- name: 🔔 Notify Slack of Production Build
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
uses: ./.github/workflows/build-submit-ios.yml
|
uses: ./.github/workflows/build-submit-ios.yml
|
||||||
with:
|
with:
|
||||||
profile: testflight
|
profile: testflight
|
||||||
assignTestFlightGroup: true
|
testFlightGroup: "QA Team"
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
android:
|
android:
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ jobs:
|
|||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
# Don't persist the checkout auth header; the push below authenticates
|
||||||
|
# with the app token embedded in the remote URL instead
|
||||||
|
persist-credentials: false
|
||||||
- name: Generate GitHub App Token
|
- name: Generate GitHub App Token
|
||||||
id: app-token
|
id: app-token
|
||||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||||
@@ -33,6 +36,5 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
git config user.name "github-actions"
|
git config user.name "github-actions"
|
||||||
git config user.email "test@users.noreply.github.com"
|
git config user.email "test@users.noreply.github.com"
|
||||||
git config --unset-all http.https://github.com/.extraheader
|
|
||||||
git remote add internal https://x-access-token:${TOKEN}@github.com/bluesky-social/social-app-internal.git
|
git remote add internal https://x-access-token:${TOKEN}@github.com/bluesky-social/social-app-internal.git
|
||||||
git push internal main --force
|
git push internal main --force
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ type discussionForumPosting struct {
|
|||||||
CommentCount *int64 `json:"commentCount,omitempty"`
|
CommentCount *int64 `json:"commentCount,omitempty"`
|
||||||
Comment []comment `json:"comment,omitempty"`
|
Comment []comment `json:"comment,omitempty"`
|
||||||
IsBasedOn string `json:"isBasedOn,omitempty"`
|
IsBasedOn string `json:"isBasedOn,omitempty"`
|
||||||
|
IsPartOf string `json:"isPartOf,omitempty"`
|
||||||
SharedContent *sharedContent `json:"sharedContent,omitempty"`
|
SharedContent *sharedContent `json:"sharedContent,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,6 +345,43 @@ func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// threadRootURI returns the AT-URI of the root post of the thread a reply
|
||||||
|
// belongs to, or "" if the post is not a reply or the record is malformed.
|
||||||
|
func threadRootURI(pv *appbsky.FeedDefs_PostView) string {
|
||||||
|
if pv == nil || pv.Record == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rec, ok := pv.Record.Val.(*appbsky.FeedPost)
|
||||||
|
if !ok || rec.Reply == nil || rec.Reply.Root == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return rec.Reply.Root.Uri
|
||||||
|
}
|
||||||
|
|
||||||
|
// findRootPostInParents walks tv's parent chain upward and returns the
|
||||||
|
// PostView whose URI matches rootURI, or nil if the root is not present.
|
||||||
|
// The root is absent when the chain is truncated by parentHeight (reply
|
||||||
|
// deeper than the fetched height) or broken by a blocked/not-found parent.
|
||||||
|
// Avoids a separate FeedGetPosts call when the thread response already
|
||||||
|
// contains the root.
|
||||||
|
func findRootPostInParents(tv *appbsky.FeedDefs_ThreadViewPost, rootURI string) *appbsky.FeedDefs_PostView {
|
||||||
|
if rootURI == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for node := tv; node != nil; {
|
||||||
|
if node.Post != nil && node.Post.Uri == rootURI {
|
||||||
|
return node.Post
|
||||||
|
}
|
||||||
|
if node.Parent == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Only threadViewPost parents continue the chain; a blocked or
|
||||||
|
// not-found parent breaks it before reaching the root.
|
||||||
|
node = node.Parent.FeedDefs_ThreadViewPost
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// buildAuthor constructs a Person. Organization classification for
|
// buildAuthor constructs a Person. Organization classification for
|
||||||
// custom-domain accounts is a future enhancement.
|
// custom-domain accounts is a future enhancement.
|
||||||
func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
|
func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
|
||||||
@@ -584,13 +622,21 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) c
|
|||||||
|
|
||||||
// buildPostJSONLD marshals the WebPage envelope wrapping a
|
// buildPostJSONLD marshals the WebPage envelope wrapping a
|
||||||
// DiscussionForumPosting. canonicalURL is used for both envelope.url and
|
// DiscussionForumPosting. canonicalURL is used for both envelope.url and
|
||||||
// (as a fallback) mainEntity.url so they always agree.
|
// (as a fallback) mainEntity.url so they always agree. isPartOfURL, when
|
||||||
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
|
// non-empty, is the handle-form canonical URL of the thread root the handler
|
||||||
|
// resolved for a reply; pass "" to omit isPartOf (non-reply, or the root could
|
||||||
|
// not be resolved). We never emit a DID-form isPartOf because it would not
|
||||||
|
// match the root page's handle-form canonical.
|
||||||
|
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, isPartOfURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
|
||||||
if pv == nil || pv.Author == nil {
|
if pv == nil || pv.Author == nil {
|
||||||
return "", fmt.Errorf("nil post view or author")
|
return "", fmt.Errorf("nil post view or author")
|
||||||
}
|
}
|
||||||
node := buildPostNode(pv, replies, hideLabels, hideReplyLabels)
|
node := buildPostNode(pv, replies, hideLabels, hideReplyLabels)
|
||||||
|
|
||||||
|
if isPartOfURL != "" {
|
||||||
|
node.IsPartOf = isPartOfURL
|
||||||
|
}
|
||||||
|
|
||||||
// mainEntity.url is empty when the author handle is unusable; fall back
|
// mainEntity.url is empty when the author handle is unusable; fall back
|
||||||
// to canonicalURL so it agrees with envelope.url.
|
// to canonicalURL so it agrees with envelope.url.
|
||||||
if node.URL == "" {
|
if node.URL == "" {
|
||||||
|
|||||||
@@ -183,6 +183,19 @@ func withQuotePostBlocked() func(*appbsky.FeedDefs_PostView) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withReplyRoot marks the post as a reply by setting its record's Reply.Root
|
||||||
|
// strong-ref to the given thread-root post.
|
||||||
|
func withReplyRoot(rootDid, rootRkey string) func(*appbsky.FeedDefs_PostView) {
|
||||||
|
return func(pv *appbsky.FeedDefs_PostView) {
|
||||||
|
rec, _ := pv.Record.Val.(*appbsky.FeedPost)
|
||||||
|
uri := "at://" + rootDid + "/app.bsky.feed.post/" + rootRkey
|
||||||
|
rec.Reply = &appbsky.FeedPost_ReplyRef{
|
||||||
|
Root: &comatprototypes.RepoStrongRef{Uri: uri, Cid: "bafy-root"},
|
||||||
|
Parent: &comatprototypes.RepoStrongRef{Uri: uri, Cid: "bafy-root"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// withSelfLabel adds a self-label that should hide embeds.
|
// withSelfLabel adds a self-label that should hide embeds.
|
||||||
func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) {
|
func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) {
|
||||||
return func(pv *appbsky.FeedDefs_PostView) {
|
return func(pv *appbsky.FeedDefs_PostView) {
|
||||||
@@ -263,7 +276,7 @@ func unmarshalLD(t *testing.T, s string) map[string]any {
|
|||||||
func TestBuildPostJSONLD_Bare(t *testing.T) {
|
func TestBuildPostJSONLD_Bare(t *testing.T) {
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
||||||
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
||||||
out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
|
out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -333,7 +346,7 @@ func TestBuildPostJSONLD_WithImages(t *testing.T) {
|
|||||||
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
|
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
|
||||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
|
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
|
||||||
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -358,7 +371,7 @@ func TestBuildPostJSONLD_WithGallery(t *testing.T) {
|
|||||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
|
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
|
||||||
thumb3 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g3@jpeg"
|
thumb3 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g3@jpeg"
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2, thumb3))
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2, thumb3))
|
||||||
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -385,7 +398,7 @@ func TestBuildPostJSONLD_GalleryInRecordWithMedia(t *testing.T) {
|
|||||||
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
|
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery",
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery",
|
||||||
withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb))
|
withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb))
|
||||||
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -438,7 +451,7 @@ func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) {
|
|||||||
func TestBuildPostJSONLD_WithVideo(t *testing.T) {
|
func TestBuildPostJSONLD_WithVideo(t *testing.T) {
|
||||||
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
|
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
|
||||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
if main["thumbnailUrl"] != thumb {
|
if main["thumbnailUrl"] != thumb {
|
||||||
t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"])
|
t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"])
|
||||||
@@ -451,7 +464,7 @@ func TestBuildPostJSONLD_WithVideo(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildPostJSONLD_QuotePost(t *testing.T) {
|
func TestBuildPostJSONLD_QuotePost(t *testing.T) {
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting!", withQuotePost("bob.example.com", "did:plc:bob", "xyz"))
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting!", withQuotePost("bob.example.com", "did:plc:bob", "xyz"))
|
||||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
|
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
|
||||||
t.Errorf("isBasedOn wrong: %v", main["isBasedOn"])
|
t.Errorf("isBasedOn wrong: %v", main["isBasedOn"])
|
||||||
@@ -460,16 +473,122 @@ func TestBuildPostJSONLD_QuotePost(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) {
|
func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) {
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting blocked", withQuotePostBlocked())
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting blocked", withQuotePostBlocked())
|
||||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
if _, present := main["isBasedOn"]; present {
|
if _, present := main["isBasedOn"]; present {
|
||||||
t.Errorf("blocked quote should not produce isBasedOn")
|
t.Errorf("blocked quote should not produce isBasedOn")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildPostJSONLD_IsPartOf(t *testing.T) {
|
||||||
|
// isPartOf is sourced solely from the handler-resolved URL. When supplied,
|
||||||
|
// it is emitted on the main post; when empty, no isPartOf is present.
|
||||||
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply")
|
||||||
|
isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
|
||||||
|
out, _ := buildPostJSONLD(pv, nil, "u", isPartOf, hideEmbedLabels, hideReplyLabels)
|
||||||
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
|
if main["isPartOf"] != isPartOf {
|
||||||
|
t.Errorf("isPartOf = %v, want %v", main["isPartOf"], isPartOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, _ = buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
|
main = unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
|
if _, present := main["isPartOf"]; present {
|
||||||
|
t.Errorf("empty isPartOfURL should omit isPartOf, got %v", main["isPartOf"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThreadRootURI(t *testing.T) {
|
||||||
|
// A reply returns its root AT-URI; a non-reply returns "".
|
||||||
|
reply := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply",
|
||||||
|
withReplyRoot("did:plc:root", "rootrkey"))
|
||||||
|
want := "at://did:plc:root/app.bsky.feed.post/rootrkey"
|
||||||
|
if got := threadRootURI(reply); got != want {
|
||||||
|
t.Errorf("threadRootURI = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
post := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "not a reply")
|
||||||
|
if got := threadRootURI(post); got != "" {
|
||||||
|
t.Errorf("threadRootURI on non-reply = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindRootPostInParents(t *testing.T) {
|
||||||
|
rootPost := makePostView("root.bsky.social", "did:plc:root", "rootrkey", "root")
|
||||||
|
rootURI := rootPost.Uri
|
||||||
|
|
||||||
|
// tvp wraps a PostView, optionally chaining to a parent thread node.
|
||||||
|
tvp := func(pv *appbsky.FeedDefs_PostView, parent *appbsky.FeedDefs_ThreadViewPost) *appbsky.FeedDefs_ThreadViewPost {
|
||||||
|
node := &appbsky.FeedDefs_ThreadViewPost{Post: pv}
|
||||||
|
if parent != nil {
|
||||||
|
node.Parent = &appbsky.FeedDefs_ThreadViewPost_Parent{FeedDefs_ThreadViewPost: parent}
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("direct reply, parent is root", func(t *testing.T) {
|
||||||
|
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
|
||||||
|
if got := findRootPostInParents(leaf, rootURI); got != rootPost {
|
||||||
|
t.Errorf("expected root post, got %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multi-level chain", func(t *testing.T) {
|
||||||
|
mid := tvp(makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid"), tvp(rootPost, nil))
|
||||||
|
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), mid)
|
||||||
|
if got := findRootPostInParents(leaf, rootURI); got != rootPost {
|
||||||
|
t.Errorf("expected root post in chain, got %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("root absent, chain truncated", func(t *testing.T) {
|
||||||
|
// Topmost parent is not the root (e.g. parentHeight cut off the chain).
|
||||||
|
topmost := makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid")
|
||||||
|
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(topmost, nil))
|
||||||
|
if got := findRootPostInParents(leaf, rootURI); got != nil {
|
||||||
|
t.Errorf("expected nil when root absent, got %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("chain broken by blocked parent", func(t *testing.T) {
|
||||||
|
// A blocked/not-found parent yields a nil FeedDefs_ThreadViewPost,
|
||||||
|
// breaking the walk before the root.
|
||||||
|
leaf := &appbsky.FeedDefs_ThreadViewPost{
|
||||||
|
Post: makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"),
|
||||||
|
Parent: &appbsky.FeedDefs_ThreadViewPost_Parent{
|
||||||
|
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: rootURI},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if got := findRootPostInParents(leaf, rootURI); got != nil {
|
||||||
|
t.Errorf("expected nil when chain broken by blocked parent, got %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty rootURI", func(t *testing.T) {
|
||||||
|
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
|
||||||
|
if got := findRootPostInParents(leaf, ""); got != nil {
|
||||||
|
t.Errorf("expected nil for empty rootURI, got %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPostJSONLD_ReplyCommentsNoIsPartOf(t *testing.T) {
|
||||||
|
// Replies surfaced under the main post as comment[] are Comment nodes and
|
||||||
|
// never carry isPartOf, even when the main post has one.
|
||||||
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||||
|
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "a reply")
|
||||||
|
isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
|
||||||
|
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", isPartOf, hideEmbedLabels, hideReplyLabels)
|
||||||
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
|
c := main["comment"].([]any)[0].(map[string]any)
|
||||||
|
if _, present := c["isPartOf"]; present {
|
||||||
|
t.Errorf("comment entries should not carry isPartOf, got %v", c["isPartOf"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildPostJSONLD_ExternalEmbed(t *testing.T) {
|
func TestBuildPostJSONLD_ExternalEmbed(t *testing.T) {
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "check this out", withExternalEmbed("https://www.spiegel.de/article", "Title"))
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "check this out", withExternalEmbed("https://www.spiegel.de/article", "Title"))
|
||||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
sc, ok := main["sharedContent"].(map[string]any)
|
sc, ok := main["sharedContent"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -487,7 +606,7 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
|
|||||||
thumb := "https://cdn.bsky.app/img/x@jpeg"
|
thumb := "https://cdn.bsky.app/img/x@jpeg"
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
|
||||||
withImages(thumb), withSelfLabel("porn"))
|
withImages(thumb), withSelfLabel("porn"))
|
||||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
if _, present := main["image"]; present {
|
if _, present := main["image"]; present {
|
||||||
t.Errorf("hidden-embed post should not emit image")
|
t.Errorf("hidden-embed post should not emit image")
|
||||||
@@ -506,7 +625,7 @@ func TestBuildPostJSONLD_HiddenEmbed_Gallery(t *testing.T) {
|
|||||||
thumb := "https://cdn.bsky.app/img/g@jpeg"
|
thumb := "https://cdn.bsky.app/img/g@jpeg"
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
|
||||||
withGallery(thumb), withSelfLabel("porn"))
|
withGallery(thumb), withSelfLabel("porn"))
|
||||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
if _, present := main["image"]; present {
|
if _, present := main["image"]; present {
|
||||||
t.Errorf("hidden-embed gallery post should not emit image")
|
t.Errorf("hidden-embed gallery post should not emit image")
|
||||||
@@ -520,7 +639,7 @@ func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
|
|||||||
// Includes ", \, newline, </script>, and a unicode char.
|
// Includes ", \, newline, </script>, and a unicode char.
|
||||||
tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
|
tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", tricky)
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", tricky)
|
||||||
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -556,7 +675,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
|
|||||||
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: "at://x/y/z"},
|
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: "at://x/y/z"},
|
||||||
})
|
})
|
||||||
|
|
||||||
out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
|
|
||||||
if cc := main["commentCount"].(float64); int64(cc) != 14 {
|
if cc := main["commentCount"].(float64); int64(cc) != 14 {
|
||||||
@@ -598,7 +717,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
|
|||||||
func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) {
|
func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) {
|
||||||
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello")
|
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello")
|
||||||
fallback := "https://bsky.app/profile/did:plc:alice/post/abc123"
|
fallback := "https://bsky.app/profile/did:plc:alice/post/abc123"
|
||||||
out, _ := buildPostJSONLD(pv, nil, fallback, hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, nil, fallback, "", hideEmbedLabels, hideReplyLabels)
|
||||||
envelope := unmarshalLD(t, out)
|
envelope := unmarshalLD(t, out)
|
||||||
main := envelope["mainEntity"].(map[string]any)
|
main := envelope["mainEntity"].(map[string]any)
|
||||||
// mainEntity.url falls back to the caller's canonical URL so envelope
|
// mainEntity.url falls back to the caller's canonical URL so envelope
|
||||||
@@ -647,7 +766,7 @@ func TestBuildPostJSONLD_EnvelopeURLMatchesMainEntity(t *testing.T) {
|
|||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
pv := makePostView(tc.handle, tc.did, tc.rkey, "hi")
|
pv := makePostView(tc.handle, tc.did, tc.rkey, "hi")
|
||||||
out, _ := buildPostJSONLD(pv, nil, tc.canonical, hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, nil, tc.canonical, "", hideEmbedLabels, hideReplyLabels)
|
||||||
env := unmarshalLD(t, out)
|
env := unmarshalLD(t, out)
|
||||||
main := env["mainEntity"].(map[string]any)
|
main := env["mainEntity"].(map[string]any)
|
||||||
if env["url"] != tc.canonical {
|
if env["url"] != tc.canonical {
|
||||||
@@ -664,7 +783,7 @@ func TestBuildPostJSONLD_NilAuthor(t *testing.T) {
|
|||||||
// Defensive: don't panic if Author is nil.
|
// Defensive: don't panic if Author is nil.
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||||
pv.Author = nil
|
pv.Author = nil
|
||||||
if _, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels); err == nil {
|
if _, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels); err == nil {
|
||||||
t.Errorf("expected error for nil-author post, got nil")
|
t.Errorf("expected error for nil-author post, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -682,7 +801,7 @@ func TestBuildPostJSONLD_NilAuthorReply(t *testing.T) {
|
|||||||
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: goodReply}},
|
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: goodReply}},
|
||||||
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: badReply}},
|
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: badReply}},
|
||||||
}
|
}
|
||||||
out, err := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
|
out, err := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -703,7 +822,7 @@ func TestBuildPostJSONLD_CommentMedia(t *testing.T) {
|
|||||||
replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
|
replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
|
||||||
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply}},
|
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply}},
|
||||||
}
|
}
|
||||||
out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
c := main["comment"].([]any)[0].(map[string]any)
|
c := main["comment"].([]any)[0].(map[string]any)
|
||||||
imgs, ok := c["image"].([]any)
|
imgs, ok := c["image"].([]any)
|
||||||
@@ -910,7 +1029,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_PostViewLabel(t *testing.T) {
|
|||||||
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
|
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
|
||||||
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
|
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
|
||||||
withPostLabel("!hide", false))
|
withPostLabel("!hide", false))
|
||||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
ids := commentIdentifiers(t, out)
|
ids := commentIdentifiers(t, out)
|
||||||
if len(ids) != 1 || ids[0] != good.Uri {
|
if len(ids) != 1 || ids[0] != good.Uri {
|
||||||
t.Errorf("expected only the unlabeled reply to remain, got %v", ids)
|
t.Errorf("expected only the unlabeled reply to remain, got %v", ids)
|
||||||
@@ -923,7 +1042,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_SelfLabel(t *testing.T) {
|
|||||||
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
|
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
|
||||||
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
|
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
|
||||||
withSelfLabel("spam"))
|
withSelfLabel("spam"))
|
||||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
ids := commentIdentifiers(t, out)
|
ids := commentIdentifiers(t, out)
|
||||||
if len(ids) != 1 || ids[0] != good.Uri {
|
if len(ids) != 1 || ids[0] != good.Uri {
|
||||||
t.Errorf("expected self-labeled reply dropped, got %v", ids)
|
t.Errorf("expected self-labeled reply dropped, got %v", ids)
|
||||||
@@ -939,7 +1058,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_EmbedLabel(t *testing.T) {
|
|||||||
// union behavior.
|
// union behavior.
|
||||||
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "concerning reply",
|
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "concerning reply",
|
||||||
withPostLabel("self-harm", false))
|
withPostLabel("self-harm", false))
|
||||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
ids := commentIdentifiers(t, out)
|
ids := commentIdentifiers(t, out)
|
||||||
if len(ids) != 1 || ids[0] != good.Uri {
|
if len(ids) != 1 || ids[0] != good.Uri {
|
||||||
t.Errorf("expected embed-labeled reply dropped, got %v", ids)
|
t.Errorf("expected embed-labeled reply dropped, got %v", ids)
|
||||||
@@ -951,7 +1070,7 @@ func TestBuildPostJSONLD_NegatedHideLabelKept(t *testing.T) {
|
|||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||||
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "fine reply",
|
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "fine reply",
|
||||||
withPostLabel("!hide", true))
|
withPostLabel("!hide", true))
|
||||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
ids := commentIdentifiers(t, out)
|
ids := commentIdentifiers(t, out)
|
||||||
if len(ids) != 1 || ids[0] != reply.Uri {
|
if len(ids) != 1 || ids[0] != reply.Uri {
|
||||||
t.Errorf("expected negated-label reply to be kept, got %v", ids)
|
t.Errorf("expected negated-label reply to be kept, got %v", ids)
|
||||||
@@ -962,7 +1081,7 @@ func TestBuildPostJSONLD_ReplyAuthorHasIdentifier(t *testing.T) {
|
|||||||
// Reply author should also carry a DID identifier.
|
// Reply author should also carry a DID identifier.
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||||
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi")
|
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi")
|
||||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
c := main["comment"].([]any)[0].(map[string]any)
|
c := main["comment"].([]any)[0].(map[string]any)
|
||||||
auth, ok := c["author"].(map[string]any)
|
auth, ok := c["author"].(map[string]any)
|
||||||
@@ -1103,7 +1222,7 @@ func TestBuildPostJSONLD_AuthorReviewedBy(t *testing.T) {
|
|||||||
})
|
})
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi",
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi",
|
||||||
withVerifications(state))
|
withVerifications(state))
|
||||||
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -1143,7 +1262,7 @@ func TestBuildPostJSONLD_ReplyAuthorNoReviewedBy(t *testing.T) {
|
|||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||||
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi",
|
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi",
|
||||||
withVerifications(state))
|
withVerifications(state))
|
||||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
c := main["comment"].([]any)[0].(map[string]any)
|
c := main["comment"].([]any)[0].(map[string]any)
|
||||||
auth := c["author"].(map[string]any)
|
auth := c["author"].(map[string]any)
|
||||||
@@ -1267,7 +1386,7 @@ func TestBuildPostJSONLD_WithVideoObject(t *testing.T) {
|
|||||||
hasAspect: true, width: 16, height: 9,
|
hasAspect: true, width: 16, height: 9,
|
||||||
}))
|
}))
|
||||||
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
||||||
out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
|
out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -1315,7 +1434,7 @@ func TestBuildPostJSONLD_VideoNameFallback(t *testing.T) {
|
|||||||
withVideoFull(videoEmbedOpts{
|
withVideoFull(videoEmbedOpts{
|
||||||
playlist: "https://video.bsky.app/p.m3u8",
|
playlist: "https://video.bsky.app/p.m3u8",
|
||||||
}))
|
}))
|
||||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
video, ok := main["video"].(map[string]any)
|
video, ok := main["video"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1332,7 +1451,7 @@ func TestBuildPostJSONLD_VideoNameFallbackHandleInvalid(t *testing.T) {
|
|||||||
withVideoFull(videoEmbedOpts{
|
withVideoFull(videoEmbedOpts{
|
||||||
playlist: "https://video.bsky.app/p.m3u8",
|
playlist: "https://video.bsky.app/p.m3u8",
|
||||||
}))
|
}))
|
||||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
video := main["video"].(map[string]any)
|
video := main["video"].(map[string]any)
|
||||||
if video["name"] != "Video on Bluesky" {
|
if video["name"] != "Video on Bluesky" {
|
||||||
@@ -1347,7 +1466,7 @@ func TestBuildPostJSONLD_VideoDescriptionFallback(t *testing.T) {
|
|||||||
withVideoFull(videoEmbedOpts{
|
withVideoFull(videoEmbedOpts{
|
||||||
playlist: "https://video.bsky.app/p.m3u8", alt: "scenic clip",
|
playlist: "https://video.bsky.app/p.m3u8", alt: "scenic clip",
|
||||||
}))
|
}))
|
||||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
video := main["video"].(map[string]any)
|
video := main["video"].(map[string]any)
|
||||||
if video["description"] != "scenic clip" {
|
if video["description"] != "scenic clip" {
|
||||||
@@ -1360,7 +1479,7 @@ func TestBuildPostJSONLD_VideoNoAspectRatio(t *testing.T) {
|
|||||||
withVideoFull(videoEmbedOpts{
|
withVideoFull(videoEmbedOpts{
|
||||||
playlist: "https://video.bsky.app/p.m3u8", alt: "alt",
|
playlist: "https://video.bsky.app/p.m3u8", alt: "alt",
|
||||||
}))
|
}))
|
||||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
video := main["video"].(map[string]any)
|
video := main["video"].(map[string]any)
|
||||||
if _, present := video["width"]; present {
|
if _, present := video["width"]; present {
|
||||||
@@ -1377,7 +1496,7 @@ func TestBuildPostJSONLD_VideoMissingPlaylist(t *testing.T) {
|
|||||||
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
|
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
|
||||||
withVideo(thumb))
|
withVideo(thumb))
|
||||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
if _, present := main["video"]; present {
|
if _, present := main["video"]; present {
|
||||||
t.Errorf("video without playlist should not produce VideoObject")
|
t.Errorf("video without playlist should not produce VideoObject")
|
||||||
@@ -1396,7 +1515,7 @@ func TestBuildPostJSONLD_VideoHiddenEmbed(t *testing.T) {
|
|||||||
alt: "should be dropped",
|
alt: "should be dropped",
|
||||||
}),
|
}),
|
||||||
withSelfLabel("porn"))
|
withSelfLabel("porn"))
|
||||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
if _, present := main["video"]; present {
|
if _, present := main["video"]; present {
|
||||||
t.Errorf("hidden-embed post should not emit video")
|
t.Errorf("hidden-embed post should not emit video")
|
||||||
@@ -1411,7 +1530,7 @@ func TestBuildPostJSONLD_VideoInRecordWithMedia(t *testing.T) {
|
|||||||
thumbnail: thumb, playlist: playlist, alt: "alt", recordMedia: true,
|
thumbnail: thumb, playlist: playlist, alt: "alt", recordMedia: true,
|
||||||
hasAspect: true, width: 4, height: 3,
|
hasAspect: true, width: 4, height: 3,
|
||||||
}))
|
}))
|
||||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
video, ok := main["video"].(map[string]any)
|
video, ok := main["video"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1438,7 +1557,7 @@ func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
|
|||||||
withVideoFull(videoEmbedOpts{
|
withVideoFull(videoEmbedOpts{
|
||||||
thumbnail: thumb, playlist: playlist, alt: "bob's clip",
|
thumbnail: thumb, playlist: playlist, alt: "bob's clip",
|
||||||
}))
|
}))
|
||||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
c := main["comment"].([]any)[0].(map[string]any)
|
c := main["comment"].([]any)[0].(map[string]any)
|
||||||
video, ok := c["video"].(map[string]any)
|
video, ok := c["video"].(map[string]any)
|
||||||
@@ -1461,7 +1580,7 @@ func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildPostJSONLD_NoVideoNoField(t *testing.T) {
|
func TestBuildPostJSONLD_NoVideoNoField(t *testing.T) {
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no embed")
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no embed")
|
||||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
if _, present := main["video"]; present {
|
if _, present := main["video"]; present {
|
||||||
t.Errorf("post without video should not include video field")
|
t.Errorf("post without video should not include video field")
|
||||||
@@ -1505,7 +1624,7 @@ func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL(t *testing.T) {
|
|||||||
playlist: playlist, alt: "scenic clip",
|
playlist: playlist, alt: "scenic clip",
|
||||||
}))
|
}))
|
||||||
canonical := "https://bsky.app/profile/did:plc:alice/post/abc123"
|
canonical := "https://bsky.app/profile/did:plc:alice/post/abc123"
|
||||||
out, _ := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
video, ok := main["video"].(map[string]any)
|
video, ok := main["video"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1528,7 +1647,7 @@ func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL_Reply(t *testing.T) {
|
|||||||
withVideoFull(videoEmbedOpts{
|
withVideoFull(videoEmbedOpts{
|
||||||
playlist: playlist, alt: "bob's clip",
|
playlist: playlist, alt: "bob's clip",
|
||||||
}))
|
}))
|
||||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
|
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
c := main["comment"].([]any)[0].(map[string]any)
|
c := main["comment"].([]any)[0].(map[string]any)
|
||||||
video, ok := c["video"].(map[string]any)
|
video, ok := c["video"].(map[string]any)
|
||||||
|
|||||||
@@ -20,3 +20,20 @@ func profileRequiresAuth(pv *appbsky.ActorDefs_ProfileViewDetailed) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// postAuthorRequiresAuth reports whether the post author self-applied the
|
||||||
|
// !no-unauthenticated label, read from the author view embedded in a
|
||||||
|
// getPostThread response. The appview surfaces the account's profile-record
|
||||||
|
// self-labels on the post author (src == author DID), so this mirrors
|
||||||
|
// profileRequiresAuth without a separate ActorGetProfile call.
|
||||||
|
func postAuthorRequiresAuth(pv *appbsky.FeedDefs_PostView) bool {
|
||||||
|
if pv == nil || pv.Author == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, label := range pv.Author.Labels {
|
||||||
|
if label.Src == pv.Author.Did && label.Val == "!no-unauthenticated" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestRenderPost_EmitsJSONLD(t *testing.T) {
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
||||||
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
|
|||||||
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
|
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
|
||||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
|
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
|
||||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||||
"postView": pv,
|
"postView": pv,
|
||||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||||
@@ -113,7 +113,7 @@ func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
|
|||||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
|
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
|
||||||
thumbs := extractPostMedia(pv, false)
|
thumbs := extractPostMedia(pv, false)
|
||||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||||
"postView": pv,
|
"postView": pv,
|
||||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||||
@@ -142,7 +142,7 @@ func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
|
|||||||
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
|
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
|
||||||
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
|
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||||
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||||
"postView": pv,
|
"postView": pv,
|
||||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo",
|
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo",
|
||||||
@@ -208,7 +208,7 @@ func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) {
|
|||||||
// og:url and <link rel="canonical"> must emit the same URL.
|
// og:url and <link rel="canonical"> must emit the same URL.
|
||||||
func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
|
func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||||
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
||||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||||
"postView": pv,
|
"postView": pv,
|
||||||
@@ -233,7 +233,7 @@ func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
|
|||||||
// video without a thumbnail dropped og:video entirely.
|
// video without a thumbnail dropped og:video entirely.
|
||||||
func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
|
func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch")
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch")
|
||||||
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||||
videoURL := "https://video.bsky.app/v.m3u8"
|
videoURL := "https://video.bsky.app/v.m3u8"
|
||||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||||
"postView": pv,
|
"postView": pv,
|
||||||
@@ -286,7 +286,7 @@ func TestRenderProfile_AuthRequiredNoindex(t *testing.T) {
|
|||||||
// flip of the noindex flag for indexable pages.
|
// flip of the noindex flag for indexable pages.
|
||||||
func TestRenderPost_PublicNoNoindex(t *testing.T) {
|
func TestRenderPost_PublicNoNoindex(t *testing.T) {
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
||||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||||
"postView": pv,
|
"postView": pv,
|
||||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||||
|
|||||||
@@ -620,23 +620,39 @@ func (srv *Server) WebPost(c echo.Context) error {
|
|||||||
|
|
||||||
identifier := handleOrDID.Normalize().String()
|
identifier := handleOrDID.Normalize().String()
|
||||||
|
|
||||||
// requires two fetches: first fetch profile (!)
|
|
||||||
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, identifier)
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
|
|
||||||
return c.Render(http.StatusOK, "post.html", data)
|
|
||||||
}
|
|
||||||
unauthedViewingOkay := !profileRequiresAuth(pv)
|
|
||||||
|
|
||||||
req := c.Request()
|
req := c.Request()
|
||||||
requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||||
|
|
||||||
|
// Fetch the post thread directly. The AT-URI authority accepts either a
|
||||||
|
// handle or a DID (the appview resolves it), so we skip the separate
|
||||||
|
// ActorGetProfile call and source identity, the canonical URL, and the
|
||||||
|
// auth gate from the thread response's author view instead.
|
||||||
|
// parentHeight=80 (the lexicon default) pulls the reply's ancestor chain
|
||||||
|
// up to the root in nearly all threads, letting isPartOf resolve from this
|
||||||
|
// response without a separate FeedGetPosts call.
|
||||||
|
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", identifier, rkey)
|
||||||
|
tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 80, uri)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("failed to fetch post: %s\t%v", uri, err)
|
||||||
|
return c.Render(http.StatusOK, "post.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
threadView := tpv.Thread.FeedDefs_ThreadViewPost
|
||||||
|
if threadView == nil || threadView.Post == nil || threadView.Post.Author == nil {
|
||||||
|
return c.Render(http.StatusOK, "post.html", data)
|
||||||
|
}
|
||||||
|
postView := threadView.Post
|
||||||
|
|
||||||
// Always prefer the handle-form URL so JSON-LD `url` and
|
// Always prefer the handle-form URL so JSON-LD `url` and
|
||||||
// <link rel="canonical"> match. Falls back to requestURI when the
|
// <link rel="canonical"> match. Falls back to requestURI when the
|
||||||
// handle is unusable (template strips query/fragment).
|
// handle is unusable (template strips query/fragment).
|
||||||
canonicalURL := bskyPostURL(pv.Handle, rkey.String())
|
canonicalURL := bskyPostURL(postView.Author.Handle, rkey.String())
|
||||||
|
|
||||||
if !unauthedViewingOkay {
|
// Gate before populating any post content into the template so that
|
||||||
|
// !no-unauthenticated posts never leak text/media. The appview returns the
|
||||||
|
// post (with the author self-label) to unauthed callers, so we detect the
|
||||||
|
// label here rather than via a profile fetch.
|
||||||
|
if postAuthorRequiresAuth(postView) {
|
||||||
// Provide minimal OpenGraph data for auth-required posts
|
// Provide minimal OpenGraph data for auth-required posts
|
||||||
data["requestURI"] = requestURI
|
data["requestURI"] = requestURI
|
||||||
if canonicalURL != "" {
|
if canonicalURL != "" {
|
||||||
@@ -645,26 +661,13 @@ func (srv *Server) WebPost(c echo.Context) error {
|
|||||||
data["requiresAuth"] = true
|
data["requiresAuth"] = true
|
||||||
data["noindex"] = true
|
data["noindex"] = true
|
||||||
data["nofollow"] = true
|
data["nofollow"] = true
|
||||||
data["profileHandle"] = pv.Handle
|
data["profileHandle"] = postView.Author.Handle
|
||||||
if pv.DisplayName != nil {
|
if postView.Author.DisplayName != nil {
|
||||||
data["profileDisplayName"] = *pv.DisplayName
|
data["profileDisplayName"] = *postView.Author.DisplayName
|
||||||
}
|
}
|
||||||
return c.Render(http.StatusOK, "post.html", data)
|
return c.Render(http.StatusOK, "post.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// then fetch the post thread (with extra context)
|
|
||||||
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", pv.Did, rkey)
|
|
||||||
tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 0, uri)
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("failed to fetch post: %s\t%v", uri, err)
|
|
||||||
return c.Render(http.StatusOK, "post.html", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
threadView := tpv.Thread.FeedDefs_ThreadViewPost
|
|
||||||
if threadView == nil || threadView.Post == nil {
|
|
||||||
return c.Render(http.StatusOK, "post.html", data)
|
|
||||||
}
|
|
||||||
postView := threadView.Post
|
|
||||||
data["postView"] = postView
|
data["postView"] = postView
|
||||||
data["requestURI"] = requestURI
|
data["requestURI"] = requestURI
|
||||||
if canonicalURL != "" {
|
if canonicalURL != "" {
|
||||||
@@ -694,7 +697,32 @@ func (srv *Server) WebPost(c echo.Context) error {
|
|||||||
if jsonldURL == "" {
|
if jsonldURL == "" {
|
||||||
jsonldURL = requestURI
|
jsonldURL = requestURI
|
||||||
}
|
}
|
||||||
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, hideEmbedLabels, hideReplyLabels); err == nil {
|
|
||||||
|
// Best-effort: resolve a reply's thread root to its handle-form canonical
|
||||||
|
// URL for isPartOf. Prefer the root already present in the thread response
|
||||||
|
// (parentHeight=80); fall back to a bounded FeedGetPosts only when the
|
||||||
|
// chain is truncated (very deep thread) or broken by a blocked/not-found
|
||||||
|
// ancestor. On timeout, error, or an unresolvable root we omit isPartOf
|
||||||
|
// rather than point at a non-indexable page.
|
||||||
|
isPartOfURL := ""
|
||||||
|
if rootURI := threadRootURI(postView); rootURI != "" {
|
||||||
|
if rootPost := findRootPostInParents(threadView, rootURI); rootPost != nil && rootPost.Author != nil {
|
||||||
|
isPartOfURL = bskyPostURLFromATURI(rootPost.Author.Handle, rootURI)
|
||||||
|
} else {
|
||||||
|
pctx, cancel := context.WithTimeout(ctx, 1*time.Second)
|
||||||
|
if posts, perr := appbsky.FeedGetPosts(pctx, srv.xrpcc, []string{rootURI}); perr != nil {
|
||||||
|
log.Warnf("failed to resolve thread root post for isPartOf: %s\t%v", rootURI, perr)
|
||||||
|
} else if len(posts.Posts) > 0 && posts.Posts[0].Author != nil {
|
||||||
|
// Handle-form only (no DID fallback): isPartOf must match the
|
||||||
|
// root page's handle-form canonical, so an unusable handle omits
|
||||||
|
// isPartOf rather than point at a non-canonical DID-form URL.
|
||||||
|
isPartOfURL = bskyPostURLFromATURI(posts.Posts[0].Author.Handle, rootURI)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, isPartOfURL, hideEmbedLabels, hideReplyLabels); err == nil {
|
||||||
data["postJSONLD"] = jsonld
|
data["postJSONLD"] = jsonld
|
||||||
} else {
|
} else {
|
||||||
log.Warnf("failed to build post JSON-LD for %s: %v", uri, err)
|
log.Warnf("failed to build post JSON-LD for %s: %v", uri, err)
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ function AccessSection() {
|
|||||||
locationControl.open()
|
locationControl.open()
|
||||||
})}>
|
})}>
|
||||||
Tap here to update your location with GPS.
|
Tap here to update your location with GPS.
|
||||||
</SimpleInlineLinkText>{' '}
|
</SimpleInlineLinkText>
|
||||||
</Trans>
|
</Trans>
|
||||||
</Admonition>
|
</Admonition>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export enum Features {
|
|||||||
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
|
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
|
||||||
LiveNowBetaDisable = 'live_now_beta:disable',
|
LiveNowBetaDisable = 'live_now_beta:disable',
|
||||||
GroupChatsDisable = 'group_chats:disable',
|
GroupChatsDisable = 'group_chats:disable',
|
||||||
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
|
||||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||||
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
|
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
|
||||||
|
|||||||
@@ -1319,7 +1319,7 @@ export type Events = {
|
|||||||
'invite:action:scan': {}
|
'invite:action:scan': {}
|
||||||
// user changed the QR card color theme
|
// user changed the QR card color theme
|
||||||
'invite:theme:change': {
|
'invite:theme:change': {
|
||||||
themeKey: 'dawn' | 'day' | 'dusk' | 'night'
|
themeKey: 'dawn' | 'sunlight' | 'day' | 'dusk' | 'twilight' | 'night'
|
||||||
}
|
}
|
||||||
// QR scanner decoded a code; result indicates whether it resolved to a profile
|
// QR scanner decoded a code; result indicates whether it resolved to a profile
|
||||||
'invite:scanner:scanned': {
|
'invite:scanner:scanned': {
|
||||||
|
|||||||
@@ -615,6 +615,8 @@ export function Outer({
|
|||||||
label?: string
|
label?: string
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
align?: 'left' | 'right'
|
align?: 'left' | 'right'
|
||||||
|
/** Web only. Native restores focus differently. */
|
||||||
|
onCloseAutoFocus?: (event: Event) => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const context = useContextMenuContext()
|
const context = useContextMenuContext()
|
||||||
|
|||||||
@@ -34,14 +34,16 @@ export function Outer({
|
|||||||
children,
|
children,
|
||||||
label,
|
label,
|
||||||
style,
|
style,
|
||||||
|
onCloseAutoFocus,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
label?: string
|
label?: string
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
|
onCloseAutoFocus?: (event: Event) => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
return (
|
return (
|
||||||
<Menu.Outer style={style}>
|
<Menu.Outer style={style} onCloseAutoFocus={onCloseAutoFocus}>
|
||||||
{label ? (
|
{label ? (
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {Trans} from '@lingui/react/macro'
|
|
||||||
import flattenReactChildren from 'react-keyed-flatten-children'
|
import flattenReactChildren from 'react-keyed-flatten-children'
|
||||||
|
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
@@ -32,6 +30,11 @@ import {
|
|||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env'
|
import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env'
|
||||||
|
|
||||||
|
// iOS 26's floaty sheet presentation subtracts the bottom safe-area inset from
|
||||||
|
// the requested detent, which eats the visible bottom padding of short (e.g.
|
||||||
|
// single-item) menus. Flooring the native sheet height restores that padding.
|
||||||
|
const IOS_MENU_MIN_HEIGHT = 128
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type DialogControlProps as MenuControlProps,
|
type DialogControlProps as MenuControlProps,
|
||||||
useDialogControl as useMenuControl,
|
useDialogControl as useMenuControl,
|
||||||
@@ -101,16 +104,19 @@ export function Outer({
|
|||||||
onCloseAutoFocus?: (event: Event) => void
|
onCloseAutoFocus?: (event: Event) => void
|
||||||
}>) {
|
}>) {
|
||||||
const context = useMenuContext()
|
const context = useMenuContext()
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.Outer
|
<Dialog.Outer
|
||||||
control={context.control}
|
control={context.control}
|
||||||
nativeOptions={{preventExpansion: true}}>
|
nativeOptions={{
|
||||||
|
preventExpansion: true,
|
||||||
|
minHeight: IS_IOS ? IOS_MENU_MIN_HEIGHT : undefined,
|
||||||
|
}}>
|
||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
{/* Re-wrap with context since Dialogs are portal-ed to root */}
|
{/* Re-wrap with context since Dialogs are portal-ed to root */}
|
||||||
<Context.Provider value={context}>
|
<Context.Provider value={context}>
|
||||||
<Dialog.ScrollableInner label={_(msg`Menu`)}>
|
<Dialog.ScrollableInner label={l`Menu`}>
|
||||||
<View style={[a.gap_lg]}>
|
<View style={[a.gap_lg]}>
|
||||||
{children}
|
{children}
|
||||||
{IS_NATIVE && showCancel && <Cancel />}
|
{IS_NATIVE && showCancel && <Cancel />}
|
||||||
@@ -353,12 +359,12 @@ export function Group({children, style}: GroupProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Cancel() {
|
function Cancel() {
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const context = useMenuContext()
|
const context = useMenuContext()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Close this dialog`)}
|
label={l`Close this dialog`}
|
||||||
size="small"
|
size="small"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
|||||||
@@ -40,11 +40,7 @@ import {ModeratedFeedEmbed} from './FeedEmbed'
|
|||||||
import {ImageEmbed} from './ImageEmbed'
|
import {ImageEmbed} from './ImageEmbed'
|
||||||
import {ModeratedListEmbed} from './ListEmbed'
|
import {ModeratedListEmbed} from './ListEmbed'
|
||||||
import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder'
|
import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder'
|
||||||
import {
|
import {type CommonProps, type EmbedProps, PostEmbedViewContext} from './types'
|
||||||
type CommonProps,
|
|
||||||
type EmbedProps,
|
|
||||||
type PostEmbedViewContext,
|
|
||||||
} from './types'
|
|
||||||
import {VideoEmbed} from './VideoEmbed'
|
import {VideoEmbed} from './VideoEmbed'
|
||||||
|
|
||||||
export {PostEmbedViewContext} from './types'
|
export {PostEmbedViewContext} from './types'
|
||||||
@@ -356,7 +352,7 @@ export function QuoteEmbed({
|
|||||||
return (
|
return (
|
||||||
<GalleryBleed>
|
<GalleryBleed>
|
||||||
<View
|
<View
|
||||||
style={[a.mt_sm]}
|
style={[viewContext !== PostEmbedViewContext.ChatMessage && a.mt_sm]}
|
||||||
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
|
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
|
||||||
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
|
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
|
||||||
<ContentHider
|
<ContentHider
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export function GroupChatsAnnouncement() {
|
|||||||
a.font_medium,
|
a.font_medium,
|
||||||
{color: t.palette.primary_500},
|
{color: t.palette.primary_500},
|
||||||
]}>
|
]}>
|
||||||
<Trans>New</Trans>
|
<Trans context="nux-description">New</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -60,6 +60,20 @@ export let MessageContextMenu = ({
|
|||||||
const langPrefs = useLanguagePrefs()
|
const langPrefs = useLanguagePrefs()
|
||||||
const translate = useGoogleTranslate()
|
const translate = useGoogleTranslate()
|
||||||
|
|
||||||
|
const onReply = useCallback(() => {
|
||||||
|
setReply(message)
|
||||||
|
}, [setReply, message])
|
||||||
|
// On web, the menu is a Radix dropdown that restores focus to the trigger on
|
||||||
|
// close. When Reply moves focus to the composer, don't let Radix steal it
|
||||||
|
// back. Checking activeElement (rather than tracking reply intent) also
|
||||||
|
// handles re-replying to the same message, where the composer's focus effect
|
||||||
|
// bails on an unchanged reply target and focus should stay on the trigger.
|
||||||
|
const onCloseAutoFocus = useCallback((event: Event) => {
|
||||||
|
if (document.activeElement && document.activeElement !== document.body) {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||||
const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable)
|
const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable)
|
||||||
|
|
||||||
@@ -161,11 +175,12 @@ export let MessageContextMenu = ({
|
|||||||
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
|
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
})}`}
|
})}`}
|
||||||
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
|
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}
|
||||||
|
onCloseAutoFocus={onCloseAutoFocus}>
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
testID="messageDropdownReplyBtn"
|
testID="messageDropdownReplyBtn"
|
||||||
label={l`Reply`}
|
label={l`Reply`}
|
||||||
onPress={() => setReply(message)}>
|
onPress={onReply}>
|
||||||
<ContextMenu.ItemIcon icon={ReplyIcon} position="left" />
|
<ContextMenu.ItemIcon icon={ReplyIcon} position="left" />
|
||||||
<ContextMenu.ItemText>{l`Reply`}</ContextMenu.ItemText>
|
<ContextMenu.ItemText>{l`Reply`}</ContextMenu.ItemText>
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
|
|||||||
@@ -77,9 +77,6 @@ let MessageItemEmbed = ({
|
|||||||
minWidth: 280,
|
minWidth: 280,
|
||||||
maxWidth: 360,
|
maxWidth: 360,
|
||||||
}),
|
}),
|
||||||
// Cancel out the embed's internal a.mt_sm so the container's
|
|
||||||
// CLUSTERED_MESSAGE_GAP (2px) is the only spacing applied
|
|
||||||
{marginTop: -a.mt_sm.marginTop},
|
|
||||||
]}>
|
]}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[a.rounded_xl, a.overflow_hidden, radiiStyle, highlightStyle]}>
|
style={[a.rounded_xl, a.overflow_hidden, radiiStyle, highlightStyle]}>
|
||||||
|
|||||||
@@ -362,14 +362,14 @@ function MutualGroupChat({
|
|||||||
<Button
|
<Button
|
||||||
color="negative_subtle"
|
color="negative_subtle"
|
||||||
disabled={isRemovePending}
|
disabled={isRemovePending}
|
||||||
label={l`Kick member`}
|
label={l`Remove member`}
|
||||||
size="small"
|
size="small"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onOptimisticallyRemoveConvo(view.id)
|
onOptimisticallyRemoveConvo(view.id)
|
||||||
removeMembers({members: [profileDid]})
|
removeMembers({members: [profileDid]})
|
||||||
}}>
|
}}>
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Kick member</Trans>
|
<Trans>Remove member</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
{isRemovePending ? <ButtonIcon icon={Loader} /> : null}
|
{isRemovePending ? <ButtonIcon icon={Loader} /> : null}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ export function ThemePicker({
|
|||||||
|
|
||||||
const labels: Record<InviteThemeKey, string> = {
|
const labels: Record<InviteThemeKey, string> = {
|
||||||
dawn: l`Dawn`,
|
dawn: l`Dawn`,
|
||||||
|
sunlight: l`Sunlight`,
|
||||||
day: l`Day`,
|
day: l`Day`,
|
||||||
dusk: l`Dusk`,
|
dusk: l`Dusk`,
|
||||||
|
twilight: l`Twilight`,
|
||||||
night: l`Night`,
|
night: l`Night`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,15 @@ import {describe, expect, it} from '@jest/globals'
|
|||||||
import {getInviteTheme, INVITE_THEME_KEYS, INVITE_THEMES} from './themes'
|
import {getInviteTheme, INVITE_THEME_KEYS, INVITE_THEMES} from './themes'
|
||||||
|
|
||||||
describe('invite themes', () => {
|
describe('invite themes', () => {
|
||||||
it('exposes four themes in canonical order', () => {
|
it('exposes six themes in canonical order', () => {
|
||||||
expect(INVITE_THEME_KEYS).toEqual(['dawn', 'day', 'dusk', 'night'])
|
expect(INVITE_THEME_KEYS).toEqual([
|
||||||
|
'dawn',
|
||||||
|
'sunlight',
|
||||||
|
'day',
|
||||||
|
'dusk',
|
||||||
|
'twilight',
|
||||||
|
'night',
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has light and dark variants for every theme', () => {
|
it('has light and dark variants for every theme', () => {
|
||||||
|
|||||||
@@ -5,7 +5,13 @@
|
|||||||
* matching primary color; the QR data uses qrPrimary; the avatar handle text
|
* matching primary color; the QR data uses qrPrimary; the avatar handle text
|
||||||
* follows handleColor.
|
* follows handleColor.
|
||||||
*/
|
*/
|
||||||
export type InviteThemeKey = 'dawn' | 'day' | 'dusk' | 'night'
|
export type InviteThemeKey =
|
||||||
|
| 'dawn'
|
||||||
|
| 'sunlight'
|
||||||
|
| 'day'
|
||||||
|
| 'dusk'
|
||||||
|
| 'twilight'
|
||||||
|
| 'night'
|
||||||
|
|
||||||
export type InviteThemeVariant = {
|
export type InviteThemeVariant = {
|
||||||
/** QR data + eye color */
|
/** QR data + eye color */
|
||||||
@@ -30,8 +36,10 @@ export type InviteTheme = {
|
|||||||
|
|
||||||
export const INVITE_THEME_KEYS: readonly InviteThemeKey[] = [
|
export const INVITE_THEME_KEYS: readonly InviteThemeKey[] = [
|
||||||
'dawn',
|
'dawn',
|
||||||
|
'sunlight',
|
||||||
'day',
|
'day',
|
||||||
'dusk',
|
'dusk',
|
||||||
|
'twilight',
|
||||||
'night',
|
'night',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -54,6 +62,24 @@ export const INVITE_THEMES: Record<InviteThemeKey, InviteTheme> = {
|
|||||||
handleColor: '#ffffff',
|
handleColor: '#ffffff',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
sunlight: {
|
||||||
|
key: 'sunlight',
|
||||||
|
swatch: '#ff8159',
|
||||||
|
light: {
|
||||||
|
qrPrimary: '#ff8159',
|
||||||
|
gradientFrom: '#ffc785',
|
||||||
|
gradientTo: '#ff8159',
|
||||||
|
shadowColor: '#ff8159',
|
||||||
|
handleColor: '#ffffff',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
qrPrimary: '#ff8159',
|
||||||
|
gradientFrom: '#ffc785',
|
||||||
|
gradientTo: '#ff8159',
|
||||||
|
shadowColor: '#ff8159',
|
||||||
|
handleColor: '#ffffff',
|
||||||
|
},
|
||||||
|
},
|
||||||
day: {
|
day: {
|
||||||
key: 'day',
|
key: 'day',
|
||||||
swatch: '#006aff',
|
swatch: '#006aff',
|
||||||
@@ -90,6 +116,24 @@ export const INVITE_THEMES: Record<InviteThemeKey, InviteTheme> = {
|
|||||||
handleColor: '#ffffff',
|
handleColor: '#ffffff',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
twilight: {
|
||||||
|
key: 'twilight',
|
||||||
|
swatch: '#8b60f7',
|
||||||
|
light: {
|
||||||
|
qrPrimary: '#8b60f7',
|
||||||
|
gradientFrom: '#8ec1ff',
|
||||||
|
gradientTo: '#8b60f7',
|
||||||
|
shadowColor: '#8b60f7',
|
||||||
|
handleColor: '#ffffff',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
qrPrimary: '#8b60f7',
|
||||||
|
gradientFrom: '#8ec1ff',
|
||||||
|
gradientTo: '#8b60f7',
|
||||||
|
shadowColor: '#8b60f7',
|
||||||
|
handleColor: '#ffffff',
|
||||||
|
},
|
||||||
|
},
|
||||||
night: {
|
night: {
|
||||||
key: 'night',
|
key: 'night',
|
||||||
swatch: '#0048ad',
|
swatch: '#0048ad',
|
||||||
|
|||||||
@@ -1145,7 +1145,7 @@ msgid "Add image"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. Accessibility label for button in composer to add images, a video, or a GIF to a post
|
#. Accessibility label for button in composer to add images, a video, or a GIF to a post
|
||||||
#: src/view/com/composer/SelectMediaButton.tsx:505
|
#: src/view/com/composer/SelectMediaButton.tsx:511
|
||||||
msgid "Add media to post"
|
msgid "Add media to post"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -8053,11 +8053,11 @@ msgstr ""
|
|||||||
msgid "One or more images is missing alt text."
|
msgid "One or more images is missing alt text."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/view/com/composer/SelectMediaButton.tsx:417
|
#: src/view/com/composer/SelectMediaButton.tsx:423
|
||||||
msgid "One or more of your selected files are not supported."
|
msgid "One or more of your selected files are not supported."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/view/com/composer/SelectMediaButton.tsx:440
|
#: src/view/com/composer/SelectMediaButton.tsx:446
|
||||||
msgid "One or more of your selected files are too large. Maximum size is {VIDEO_MAX_SIZE_MB} MB."
|
msgid "One or more of your selected files are too large. Maximum size is {VIDEO_MAX_SIZE_MB} MB."
|
||||||
msgstr "One or more of your selected files are too large. Maximum size is {VIDEO_MAX_SIZE_MB} MB."
|
msgstr "One or more of your selected files are too large. Maximum size is {VIDEO_MAX_SIZE_MB} MB."
|
||||||
|
|
||||||
@@ -8308,7 +8308,7 @@ msgid "Opens device camera"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. Accessibility hint for button in composer to add images, a video, or a GIF to a post.
|
#. Accessibility hint for button in composer to add images, a video, or a GIF to a post.
|
||||||
#: src/view/com/composer/SelectMediaButton.tsx:511
|
#: src/view/com/composer/SelectMediaButton.tsx:517
|
||||||
msgid "Opens device gallery to select up to {MAX_GALLERY_IMAGES, plural, other {# images}}, or a single video or GIF."
|
msgid "Opens device gallery to select up to {MAX_GALLERY_IMAGES, plural, other {# images}}, or a single video or GIF."
|
||||||
msgstr "Opens device gallery to select up to {MAX_GALLERY_IMAGES, plural, other {# images}}, or a single video or GIF."
|
msgstr "Opens device gallery to select up to {MAX_GALLERY_IMAGES, plural, other {# images}}, or a single video or GIF."
|
||||||
|
|
||||||
@@ -10455,7 +10455,7 @@ msgstr ""
|
|||||||
msgid "Select your preferred notification channels"
|
msgid "Select your preferred notification channels"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/view/com/composer/SelectMediaButton.tsx:420
|
#: src/view/com/composer/SelectMediaButton.tsx:426
|
||||||
msgid "Selecting multiple media types is not supported."
|
msgid "Selecting multiple media types is not supported."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -13092,7 +13092,7 @@ msgstr ""
|
|||||||
msgid "Videos"
|
msgid "Videos"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/view/com/composer/SelectMediaButton.tsx:434
|
#: src/view/com/composer/SelectMediaButton.tsx:440
|
||||||
msgid "Videos must be less than 3 minutes long."
|
msgid "Videos must be less than 3 minutes long."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -13816,11 +13816,11 @@ msgstr ""
|
|||||||
msgid "You can only save drafts up to 1000 characters. Would you like to discard this post before viewing your drafts?"
|
msgid "You can only save drafts up to 1000 characters. Would you like to discard this post before viewing your drafts?"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/view/com/composer/SelectMediaButton.tsx:437
|
#: src/view/com/composer/SelectMediaButton.tsx:443
|
||||||
msgid "You can only select one GIF at a time."
|
msgid "You can only select one GIF at a time."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/view/com/composer/SelectMediaButton.tsx:431
|
#: src/view/com/composer/SelectMediaButton.tsx:437
|
||||||
msgid "You can only select one video at a time."
|
msgid "You can only select one video at a time."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -13833,7 +13833,7 @@ msgid "You can read chat history but can’t send new messages."
|
|||||||
msgstr "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.
|
#. Error message for maximum number of images that can be selected to add to a post.
|
||||||
#: src/view/com/composer/SelectMediaButton.tsx:423
|
#: src/view/com/composer/SelectMediaButton.tsx:429
|
||||||
msgid "You can select up to {MAX_GALLERY_IMAGES, plural, other {# images}} in total."
|
msgid "You can select up to {MAX_GALLERY_IMAGES, plural, other {# images}} in total."
|
||||||
msgstr "You can select up to {MAX_GALLERY_IMAGES, plural, other {# images}} in total."
|
msgstr "You can select up to {MAX_GALLERY_IMAGES, plural, other {# images}} in total."
|
||||||
|
|
||||||
@@ -14028,7 +14028,7 @@ msgstr ""
|
|||||||
msgid "You must grant access to your photo library to save a QR code"
|
msgid "You must grant access to your photo library to save a QR code"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/view/com/composer/SelectMediaButton.tsx:466
|
#: src/view/com/composer/SelectMediaButton.tsx:472
|
||||||
msgid "You need to allow access to your media library."
|
msgid "You need to allow access to your media library."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,21 @@ import {View} from 'react-native'
|
|||||||
import {useAnimatedRef} from 'react-native-reanimated'
|
import {useAnimatedRef} from 'react-native-reanimated'
|
||||||
import {type ChatBskyActorGetStatus, type ChatBskyConvoDefs} from '@atproto/api'
|
import {type ChatBskyActorGetStatus, type ChatBskyConvoDefs} from '@atproto/api'
|
||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
|
import {
|
||||||
|
useFocusEffect,
|
||||||
|
useIsFocused,
|
||||||
|
useNavigation,
|
||||||
|
} from '@react-navigation/native'
|
||||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||||
|
|
||||||
import {useAppState} from '#/lib/appState'
|
import {useAppState} from '#/lib/appState'
|
||||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
|
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
|
||||||
import {type MessagesTabNavigatorParams} from '#/lib/routes/types'
|
import {
|
||||||
|
type MessagesTabNavigatorParams,
|
||||||
|
type NavigationProp,
|
||||||
|
} from '#/lib/routes/types'
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {listenSoftReset} from '#/state/events'
|
import {listenSoftReset} from '#/state/events'
|
||||||
@@ -19,6 +26,7 @@ import {useMessagesEventBus} from '#/state/messages/events'
|
|||||||
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
|
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
|
||||||
import {useUnreadCountsQuery} from '#/state/queries/messages/get-unread-counts'
|
import {useUnreadCountsQuery} from '#/state/queries/messages/get-unread-counts'
|
||||||
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
|
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
|
||||||
|
import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
|
||||||
import {EmptyState} from '#/view/com/util/EmptyState'
|
import {EmptyState} from '#/view/com/util/EmptyState'
|
||||||
import {List, type ListRef} from '#/view/com/util/List'
|
import {List, type ListRef} from '#/view/com/util/List'
|
||||||
import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||||
@@ -31,6 +39,7 @@ import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
|
|||||||
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
|
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
|
||||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
|
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
|
||||||
import {BubbleSmile_Stroke2_Corner2_Rounded_Large as BubbleSmileIcon} from '#/components/icons/Bubble'
|
import {BubbleSmile_Stroke2_Corner2_Rounded_Large as BubbleSmileIcon} from '#/components/icons/Bubble'
|
||||||
|
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
|
||||||
import {Inbox_Stroke2_Corner2_Rounded_Large as InboxLargeIcon} from '#/components/icons/Inbox'
|
import {Inbox_Stroke2_Corner2_Rounded_Large as InboxLargeIcon} from '#/components/icons/Inbox'
|
||||||
import {
|
import {
|
||||||
@@ -41,6 +50,8 @@ import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/component
|
|||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
import {Link} from '#/components/Link'
|
import {Link} from '#/components/Link'
|
||||||
import {ListFooter} from '#/components/Lists'
|
import {ListFooter} from '#/components/Lists'
|
||||||
|
import * as Menu from '#/components/Menu'
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useAgeAssurance} from '#/ageAssurance'
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||||
@@ -289,7 +300,10 @@ export function ChatList({
|
|||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
} = useListConvosQuery({status: 'accepted'})
|
} = useListConvosQuery({
|
||||||
|
status: 'accepted',
|
||||||
|
kind: aa.flags.groupChatDisabled ? 'direct' : 'all',
|
||||||
|
})
|
||||||
|
|
||||||
const {refetch: refetchInbox} = useListConvosQuery({
|
const {refetch: refetchInbox} = useListConvosQuery({
|
||||||
status: 'request',
|
status: 'request',
|
||||||
@@ -546,16 +560,19 @@ export function Header({
|
|||||||
variant="solid"
|
variant="solid"
|
||||||
action={action}
|
action={action}
|
||||||
/>
|
/>
|
||||||
<Link
|
<ChatSettingsMenu action={action}>
|
||||||
to="/messages/settings"
|
{({props}) => (
|
||||||
action={action}
|
<Button
|
||||||
label={l`Chat settings`}
|
{...props}
|
||||||
|
label={l`Chat options`}
|
||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
shape="round"
|
shape="round"
|
||||||
style={[a.justify_center]}>
|
style={[a.justify_center]}>
|
||||||
<ButtonIcon icon={SettingsIcon} />
|
<ButtonIcon icon={SettingsIcon} />
|
||||||
</Link>
|
</Button>
|
||||||
|
)}
|
||||||
|
</ChatSettingsMenu>
|
||||||
{!chatStatus?.chatDisabled && (
|
{!chatStatus?.chatDisabled && (
|
||||||
<Button
|
<Button
|
||||||
label={l`New chat`}
|
label={l`New chat`}
|
||||||
@@ -578,19 +595,75 @@ export function Header({
|
|||||||
</Layout.Header.Content>
|
</Layout.Header.Content>
|
||||||
<InboxRequests count={requestCount} variant="ghost" />
|
<InboxRequests count={requestCount} variant="ghost" />
|
||||||
<Layout.Header.Slot>
|
<Layout.Header.Slot>
|
||||||
<Link
|
<ChatSettingsMenu action={action}>
|
||||||
to="/messages/settings"
|
{({props}) => (
|
||||||
label={l`Chat settings`}
|
<Button
|
||||||
|
{...props}
|
||||||
|
label={l`Chat options`}
|
||||||
size="small"
|
size="small"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
shape="round"
|
shape="round"
|
||||||
style={[a.justify_center]}>
|
style={[a.justify_center]}>
|
||||||
<ButtonIcon icon={SettingsIcon} size="lg" />
|
<ButtonIcon icon={SettingsIcon} size="lg" />
|
||||||
</Link>
|
</Button>
|
||||||
|
)}
|
||||||
|
</ChatSettingsMenu>
|
||||||
</Layout.Header.Slot>
|
</Layout.Header.Slot>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Layout.Header.Outer>
|
</Layout.Header.Outer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChatSettingsMenu({
|
||||||
|
action,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
action: 'navigate' | 'push'
|
||||||
|
children: React.ComponentProps<typeof Menu.Trigger>['children']
|
||||||
|
}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
const {mutate: markAllChatsRead} = useUpdateAllRead('accepted', {
|
||||||
|
onMutate: () => {
|
||||||
|
Toast.show(l`Marked all chats as read`, {type: 'success'})
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Toast.show(l`Failed to mark all chats as read`, {type: 'error'})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu.Root>
|
||||||
|
<Menu.Trigger label={l`Chat options`}>{children}</Menu.Trigger>
|
||||||
|
<Menu.Outer>
|
||||||
|
<Menu.Group>
|
||||||
|
<Menu.Item
|
||||||
|
label={l`Mark all chats as read`}
|
||||||
|
onPress={() => markAllChatsRead()}>
|
||||||
|
<Menu.ItemIcon icon={CircleCheckIcon} />
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Mark all chats as read</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
label={l`Chat settings`}
|
||||||
|
onPress={() => {
|
||||||
|
if (action === 'navigate') {
|
||||||
|
navigation.navigate('MessagesSettings')
|
||||||
|
} else {
|
||||||
|
navigation.push('MessagesSettings')
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Menu.ItemIcon icon={SettingsIcon} />
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Chat settings</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Group>
|
||||||
|
</Menu.Outer>
|
||||||
|
</Menu.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ function JoinRequestsList({
|
|||||||
useJoinRequestMutation('reject', convoId, {
|
useJoinRequestMutation('reject', convoId, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
ax.metric('groupchat:owner:joinRequest:reject', {convoId})
|
ax.metric('groupchat:owner:joinRequest:reject', {convoId})
|
||||||
Toast.show(l`Request ignored.`)
|
Toast.show(l`Request rejected.`)
|
||||||
if (getRemainingRequestCount() < 1) {
|
if (getRemainingRequestCount() < 1) {
|
||||||
navigation.replace('MessagesConversationSettings', {
|
navigation.replace('MessagesConversationSettings', {
|
||||||
conversation: convoId,
|
conversation: convoId,
|
||||||
@@ -220,7 +220,7 @@ function JoinRequestsList({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: error => {
|
onError: error => {
|
||||||
let errorMessage = l`Failed to ignore join request`
|
let errorMessage = l`Failed to reject join request`
|
||||||
if (isNetworkError(error)) {
|
if (isNetworkError(error)) {
|
||||||
errorMessage = l`A network error occurred. Please check your internet connection.`
|
errorMessage = l`A network error occurred. Please check your internet connection.`
|
||||||
} else if (
|
} else if (
|
||||||
@@ -230,7 +230,7 @@ function JoinRequestsList({
|
|||||||
} else if (
|
} else if (
|
||||||
error instanceof ChatBskyGroupRejectJoinRequest.InsufficientRoleError
|
error instanceof ChatBskyGroupRejectJoinRequest.InsufficientRoleError
|
||||||
) {
|
) {
|
||||||
errorMessage = l`Only admins can ignore join requests.`
|
errorMessage = l`Only admins can reject join requests.`
|
||||||
}
|
}
|
||||||
Toast.show(errorMessage, {type: 'error'})
|
Toast.show(errorMessage, {type: 'error'})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ export function MessageComposer({
|
|||||||
composerInternalApiRef.current?.input?.focus()
|
composerInternalApiRef.current?.input?.focus()
|
||||||
}, [replyTo, composerInternalApiRef])
|
}, [replyTo, composerInternalApiRef])
|
||||||
|
|
||||||
|
// On web, focus the input once the conversation is ready. The composer also
|
||||||
|
// mounts during the loading state (when it isn't editable), so a mount-time
|
||||||
|
// autoFocus would fire too early to land focus.
|
||||||
|
useEffect(() => {
|
||||||
|
if (IS_WEB && editable) {
|
||||||
|
composerInternalApiRef.current?.input?.focus()
|
||||||
|
}
|
||||||
|
}, [editable, composerInternalApiRef])
|
||||||
|
|
||||||
// Android interactive dismiss sometimes doesn't blur the input
|
// Android interactive dismiss sometimes doesn't blur the input
|
||||||
const blur = useNonReactiveCallback(() => {
|
const blur = useNonReactiveCallback(() => {
|
||||||
composerInternalApiRef.current?.input?.blur()
|
composerInternalApiRef.current?.input?.blur()
|
||||||
@@ -243,13 +252,12 @@ export function MessageComposer({
|
|||||||
placeholder={
|
placeholder={
|
||||||
loading
|
loading
|
||||||
? l({message: 'Loading chat…', context: 'placeholder'})
|
? l({message: 'Loading chat…', context: 'placeholder'})
|
||||||
: l({message: 'Message', context: 'action'})
|
: l({message: 'Message', context: 'description'})
|
||||||
}
|
}
|
||||||
autocompletePlacement="top-start"
|
autocompletePlacement="top-start"
|
||||||
internalApiRef={composerInternalApiRef}
|
internalApiRef={composerInternalApiRef}
|
||||||
defaultValue={text}
|
defaultValue={text}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
autoFocus={IS_WEB}
|
|
||||||
maxRows={12}
|
maxRows={12}
|
||||||
outerStyle={[a.flex_1]}
|
outerStyle={[a.flex_1]}
|
||||||
contentTextStyle={[a.text_md, a.leading_snug]}
|
contentTextStyle={[a.text_md, a.leading_snug]}
|
||||||
@@ -334,8 +342,7 @@ function SubmitButton({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: remove export when MessageInput is deleted
|
function ComposerContainer({children}: {children: React.ReactNode}) {
|
||||||
export function ComposerContainer({children}: {children: React.ReactNode}) {
|
|
||||||
const {bottom: bottomInset} = useSafeAreaInsets()
|
const {bottom: bottomInset} = useSafeAreaInsets()
|
||||||
const {progress} = useReanimatedKeyboardAnimation()
|
const {progress} = useReanimatedKeyboardAnimation()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|||||||
@@ -1,288 +0,0 @@
|
|||||||
import {useCallback, useState} from 'react'
|
|
||||||
import {Pressable, TextInput, useWindowDimensions} from 'react-native'
|
|
||||||
import {
|
|
||||||
useFocusedInputHandler,
|
|
||||||
useKeyboardHandler,
|
|
||||||
useReanimatedKeyboardAnimation,
|
|
||||||
} from 'react-native-keyboard-controller'
|
|
||||||
import Animated, {
|
|
||||||
measure,
|
|
||||||
runOnJS,
|
|
||||||
useAnimatedProps,
|
|
||||||
useAnimatedRef,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
} from 'react-native-reanimated'
|
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
|
||||||
import {GlassContainer} from 'expo-glass-effect'
|
|
||||||
import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api'
|
|
||||||
import {useLingui} from '@lingui/react/macro'
|
|
||||||
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
|
||||||
|
|
||||||
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
|
|
||||||
import {useHaptics} from '#/lib/haptics'
|
|
||||||
import {useEmail} from '#/state/email-verification'
|
|
||||||
import {
|
|
||||||
useMessageDraft,
|
|
||||||
useSaveMessageDraft,
|
|
||||||
} from '#/state/messages/message-drafts'
|
|
||||||
import {atoms as a, platform, tokens, useTheme} from '#/alf'
|
|
||||||
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
|
||||||
import {GlassView} from '#/components/GlassView'
|
|
||||||
import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
|
|
||||||
import {Loader} from '#/components/Loader'
|
|
||||||
import * as Toast from '#/components/Toast'
|
|
||||||
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
|
|
||||||
import {ComposerContainer} from './MessageComposer'
|
|
||||||
import {
|
|
||||||
type MessageEmbedState,
|
|
||||||
useExtractEmbedFromFacets,
|
|
||||||
} from './MessageInputEmbed'
|
|
||||||
|
|
||||||
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
|
|
||||||
|
|
||||||
const MIN_HEIGHT = 40
|
|
||||||
|
|
||||||
export function MessageInput({
|
|
||||||
textInputId,
|
|
||||||
onSendMessage,
|
|
||||||
messageEmbed,
|
|
||||||
setEmbed,
|
|
||||||
children,
|
|
||||||
loading = false,
|
|
||||||
}: {
|
|
||||||
textInputId?: string
|
|
||||||
onSendMessage: (
|
|
||||||
message: string,
|
|
||||||
embed?: MessageEmbedState,
|
|
||||||
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
|
|
||||||
) => Promise<void> | void
|
|
||||||
messageEmbed: MessageEmbedState | undefined
|
|
||||||
setEmbed: (embedUrl: string | undefined) => void
|
|
||||||
children?: React.ReactNode
|
|
||||||
loading?: boolean
|
|
||||||
}) {
|
|
||||||
const {t: l} = useLingui()
|
|
||||||
const t = useTheme()
|
|
||||||
const playHaptic = useHaptics()
|
|
||||||
const {getDraft, clearDraft} = useMessageDraft()
|
|
||||||
const {replyTo, clearReply} = useMessageReplies()
|
|
||||||
|
|
||||||
// Input layout
|
|
||||||
const {top: topInset} = useSafeAreaInsets()
|
|
||||||
const {height: windowHeight} = useWindowDimensions()
|
|
||||||
const {height: keyboardHeight} = useReanimatedKeyboardAnimation()
|
|
||||||
const maxHeight = useSharedValue<undefined | number>(undefined)
|
|
||||||
const isInputScrollable = useSharedValue(false)
|
|
||||||
|
|
||||||
const [message, setMessage] = useState(getDraft)
|
|
||||||
const inputRef = useAnimatedRef<TextInput>()
|
|
||||||
const [shouldEnforceClear, setShouldEnforceClear] = useState(false)
|
|
||||||
|
|
||||||
const {needsEmailVerification} = useEmail()
|
|
||||||
const editable = !needsEmailVerification && !loading
|
|
||||||
|
|
||||||
useSaveMessageDraft(message)
|
|
||||||
useExtractEmbedFromFacets(message, setEmbed)
|
|
||||||
|
|
||||||
const onSubmit = useCallback(() => {
|
|
||||||
if (!editable) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!messageEmbed && message.trim() === '') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
|
|
||||||
Toast.show(l`Message is too long`, {
|
|
||||||
type: 'error',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clearDraft()
|
|
||||||
playHaptic()
|
|
||||||
// Capture the embed before clearing - the deferred send below reads it.
|
|
||||||
const embed = messageEmbed
|
|
||||||
setEmbed(undefined)
|
|
||||||
setMessage('')
|
|
||||||
// Capture the reply before clearing - the deferred send below reads it.
|
|
||||||
const reply = replyTo
|
|
||||||
clearReply()
|
|
||||||
if (IS_IOS) {
|
|
||||||
setShouldEnforceClear(true)
|
|
||||||
}
|
|
||||||
if (IS_WEB) {
|
|
||||||
// Pressing the send button causes the text input to lose focus, so we need to
|
|
||||||
// re-focus it after sending
|
|
||||||
setTimeout(() => {
|
|
||||||
inputRef.current?.focus()
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
void onSendMessage(
|
|
||||||
message,
|
|
||||||
embed,
|
|
||||||
reply
|
|
||||||
? {...reply, $type: 'chat.bsky.convo.defs#messageView'}
|
|
||||||
: undefined,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}, [
|
|
||||||
editable,
|
|
||||||
messageEmbed,
|
|
||||||
message,
|
|
||||||
clearDraft,
|
|
||||||
onSendMessage,
|
|
||||||
playHaptic,
|
|
||||||
setEmbed,
|
|
||||||
inputRef,
|
|
||||||
l,
|
|
||||||
replyTo,
|
|
||||||
clearReply,
|
|
||||||
])
|
|
||||||
|
|
||||||
useFocusedInputHandler(
|
|
||||||
{
|
|
||||||
onChangeText: () => {
|
|
||||||
'worklet'
|
|
||||||
const measurement = measure(inputRef)
|
|
||||||
if (!measurement) return
|
|
||||||
|
|
||||||
const max = windowHeight - -keyboardHeight.get() - topInset - 150
|
|
||||||
const availableSpace = max - measurement.height
|
|
||||||
|
|
||||||
maxHeight.set(max)
|
|
||||||
isInputScrollable.set(availableSpace < 30)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[windowHeight, topInset],
|
|
||||||
)
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
|
||||||
maxHeight: maxHeight.get(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const animatedProps = useAnimatedProps(() => ({
|
|
||||||
scrollEnabled: isInputScrollable.get(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const submitDisabled =
|
|
||||||
!editable || (!messageEmbed && message.trim().length === 0)
|
|
||||||
|
|
||||||
const blur = useCallback(() => {
|
|
||||||
inputRef.current?.blur()
|
|
||||||
}, [inputRef])
|
|
||||||
|
|
||||||
useKeyboardHandler({
|
|
||||||
onEnd: evt => {
|
|
||||||
'worklet'
|
|
||||||
// small hack: interactive dismiss on Android sometimes doesn't blur the input
|
|
||||||
if (IS_ANDROID && evt.progress === 0) {
|
|
||||||
runOnJS(blur)()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ComposerContainer>
|
|
||||||
<GlassContainer
|
|
||||||
style={[a.flex_row, a.align_end, a.gap_sm]}
|
|
||||||
spacing={tokens.space.xs}>
|
|
||||||
<GlassView
|
|
||||||
isInteractive
|
|
||||||
glassEffectStyle="regular"
|
|
||||||
style={[a.flex_1, a.rounded_xl, {minHeight: MIN_HEIGHT}]}
|
|
||||||
tintColor={t.palette.contrast_50}
|
|
||||||
fallbackStyle={[t.atoms.bg_contrast_50]}>
|
|
||||||
{children}
|
|
||||||
<AnimatedTextInput
|
|
||||||
nativeID={textInputId}
|
|
||||||
accessibilityLabel={l`Message input field`}
|
|
||||||
accessibilityHint={l`Type your message here`}
|
|
||||||
placeholder={l`Message`}
|
|
||||||
placeholderTextColor={t.palette.contrast_500}
|
|
||||||
value={message}
|
|
||||||
onChange={evt => {
|
|
||||||
// bit of a hack: iOS automatically accepts autocomplete suggestions when you tap anywhere on the screen
|
|
||||||
// including the button we just pressed - and this overrides clearing the input! so we watch for the
|
|
||||||
// next change and double make sure the input is cleared. It should *always* send an onChange event after
|
|
||||||
// clearing via setMessage('') that happens in onSubmit()
|
|
||||||
// -sfn
|
|
||||||
if (IS_IOS && shouldEnforceClear) {
|
|
||||||
setShouldEnforceClear(false)
|
|
||||||
setMessage('')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const text = evt.nativeEvent.text
|
|
||||||
setMessage(text)
|
|
||||||
}}
|
|
||||||
multiline={true}
|
|
||||||
style={[
|
|
||||||
{flexBasis: 'auto', minHeight: MIN_HEIGHT},
|
|
||||||
a.flex_shrink_0,
|
|
||||||
a.flex_grow,
|
|
||||||
a.text_md,
|
|
||||||
a.px_lg,
|
|
||||||
t.atoms.text,
|
|
||||||
platform({
|
|
||||||
android: {paddingTop: 2, paddingBottom: 3},
|
|
||||||
ios: {paddingTop: 10, paddingBottom: 5},
|
|
||||||
}),
|
|
||||||
animatedStyle,
|
|
||||||
]}
|
|
||||||
verticalAlign="middle"
|
|
||||||
keyboardAppearance={t.scheme}
|
|
||||||
submitBehavior="newline"
|
|
||||||
ref={inputRef}
|
|
||||||
hitSlop={HITSLOP_10}
|
|
||||||
animatedProps={animatedProps}
|
|
||||||
editable={editable}
|
|
||||||
/>
|
|
||||||
</GlassView>
|
|
||||||
<GlassView
|
|
||||||
isInteractive
|
|
||||||
glassEffectStyle="regular"
|
|
||||||
style={[a.rounded_full]}
|
|
||||||
tintColor={
|
|
||||||
submitDisabled ? t.palette.contrast_100 : t.palette.primary_500
|
|
||||||
}
|
|
||||||
fallbackStyle={{
|
|
||||||
backgroundColor: submitDisabled
|
|
||||||
? t.palette.contrast_100
|
|
||||||
: t.palette.primary_500,
|
|
||||||
}}>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={
|
|
||||||
loading
|
|
||||||
? l({message: 'Loading chat…', context: 'placeholder'})
|
|
||||||
: l({message: 'Message', context: 'action'})
|
|
||||||
}
|
|
||||||
accessibilityHint=""
|
|
||||||
hitSlop={HITSLOP_10}
|
|
||||||
style={[
|
|
||||||
a.rounded_full,
|
|
||||||
a.align_center,
|
|
||||||
a.justify_center,
|
|
||||||
{
|
|
||||||
height: MIN_HEIGHT,
|
|
||||||
width: MIN_HEIGHT,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onPress={onSubmit}
|
|
||||||
disabled={submitDisabled}>
|
|
||||||
{loading ? (
|
|
||||||
<Loader size="md" fill={t.palette.white} style={[a.mb_2xs]} />
|
|
||||||
) : (
|
|
||||||
<PaperPlaneIcon
|
|
||||||
size="md"
|
|
||||||
fill={t.palette.white}
|
|
||||||
style={[a.mb_2xs]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</GlassView>
|
|
||||||
</GlassContainer>
|
|
||||||
</ComposerContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
import {useCallback, useRef, useState} from 'react'
|
|
||||||
import {Pressable, View} from 'react-native'
|
|
||||||
import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api'
|
|
||||||
import {useLingui} from '@lingui/react/macro'
|
|
||||||
import {flushSync} from 'react-dom'
|
|
||||||
import TextareaAutosize from 'react-textarea-autosize'
|
|
||||||
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
|
||||||
|
|
||||||
import {MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
|
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
|
||||||
import {
|
|
||||||
useMessageDraft,
|
|
||||||
useSaveMessageDraft,
|
|
||||||
} from '#/state/messages/message-drafts'
|
|
||||||
import {atoms as a, flatten, useTheme} from '#/alf'
|
|
||||||
import {Button} from '#/components/Button'
|
|
||||||
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
|
||||||
import * as EmojiPicker from '#/components/EmojiPicker'
|
|
||||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
|
||||||
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
|
||||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
|
||||||
import * as Toast from '#/components/Toast'
|
|
||||||
import {IS_WEB_SAFARI, IS_WEB_TOUCH_DEVICE} from '#/env'
|
|
||||||
import {
|
|
||||||
type MessageEmbedState,
|
|
||||||
useExtractEmbedFromFacets,
|
|
||||||
} from './MessageInputEmbed'
|
|
||||||
|
|
||||||
export function MessageInput({
|
|
||||||
onSendMessage,
|
|
||||||
messageEmbed,
|
|
||||||
setEmbed,
|
|
||||||
children,
|
|
||||||
loading = false,
|
|
||||||
}: {
|
|
||||||
onSendMessage: (
|
|
||||||
message: string,
|
|
||||||
embed?: MessageEmbedState,
|
|
||||||
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
|
|
||||||
) => void
|
|
||||||
messageEmbed: MessageEmbedState | undefined
|
|
||||||
setEmbed: (embedUrl: string | undefined) => void
|
|
||||||
children?: React.ReactNode
|
|
||||||
loading?: boolean
|
|
||||||
}) {
|
|
||||||
const {isMobile} = useWebMediaQueries()
|
|
||||||
const {t: l} = useLingui()
|
|
||||||
const t = useTheme()
|
|
||||||
const {getDraft, clearDraft} = useMessageDraft()
|
|
||||||
const {replyTo, clearReply} = useMessageReplies()
|
|
||||||
const [message, setMessage] = useState(getDraft)
|
|
||||||
|
|
||||||
const inputStyles = useSharedInputStyles()
|
|
||||||
const isComposing = useRef(false)
|
|
||||||
const [isFocused, setIsFocused] = useState(false)
|
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
|
||||||
const [textAreaHeight, setTextAreaHeight] = useState(38)
|
|
||||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
|
||||||
|
|
||||||
const onSubmit = useCallback(() => {
|
|
||||||
if (!messageEmbed && message.trim() === '') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
|
|
||||||
Toast.show(l`Message is too long`, {
|
|
||||||
type: 'error',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clearDraft()
|
|
||||||
onSendMessage(
|
|
||||||
message,
|
|
||||||
messageEmbed,
|
|
||||||
replyTo
|
|
||||||
? {...replyTo, $type: 'chat.bsky.convo.defs#messageView'}
|
|
||||||
: undefined,
|
|
||||||
)
|
|
||||||
clearReply()
|
|
||||||
setMessage('')
|
|
||||||
setEmbed(undefined)
|
|
||||||
}, [
|
|
||||||
message,
|
|
||||||
onSendMessage,
|
|
||||||
l,
|
|
||||||
clearDraft,
|
|
||||||
messageEmbed,
|
|
||||||
setEmbed,
|
|
||||||
replyTo,
|
|
||||||
clearReply,
|
|
||||||
])
|
|
||||||
|
|
||||||
const onKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
// Don't submit the form when the Japanese or any other IME is composing
|
|
||||||
if (isComposing.current) return
|
|
||||||
|
|
||||||
// see https://github.com/bluesky-social/social-app/issues/4178
|
|
||||||
// see https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/
|
|
||||||
// see https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html
|
|
||||||
//
|
|
||||||
// On Safari, the final keydown event to dismiss the IME - which is the enter key - is also "Enter" below.
|
|
||||||
// Obviously, this causes problems because the final dismissal should _not_ submit the text, but should just
|
|
||||||
// stop the IME editing. This is the behavior of Chrome and Firefox, but not Safari.
|
|
||||||
//
|
|
||||||
// Keycode is deprecated, however the alternative seems to only be to compare the timestamp from the
|
|
||||||
// onCompositionEnd event to the timestamp of the keydown event, which is not reliable. For example, this hack
|
|
||||||
// uses that method: https://github.com/ProseMirror/prosemirror-view/pull/44. However, from my 500ms resulted in
|
|
||||||
// far too long of a delay, and a subsequent enter press would often just end up doing nothing. A shorter time
|
|
||||||
// frame was also not great, since it was too short to be reliable (i.e. an older system might have a larger
|
|
||||||
// time gap between the two events firing.
|
|
||||||
if (IS_WEB_SAFARI && e.key === 'Enter' && e.keyCode === 229) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
if (e.shiftKey) return
|
|
||||||
e.preventDefault()
|
|
||||||
onSubmit()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onSubmit],
|
|
||||||
)
|
|
||||||
|
|
||||||
const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
setMessage(e.target.value)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onEmojiInserted = useCallback(
|
|
||||||
(emoji: EmojiPicker.Emoji) => {
|
|
||||||
if (!textAreaRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const position = textAreaRef.current.selectionStart ?? 0
|
|
||||||
flushSync(() => {
|
|
||||||
setMessage(
|
|
||||||
message =>
|
|
||||||
message.slice(0, position) + emoji.native + message.slice(position),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
textAreaRef.current.selectionStart = position + emoji.native.length
|
|
||||||
textAreaRef.current.selectionEnd = position + emoji.native.length
|
|
||||||
},
|
|
||||||
[setMessage],
|
|
||||||
)
|
|
||||||
|
|
||||||
useSaveMessageDraft(message)
|
|
||||||
useExtractEmbedFromFacets(message, setEmbed)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={a.p_sm}>
|
|
||||||
{children}
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.flex_row,
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
{
|
|
||||||
paddingRight: a.p_sm.padding - 2,
|
|
||||||
paddingLeft: a.p_sm.padding - 2,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 23,
|
|
||||||
borderColor: 'transparent',
|
|
||||||
height: textAreaHeight + 23,
|
|
||||||
},
|
|
||||||
isHovered && inputStyles.chromeHover,
|
|
||||||
isFocused && inputStyles.chromeFocus,
|
|
||||||
]}
|
|
||||||
// @ts-expect-error web only
|
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
|
||||||
onMouseLeave={() => setIsHovered(false)}>
|
|
||||||
{loading ? null : (
|
|
||||||
<EmojiPicker.Root
|
|
||||||
onEmojiSelect={onEmojiInserted}
|
|
||||||
nextFocusRef={textAreaRef}>
|
|
||||||
<EmojiPicker.Trigger label={l`Open emoji picker`}>
|
|
||||||
{({props, state}) => (
|
|
||||||
<Button
|
|
||||||
style={[
|
|
||||||
a.rounded_full,
|
|
||||||
a.overflow_hidden,
|
|
||||||
a.align_center,
|
|
||||||
a.justify_center,
|
|
||||||
{
|
|
||||||
marginTop: 5,
|
|
||||||
height: 30,
|
|
||||||
width: 30,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
label={props.accessibilityLabel}
|
|
||||||
{...props}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.absolute,
|
|
||||||
a.inset_0,
|
|
||||||
a.align_center,
|
|
||||||
a.justify_center,
|
|
||||||
{
|
|
||||||
backgroundColor:
|
|
||||||
state.hovered || state.focused || state.pressed
|
|
||||||
? t.atoms.bg.backgroundColor
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<EmojiSmile size="lg" />
|
|
||||||
</View>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</EmojiPicker.Trigger>
|
|
||||||
<EmojiPicker.Picker />
|
|
||||||
</EmojiPicker.Root>
|
|
||||||
)}
|
|
||||||
<TextareaAutosize
|
|
||||||
ref={textAreaRef}
|
|
||||||
disabled={loading}
|
|
||||||
style={flatten([
|
|
||||||
a.flex_1,
|
|
||||||
a.px_sm,
|
|
||||||
a.border_0,
|
|
||||||
t.atoms.text,
|
|
||||||
{
|
|
||||||
paddingTop: 10,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
resize: 'none',
|
|
||||||
},
|
|
||||||
])}
|
|
||||||
maxRows={12}
|
|
||||||
placeholder={
|
|
||||||
loading
|
|
||||||
? l({message: 'Loading chat…', context: 'placeholder'})
|
|
||||||
: l({message: 'Message', context: 'action'})
|
|
||||||
}
|
|
||||||
defaultValue=""
|
|
||||||
value={message}
|
|
||||||
dirName="ltr"
|
|
||||||
autoFocus={true}
|
|
||||||
onFocus={() => setIsFocused(true)}
|
|
||||||
onBlur={() => setIsFocused(false)}
|
|
||||||
onCompositionStart={() => {
|
|
||||||
isComposing.current = true
|
|
||||||
}}
|
|
||||||
onCompositionEnd={() => {
|
|
||||||
isComposing.current = false
|
|
||||||
}}
|
|
||||||
onHeightChange={height => setTextAreaHeight(height)}
|
|
||||||
onChange={onChange}
|
|
||||||
// On mobile web phones, we want to keep the same behavior as the native app. Do not submit the message
|
|
||||||
// in these cases.
|
|
||||||
onKeyDown={IS_WEB_TOUCH_DEVICE && isMobile ? undefined : onKeyDown}
|
|
||||||
/>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={l`Send message`}
|
|
||||||
accessibilityHint=""
|
|
||||||
disabled={loading}
|
|
||||||
style={[
|
|
||||||
a.rounded_full,
|
|
||||||
a.align_center,
|
|
||||||
a.justify_center,
|
|
||||||
{
|
|
||||||
height: 30,
|
|
||||||
width: 30,
|
|
||||||
marginTop: 5,
|
|
||||||
backgroundColor: t.palette.primary_500,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onPress={onSubmit}>
|
|
||||||
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -57,7 +57,6 @@ import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util'
|
|||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import {List, type ListMethods} from '#/view/com/util/List'
|
import {List, type ListMethods} from '#/view/com/util/List'
|
||||||
import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
|
import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
|
||||||
import {MessageInput} from '#/screens/Messages/components/MessageInput'
|
|
||||||
import {MessageListError} from '#/screens/Messages/components/MessageListError'
|
import {MessageListError} from '#/screens/Messages/components/MessageListError'
|
||||||
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
|
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
|
||||||
import {DateDivider} from '#/components/dms/DateDivider'
|
import {DateDivider} from '#/components/dms/DateDivider'
|
||||||
@@ -704,9 +703,6 @@ export function MessagesList({
|
|||||||
messageEmbed={messageEmbed}
|
messageEmbed={messageEmbed}
|
||||||
setEmbed={setEmbed}
|
setEmbed={setEmbed}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
useNewComposer={ax.features.enabled(
|
|
||||||
ax.features.DmsNewMessageComposerEnable,
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ConversationFooter>
|
</ConversationFooter>
|
||||||
@@ -735,7 +731,6 @@ function Composer({
|
|||||||
messageEmbed,
|
messageEmbed,
|
||||||
setEmbed,
|
setEmbed,
|
||||||
loading,
|
loading,
|
||||||
useNewComposer,
|
|
||||||
}: {
|
}: {
|
||||||
textInputId: string
|
textInputId: string
|
||||||
onSendMessage: (
|
onSendMessage: (
|
||||||
@@ -746,7 +741,6 @@ function Composer({
|
|||||||
messageEmbed: MessageEmbedState | undefined
|
messageEmbed: MessageEmbedState | undefined
|
||||||
setEmbed: (embedUrl: string | undefined) => void
|
setEmbed: (embedUrl: string | undefined) => void
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
useNewComposer: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const handleSendMessage = useNonReactiveCallback(
|
const handleSendMessage = useNonReactiveCallback(
|
||||||
(
|
(
|
||||||
@@ -758,31 +752,16 @@ function Composer({
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const previews = (
|
return (
|
||||||
<>
|
|
||||||
<MessageInputReply />
|
|
||||||
<MessageInputEmbed embed={messageEmbed} setEmbed={setEmbed} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
return useNewComposer ? (
|
|
||||||
<MessageComposer
|
<MessageComposer
|
||||||
textInputId={textInputId}
|
textInputId={textInputId}
|
||||||
onSendMessage={handleSendMessage}
|
onSendMessage={handleSendMessage}
|
||||||
messageEmbed={messageEmbed}
|
messageEmbed={messageEmbed}
|
||||||
setEmbed={setEmbed}
|
setEmbed={setEmbed}
|
||||||
loading={loading}>
|
loading={loading}>
|
||||||
{previews}
|
<MessageInputReply />
|
||||||
|
<MessageInputEmbed embed={messageEmbed} setEmbed={setEmbed} />
|
||||||
</MessageComposer>
|
</MessageComposer>
|
||||||
) : (
|
|
||||||
<MessageInput
|
|
||||||
textInputId={textInputId}
|
|
||||||
onSendMessage={handleSendMessage}
|
|
||||||
messageEmbed={messageEmbed}
|
|
||||||
setEmbed={setEmbed}
|
|
||||||
loading={loading}>
|
|
||||||
{previews}
|
|
||||||
</MessageInput>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -292,7 +292,10 @@ export function HeaderLabelerButtons({
|
|||||||
testID="profileHeaderEditProfileButton"
|
testID="profileHeaderEditProfileButton"
|
||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
onPress={editProfileControl.open}
|
onPress={() => {
|
||||||
|
playHaptic('Light')
|
||||||
|
editProfileControl.open()
|
||||||
|
}}
|
||||||
label={_(msg`Edit profile`)}
|
label={_(msg`Edit profile`)}
|
||||||
style={a.rounded_full}>
|
style={a.rounded_full}>
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {AppState, type AppStateStatus, View} from 'react-native'
|
|||||||
import ReactNativeDeviceAttest from 'react-native-device-attest'
|
import ReactNativeDeviceAttest from 'react-native-device-attest'
|
||||||
import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'
|
import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'
|
||||||
import {AppBskyGraphStarterpack} from '@atproto/api'
|
import {AppBskyGraphStarterpack} from '@atproto/api'
|
||||||
|
import {tokens} from '@bsky.app/alf'
|
||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {FEEDBACK_FORM_URL} from '#/lib/constants'
|
import {FEEDBACK_FORM_URL} from '#/lib/constants'
|
||||||
@@ -222,11 +223,17 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
|||||||
a.align_center,
|
a.align_center,
|
||||||
]}>
|
]}>
|
||||||
<AppLanguageDropdown />
|
<AppLanguageDropdown />
|
||||||
|
<View
|
||||||
|
style={
|
||||||
|
gtMobile
|
||||||
|
? [a.flex_1, a.flex, a.flex_row, a.justify_end]
|
||||||
|
: []
|
||||||
|
}>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
|
||||||
t.atoms.text_contrast_medium,
|
t.atoms.text_contrast_medium,
|
||||||
!gtMobile && a.text_md,
|
!gtMobile && a.text_md,
|
||||||
|
{paddingInline: tokens.space.sm},
|
||||||
]}>
|
]}>
|
||||||
<Trans>Having trouble?</Trans>{' '}
|
<Trans>Having trouble?</Trans>{' '}
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
@@ -238,6 +245,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
</ScreenTransition>
|
</ScreenTransition>
|
||||||
</LayoutAnimationConfig>
|
</LayoutAnimationConfig>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -132,6 +132,12 @@ export function ConvoProvider({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const [root, id] = getConvoKey(convoId)
|
const [root, id] = getConvoKey(convoId)
|
||||||
return queryClient.getQueryCache().subscribe(event => {
|
return queryClient.getQueryCache().subscribe(event => {
|
||||||
|
// Only react to data updates. Other event types (e.g. `added`) can be
|
||||||
|
// emitted synchronously while another component reads this same query
|
||||||
|
// during its render (React Query builds the query in `getOptimisticResult`),
|
||||||
|
// and committing to the convo store then would set state on this provider
|
||||||
|
// mid-render of that component.
|
||||||
|
if (event.type !== 'updated') return
|
||||||
const queryKey = event.query.queryKey as string[]
|
const queryKey = event.query.queryKey as string[]
|
||||||
if (queryKey[0] === root && queryKey[1] === id) {
|
if (queryKey[0] === root && queryKey[1] === id) {
|
||||||
const data = event.query.state.data as
|
const data = event.query.state.data as
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
type ChatBskyActorDefs,
|
type ChatBskyActorDefs,
|
||||||
type ChatBskyConvoDefs,
|
type ChatBskyConvoDefs,
|
||||||
type ChatBskyConvoGetConvo,
|
type ChatBskyConvoGetConvo,
|
||||||
|
type ChatBskyConvoGetUnreadCounts,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {
|
import {
|
||||||
type QueryClient,
|
type QueryClient,
|
||||||
@@ -14,6 +15,11 @@ import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
|||||||
import {STALE} from '#/state/queries'
|
import {STALE} from '#/state/queries'
|
||||||
import {useOnMarkAsRead} from '#/state/queries/messages/list-conversations'
|
import {useOnMarkAsRead} from '#/state/queries/messages/list-conversations'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
|
import {
|
||||||
|
RQKEY_PARTIAL as UNREAD_COUNTS_PARTIAL_KEY,
|
||||||
|
UNREAD_ACCEPTED_CAP,
|
||||||
|
UNREAD_REQUEST_CAP,
|
||||||
|
} from './get-unread-counts'
|
||||||
import {
|
import {
|
||||||
type ConvoListQueryData,
|
type ConvoListQueryData,
|
||||||
getConvoFromQueryData,
|
getConvoFromQueryData,
|
||||||
@@ -74,11 +80,81 @@ export function useMarkAsReadMutation() {
|
|||||||
},
|
},
|
||||||
onMutate({convoId}) {
|
onMutate({convoId}) {
|
||||||
if (!convoId) throw new Error('No convoId provided')
|
if (!convoId) throw new Error('No convoId provided')
|
||||||
|
|
||||||
|
// snapshot the list caches before the optimistic update so onError can
|
||||||
|
// restore the convo rows alongside the badge count
|
||||||
|
const prevListQueries = queryClient.getQueriesData<ConvoListQueryData>({
|
||||||
|
queryKey: [LIST_CONVOS_KEY],
|
||||||
|
})
|
||||||
|
|
||||||
|
// find the convo so we know which badge counter (if any) to decrement.
|
||||||
|
// keep scanning past a stale unreadCount === 0 cache so another cache
|
||||||
|
// holding the true unread state still drives the decrement
|
||||||
|
let unreadStatus: ChatBskyConvoDefs.ConvoView['status'] | undefined
|
||||||
|
for (const [, data] of prevListQueries) {
|
||||||
|
if (!data) continue
|
||||||
|
const convo = getConvoFromQueryData(convoId, data)
|
||||||
|
if (convo?.unreadCount) {
|
||||||
|
unreadStatus = convo.status
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
optimisticUpdate(convoId)
|
optimisticUpdate(convoId)
|
||||||
|
|
||||||
|
// the badge count query is a separate server query that the list caches
|
||||||
|
// don't feed, so decrement it here to keep the badge in sync
|
||||||
|
const prevUnreadCountsQueries =
|
||||||
|
queryClient.getQueriesData<ChatBskyConvoGetUnreadCounts.OutputSchema>({
|
||||||
|
queryKey: UNREAD_COUNTS_PARTIAL_KEY,
|
||||||
|
})
|
||||||
|
if (unreadStatus) {
|
||||||
|
queryClient.setQueriesData<ChatBskyConvoGetUnreadCounts.OutputSchema>(
|
||||||
|
{queryKey: UNREAD_COUNTS_PARTIAL_KEY},
|
||||||
|
old => {
|
||||||
|
if (!old) return old
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
...(unreadStatus === 'request'
|
||||||
|
? {
|
||||||
|
unreadRequestConvos:
|
||||||
|
old.unreadRequestConvos >= UNREAD_REQUEST_CAP
|
||||||
|
? old.unreadRequestConvos
|
||||||
|
: Math.max(0, old.unreadRequestConvos - 1),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
unreadAcceptedConvos:
|
||||||
|
old.unreadAcceptedConvos >= UNREAD_ACCEPTED_CAP
|
||||||
|
? old.unreadAcceptedConvos
|
||||||
|
: Math.max(0, old.unreadAcceptedConvos - 1),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return {prevListQueries, prevUnreadCountsQueries}
|
||||||
|
},
|
||||||
|
onError(_, __, context) {
|
||||||
|
if (context?.prevListQueries) {
|
||||||
|
for (const [queryKey, prevData] of context.prevListQueries) {
|
||||||
|
queryClient.setQueryData(queryKey, prevData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (context?.prevUnreadCountsQueries) {
|
||||||
|
for (const [queryKey, prevData] of context.prevUnreadCountsQueries) {
|
||||||
|
queryClient.setQueryData(queryKey, prevData)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onSuccess(_, {convoId}) {
|
onSuccess(_, {convoId}) {
|
||||||
if (!convoId) return
|
if (!convoId) return
|
||||||
|
|
||||||
|
// the optimistic badge arithmetic can drift from the server (e.g. a convo
|
||||||
|
// whose status differs between caches, or a sentinel-capped count). invalidate
|
||||||
|
// so the 15s-stale count query self-corrects on next access rather than
|
||||||
|
// waiting for a log event
|
||||||
|
void queryClient.invalidateQueries({queryKey: UNREAD_COUNTS_PARTIAL_KEY})
|
||||||
|
|
||||||
queryClient.setQueriesData(
|
queryClient.setQueriesData(
|
||||||
{queryKey: [LIST_CONVOS_KEY]},
|
{queryKey: [LIST_CONVOS_KEY]},
|
||||||
(old?: ConvoListQueryData) => {
|
(old?: ConvoListQueryData) => {
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ export const RQKEY = (includeGroupChats: boolean) =>
|
|||||||
[RQKEY_ROOT, includeGroupChats] as const
|
[RQKEY_ROOT, includeGroupChats] as const
|
||||||
export const RQKEY_PARTIAL = [RQKEY_ROOT] as const
|
export const RQKEY_PARTIAL = [RQKEY_ROOT] as const
|
||||||
|
|
||||||
|
// the server sentinel-caps the badge counts: unreadAcceptedConvos maxes at 31
|
||||||
|
// (meaning "more than 30") and unreadRequestConvos at 11 (meaning "more than
|
||||||
|
// 10"). at the cap the value is no longer an exact count, so consumers must not
|
||||||
|
// treat it as one - both the optimistic decrement and the badge display ceiling
|
||||||
|
// key off these.
|
||||||
|
export const UNREAD_ACCEPTED_CAP = 31
|
||||||
|
export const UNREAD_REQUEST_CAP = 11
|
||||||
|
|
||||||
export function useUnreadCountsQuery() {
|
export function useUnreadCountsQuery() {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const {hasSession} = useSession()
|
const {hasSession} = useSession()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import * as bsky from '#/types/bsky'
|
|||||||
import {RQKEY as CONVO_KEY} from './conversation'
|
import {RQKEY as CONVO_KEY} from './conversation'
|
||||||
import {
|
import {
|
||||||
RQKEY_PARTIAL as UNREAD_COUNTS_RQKEY_PARTIAL,
|
RQKEY_PARTIAL as UNREAD_COUNTS_RQKEY_PARTIAL,
|
||||||
|
UNREAD_ACCEPTED_CAP,
|
||||||
useUnreadCountsQuery,
|
useUnreadCountsQuery,
|
||||||
} from './get-unread-counts'
|
} from './get-unread-counts'
|
||||||
import {
|
import {
|
||||||
@@ -858,7 +859,15 @@ export function useUnreadMessageCount(): {
|
|||||||
const total = accepted + Math.min(request, 1)
|
const total = accepted + Math.min(request, 1)
|
||||||
return {
|
return {
|
||||||
count: total,
|
count: total,
|
||||||
numUnread: total > 10 ? '10+' : String(total),
|
// accepted is sentinel-capped at UNREAD_ACCEPTED_CAP (meaning "more than
|
||||||
|
// cap - 1"). show the "+" overflow label only when accepted is actually
|
||||||
|
// capped - the +1 request nudge must not trip it at exactly cap - 1
|
||||||
|
// accepted convos. otherwise clamp the number to cap - 1 so the nudge
|
||||||
|
// never surfaces the sentinel value (31) itself
|
||||||
|
numUnread:
|
||||||
|
accepted >= UNREAD_ACCEPTED_CAP
|
||||||
|
? `${UNREAD_ACCEPTED_CAP - 1}+`
|
||||||
|
: String(Math.min(total, UNREAD_ACCEPTED_CAP - 1)),
|
||||||
// only needed when numUnread is undefined
|
// only needed when numUnread is undefined
|
||||||
hasNew: false,
|
hasNew: false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import {type ChatBskyConvoGetUnreadCounts} from '@atproto/api'
|
||||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
|
import {RQKEY_PARTIAL as UNREAD_COUNTS_PARTIAL_KEY} from './get-unread-counts'
|
||||||
import {
|
import {
|
||||||
type ConvoRequestListQueryData,
|
type ConvoRequestListQueryData,
|
||||||
markAllRead as markAllRequestsRead,
|
markAllRead as markAllRequestsRead,
|
||||||
@@ -94,10 +96,36 @@ export function useUpdateAllRead(
|
|||||||
markAllRequestsRead,
|
markAllRequestsRead,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// zero out the badge count query that actually drives the unread badge,
|
||||||
|
// since it's a separate server query that the list caches don't feed
|
||||||
|
const prevUnreadCountsQueries =
|
||||||
|
queryClient.getQueriesData<ChatBskyConvoGetUnreadCounts.OutputSchema>({
|
||||||
|
queryKey: UNREAD_COUNTS_PARTIAL_KEY,
|
||||||
|
})
|
||||||
|
queryClient.setQueriesData<ChatBskyConvoGetUnreadCounts.OutputSchema>(
|
||||||
|
{queryKey: UNREAD_COUNTS_PARTIAL_KEY},
|
||||||
|
old => {
|
||||||
|
if (!old) return old
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
...(status === 'accepted'
|
||||||
|
? {unreadAcceptedConvos: 0}
|
||||||
|
: {unreadRequestConvos: 0}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
onMutate?.()
|
onMutate?.()
|
||||||
return {prevConvoListQueries, prevRequestsQueries}
|
return {
|
||||||
|
prevConvoListQueries,
|
||||||
|
prevRequestsQueries,
|
||||||
|
prevUnreadCountsQueries,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
// the optimistic badge zeroing can drift from the server, so invalidate
|
||||||
|
// the count query to let it self-correct on next access rather than
|
||||||
|
// waiting for a log event
|
||||||
|
void queryClient.invalidateQueries({queryKey: UNREAD_COUNTS_PARTIAL_KEY})
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: CONVO_LIST_PARTIAL_KEY(status),
|
queryKey: CONVO_LIST_PARTIAL_KEY(status),
|
||||||
})
|
})
|
||||||
@@ -121,6 +149,11 @@ export function useUpdateAllRead(
|
|||||||
queryClient.setQueryData(queryKey, prevData)
|
queryClient.setQueryData(queryKey, prevData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (context?.prevUnreadCountsQueries) {
|
||||||
|
for (const [queryKey, prevData] of context.prevUnreadCountsQueries) {
|
||||||
|
queryClient.setQueryData(queryKey, prevData)
|
||||||
|
}
|
||||||
|
}
|
||||||
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]})
|
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]})
|
||||||
if (status === 'request') {
|
if (status === 'request') {
|
||||||
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
|
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
|
||||||
|
|||||||
Reference in New Issue
Block a user