Merge remote-tracking branch 'origin/main' into app-1715

* origin/main: (43 commits)
  Release Prep 1.126.0 (#11015)
  Fix chat input positioning on mobile android (#11013)
  Disable selectable profile description on Android (#11002)
  Fix CI - grant workflows permission for internal sync push (#11010)
  Set apple team id via environment variable (#11008)
  Fix 149/150 user limit for starter packs (#11001)
  Nightly source-language update
  Fix GrowthBook exposure did mis-attribution across account switches (#11000)
  Add advanced search UI (#10992)
  Fail video duration check before compression (#10999)
  Add app language: Czech (`cs`) (#10994)
  Clean up type/lint errors in `modules` (#10996)
  Bump actions/download-artifact from 7.0.0 to 8.0.1 (#10971)
  Nightly source-language update
  Change dialog label and header from 'Invite Friends' to 'Share Profile' (#10987)
  Add placeholder, empty-value, and onConfirm support to DateField (#10993)
  Add video upload telemetry events (#10991)
  Hide autocomplete suggestions when input loses focus (#10989)
  Increase unread cap to 99+ (#10985)
  Add icon to AppLanguageDropdown (#10977)
  ...
This commit is contained in:
Eric Bailey
2026-06-29 19:32:08 -05:00
187 changed files with 52545 additions and 29898 deletions
+1 -1
View File
@@ -227,7 +227,7 @@ jobs:
- name: ⬇️ Download APK artifact - name: ⬇️ Download APK artifact
if: ${{ steps.release-check.outputs.exists == 'true' }} if: ${{ steps.release-check.outputs.exists == 'true' }}
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: ${{ needs.build.outputs.apk-artifact-name }} name: ${{ needs.build.outputs.apk-artifact-name }}
+15 -10
View File
@@ -10,20 +10,24 @@ on:
options: options:
- testflight - testflight
- production - production
assignTestFlightGroup: testFlightGroup:
type: boolean type: choice
description: Assign the build to the "QA Team" TestFlight group after submitting description: TestFlight group to assign the build to after submitting
default: false options:
- none
- QA Team
- Software Mansion
default: none
workflow_call: workflow_call:
inputs: inputs:
profile: profile:
type: string type: string
description: Build profile to use description: Build profile to use
required: true required: true
assignTestFlightGroup: testFlightGroup:
type: boolean type: string
description: Assign the build to the "QA Team" TestFlight group after submitting description: TestFlight group to assign the build to after submitting ("none" to skip)
default: false default: none
outputs: outputs:
package-version: package-version:
description: Version from package.json description: Version from package.json
@@ -212,8 +216,9 @@ jobs:
# TestFlight group. fastlane's distribute_only mode skips the upload and assigns the # TestFlight group. fastlane's distribute_only mode skips the upload and assigns the
# already-submitted build to the group, polling until Apple finishes processing it. # already-submitted build to the group, polling until Apple finishes processing it.
- name: 🧪 Assign build to TestFlight group - name: 🧪 Assign build to TestFlight group
if: ${{ inputs.assignTestFlightGroup }} if: ${{ inputs.testFlightGroup != 'none' }}
env: env:
TESTFLIGHT_GROUP: ${{ inputs.testFlightGroup }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }} ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
@@ -241,7 +246,7 @@ jobs:
app_identifier:"xyz.blueskyweb.app" \ app_identifier:"xyz.blueskyweb.app" \
app_version:"$APP_VERSION" \ app_version:"$APP_VERSION" \
build_number:"$BUILD_NUMBER" \ build_number:"$BUILD_NUMBER" \
groups:"QA Team" \ groups:"$TESTFLIGHT_GROUP" \
notify_external_testers:true notify_external_testers:true
- name: 🔔 Notify Slack of Production Build - name: 🔔 Notify Slack of Production Build
+1 -1
View File
@@ -77,7 +77,7 @@ jobs:
uses: ./.github/workflows/build-submit-ios.yml uses: ./.github/workflows/build-submit-ios.yml
with: with:
profile: testflight profile: testflight
assignTestFlightGroup: true testFlightGroup: "QA Team"
secrets: inherit secrets: inherit
android: android:
+7 -2
View File
@@ -17,6 +17,9 @@ jobs:
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 0 fetch-depth: 0
# Don't persist the checkout auth header; the push below authenticates
# with the app token embedded in the remote URL instead
persist-credentials: false
- name: Generate GitHub App Token - name: Generate GitHub App Token
id: app-token id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
@@ -25,14 +28,16 @@ jobs:
private-key: ${{ secrets.SYNC_INTERNAL_PK }} private-key: ${{ secrets.SYNC_INTERNAL_PK }}
repositories: social-app-internal repositories: social-app-internal
# Scope the token down from the app's full installation permissions; # Scope the token down from the app's full installation permissions;
# pushing is the only thing this token is used for # pushing is the only thing this token is used for. The workflows
# permission is required because the sync includes files under
# .github/workflows/, which GitHub refuses to push without it.
permission-contents: write permission-contents: write
permission-workflows: write
- name: Push to internal repo - name: Push to internal repo
env: env:
TOKEN: ${{ steps.app-token.outputs.token }} TOKEN: ${{ steps.app-token.outputs.token }}
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
+34 -1
View File
@@ -164,12 +164,45 @@ related code together and gives us a better visual cue that there are probably
other files contained within this "macro" feature, whereas `Component.tsx` on other files contained within this "macro" feature, whereas `Component.tsx` on
its own looks more like a single component file. its own looks more like a single component file.
### Documentation and Tests Within Features ### Comments
Comment code when necessary to explain the “why” behind something; avoid Comment code when necessary to explain the “why” behind something; avoid
comments that simply describe the code. Avoid Unicode characters in comments, comments that simply describe the code. Avoid Unicode characters in comments,
e.g., use `-` not `—`. e.g., use `-` not `—`.
Always use docblock (`/** */`) syntax for comments that document a type, type
member, method, function, or variable. These are the comments a reader expects
to find attached to a named declaration, and the docblock form makes that intent
clear and surfaces nicely in editor tooltips.
```tsx
type DateFieldProps = {
/**
* An empty string renders the placeholder and opens the picker at today (or
* maximumDate, if earlier).
*/
value: string | Date
}
/**
* Date-only input. Accepts a string in the format YYYY-MM-DD, or a Date object.
*/
export function DateField() {}
```
More generally, any multiline comment should use the `/* */` block syntax rather
than stacked `//` lines. Reserve `//` for short, single-line comments.
```tsx
/*
* The picker requires a valid date, so when value is empty we fall back to
* maximumDate (if set) or today.
*/
const fallbackDate = maximumDate ? toSimpleDateString(maximumDate) : today
```
### Documentation and Tests Within Features
For larger features or components, it's helpful to include a README.md file For larger features or components, it's helpful to include a README.md file
within the directory that explains the purpose of the feature, how it works, and within the directory that explains the purpose of the feature, how it works, and
any important implementation details. The `/Component/index.tsx` pattern lends any important implementation details. The `/Component/index.tsx` pattern lends
+1 -1
View File
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
RUN \. "$NVM_DIR/nvm.sh" && \ RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \ nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \ nvm use $NODE_VERSION && \
npm install --global pnpm@11.7.0 && \ npm install --global pnpm@11.9.0 && \
pnpm install --frozen-lockfile && \ pnpm install --frozen-lockfile && \
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \ cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
pnpm intl:build && \ pnpm intl:build && \
+2 -1
View File
@@ -135,7 +135,8 @@ appId: xyz.blueskyweb.app
- tapOn: - tapOn:
id: "bottomBarSearchBtn" id: "bottomBarSearchBtn"
- tapOn: "Search for posts, users[,]? or feeds" - tapOn:
id: "searchScreenInput"
- inputText: "bob" - inputText: "bob"
- tapOn: - tapOn:
id: "searchAutoCompleteResult-bob.test" id: "searchAutoCompleteResult-bob.test"
+2 -1
View File
@@ -15,7 +15,8 @@ appId: xyz.blueskyweb.app
id: "bottomBarSearchBtn" id: "bottomBarSearchBtn"
- tapOn: - tapOn:
id: "bottomBarSearchBtn" id: "bottomBarSearchBtn"
- tapOn: "Search for posts, users[,]? or feeds" - tapOn:
id: "searchScreenInput"
- inputText: "b" - inputText: "b"
- tapOn: - tapOn:
id: "searchAutoCompleteResult-bob.test" id: "searchAutoCompleteResult-bob.test"
+4 -2
View File
@@ -15,8 +15,10 @@ appId: xyz.blueskyweb.app
id: "bottomBarSearchBtn" id: "bottomBarSearchBtn"
- tapOn: - tapOn:
id: "bottomBarSearchBtn" id: "bottomBarSearchBtn"
- assertVisible: "Search for posts, users[,]? or feeds" - assertVisible:
- tapOn: "Search for posts, users[,]? or feeds" id: "searchScreenInput"
- tapOn:
id: "searchScreenInput"
- inputText: "b" - inputText: "b"
- tapOn: - tapOn:
id: "searchAutoCompleteResult-bob.test" id: "searchAutoCompleteResult-bob.test"
+2
View File
@@ -59,6 +59,7 @@ module.exports = function (_config) {
ios: { ios: {
supportsTablet: false, supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app', bundleIdentifier: 'xyz.blueskyweb.app',
appleTeamId: process.env.EXPO_APPLE_TEAM_ID,
config: { config: {
usesNonExemptEncryption: false, usesNonExemptEncryption: false,
}, },
@@ -81,6 +82,7 @@ module.exports = function (_config) {
'an', 'an',
'ast', 'ast',
'ca', 'ca',
'cs',
'cy', 'cy',
'da', 'da',
'de', 'de',
+48 -2
View File
@@ -65,6 +65,7 @@ type discussionForumPosting struct {
CommentCount *int64 `json:"commentCount,omitempty"` CommentCount *int64 `json:"commentCount,omitempty"`
Comment []comment `json:"comment,omitempty"` Comment []comment `json:"comment,omitempty"`
IsBasedOn string `json:"isBasedOn,omitempty"` IsBasedOn string `json:"isBasedOn,omitempty"`
IsPartOf string `json:"isPartOf,omitempty"`
SharedContent *sharedContent `json:"sharedContent,omitempty"` SharedContent *sharedContent `json:"sharedContent,omitempty"`
} }
@@ -344,6 +345,43 @@ func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string {
return "" return ""
} }
// threadRootURI returns the AT-URI of the root post of the thread a reply
// belongs to, or "" if the post is not a reply or the record is malformed.
func threadRootURI(pv *appbsky.FeedDefs_PostView) string {
if pv == nil || pv.Record == nil {
return ""
}
rec, ok := pv.Record.Val.(*appbsky.FeedPost)
if !ok || rec.Reply == nil || rec.Reply.Root == nil {
return ""
}
return rec.Reply.Root.Uri
}
// findRootPostInParents walks tv's parent chain upward and returns the
// PostView whose URI matches rootURI, or nil if the root is not present.
// The root is absent when the chain is truncated by parentHeight (reply
// deeper than the fetched height) or broken by a blocked/not-found parent.
// Avoids a separate FeedGetPosts call when the thread response already
// contains the root.
func findRootPostInParents(tv *appbsky.FeedDefs_ThreadViewPost, rootURI string) *appbsky.FeedDefs_PostView {
if rootURI == "" {
return nil
}
for node := tv; node != nil; {
if node.Post != nil && node.Post.Uri == rootURI {
return node.Post
}
if node.Parent == nil {
return nil
}
// Only threadViewPost parents continue the chain; a blocked or
// not-found parent breaks it before reaching the root.
node = node.Parent.FeedDefs_ThreadViewPost
}
return nil
}
// buildAuthor constructs a Person. Organization classification for // buildAuthor constructs a Person. Organization classification for
// custom-domain accounts is a future enhancement. // custom-domain accounts is a future enhancement.
func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg { func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
@@ -584,13 +622,21 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) c
// buildPostJSONLD marshals the WebPage envelope wrapping a // buildPostJSONLD marshals the WebPage envelope wrapping a
// DiscussionForumPosting. canonicalURL is used for both envelope.url and // DiscussionForumPosting. canonicalURL is used for both envelope.url and
// (as a fallback) mainEntity.url so they always agree. // (as a fallback) mainEntity.url so they always agree. isPartOfURL, when
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) { // non-empty, is the handle-form canonical URL of the thread root the handler
// resolved for a reply; pass "" to omit isPartOf (non-reply, or the root could
// not be resolved). We never emit a DID-form isPartOf because it would not
// match the root page's handle-form canonical.
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, isPartOfURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
if pv == nil || pv.Author == nil { if pv == nil || pv.Author == nil {
return "", fmt.Errorf("nil post view or author") return "", fmt.Errorf("nil post view or author")
} }
node := buildPostNode(pv, replies, hideLabels, hideReplyLabels) node := buildPostNode(pv, replies, hideLabels, hideReplyLabels)
if isPartOfURL != "" {
node.IsPartOf = isPartOfURL
}
// mainEntity.url is empty when the author handle is unusable; fall back // mainEntity.url is empty when the author handle is unusable; fall back
// to canonicalURL so it agrees with envelope.url. // to canonicalURL so it agrees with envelope.url.
if node.URL == "" { if node.URL == "" {
+155 -36
View File
@@ -183,6 +183,19 @@ func withQuotePostBlocked() func(*appbsky.FeedDefs_PostView) {
} }
} }
// withReplyRoot marks the post as a reply by setting its record's Reply.Root
// strong-ref to the given thread-root post.
func withReplyRoot(rootDid, rootRkey string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
rec, _ := pv.Record.Val.(*appbsky.FeedPost)
uri := "at://" + rootDid + "/app.bsky.feed.post/" + rootRkey
rec.Reply = &appbsky.FeedPost_ReplyRef{
Root: &comatprototypes.RepoStrongRef{Uri: uri, Cid: "bafy-root"},
Parent: &comatprototypes.RepoStrongRef{Uri: uri, Cid: "bafy-root"},
}
}
}
// withSelfLabel adds a self-label that should hide embeds. // withSelfLabel adds a self-label that should hide embeds.
func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) { func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) { return func(pv *appbsky.FeedDefs_PostView) {
@@ -263,7 +276,7 @@ func unmarshalLD(t *testing.T, s string) map[string]any {
func TestBuildPostJSONLD_Bare(t *testing.T) { func TestBuildPostJSONLD_Bare(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123" canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels) out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -333,7 +346,7 @@ func TestBuildPostJSONLD_WithImages(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg" thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg" thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2)) pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels) out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -358,7 +371,7 @@ func TestBuildPostJSONLD_WithGallery(t *testing.T) {
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg" thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
thumb3 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g3@jpeg" thumb3 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g3@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2, thumb3)) pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2, thumb3))
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels) out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -385,7 +398,7 @@ func TestBuildPostJSONLD_GalleryInRecordWithMedia(t *testing.T) {
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg" thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery", pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery",
withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb)) withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb))
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -438,7 +451,7 @@ func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) {
func TestBuildPostJSONLD_WithVideo(t *testing.T) { func TestBuildPostJSONLD_WithVideo(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg" thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb)) pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["thumbnailUrl"] != thumb { if main["thumbnailUrl"] != thumb {
t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"]) t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"])
@@ -451,7 +464,7 @@ func TestBuildPostJSONLD_WithVideo(t *testing.T) {
func TestBuildPostJSONLD_QuotePost(t *testing.T) { func TestBuildPostJSONLD_QuotePost(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting!", withQuotePost("bob.example.com", "did:plc:bob", "xyz")) pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting!", withQuotePost("bob.example.com", "did:plc:bob", "xyz"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" { if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
t.Errorf("isBasedOn wrong: %v", main["isBasedOn"]) t.Errorf("isBasedOn wrong: %v", main["isBasedOn"])
@@ -460,16 +473,122 @@ func TestBuildPostJSONLD_QuotePost(t *testing.T) {
func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) { func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting blocked", withQuotePostBlocked()) pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting blocked", withQuotePostBlocked())
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["isBasedOn"]; present { if _, present := main["isBasedOn"]; present {
t.Errorf("blocked quote should not produce isBasedOn") t.Errorf("blocked quote should not produce isBasedOn")
} }
} }
func TestBuildPostJSONLD_IsPartOf(t *testing.T) {
// isPartOf is sourced solely from the handler-resolved URL. When supplied,
// it is emitted on the main post; when empty, no isPartOf is present.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply")
isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
out, _ := buildPostJSONLD(pv, nil, "u", isPartOf, hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["isPartOf"] != isPartOf {
t.Errorf("isPartOf = %v, want %v", main["isPartOf"], isPartOf)
}
out, _ = buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main = unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["isPartOf"]; present {
t.Errorf("empty isPartOfURL should omit isPartOf, got %v", main["isPartOf"])
}
}
func TestThreadRootURI(t *testing.T) {
// A reply returns its root AT-URI; a non-reply returns "".
reply := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply",
withReplyRoot("did:plc:root", "rootrkey"))
want := "at://did:plc:root/app.bsky.feed.post/rootrkey"
if got := threadRootURI(reply); got != want {
t.Errorf("threadRootURI = %q, want %q", got, want)
}
post := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "not a reply")
if got := threadRootURI(post); got != "" {
t.Errorf("threadRootURI on non-reply = %q, want empty", got)
}
}
func TestFindRootPostInParents(t *testing.T) {
rootPost := makePostView("root.bsky.social", "did:plc:root", "rootrkey", "root")
rootURI := rootPost.Uri
// tvp wraps a PostView, optionally chaining to a parent thread node.
tvp := func(pv *appbsky.FeedDefs_PostView, parent *appbsky.FeedDefs_ThreadViewPost) *appbsky.FeedDefs_ThreadViewPost {
node := &appbsky.FeedDefs_ThreadViewPost{Post: pv}
if parent != nil {
node.Parent = &appbsky.FeedDefs_ThreadViewPost_Parent{FeedDefs_ThreadViewPost: parent}
}
return node
}
t.Run("direct reply, parent is root", func(t *testing.T) {
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
if got := findRootPostInParents(leaf, rootURI); got != rootPost {
t.Errorf("expected root post, got %v", got)
}
})
t.Run("multi-level chain", func(t *testing.T) {
mid := tvp(makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid"), tvp(rootPost, nil))
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), mid)
if got := findRootPostInParents(leaf, rootURI); got != rootPost {
t.Errorf("expected root post in chain, got %v", got)
}
})
t.Run("root absent, chain truncated", func(t *testing.T) {
// Topmost parent is not the root (e.g. parentHeight cut off the chain).
topmost := makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid")
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(topmost, nil))
if got := findRootPostInParents(leaf, rootURI); got != nil {
t.Errorf("expected nil when root absent, got %v", got)
}
})
t.Run("chain broken by blocked parent", func(t *testing.T) {
// A blocked/not-found parent yields a nil FeedDefs_ThreadViewPost,
// breaking the walk before the root.
leaf := &appbsky.FeedDefs_ThreadViewPost{
Post: makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"),
Parent: &appbsky.FeedDefs_ThreadViewPost_Parent{
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: rootURI},
},
}
if got := findRootPostInParents(leaf, rootURI); got != nil {
t.Errorf("expected nil when chain broken by blocked parent, got %v", got)
}
})
t.Run("empty rootURI", func(t *testing.T) {
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
if got := findRootPostInParents(leaf, ""); got != nil {
t.Errorf("expected nil for empty rootURI, got %v", got)
}
})
}
func TestBuildPostJSONLD_ReplyCommentsNoIsPartOf(t *testing.T) {
// Replies surfaced under the main post as comment[] are Comment nodes and
// never carry isPartOf, even when the main post has one.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "a reply")
isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", isPartOf, hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
if _, present := c["isPartOf"]; present {
t.Errorf("comment entries should not carry isPartOf, got %v", c["isPartOf"])
}
}
func TestBuildPostJSONLD_ExternalEmbed(t *testing.T) { func TestBuildPostJSONLD_ExternalEmbed(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "check this out", withExternalEmbed("https://www.spiegel.de/article", "Title")) pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "check this out", withExternalEmbed("https://www.spiegel.de/article", "Title"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
sc, ok := main["sharedContent"].(map[string]any) sc, ok := main["sharedContent"].(map[string]any)
if !ok { if !ok {
@@ -487,7 +606,7 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
thumb := "https://cdn.bsky.app/img/x@jpeg" thumb := "https://cdn.bsky.app/img/x@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw", pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withImages(thumb), withSelfLabel("porn")) withImages(thumb), withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["image"]; present { if _, present := main["image"]; present {
t.Errorf("hidden-embed post should not emit image") t.Errorf("hidden-embed post should not emit image")
@@ -506,7 +625,7 @@ func TestBuildPostJSONLD_HiddenEmbed_Gallery(t *testing.T) {
thumb := "https://cdn.bsky.app/img/g@jpeg" thumb := "https://cdn.bsky.app/img/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw", pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withGallery(thumb), withSelfLabel("porn")) withGallery(thumb), withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["image"]; present { if _, present := main["image"]; present {
t.Errorf("hidden-embed gallery post should not emit image") t.Errorf("hidden-embed gallery post should not emit image")
@@ -520,7 +639,7 @@ func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
// Includes ", \, newline, </script>, and a unicode char. // Includes ", \, newline, </script>, and a unicode char.
tricky := "hello \"world\" \\ <\\>\n</script> 🎉" tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", tricky) pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", tricky)
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -556,7 +675,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: "at://x/y/z"}, FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: "at://x/y/z"},
}) })
out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if cc := main["commentCount"].(float64); int64(cc) != 14 { if cc := main["commentCount"].(float64); int64(cc) != 14 {
@@ -598,7 +717,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) { func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) {
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello") pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello")
fallback := "https://bsky.app/profile/did:plc:alice/post/abc123" fallback := "https://bsky.app/profile/did:plc:alice/post/abc123"
out, _ := buildPostJSONLD(pv, nil, fallback, hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, fallback, "", hideEmbedLabels, hideReplyLabels)
envelope := unmarshalLD(t, out) envelope := unmarshalLD(t, out)
main := envelope["mainEntity"].(map[string]any) main := envelope["mainEntity"].(map[string]any)
// mainEntity.url falls back to the caller's canonical URL so envelope // mainEntity.url falls back to the caller's canonical URL so envelope
@@ -647,7 +766,7 @@ func TestBuildPostJSONLD_EnvelopeURLMatchesMainEntity(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
pv := makePostView(tc.handle, tc.did, tc.rkey, "hi") pv := makePostView(tc.handle, tc.did, tc.rkey, "hi")
out, _ := buildPostJSONLD(pv, nil, tc.canonical, hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, tc.canonical, "", hideEmbedLabels, hideReplyLabels)
env := unmarshalLD(t, out) env := unmarshalLD(t, out)
main := env["mainEntity"].(map[string]any) main := env["mainEntity"].(map[string]any)
if env["url"] != tc.canonical { if env["url"] != tc.canonical {
@@ -664,7 +783,7 @@ func TestBuildPostJSONLD_NilAuthor(t *testing.T) {
// Defensive: don't panic if Author is nil. // Defensive: don't panic if Author is nil.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
pv.Author = nil pv.Author = nil
if _, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels); err == nil { if _, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels); err == nil {
t.Errorf("expected error for nil-author post, got nil") t.Errorf("expected error for nil-author post, got nil")
} }
} }
@@ -682,7 +801,7 @@ func TestBuildPostJSONLD_NilAuthorReply(t *testing.T) {
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: goodReply}}, {FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: goodReply}},
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: badReply}}, {FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: badReply}},
} }
out, err := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels) out, err := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@@ -703,7 +822,7 @@ func TestBuildPostJSONLD_CommentMedia(t *testing.T) {
replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{ replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply}}, {FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply}},
} }
out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any) c := main["comment"].([]any)[0].(map[string]any)
imgs, ok := c["image"].([]any) imgs, ok := c["image"].([]any)
@@ -910,7 +1029,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_PostViewLabel(t *testing.T) {
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply") good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply", bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
withPostLabel("!hide", false)) withPostLabel("!hide", false))
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out) ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri { if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected only the unlabeled reply to remain, got %v", ids) t.Errorf("expected only the unlabeled reply to remain, got %v", ids)
@@ -923,7 +1042,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_SelfLabel(t *testing.T) {
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply") good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply", bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
withSelfLabel("spam")) withSelfLabel("spam"))
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out) ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri { if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected self-labeled reply dropped, got %v", ids) t.Errorf("expected self-labeled reply dropped, got %v", ids)
@@ -939,7 +1058,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_EmbedLabel(t *testing.T) {
// union behavior. // union behavior.
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "concerning reply", bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "concerning reply",
withPostLabel("self-harm", false)) withPostLabel("self-harm", false))
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out) ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri { if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected embed-labeled reply dropped, got %v", ids) t.Errorf("expected embed-labeled reply dropped, got %v", ids)
@@ -951,7 +1070,7 @@ func TestBuildPostJSONLD_NegatedHideLabelKept(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "fine reply", reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "fine reply",
withPostLabel("!hide", true)) withPostLabel("!hide", true))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out) ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != reply.Uri { if len(ids) != 1 || ids[0] != reply.Uri {
t.Errorf("expected negated-label reply to be kept, got %v", ids) t.Errorf("expected negated-label reply to be kept, got %v", ids)
@@ -962,7 +1081,7 @@ func TestBuildPostJSONLD_ReplyAuthorHasIdentifier(t *testing.T) {
// Reply author should also carry a DID identifier. // Reply author should also carry a DID identifier.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi") reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi")
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any) c := main["comment"].([]any)[0].(map[string]any)
auth, ok := c["author"].(map[string]any) auth, ok := c["author"].(map[string]any)
@@ -1103,7 +1222,7 @@ func TestBuildPostJSONLD_AuthorReviewedBy(t *testing.T) {
}) })
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi", pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi",
withVerifications(state)) withVerifications(state))
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1143,7 +1262,7 @@ func TestBuildPostJSONLD_ReplyAuthorNoReviewedBy(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi", reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi",
withVerifications(state)) withVerifications(state))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any) c := main["comment"].([]any)[0].(map[string]any)
auth := c["author"].(map[string]any) auth := c["author"].(map[string]any)
@@ -1267,7 +1386,7 @@ func TestBuildPostJSONLD_WithVideoObject(t *testing.T) {
hasAspect: true, width: 16, height: 9, hasAspect: true, width: 16, height: 9,
})) }))
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123" canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels) out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1315,7 +1434,7 @@ func TestBuildPostJSONLD_VideoNameFallback(t *testing.T) {
withVideoFull(videoEmbedOpts{ withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", playlist: "https://video.bsky.app/p.m3u8",
})) }))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any) video, ok := main["video"].(map[string]any)
if !ok { if !ok {
@@ -1332,7 +1451,7 @@ func TestBuildPostJSONLD_VideoNameFallbackHandleInvalid(t *testing.T) {
withVideoFull(videoEmbedOpts{ withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", playlist: "https://video.bsky.app/p.m3u8",
})) }))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any) video := main["video"].(map[string]any)
if video["name"] != "Video on Bluesky" { if video["name"] != "Video on Bluesky" {
@@ -1347,7 +1466,7 @@ func TestBuildPostJSONLD_VideoDescriptionFallback(t *testing.T) {
withVideoFull(videoEmbedOpts{ withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", alt: "scenic clip", playlist: "https://video.bsky.app/p.m3u8", alt: "scenic clip",
})) }))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any) video := main["video"].(map[string]any)
if video["description"] != "scenic clip" { if video["description"] != "scenic clip" {
@@ -1360,7 +1479,7 @@ func TestBuildPostJSONLD_VideoNoAspectRatio(t *testing.T) {
withVideoFull(videoEmbedOpts{ withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", alt: "alt", playlist: "https://video.bsky.app/p.m3u8", alt: "alt",
})) }))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any) video := main["video"].(map[string]any)
if _, present := video["width"]; present { if _, present := video["width"]; present {
@@ -1377,7 +1496,7 @@ func TestBuildPostJSONLD_VideoMissingPlaylist(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg" thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x", pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
withVideo(thumb)) withVideo(thumb))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present { if _, present := main["video"]; present {
t.Errorf("video without playlist should not produce VideoObject") t.Errorf("video without playlist should not produce VideoObject")
@@ -1396,7 +1515,7 @@ func TestBuildPostJSONLD_VideoHiddenEmbed(t *testing.T) {
alt: "should be dropped", alt: "should be dropped",
}), }),
withSelfLabel("porn")) withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present { if _, present := main["video"]; present {
t.Errorf("hidden-embed post should not emit video") t.Errorf("hidden-embed post should not emit video")
@@ -1411,7 +1530,7 @@ func TestBuildPostJSONLD_VideoInRecordWithMedia(t *testing.T) {
thumbnail: thumb, playlist: playlist, alt: "alt", recordMedia: true, thumbnail: thumb, playlist: playlist, alt: "alt", recordMedia: true,
hasAspect: true, width: 4, height: 3, hasAspect: true, width: 4, height: 3,
})) }))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any) video, ok := main["video"].(map[string]any)
if !ok { if !ok {
@@ -1438,7 +1557,7 @@ func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
withVideoFull(videoEmbedOpts{ withVideoFull(videoEmbedOpts{
thumbnail: thumb, playlist: playlist, alt: "bob's clip", thumbnail: thumb, playlist: playlist, alt: "bob's clip",
})) }))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any) c := main["comment"].([]any)[0].(map[string]any)
video, ok := c["video"].(map[string]any) video, ok := c["video"].(map[string]any)
@@ -1461,7 +1580,7 @@ func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
func TestBuildPostJSONLD_NoVideoNoField(t *testing.T) { func TestBuildPostJSONLD_NoVideoNoField(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no embed") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no embed")
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present { if _, present := main["video"]; present {
t.Errorf("post without video should not include video field") t.Errorf("post without video should not include video field")
@@ -1505,7 +1624,7 @@ func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL(t *testing.T) {
playlist: playlist, alt: "scenic clip", playlist: playlist, alt: "scenic clip",
})) }))
canonical := "https://bsky.app/profile/did:plc:alice/post/abc123" canonical := "https://bsky.app/profile/did:plc:alice/post/abc123"
out, _ := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any) video, ok := main["video"].(map[string]any)
if !ok { if !ok {
@@ -1528,7 +1647,7 @@ func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL_Reply(t *testing.T) {
withVideoFull(videoEmbedOpts{ withVideoFull(videoEmbedOpts{
playlist: playlist, alt: "bob's clip", playlist: playlist, alt: "bob's clip",
})) }))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any) c := main["comment"].([]any)[0].(map[string]any)
video, ok := c["video"].(map[string]any) video, ok := c["video"].(map[string]any)
+17
View File
@@ -20,3 +20,20 @@ func profileRequiresAuth(pv *appbsky.ActorDefs_ProfileViewDetailed) bool {
} }
return false return false
} }
// postAuthorRequiresAuth reports whether the post author self-applied the
// !no-unauthenticated label, read from the author view embedded in a
// getPostThread response. The appview surfaces the account's profile-record
// self-labels on the post author (src == author DID), so this mirrors
// profileRequiresAuth without a separate ActorGetProfile call.
func postAuthorRequiresAuth(pv *appbsky.FeedDefs_PostView) bool {
if pv == nil || pv.Author == nil {
return false
}
for _, label := range pv.Author.Labels {
if label.Src == pv.Author.Did && label.Val == "!no-unauthenticated" {
return true
}
}
return false
}
+73
View File
@@ -77,3 +77,76 @@ func TestProfileRequiresAuth(t *testing.T) {
}) })
} }
} }
func TestPostAuthorRequiresAuth(t *testing.T) {
negTrue := true
authorPV := func(labels []*comatprototypes.LabelDefs_Label) *appbsky.FeedDefs_PostView {
return &appbsky.FeedDefs_PostView{
Author: &appbsky.ActorDefs_ProfileViewBasic{
Did: "did:plc:alice",
Handle: "alice.bsky.social",
Labels: labels,
},
}
}
tests := []struct {
name string
pv *appbsky.FeedDefs_PostView
want bool
}{
{
name: "nil post view",
pv: nil,
want: false,
},
{
name: "nil author",
pv: &appbsky.FeedDefs_PostView{},
want: false,
},
{
name: "no labels",
pv: authorPV(nil),
want: false,
},
{
name: "self-applied !no-unauthenticated",
pv: authorPV([]*comatprototypes.LabelDefs_Label{
{Src: "did:plc:alice", Val: "!no-unauthenticated"},
}),
want: true,
},
{
name: "label from a different src does not gate",
pv: authorPV([]*comatprototypes.LabelDefs_Label{
{Src: "did:plc:labeler", Val: "!no-unauthenticated"},
}),
want: false,
},
{
name: "different label value does not gate",
pv: authorPV([]*comatprototypes.LabelDefs_Label{
{Src: "did:plc:alice", Val: "spam"},
}),
want: false,
},
{
// Negation isn't honored - matches profileRequiresAuth behavior.
name: "negated label still triggers (matches profile behavior)",
pv: authorPV([]*comatprototypes.LabelDefs_Label{
{Src: "did:plc:alice", Val: "!no-unauthenticated", Neg: &negTrue},
}),
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := postAuthorRequiresAuth(tt.pv); got != tt.want {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
+7 -7
View File
@@ -52,7 +52,7 @@ func TestRenderBase_NoindexMeta(t *testing.T) {
func TestRenderPost_EmitsJSONLD(t *testing.T) { func TestRenderPost_EmitsJSONLD(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels) ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -81,7 +81,7 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg" thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg" thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2)) pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels) ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{ html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv, "postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123", "requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
@@ -113,7 +113,7 @@ func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg" thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2)) pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
thumbs := extractPostMedia(pv, false) thumbs := extractPostMedia(pv, false)
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels) ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{ html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv, "postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123", "requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
@@ -142,7 +142,7 @@ func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) { func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
// Without canonicalURL, the template falls back to requestURI|canonicalize_url. // Without canonicalURL, the template falls back to requestURI|canonicalize_url.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{ html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv, "postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo", "requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo",
@@ -208,7 +208,7 @@ func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) {
// og:url and <link rel="canonical"> must emit the same URL. // og:url and <link rel="canonical"> must emit the same URL.
func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) { func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123" canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
html := renderTemplate(t, "post.html", pongo2.Context{ html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv, "postView": pv,
@@ -233,7 +233,7 @@ func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
// video without a thumbnail dropped og:video entirely. // video without a thumbnail dropped og:video entirely.
func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) { func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch")
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
videoURL := "https://video.bsky.app/v.m3u8" videoURL := "https://video.bsky.app/v.m3u8"
html := renderTemplate(t, "post.html", pongo2.Context{ html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv, "postView": pv,
@@ -286,7 +286,7 @@ func TestRenderProfile_AuthRequiredNoindex(t *testing.T) {
// flip of the noindex flag for indexable pages. // flip of the noindex flag for indexable pages.
func TestRenderPost_PublicNoNoindex(t *testing.T) { func TestRenderPost_PublicNoNoindex(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels) ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{ html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv, "postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123", "requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
+55 -27
View File
@@ -620,23 +620,39 @@ func (srv *Server) WebPost(c echo.Context) error {
identifier := handleOrDID.Normalize().String() identifier := handleOrDID.Normalize().String()
// requires two fetches: first fetch profile (!)
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, identifier)
if err != nil {
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
return c.Render(http.StatusOK, "post.html", data)
}
unauthedViewingOkay := !profileRequiresAuth(pv)
req := c.Request() req := c.Request()
requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path) requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
// Fetch the post thread directly. The AT-URI authority accepts either a
// handle or a DID (the appview resolves it), so we skip the separate
// ActorGetProfile call and source identity, the canonical URL, and the
// auth gate from the thread response's author view instead.
// parentHeight=80 (the lexicon default) pulls the reply's ancestor chain
// up to the root in nearly all threads, letting isPartOf resolve from this
// response without a separate FeedGetPosts call.
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", identifier, rkey)
tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 80, uri)
if err != nil {
log.Warnf("failed to fetch post: %s\t%v", uri, err)
return c.Render(http.StatusOK, "post.html", data)
}
threadView := tpv.Thread.FeedDefs_ThreadViewPost
if threadView == nil || threadView.Post == nil || threadView.Post.Author == nil {
return c.Render(http.StatusOK, "post.html", data)
}
postView := threadView.Post
// Always prefer the handle-form URL so JSON-LD `url` and // Always prefer the handle-form URL so JSON-LD `url` and
// <link rel="canonical"> match. Falls back to requestURI when the // <link rel="canonical"> match. Falls back to requestURI when the
// handle is unusable (template strips query/fragment). // handle is unusable (template strips query/fragment).
canonicalURL := bskyPostURL(pv.Handle, rkey.String()) canonicalURL := bskyPostURL(postView.Author.Handle, rkey.String())
if !unauthedViewingOkay { // Gate before populating any post content into the template so that
// !no-unauthenticated posts never leak text/media. The appview returns the
// post (with the author self-label) to unauthed callers, so we detect the
// label here rather than via a profile fetch.
if postAuthorRequiresAuth(postView) {
// Provide minimal OpenGraph data for auth-required posts // Provide minimal OpenGraph data for auth-required posts
data["requestURI"] = requestURI data["requestURI"] = requestURI
if canonicalURL != "" { if canonicalURL != "" {
@@ -645,26 +661,13 @@ func (srv *Server) WebPost(c echo.Context) error {
data["requiresAuth"] = true data["requiresAuth"] = true
data["noindex"] = true data["noindex"] = true
data["nofollow"] = true data["nofollow"] = true
data["profileHandle"] = pv.Handle data["profileHandle"] = postView.Author.Handle
if pv.DisplayName != nil { if postView.Author.DisplayName != nil {
data["profileDisplayName"] = *pv.DisplayName data["profileDisplayName"] = *postView.Author.DisplayName
} }
return c.Render(http.StatusOK, "post.html", data) return c.Render(http.StatusOK, "post.html", data)
} }
// then fetch the post thread (with extra context)
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", pv.Did, rkey)
tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 0, uri)
if err != nil {
log.Warnf("failed to fetch post: %s\t%v", uri, err)
return c.Render(http.StatusOK, "post.html", data)
}
threadView := tpv.Thread.FeedDefs_ThreadViewPost
if threadView == nil || threadView.Post == nil {
return c.Render(http.StatusOK, "post.html", data)
}
postView := threadView.Post
data["postView"] = postView data["postView"] = postView
data["requestURI"] = requestURI data["requestURI"] = requestURI
if canonicalURL != "" { if canonicalURL != "" {
@@ -694,7 +697,32 @@ func (srv *Server) WebPost(c echo.Context) error {
if jsonldURL == "" { if jsonldURL == "" {
jsonldURL = requestURI jsonldURL = requestURI
} }
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, hideEmbedLabels, hideReplyLabels); err == nil {
// Best-effort: resolve a reply's thread root to its handle-form canonical
// URL for isPartOf. Prefer the root already present in the thread response
// (parentHeight=80); fall back to a bounded FeedGetPosts only when the
// chain is truncated (very deep thread) or broken by a blocked/not-found
// ancestor. On timeout, error, or an unresolvable root we omit isPartOf
// rather than point at a non-indexable page.
isPartOfURL := ""
if rootURI := threadRootURI(postView); rootURI != "" {
if rootPost := findRootPostInParents(threadView, rootURI); rootPost != nil && rootPost.Author != nil {
isPartOfURL = bskyPostURLFromATURI(rootPost.Author.Handle, rootURI)
} else {
pctx, cancel := context.WithTimeout(ctx, 1*time.Second)
if posts, perr := appbsky.FeedGetPosts(pctx, srv.xrpcc, []string{rootURI}); perr != nil {
log.Warnf("failed to resolve thread root post for isPartOf: %s\t%v", rootURI, perr)
} else if len(posts.Posts) > 0 && posts.Posts[0].Author != nil {
// Handle-form only (no DID fallback): isPartOf must match the
// root page's handle-form canonical, so an unusable handle omits
// isPartOf rather than point at a non-canonical DID-form URL.
isPartOfURL = bskyPostURLFromATURI(posts.Posts[0].Author.Handle, rootURI)
}
cancel()
}
}
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, isPartOfURL, hideEmbedLabels, hideReplyLabels); err == nil {
data["postJSONLD"] = jsonld data["postJSONLD"] = jsonld
} else { } else {
log.Warnf("failed to build post JSON-LD for %s: %v", uri, err) log.Warnf("failed to build post JSON-LD for %s: %v", uri, err)
+2 -2
View File
@@ -6,8 +6,8 @@
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh node ./mock-server.ts" "start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh node ./mock-server.ts"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.20.0", "@atproto/api": "^0.20.22",
"@atproto/dev-env": "^0.5.0", "@atproto/dev-env": "^0.5.3",
"typescript": "^6.0.3" "typescript": "^6.0.3"
}, },
"devDependencies": { "devDependencies": {
+73 -13
View File
@@ -208,10 +208,10 @@ importers:
.: .:
dependencies: dependencies:
'@atproto/api': '@atproto/api':
specifier: ^0.20.0 specifier: ^0.20.22
version: 0.20.5 version: 0.20.22
'@atproto/dev-env': '@atproto/dev-env':
specifier: ^0.5.0 specifier: ^0.5.3
version: 0.5.3 version: 0.5.3
typescript: typescript:
specifier: ^6.0.3 specifier: ^6.0.3
@@ -257,8 +257,8 @@ packages:
resolution: {integrity: sha512-nmjM83KucbRnz/CDSXQX6FLH/GP93XjcI1u5WFlomPscmeOAP1+9AOVsMQlKmh27I3hgaSOdCr03GJjAIpuPZQ==} resolution: {integrity: sha512-nmjM83KucbRnz/CDSXQX6FLH/GP93XjcI1u5WFlomPscmeOAP1+9AOVsMQlKmh27I3hgaSOdCr03GJjAIpuPZQ==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/api@0.20.5': '@atproto/api@0.20.22':
resolution: {integrity: sha512-VqkRYKR9vRRk36NhtytJz5TPhNtR1hczCcfM+bWoOK6MBvWUT8HwelufrycFAIbFIH7n4ws+5UbnLYpTIBQZ6Q==} resolution: {integrity: sha512-TdT9ktYc0FMmMZ8HjkMz13QcSCcYiesCGN81mFZd5owQASHVM9GBtAAmzpkrPyZhUkiIjgh/nUHk8xWVu+4dVA==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/aws@0.3.0': '@atproto/aws@0.3.0':
@@ -277,6 +277,10 @@ packages:
resolution: {integrity: sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==} resolution: {integrity: sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/common-web@0.5.2':
resolution: {integrity: sha512-oO0JEvM7MM7iXMngq6V51IJlzBZFhFJoDjnGnQT75EO94/8Q5hnM/LP+a8KyWjcBcpcSZwQ0bukNv9phZONdGA==}
engines: {node: '>=22'}
'@atproto/common@0.1.0': '@atproto/common@0.1.0':
resolution: {integrity: sha512-OB5tWE2R19jwiMIs2IjQieH5KTUuMb98XGCn9h3xuu6NanwjlmbCYMv08fMYwIp3UQ6jcq//84cDT3Bu6fJD+A==} resolution: {integrity: sha512-OB5tWE2R19jwiMIs2IjQieH5KTUuMb98XGCn9h3xuu6NanwjlmbCYMv08fMYwIp3UQ6jcq//84cDT3Bu6fJD+A==}
@@ -331,6 +335,10 @@ packages:
resolution: {integrity: sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==} resolution: {integrity: sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/lex-data@0.1.3':
resolution: {integrity: sha512-ysqMYW6cIKce52/+EIbTa+I4pLqZQSBP9aGImN88vAQtd1oZBKPmut6dQk00xSQ8PW9REJRuIHNSFAF4HD+fsw==}
engines: {node: '>=22'}
'@atproto/lex-document@0.1.0': '@atproto/lex-document@0.1.0':
resolution: {integrity: sha512-I2q2iwK8RnSHkBeAj5xdJNYdHiUQqlkbbleBSJKJXlYOYy5y86dWRo9IfE0l9E3qZ3/zvy1ydlEZmZupEt0FGg==} resolution: {integrity: sha512-I2q2iwK8RnSHkBeAj5xdJNYdHiUQqlkbbleBSJKJXlYOYy5y86dWRo9IfE0l9E3qZ3/zvy1ydlEZmZupEt0FGg==}
engines: {node: '>=22'} engines: {node: '>=22'}
@@ -343,6 +351,10 @@ packages:
resolution: {integrity: sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==} resolution: {integrity: sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/lex-json@0.1.2':
resolution: {integrity: sha512-58nIjoWX0c8T5fcoPPmIaShxKZgfPMhNmrprtR7GuN4PzyMwm59A3CLlKAzzR+vjI3IidEMU+enpLuwUT8L+Nw==}
engines: {node: '>=22'}
'@atproto/lex-resolver@0.1.0': '@atproto/lex-resolver@0.1.0':
resolution: {integrity: sha512-zliMiRW4ttSNFreKxyvSpaYQjeWrKpV/lutnXI9BNE8Ysgnw8Rltm0bR7kG/y0OlyClxhhU/vAG+OR8NpjhU1Q==} resolution: {integrity: sha512-zliMiRW4ttSNFreKxyvSpaYQjeWrKpV/lutnXI9BNE8Ysgnw8Rltm0bR7kG/y0OlyClxhhU/vAG+OR8NpjhU1Q==}
engines: {node: '>=22'} engines: {node: '>=22'}
@@ -360,6 +372,10 @@ packages:
resolution: {integrity: sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==} resolution: {integrity: sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/lexicon@0.7.3':
resolution: {integrity: sha512-WP6ct2rjNCKSJN/VFc+8x6JQ7PvRMlfHUlmQM5hzvE3a6nOjm6kwSicjSIV/QldurdRGJ4FCQZ3uZu9Wqt4SxQ==}
engines: {node: '>=22'}
'@atproto/oauth-provider-api@0.6.0': '@atproto/oauth-provider-api@0.6.0':
resolution: {integrity: sha512-wFioPBgI71v4PuEwYmIYPp4GNOyaRw1UX8FJq0AiNuUGMbqCjdd7ImSsG4b43hsHr5wOsMirsWyccjUEdhb+yQ==} resolution: {integrity: sha512-wFioPBgI71v4PuEwYmIYPp4GNOyaRw1UX8FJq0AiNuUGMbqCjdd7ImSsG4b43hsHr5wOsMirsWyccjUEdhb+yQ==}
engines: {node: '>=22'} engines: {node: '>=22'}
@@ -400,6 +416,10 @@ packages:
resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==} resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/syntax@0.6.3':
resolution: {integrity: sha512-io7Ck4o+40iFXhetHYoEtok2gZ8cWcJ1yRftEHe5AmAF0dSGcYmclbx5GatVew59taw+eSG7Ty5D3llop/0BhA==}
engines: {node: '>=22'}
'@atproto/ws-client@0.1.0': '@atproto/ws-client@0.1.0':
resolution: {integrity: sha512-8qG+A+htxEHpDJRgtYNUExUOejDwU4nE2SEJCFLiTc1G+s0GbFaeg7Fk5drQA9tLMHQWoU9uqIr1YQpJyvJs8w==} resolution: {integrity: sha512-8qG+A+htxEHpDJRgtYNUExUOejDwU4nE2SEJCFLiTc1G+s0GbFaeg7Fk5drQA9tLMHQWoU9uqIr1YQpJyvJs8w==}
engines: {node: '>=22'} engines: {node: '>=22'}
@@ -412,6 +432,10 @@ packages:
resolution: {integrity: sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==} resolution: {integrity: sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/xrpc@0.8.2':
resolution: {integrity: sha512-geLqJwazZuCGnae68KZppciS8DujhGvYtpc/aAj16XpHkUCf5Ds64H1IPrV0uv7SFvd/IAuMZAXZS4GIpfp1iw==}
engines: {node: '>=22'}
'@aws-crypto/crc32@5.2.0': '@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@@ -2110,12 +2134,12 @@ snapshots:
- supports-color - supports-color
- utf-8-validate - utf-8-validate
'@atproto/api@0.20.5': '@atproto/api@0.20.22':
dependencies: dependencies:
'@atproto/common-web': 0.5.0 '@atproto/common-web': 0.5.2
'@atproto/lexicon': 0.7.1 '@atproto/lexicon': 0.7.3
'@atproto/syntax': 0.6.1 '@atproto/syntax': 0.6.3
'@atproto/xrpc': 0.8.0 '@atproto/xrpc': 0.8.2
await-lock: 3.0.0 await-lock: 3.0.0
multiformats: 13.4.2 multiformats: 13.4.2
tlds: 1.261.0 tlds: 1.261.0
@@ -2140,7 +2164,7 @@ snapshots:
dependencies: dependencies:
'@atproto-labs/fetch-node': 0.3.0 '@atproto-labs/fetch-node': 0.3.0
'@atproto-labs/xrpc-utils': 0.1.0 '@atproto-labs/xrpc-utils': 0.1.0
'@atproto/api': 0.20.5 '@atproto/api': 0.20.22
'@atproto/common': 0.6.1 '@atproto/common': 0.6.1
'@atproto/crypto': 0.5.0 '@atproto/crypto': 0.5.0
'@atproto/did': 0.4.0 '@atproto/did': 0.4.0
@@ -2210,6 +2234,13 @@ snapshots:
'@atproto/syntax': 0.6.1 '@atproto/syntax': 0.6.1
zod: 3.25.76 zod: 3.25.76
'@atproto/common-web@0.5.2':
dependencies:
'@atproto/lex-data': 0.1.3
'@atproto/lex-json': 0.1.2
'@atproto/syntax': 0.6.3
zod: 3.25.76
'@atproto/common@0.1.0': '@atproto/common@0.1.0':
dependencies: dependencies:
'@ipld/dag-cbor': 7.0.3 '@ipld/dag-cbor': 7.0.3
@@ -2248,7 +2279,7 @@ snapshots:
'@atproto/dev-env@0.5.3': '@atproto/dev-env@0.5.3':
dependencies: dependencies:
'@atproto/api': 0.20.5 '@atproto/api': 0.20.22
'@atproto/bsky': 0.0.234 '@atproto/bsky': 0.0.234
'@atproto/bsync': 0.0.27 '@atproto/bsync': 0.0.27
'@atproto/common-web': 0.5.0 '@atproto/common-web': 0.5.0
@@ -2323,6 +2354,13 @@ snapshots:
uint8arrays: 5.1.1 uint8arrays: 5.1.1
unicode-segmenter: 0.14.5 unicode-segmenter: 0.14.5
'@atproto/lex-data@0.1.3':
dependencies:
multiformats: 13.4.2
tslib: 2.8.1
uint8arrays: 5.1.1
unicode-segmenter: 0.14.5
'@atproto/lex-document@0.1.0': '@atproto/lex-document@0.1.0':
dependencies: dependencies:
'@atproto/lex-schema': 0.1.1 '@atproto/lex-schema': 0.1.1
@@ -2345,6 +2383,11 @@ snapshots:
'@atproto/lex-data': 0.1.1 '@atproto/lex-data': 0.1.1
tslib: 2.8.1 tslib: 2.8.1
'@atproto/lex-json@0.1.2':
dependencies:
'@atproto/lex-data': 0.1.3
tslib: 2.8.1
'@atproto/lex-resolver@0.1.0': '@atproto/lex-resolver@0.1.0':
dependencies: dependencies:
'@atproto-labs/did-resolver': 0.3.0 '@atproto-labs/did-resolver': 0.3.0
@@ -2382,6 +2425,13 @@ snapshots:
multiformats: 13.4.2 multiformats: 13.4.2
zod: 3.25.76 zod: 3.25.76
'@atproto/lexicon@0.7.3':
dependencies:
'@atproto/common-web': 0.5.2
'@atproto/syntax': 0.6.3
multiformats: 13.4.2
zod: 3.25.76
'@atproto/oauth-provider-api@0.6.0': '@atproto/oauth-provider-api@0.6.0':
dependencies: dependencies:
'@atproto/jwk': 0.7.0 '@atproto/jwk': 0.7.0
@@ -2436,7 +2486,7 @@ snapshots:
'@atproto/ozone@0.1.176': '@atproto/ozone@0.1.176':
dependencies: dependencies:
'@atproto/api': 0.20.5 '@atproto/api': 0.20.22
'@atproto/common': 0.6.1 '@atproto/common': 0.6.1
'@atproto/crypto': 0.5.0 '@atproto/crypto': 0.5.0
'@atproto/identity': 0.5.0 '@atproto/identity': 0.5.0
@@ -2551,6 +2601,11 @@ snapshots:
iso-datestring-validator: 2.2.2 iso-datestring-validator: 2.2.2
tslib: 2.8.1 tslib: 2.8.1
'@atproto/syntax@0.6.3':
dependencies:
iso-datestring-validator: 2.2.2
tslib: 2.8.1
'@atproto/ws-client@0.1.0': '@atproto/ws-client@0.1.0':
dependencies: dependencies:
'@atproto/common': 0.6.1 '@atproto/common': 0.6.1
@@ -2586,6 +2641,11 @@ snapshots:
'@atproto/lexicon': 0.7.1 '@atproto/lexicon': 0.7.1
zod: 3.25.76 zod: 3.25.76
'@atproto/xrpc@0.8.2':
dependencies:
'@atproto/lexicon': 0.7.3
zod: 3.25.76
'@aws-crypto/crc32@5.2.0': '@aws-crypto/crc32@5.2.0':
dependencies: dependencies:
'@aws-crypto/util': 5.2.0 '@aws-crypto/util': 5.2.0
-85
View File
@@ -10,22 +10,9 @@
"count": 2 "count": 2
} }
}, },
"modules/bottom-sheet/src/BottomSheetPortal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"modules/bottom-sheet/src/lib/Portal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider.tsx": { "modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider.tsx": {
"@typescript-eslint/no-floating-promises": { "@typescript-eslint/no-floating-promises": {
"count": 1 "count": 1
},
"no-restricted-imports": {
"count": 1
} }
}, },
"modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.web.ts": { "modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.web.ts": {
@@ -33,28 +20,6 @@
"count": 4 "count": 4
} }
}, },
"modules/expo-bluesky-gif-view/src/GifView.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-unsafe-call": {
"count": 4
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 4
},
"no-restricted-imports": {
"count": 1
}
},
"modules/expo-bluesky-gif-view/src/GifView.web.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 2
},
"@typescript-eslint/require-await": {
"count": 2
}
},
"modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts": { "modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts": {
"@typescript-eslint/no-unsafe-call": { "@typescript-eslint/no-unsafe-call": {
"count": 3 "count": 3
@@ -88,9 +53,6 @@
}, },
"@typescript-eslint/no-unsafe-member-access": { "@typescript-eslint/no-unsafe-member-access": {
"count": 1 "count": 1
},
"no-restricted-imports": {
"count": 1
} }
}, },
"modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx": { "modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx": {
@@ -98,16 +60,6 @@
"count": 1 "count": 1
} }
}, },
"modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts": {
"no-restricted-imports": {
"count": 1
}
},
"modules/expo-emoji-picker/src/EmojiPickerView.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/Navigation.tsx": { "src/Navigation.tsx": {
"@typescript-eslint/no-floating-promises": { "@typescript-eslint/no-floating-promises": {
"count": 1 "count": 1
@@ -210,11 +162,6 @@
"count": 1 "count": 1
} }
}, },
"src/components/Autocomplete/Autocomplete.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
}
},
"src/components/Autocomplete/useAutocomplete/index.ts": { "src/components/Autocomplete/useAutocomplete/index.ts": {
"react-hooks/immutability": { "react-hooks/immutability": {
"count": 1 "count": 1
@@ -946,14 +893,6 @@
"count": 1 "count": 1
} }
}, },
"src/lib/hooks/useNotificationHandler.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 16
},
"@typescript-eslint/require-await": {
"count": 1
}
},
"src/lib/hooks/useOTAUpdates.ts": { "src/lib/hooks/useOTAUpdates.ts": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 2 "count": 2
@@ -1922,14 +1861,6 @@
"count": 2 "count": 2
} }
}, },
"src/state/queries/search-posts.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 2
}
},
"src/state/queries/starter-packs.ts": { "src/state/queries/starter-packs.ts": {
"@typescript-eslint/require-await": { "@typescript-eslint/require-await": {
"count": 1 "count": 1
@@ -2028,9 +1959,6 @@
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 2 "count": 2
}, },
"@typescript-eslint/no-floating-promises": {
"count": 1
},
"@typescript-eslint/no-misused-promises": { "@typescript-eslint/no-misused-promises": {
"count": 4 "count": 4
}, },
@@ -2126,11 +2054,6 @@
"count": 1 "count": 1
} }
}, },
"src/view/com/composer/videos/pickVideo.web.ts": {
"@typescript-eslint/no-misused-promises": {
"count": 1
}
},
"src/view/com/feeds/ComposerPrompt.tsx": { "src/view/com/feeds/ComposerPrompt.tsx": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 2 "count": 2
@@ -2183,14 +2106,6 @@
"count": 2 "count": 2
} }
}, },
"src/view/com/modals/Modal.tsx": {
"@typescript-eslint/no-misused-promises": {
"count": 1
},
"@typescript-eslint/require-await": {
"count": 1
}
},
"src/view/com/notifications/NotificationFeed.tsx": { "src/view/com/notifications/NotificationFeed.tsx": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 2 "count": 2
+1
View File
@@ -7,6 +7,7 @@ export default defineConfig({
'an', 'an',
'ast', 'ast',
'ca', 'ca',
'cs',
'cy', 'cy',
'da', 'da',
'de', 'de',
@@ -1,20 +1,20 @@
import React from 'react' import {createContext, useContext, useMemo} from 'react'
import {createPortalGroup_INTERNAL} from './lib/Portal' import {createPortalGroup_INTERNAL} from './lib/Portal'
type PortalContext = React.ElementType<{children: React.ReactNode}> type PortalContext = React.ElementType<{children: React.ReactNode}>
export const Context = React.createContext({} as PortalContext) export const Context = createContext({} as PortalContext)
Context.displayName = 'BottomSheetPortalContext' Context.displayName = 'BottomSheetPortalContext'
export const useBottomSheetPortal_INTERNAL = () => React.useContext(Context) export const useBottomSheetPortal_INTERNAL = () => useContext(Context)
export function BottomSheetPortalProvider({ export function BottomSheetPortalProvider({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const portal = React.useMemo(() => { const portal = useMemo(() => {
return createPortalGroup_INTERNAL() return createPortalGroup_INTERNAL()
}, []) }, [])
+22 -12
View File
@@ -1,4 +1,14 @@
import React from 'react' import {
createContext,
Fragment,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react'
type Component = React.ReactElement type Component = React.ReactElement
@@ -13,7 +23,7 @@ type ComponentMap = {
} }
export function createPortalGroup_INTERNAL() { export function createPortalGroup_INTERNAL() {
const Context = React.createContext<ContextType>({ const Context = createContext<ContextType>({
outlet: null, outlet: null,
append: () => {}, append: () => {},
remove: () => {}, remove: () => {},
@@ -21,21 +31,21 @@ export function createPortalGroup_INTERNAL() {
Context.displayName = 'BottomSheetPortalContext' Context.displayName = 'BottomSheetPortalContext'
function Provider(props: React.PropsWithChildren<{}>) { function Provider(props: React.PropsWithChildren<{}>) {
const map = React.useRef<ComponentMap>({}) const map = useRef<ComponentMap>({})
const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null) const [outlet, setOutlet] = useState<ContextType['outlet']>(null)
const append = React.useCallback<ContextType['append']>((id, component) => { const append = useCallback<ContextType['append']>((id, component) => {
if (map.current[id]) return if (map.current[id]) return
map.current[id] = <React.Fragment key={id}>{component}</React.Fragment> map.current[id] = <Fragment key={id}>{component}</Fragment>
setOutlet(<>{Object.values(map.current)}</>) setOutlet(<>{Object.values(map.current)}</>)
}, []) }, [])
const remove = React.useCallback<ContextType['remove']>(id => { const remove = useCallback<ContextType['remove']>(id => {
delete map.current[id] delete map.current[id]
setOutlet(<>{Object.values(map.current)}</>) setOutlet(<>{Object.values(map.current)}</>)
}, []) }, [])
const contextValue = React.useMemo( const contextValue = useMemo(
() => ({ () => ({
outlet, outlet,
append, append,
@@ -50,14 +60,14 @@ export function createPortalGroup_INTERNAL() {
} }
function Outlet() { function Outlet() {
const ctx = React.useContext(Context) const ctx = useContext(Context)
return ctx.outlet return ctx.outlet
} }
function Portal({children}: React.PropsWithChildren<{}>) { function Portal({children}: React.PropsWithChildren<{}>) {
const {append, remove} = React.useContext(Context) const {append, remove} = useContext(Context)
const id = React.useId() const id = useId()
React.useEffect(() => { useEffect(() => {
append(id, children as Component) append(id, children as Component)
return () => remove(id) return () => remove(id)
}, [id, children, append, remove]) }, [id, children, append, remove])
@@ -1,4 +1,4 @@
import React from 'react' import {createContext, useContext, useEffect, useMemo, useState} from 'react'
import {type BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types' import {type BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule' import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule'
@@ -11,11 +11,10 @@ interface BackgroundNotificationPreferencesContext {
) => void ) => void
} }
const Context = React.createContext<BackgroundNotificationPreferencesContext>( const Context = createContext<BackgroundNotificationPreferencesContext>(
{} as BackgroundNotificationPreferencesContext, {} as BackgroundNotificationPreferencesContext,
) )
export const useBackgroundNotificationPreferences = () => export const useBackgroundNotificationPreferences = () => useContext(Context)
React.useContext(Context)
export function BackgroundNotificationPreferencesProvider({ export function BackgroundNotificationPreferencesProvider({
children, children,
@@ -23,18 +22,18 @@ export function BackgroundNotificationPreferencesProvider({
children: React.ReactNode children: React.ReactNode
}) { }) {
const [preferences, setPreferences] = const [preferences, setPreferences] =
React.useState<BackgroundNotificationHandlerPreferences>({ useState<BackgroundNotificationHandlerPreferences>({
playSoundChat: true, playSoundChat: true,
}) })
React.useEffect(() => { useEffect(() => {
;(async () => { ;(async () => {
const prefs = await BackgroundNotificationHandler.getAllPrefsAsync() const prefs = await BackgroundNotificationHandler.getAllPrefsAsync()
setPreferences(prefs) setPreferences(prefs)
})() })()
}, []) }, [])
const value = React.useMemo( const value = useMemo(
() => ({ () => ({
preferences, preferences,
setPref: async < setPref: async <
+16 -9
View File
@@ -1,17 +1,24 @@
import React from 'react' import {createRef, PureComponent} from 'react'
import {requireNativeModule} from 'expo' import {requireNativeModule} from 'expo'
import {requireNativeViewManager} from 'expo-modules-core' import {requireNativeViewManager} from 'expo-modules-core'
import {type GifViewProps} from './GifView.types' import {type GifViewProps} from './GifView.types'
const NativeModule = requireNativeModule('ExpoBlueskyGifView') interface GifViewNativeRef {
playAsync: () => Promise<void>
pauseAsync: () => Promise<void>
toggleAsync: () => Promise<void>
}
const NativeModule: {
prefetchAsync: (sources: string[]) => Promise<void>
} = requireNativeModule('ExpoBlueskyGifView')
const NativeView: React.ComponentType< const NativeView: React.ComponentType<
GifViewProps & {ref: React.RefObject<any>} GifViewProps & {ref: React.RefObject<GifViewNativeRef | null>}
> = requireNativeViewManager('ExpoBlueskyGifView') > = requireNativeViewManager('ExpoBlueskyGifView')
export class GifView extends React.PureComponent<GifViewProps> { export class GifView extends PureComponent<GifViewProps> {
// TODO native types, should all be the same as those in this class private nativeRef: React.RefObject<GifViewNativeRef | null> = createRef()
private nativeRef: React.RefObject<any> = React.createRef()
constructor(props: GifViewProps | Readonly<GifViewProps>) { constructor(props: GifViewProps | Readonly<GifViewProps>) {
super(props) super(props)
@@ -22,15 +29,15 @@ export class GifView extends React.PureComponent<GifViewProps> {
} }
async playAsync(): Promise<void> { async playAsync(): Promise<void> {
await this.nativeRef.current.playAsync() await this.nativeRef.current?.playAsync()
} }
async pauseAsync(): Promise<void> { async pauseAsync(): Promise<void> {
await this.nativeRef.current.pauseAsync() await this.nativeRef.current?.pauseAsync()
} }
async toggleAsync(): Promise<void> { async toggleAsync(): Promise<void> {
await this.nativeRef.current.toggleAsync() await this.nativeRef.current?.toggleAsync()
} }
render() { render() {
@@ -1,10 +1,11 @@
import {createRef, PureComponent, type RefObject} from 'react' import {createRef, PureComponent} from 'react'
import {StyleSheet} from 'react-native' import {StyleSheet} from 'react-native'
import {type GifViewProps} from './GifView.types' import {type GifViewProps} from './GifView.types'
export class GifView extends PureComponent<GifViewProps> { export class GifView extends PureComponent<GifViewProps> {
private readonly videoPlayerRef: RefObject<HTMLMediaElement> = createRef() private readonly videoPlayerRef: React.RefObject<HTMLVideoElement | null> =
createRef()
private isLoaded = false private isLoaded = false
constructor(props: GifViewProps | Readonly<GifViewProps>) { constructor(props: GifViewProps | Readonly<GifViewProps>) {
@@ -18,9 +19,9 @@ export class GifView extends PureComponent<GifViewProps> {
componentDidUpdate(prevProps: Readonly<GifViewProps>) { componentDidUpdate(prevProps: Readonly<GifViewProps>) {
if (prevProps.autoplay !== this.props.autoplay) { if (prevProps.autoplay !== this.props.autoplay) {
if (this.props.autoplay) { if (this.props.autoplay) {
this.playAsync() void this.playAsync()
} else { } else {
this.pauseAsync() void this.pauseAsync()
} }
} }
} }
@@ -29,6 +30,7 @@ export class GifView extends PureComponent<GifViewProps> {
document.removeEventListener('visibilitychange', this.onVisibilityChange) document.removeEventListener('visibilitychange', this.onVisibilityChange)
} }
// eslint-disable-next-line @typescript-eslint/require-await
static async prefetchAsync(_: string[]): Promise<void> { static async prefetchAsync(_: string[]): Promise<void> {
console.warn('prefetchAsync is not supported on web') console.warn('prefetchAsync is not supported on web')
} }
@@ -81,6 +83,7 @@ export class GifView extends PureComponent<GifViewProps> {
} }
} }
// eslint-disable-next-line @typescript-eslint/require-await
async pauseAsync(): Promise<void> { async pauseAsync(): Promise<void> {
this.videoPlayerRef.current?.pause() this.videoPlayerRef.current?.pause()
} }
@@ -102,12 +105,12 @@ export class GifView extends PureComponent<GifViewProps> {
// When `<source>` children are present, omit `src` so the browser // When `<source>` children are present, omit `src` so the browser
// walks the source list and picks via canPlayType. // walks the source list and picks via canPlayType.
src={useSources ? undefined : source} src={useSources ? undefined : source}
autoPlay={autoplay ? 'autoplay' : undefined} autoPlay={autoplay ? true : undefined}
preload={autoplay ? 'auto' : undefined} preload={autoplay ? 'auto' : undefined}
playsInline={true} playsInline={true}
loop="loop" loop={true}
muted="muted" muted={true}
style={StyleSheet.flatten(style)} style={StyleSheet.flatten(style) as React.CSSProperties}
onCanPlay={this.onLoad} onCanPlay={this.onLoad}
onPlay={this.firePlayerStateChangeEvent} onPlay={this.firePlayerStateChangeEvent}
onPause={this.firePlayerStateChangeEvent} onPause={this.firePlayerStateChangeEvent}
@@ -1,4 +1,4 @@
import React from 'react' import {useCallback} from 'react'
import {type StyleProp, type ViewStyle} from 'react-native' import {type StyleProp, type ViewStyle} from 'react-native'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core' import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
@@ -21,7 +21,7 @@ export default function VisibilityView({
onChangeStatus: onChangeStatusOuter, onChangeStatus: onChangeStatusOuter,
enabled, enabled,
}: VisibilityViewProps) { }: VisibilityViewProps) {
const onChangeStatus = React.useCallback( const onChangeStatus = useCallback(
(e: {nativeEvent: {isActive: boolean}}) => { (e: {nativeEvent: {isActive: boolean}}) => {
onChangeStatusOuter(e.nativeEvent.isActive) onChangeStatusOuter(e.nativeEvent.isActive)
}, },
@@ -1,4 +1,3 @@
import type React from 'react'
export interface VisibilityViewProps { export interface VisibilityViewProps {
children: React.ReactNode children: React.ReactNode
onChangeStatus: (isActive: boolean) => void onChangeStatus: (isActive: boolean) => void
@@ -1,5 +1,4 @@
import {requireNativeView} from 'expo' import {requireNativeView} from 'expo'
import type * as React from 'react'
import { import {
type EmojiPickerNativeViewProps, type EmojiPickerNativeViewProps,
+3 -2
View File
@@ -8,7 +8,7 @@
"devEngines": { "devEngines": {
"packageManager": { "packageManager": {
"name": "pnpm", "name": "pnpm",
"version": "11.7.0", "version": "11.9.0",
"onFail": "warn" "onFail": "warn"
}, },
"runtime": { "runtime": {
@@ -95,7 +95,7 @@
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.20.23", "@atproto/api": "0.20.23",
"@atproto/syntax": "0.6.1", "@atproto/syntax": "0.6.3",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.14", "@bsky.app/alf": "^0.1.14",
@@ -205,6 +205,7 @@
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.shuffle": "^4.2.0", "lodash.shuffle": "^4.2.0",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"mediabunny": "^1.25.3",
"multiformats": "^13.4.2", "multiformats": "^13.4.2",
"nanoid": "^5.0.5", "nanoid": "^5.0.5",
"normalize-url": "^8.0.0", "normalize-url": "^8.0.0",
+63 -49
View File
@@ -7,52 +7,52 @@ importers:
configDependencies: {} configDependencies: {}
packageManagerDependencies: packageManagerDependencies:
'@pnpm/exe': '@pnpm/exe':
specifier: 11.7.0 specifier: 11.9.0
version: 11.7.0 version: 11.9.0
pnpm: pnpm:
specifier: 11.7.0 specifier: 11.9.0
version: 11.7.0 version: 11.9.0
packages: packages:
'@pnpm/exe@11.7.0': '@pnpm/exe@11.9.0':
resolution: {integrity: sha512-3CujpSSp2PIDE0pwu7mWSdjhdDqaZa7OppVooECWWaNEoA/z66s9FZts1MhDO+2yq1XER4gBHh84DVbFN/r1rA==} resolution: {integrity: sha512-pPPOpR79qW3nsNhlyDIdfstli4Bi78mk8r22ySxpFRwMbO8KXSjGrVzGmJBsVX39NnJTh7/WADj527nZhG9H9g==}
hasBin: true hasBin: true
'@pnpm/linux-arm64@11.7.0': '@pnpm/linux-arm64@11.9.0':
resolution: {integrity: sha512-ANTX2SlMO+d2y/4bYQhHCwHPX7gSSADJ5+pMUIiDFzIsybnFFaJdZboaFfq9NOxCbETcnDxqZ95Rz3+NHx1JIw==} resolution: {integrity: sha512-XYmY2qadHauBA3QaHi2R7fI6kt5Flje0WHz9MVrbH0kVH/XLpfOLnwPeE1+EX6K/nDa2CBvzp35VjYCNGFJa9A==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@pnpm/linux-x64@11.7.0': '@pnpm/linux-x64@11.9.0':
resolution: {integrity: sha512-fr75tqixXoS8cnA81HQIomjOGEPnsOsd3xCDL5pMNY5raOXbKurtgRV+RjATvjxlJxSLIVFKegABlxAiB7q72A==} resolution: {integrity: sha512-fl7W5imnSmmgXIqMQFZ/rPaVvk9OkKF8/anqHZE3XEDfWcn3BlWGndyOEas/JN7u2BXWYjs63DJZ3rnG6WOhLA==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@pnpm/linuxstatic-arm64@11.7.0': '@pnpm/linuxstatic-arm64@11.9.0':
resolution: {integrity: sha512-Q++pgzvXkGeqnVRl26/uqmpMGdttQus0rGyL3XIfYGLCi8ZfajYUaCKdZID2MH7+CNOuugWDdFDup3r7BR7Rfg==} resolution: {integrity: sha512-fif8xbnzVEAIlvaU4yIgWKXeXYb4Kj6WMEl/KvM2x1Rp3AKAjBW/53SGzxO4cZP9doAqUzOIpMRGFVbHGeMDRw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@pnpm/linuxstatic-x64@11.7.0': '@pnpm/linuxstatic-x64@11.9.0':
resolution: {integrity: sha512-z1+exW6ocU/rmOvJnmU3FUBJYaryCUqFoaXN6KZW5BqTj7BPJb7HJcAyXRlirNZMJlEiUY5rXbfStGPQJhGsVA==} resolution: {integrity: sha512-9dKu3QdShqOpnWrjW9owARpIJeP0ul8UgIIbBUv8VDGEYibt+g49zQsNaD7SdMD3WeRSjExPQ1zIhplzr5cwvQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@pnpm/macos-arm64@11.7.0': '@pnpm/macos-arm64@11.9.0':
resolution: {integrity: sha512-gD34/k3JT5oab4BYaqrUor3e4VdwXvkfLNlEI+lvDtX1MHT+2Nauc9p9NsQnpn1zE8blQEflzbF8wAUQ6Dmvkw==} resolution: {integrity: sha512-MWzBTgeI5p3odjdVltYvFXaSWAjF2Xk5YaxiP/u2RmW8N6PHsLyIyj37Ds992CrXgFM8fO1RpvsEirozhsD6KA==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@pnpm/win-arm64@11.7.0': '@pnpm/win-arm64@11.9.0':
resolution: {integrity: sha512-hRTcDmm2j7KoRwbqNo0rUAu9A1kVJN98Eob7H09U0uPJbEMax85JGOmERkT3Lf6HjVJuFNBvfaJ3OTI3HmlVGg==} resolution: {integrity: sha512-u/QxEcbKJZxC1t3zUYCZiHzu7TaZ/iXc6EGZoQjkeVT0LXmEVR6ypcK3ByjhIqbxQ3HGX75gnpIpR+VjRjubfw==}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@pnpm/win-x64@11.7.0': '@pnpm/win-x64@11.9.0':
resolution: {integrity: sha512-r/1NuKY7Z+6ZXVIzVrUEMj6TTEBdR63geV4Rlm8HKEhgQTdxyIsJoqV3FJGqoyzbRaScObqAwRfMaK9dskPddQ==} resolution: {integrity: sha512-HqJVHmZG5UKfLi38AjMl2azVmq87TlWRhwSW+f4q9LLaZeAkFyIZ7LY/pN6mh4VlH9yWj90C+tsb1yKiy7OKnw==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -116,45 +116,45 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
pnpm@11.7.0: pnpm@11.9.0:
resolution: {integrity: sha512-GcyFLBIMcSV2DyRD7mvgyltA+fUFmN4aCaHxd1A+AQ5Xwjx3ZG4B52HeWb+HT7IqM5jDOrlpH8E+uUa28PTWIA==} resolution: {integrity: sha512-vWgtXQP+Ul73yf1ngMaITR51asTJyf4AxTh4KCQxDc+Q493E9Tg18G3669UIXkGFXgvLs7YN4qxburieUDbwOw==}
engines: {node: '>=22.13'} engines: {node: '>=22.13'}
hasBin: true hasBin: true
snapshots: snapshots:
'@pnpm/exe@11.7.0': '@pnpm/exe@11.9.0':
dependencies: dependencies:
'@reflink/reflink': 0.1.19 '@reflink/reflink': 0.1.19
detect-libc: 2.1.2 detect-libc: 2.1.2
optionalDependencies: optionalDependencies:
'@pnpm/linux-arm64': 11.7.0 '@pnpm/linux-arm64': 11.9.0
'@pnpm/linux-x64': 11.7.0 '@pnpm/linux-x64': 11.9.0
'@pnpm/linuxstatic-arm64': 11.7.0 '@pnpm/linuxstatic-arm64': 11.9.0
'@pnpm/linuxstatic-x64': 11.7.0 '@pnpm/linuxstatic-x64': 11.9.0
'@pnpm/macos-arm64': 11.7.0 '@pnpm/macos-arm64': 11.9.0
'@pnpm/win-arm64': 11.7.0 '@pnpm/win-arm64': 11.9.0
'@pnpm/win-x64': 11.7.0 '@pnpm/win-x64': 11.9.0
'@pnpm/linux-arm64@11.7.0': '@pnpm/linux-arm64@11.9.0':
optional: true optional: true
'@pnpm/linux-x64@11.7.0': '@pnpm/linux-x64@11.9.0':
optional: true optional: true
'@pnpm/linuxstatic-arm64@11.7.0': '@pnpm/linuxstatic-arm64@11.9.0':
optional: true optional: true
'@pnpm/linuxstatic-x64@11.7.0': '@pnpm/linuxstatic-x64@11.9.0':
optional: true optional: true
'@pnpm/macos-arm64@11.7.0': '@pnpm/macos-arm64@11.9.0':
optional: true optional: true
'@pnpm/win-arm64@11.7.0': '@pnpm/win-arm64@11.9.0':
optional: true optional: true
'@pnpm/win-x64@11.7.0': '@pnpm/win-x64@11.9.0':
optional: true optional: true
'@reflink/reflink-darwin-arm64@0.1.19': '@reflink/reflink-darwin-arm64@0.1.19':
@@ -194,7 +194,7 @@ snapshots:
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
pnpm@11.7.0: {} pnpm@11.9.0: {}
--- ---
lockfileVersion: '9.0' lockfileVersion: '9.0'
@@ -245,8 +245,8 @@ importers:
specifier: 0.20.23 specifier: 0.20.23
version: 0.20.23 version: 0.20.23
'@atproto/syntax': '@atproto/syntax':
specifier: 0.6.1 specifier: 0.6.3
version: 0.6.1 version: 0.6.3
'@bitdrift/react-native': '@bitdrift/react-native':
specifier: ^0.6.8 specifier: ^0.6.8
version: 0.6.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) version: 0.6.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -574,6 +574,9 @@ importers:
lodash.throttle: lodash.throttle:
specifier: ^4.1.1 specifier: ^4.1.1
version: 4.1.1 version: 4.1.1
mediabunny:
specifier: ^1.25.3
version: 1.49.0
multiformats: multiformats:
specifier: ^13.4.2 specifier: ^13.4.2
version: 13.4.2 version: 13.4.2
@@ -897,10 +900,6 @@ packages:
resolution: {integrity: sha512-WP6ct2rjNCKSJN/VFc+8x6JQ7PvRMlfHUlmQM5hzvE3a6nOjm6kwSicjSIV/QldurdRGJ4FCQZ3uZu9Wqt4SxQ==} resolution: {integrity: sha512-WP6ct2rjNCKSJN/VFc+8x6JQ7PvRMlfHUlmQM5hzvE3a6nOjm6kwSicjSIV/QldurdRGJ4FCQZ3uZu9Wqt4SxQ==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/syntax@0.6.1':
resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==}
engines: {node: '>=22'}
'@atproto/syntax@0.6.3': '@atproto/syntax@0.6.3':
resolution: {integrity: sha512-io7Ck4o+40iFXhetHYoEtok2gZ8cWcJ1yRftEHe5AmAF0dSGcYmclbx5GatVew59taw+eSG7Ty5D3llop/0BhA==} resolution: {integrity: sha512-io7Ck4o+40iFXhetHYoEtok2gZ8cWcJ1yRftEHe5AmAF0dSGcYmclbx5GatVew59taw+eSG7Ty5D3llop/0BhA==}
engines: {node: '>=22'} engines: {node: '>=22'}
@@ -3502,6 +3501,12 @@ packages:
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/dom-mediacapture-transform@0.1.11':
resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==}
'@types/dom-webcodecs@0.1.13':
resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==}
'@types/eslint-scope@3.7.7': '@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -6863,6 +6868,9 @@ packages:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
mediabunny@1.49.0:
resolution: {integrity: sha512-hDMtS/q22GFjyB3Yum+a6NHhDtVnye6hgaYTjx3DPUfnckRjzxFmXmCc3UQXdvo7d6PUCl7KM6Y9MRWzlvnAJQ==}
memfs@3.5.3: memfs@3.5.3:
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
engines: {node: '>= 4.0.0'} engines: {node: '>= 4.0.0'}
@@ -9511,11 +9519,6 @@ snapshots:
multiformats: 13.4.2 multiformats: 13.4.2
zod: 3.25.76 zod: 3.25.76
'@atproto/syntax@0.6.1':
dependencies:
iso-datestring-validator: 2.2.2
tslib: 2.8.1
'@atproto/syntax@0.6.3': '@atproto/syntax@0.6.3':
dependencies: dependencies:
iso-datestring-validator: 2.2.2 iso-datestring-validator: 2.2.2
@@ -12767,6 +12770,12 @@ snapshots:
dependencies: dependencies:
'@types/node': 24.12.4 '@types/node': 24.12.4
'@types/dom-mediacapture-transform@0.1.11':
dependencies:
'@types/dom-webcodecs': 0.1.13
'@types/dom-webcodecs@0.1.13': {}
'@types/eslint-scope@3.7.7': '@types/eslint-scope@3.7.7':
dependencies: dependencies:
'@types/eslint': 9.6.1 '@types/eslint': 9.6.1
@@ -16688,6 +16697,11 @@ snapshots:
media-typer@0.3.0: {} media-typer@0.3.0: {}
mediabunny@1.49.0:
dependencies:
'@types/dom-mediacapture-transform': 0.1.11
'@types/dom-webcodecs': 0.1.13
memfs@3.5.3: memfs@3.5.3:
dependencies: dependencies:
fs-monkey: 1.1.0 fs-monkey: 1.1.0
@@ -513,7 +513,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>
+2 -1
View File
@@ -10,10 +10,11 @@ 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',
SearchV2Enable = 'search_v2:enable',
AdvancedSearchV2Enable = 'advanced_search_v2:enable',
AATest = 'aa-test', AATest = 'aa-test',
} }
+63 -5
View File
@@ -1,5 +1,6 @@
import {createContext, useContext, useMemo} from 'react' import {createContext, useContext, useMemo} from 'react'
import {Platform} from 'react-native' import {Platform} from 'react-native'
import {type Result} from '@growthbook/growthbook-react'
import {Logger} from '#/logger' import {Logger} from '#/logger'
import { import {
@@ -169,6 +170,39 @@ export function AnalyticsContext({
return <Context.Provider value={childContext}>{children}</Context.Provider> return <Context.Provider value={childContext}>{children}</Context.Provider>
} }
/**
* GrowthBook attribute name for the did. Must match the key used in
* `setAttributes` (`#/analytics/features`) and the assignment unit configured
* in the GrowthBook dashboard.
*/
const DID_HASH_ATTRIBUTE = 'did'
/**
* Builds the session metadata override for an exposure event so the event's
* `did` is sourced from the unit GrowthBook actually bucketed on
* (`result.hashValue`) rather than from ambient React session metadata. This
* keeps the `did` and the variation in sync, since both then come from the
* same evaluation. See APP-2461.
*
* Only did-bucketed experiments are overridden. Experiments bucketed on
* another attribute (e.g. `deviceId`) have a `hashValue` that is not a did, so
* we leave their session metadata untouched and let the ambient did stand.
*/
function sessionMetadataForResult(
parentContext: AnalyticsBaseContextType,
result: Result<unknown>,
): MergeableMetadata | undefined {
if (result.hashAttribute !== DID_HASH_ATTRIBUTE) return undefined
const {session} = parentContext.metadata
if (!session) return undefined
return {
session: {
...session,
did: result.hashValue,
},
}
}
/** /**
* Feature gates provider. Decorates the parent analytics context with * Feature gates provider. Decorates the parent analytics context with
* feature gate capabilities. Should be mounted within `AnalyticsContext`, * feature gate capabilities. Should be mounted within `AnalyticsContext`,
@@ -185,22 +219,46 @@ export function AnalyticsFeaturesContext({
* Side-effects: we need to synchronously set these during the same render * Side-effects: we need to synchronously set these during the same render
* cycle. These calls do not trigger re-renders, they just set properties on * cycle. These calls do not trigger re-renders, they just set properties on
* the singleton GrowthBook instance. * the singleton GrowthBook instance.
*
* Order matters here. We register the tracking callbacks _before_ calling
* `setAttributes`, because `setAttributes` triggers a synchronous
* re-evaluation that can fire exposure events. Registering first guarantees
* those events run through this render's callback (with this render's
* metadata) rather than a stale callback left over from a previous render,
* e.g. after an account switch remounts this provider via the
* `<Fragment key={did} />` breaker in `App.<platform>.tsx`. See APP-2461.
*
* We deliberately keep these synchronous rather than moving them into a
* `useEffect`: `setAttributes` must run before children evaluate gates (or
* they bucket on the previous account's attributes), and the feature usage
* callback has no deferred-replay, so a feature evaluated before it is
* registered would lose its `feature:viewed` event entirely.
*/ */
setAttributes(parentContext.metadata)
feats.setTrackingCallback((experiment, result) => { feats.setTrackingCallback((experiment, result) => {
parentContext.metric('experiment:viewed', { parentContext.metric(
'experiment:viewed',
{
experimentId: experiment.key, experimentId: experiment.key,
variationId: result.key, variationId: result.key,
}) },
sessionMetadataForResult(parentContext, result),
)
}) })
feats.setFeatureUsageCallback((feature, result) => { feats.setFeatureUsageCallback((feature, result) => {
parentContext.metric('feature:viewed', { parentContext.metric(
'feature:viewed',
{
featureId: feature, featureId: feature,
featureResultValue: result.value, featureResultValue: result.value,
experimentId: result.experiment?.key, experimentId: result.experiment?.key,
variationId: result.experimentResult?.key, variationId: result.experimentResult?.key,
},
result.experimentResult
? sessionMetadataForResult(parentContext, result.experimentResult)
: undefined,
)
}) })
}) setAttributes(parentContext.metadata)
const childContext = useMemo<AnalyticsContextType>(() => { const childContext = useMemo<AnalyticsContextType>(() => {
return { return {
+110 -1
View File
@@ -5,6 +5,7 @@
import {type Platform} from 'react-native' import {type Platform} from 'react-native'
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
import {type VideoCompressSkipReason} from '#/lib/media/video/types'
import {type NotificationType} from '#/state/queries/notifications/types' import {type NotificationType} from '#/state/queries/notifications/types'
import {type FeedDescriptor} from '#/state/queries/post-feed' import {type FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types' import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
@@ -774,6 +775,7 @@ export type Events = {
'search:query': { 'search:query': {
source: 'typed' | 'history' | 'autocomplete' source: 'typed' | 'history' | 'autocomplete'
filterCount: number
} }
'search:results:loaded': { 'search:results:loaded': {
@@ -798,6 +800,18 @@ export type Events = {
position: number position: number
} }
'search:advanced:press': {
filterCount: number
}
'search:shareLink:press': {
filterCount: number
}
'search:addFilter:press': {
filterCount: number
}
'progressGuide:hide': {} 'progressGuide:hide': {}
'progressGuide:followDialog:open': {} 'progressGuide:followDialog:open': {}
@@ -1319,7 +1333,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': {
@@ -1331,4 +1345,99 @@ export type Events = {
'invite:followersPromo:press': {} 'invite:followersPromo:press': {}
// user dismissed the empty-followers promo banner // user dismissed the empty-followers promo banner
'invite:followersPromo:dismiss': {} 'invite:followersPromo:dismiss': {}
// === Video upload funnel (Frontend Spec section D) ===
// Every event carries uploadId (client-generated UUID, ties one upload
// session end-to-end) + engine (compression engine id, e.g.
// native:react-native-compressor@1.13.0). jobId is added once the server
// returns it. Sizes / codecs / dimensions / timings only - never content.
'video:upload:picked': {
uploadId: string
engine: string
sourceMimeType?: string
sourceBytes?: number
sourceDurationMs?: number
sourceWidth?: number
sourceHeight?: number
}
'video:upload:compressStarted': {
uploadId: string
engine: string
sourceBytes?: number
}
'video:upload:compressCompleted': {
uploadId: string
engine: string
bytesIn?: number
bytesOut: number
outputMimeType: string
elapsedMs: number
}
'video:upload:compressSkipped': {
uploadId: string
engine: string
skipReason: VideoCompressSkipReason
bytes: number
mimeType: string
elapsedMs: number
}
'video:upload:compressFailed': {
uploadId: string
engine: string
errorClass: string
elapsedMs: number
}
'video:upload:uploadStarted': {
uploadId: string
engine: string
bytes: number
}
'video:upload:uploadCompleted': {
uploadId: string
engine: string
jobId: string
bytes: number
elapsedMs: number
throughputBytesPerSec: number
}
'video:upload:uploadFailed': {
uploadId: string
engine: string
bytes: number
errorClass: string
elapsedMs: number
}
'video:upload:processingStarted': {
uploadId: string
engine: string
jobId: string
}
'video:upload:processingCompleted': {
uploadId: string
engine: string
jobId: string
elapsedMs: number
}
'video:upload:processingFailed': {
uploadId: string
engine: string
jobId: string
errorClass: string
elapsedMs: number
}
'video:upload:published': {
uploadId: string
engine: string
jobId: string
// wall-clock from picked to published
totalElapsedMs: number
}
// The event that measures the actual problem: users giving up mid-wait.
'video:upload:abandoned': {
uploadId: string
engine: string
phase: 'compress' | 'upload' | 'processing'
jobId?: string
elapsedInPhaseMs: number
}
} }
+93
View File
@@ -0,0 +1,93 @@
import {Pressable} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {HITSLOP_20} from '#/lib/constants'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
const positionStyles = {
'top-left': {
top: a.p_xs.padding,
left: a.p_xs.padding,
},
'top-right': {
top: a.p_xs.padding,
right: a.p_xs.padding,
},
'bottom-left': {
bottom: a.p_xs.padding,
left: a.p_xs.padding,
},
'bottom-right': {
bottom: a.p_xs.padding,
right: a.p_xs.padding,
},
}
export function AltBadgeWithDialog({
text,
position,
}: {
text: string
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
}) {
const t = useTheme()
const {t: l} = useLingui()
const large = useLargeAltBadgeEnabled()
const control = Prompt.usePromptControl()
const pos = position ? [a.absolute, positionStyles[position]] : {}
return (
<>
<Pressable
testID="altBadgeWithDialogButton"
accessibilityRole="button"
accessibilityLabel={l`Show alt text`}
accessibilityHint=""
hitSlop={HITSLOP_20}
onPress={control.open}
style={s => [
a.justify_center,
a.rounded_sm,
a.p_xs,
a.z_10,
t.atoms.bg_contrast_25,
large && {
padding: 6,
},
{
opacity: 0.8,
},
pos,
s.hovered || s.pressed
? [
{
opacity: 1,
},
]
: [],
]}>
<Text
accessible={false}
style={[a.font_bold, large ? a.text_xs : {fontSize: 8}]}>
<Trans>ALT</Trans>
</Text>
</Pressable>
<Prompt.Outer control={control}>
<Prompt.Content>
<Prompt.TitleText>
<Trans>Alt Text</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Cancel cta={l`Close`} />
</Prompt.Actions>
</Prompt.Outer>
</>
)
}
+5 -3
View File
@@ -7,9 +7,10 @@ import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {APP_LANGUAGES} from '#/locale/languages' import {APP_LANGUAGES} from '#/locale/languages'
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences' import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
import {resetPostsFeedQueries} from '#/state/queries/post-feed' import {resetPostsFeedQueries} from '#/state/queries/post-feed'
import {atoms as a, platform, useTheme} from '#/alf' import {atoms as a, platform, useTheme, web} from '#/alf'
import * as Select from '#/components/Select' import * as Select from '#/components/Select'
import {Button} from './Button' import {Button, ButtonIcon} from './Button'
import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from './icons/Globe'
export function AppLanguageDropdown() { export function AppLanguageDropdown() {
const t = useTheme() const t = useTheme()
@@ -57,6 +58,7 @@ export function AppLanguageDropdown() {
native: [a.gap_xs], native: [a.gap_xs],
}), }),
]}> ]}>
<ButtonIcon icon={EarthIcon} size="md" />
<Select.ValueText <Select.ValueText
placeholder={_(msg`Select an app language`)} placeholder={_(msg`Select an app language`)}
style={[t.atoms.text_contrast_medium]} style={[t.atoms.text_contrast_medium]}
@@ -68,7 +70,7 @@ export function AppLanguageDropdown() {
<Select.Content <Select.Content
label={_(msg`Select language`)} label={_(msg`Select language`)}
renderItem={({label, value}) => ( renderItem={({label, value}) => (
<Select.Item value={value} label={label}> <Select.Item value={value} label={label} style={web([a.pointer])}>
<Select.ItemIndicator /> <Select.ItemIndicator />
<Select.ItemText>{label}</Select.ItemText> <Select.ItemText>{label}</Select.ItemText>
</Select.Item> </Select.Item>
+1 -1
View File
@@ -44,7 +44,7 @@ export function Autocomplete({
const t = useTheme() const t = useTheme()
const updatePosition = useCallback(() => { const updatePosition = useCallback(() => {
sift.updatePosition() void sift.updatePosition()
}, [sift]) }, [sift])
useOnKeyboard('keyboardDidShow', updatePosition) useOnKeyboard('keyboardDidShow', updatePosition)
+2
View File
@@ -615,6 +615,8 @@ export function Outer({
label?: string label?: string
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
align?: 'left' | 'right' align?: 'left' | 'right'
/** Web only. Native restores focus differently. */
onCloseAutoFocus?: (event: Event) => void
}) { }) {
const t = useTheme() const t = useTheme()
const context = useContextMenuContext() const context = useContextMenuContext()
+3 -1
View File
@@ -34,14 +34,16 @@ export function Outer({
children, children,
label, label,
style, style,
onCloseAutoFocus,
}: { }: {
children: React.ReactNode children: React.ReactNode
label?: string label?: string
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
onCloseAutoFocus?: (event: Event) => void
}) { }) {
const t = useTheme() const t = useTheme()
return ( return (
<Menu.Outer style={style}> <Menu.Outer style={style} onCloseAutoFocus={onCloseAutoFocus}>
{label ? ( {label ? (
<Text <Text
numberOfLines={1} numberOfLines={1}
+14 -8
View File
@@ -6,9 +6,7 @@ import {
View, View,
type ViewStyle, type ViewStyle,
} from 'react-native' } from 'react-native'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import flattenReactChildren from 'react-keyed-flatten-children' import flattenReactChildren from 'react-keyed-flatten-children'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
@@ -32,6 +30,11 @@ import {
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env' import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env'
// iOS 26's floaty sheet presentation subtracts the bottom safe-area inset from
// the requested detent, which eats the visible bottom padding of short (e.g.
// single-item) menus. Flooring the native sheet height restores that padding.
const IOS_MENU_MIN_HEIGHT = 128
export { export {
type DialogControlProps as MenuControlProps, type DialogControlProps as MenuControlProps,
useDialogControl as useMenuControl, useDialogControl as useMenuControl,
@@ -101,16 +104,19 @@ export function Outer({
onCloseAutoFocus?: (event: Event) => void onCloseAutoFocus?: (event: Event) => void
}>) { }>) {
const context = useMenuContext() const context = useMenuContext()
const {_} = useLingui() const {t: l} = useLingui()
return ( return (
<Dialog.Outer <Dialog.Outer
control={context.control} control={context.control}
nativeOptions={{preventExpansion: true}}> nativeOptions={{
preventExpansion: true,
minHeight: IS_IOS ? IOS_MENU_MIN_HEIGHT : undefined,
}}>
<Dialog.Handle /> <Dialog.Handle />
{/* Re-wrap with context since Dialogs are portal-ed to root */} {/* Re-wrap with context since Dialogs are portal-ed to root */}
<Context.Provider value={context}> <Context.Provider value={context}>
<Dialog.ScrollableInner label={_(msg`Menu`)}> <Dialog.ScrollableInner label={l`Menu`}>
<View style={[a.gap_lg]}> <View style={[a.gap_lg]}>
{children} {children}
{IS_NATIVE && showCancel && <Cancel />} {IS_NATIVE && showCancel && <Cancel />}
@@ -353,12 +359,12 @@ export function Group({children, style}: GroupProps) {
} }
function Cancel() { function Cancel() {
const {_} = useLingui() const {t: l} = useLingui()
const context = useMenuContext() const context = useMenuContext()
return ( return (
<Button <Button
label={_(msg`Close this dialog`)} label={l`Close this dialog`}
size="small" size="small"
variant="ghost" variant="ghost"
color="secondary" color="secondary"
@@ -66,19 +66,7 @@ export function GifEmbed({
a.overflow_hidden, a.overflow_hidden,
{backgroundColor: t.palette.black}, {backgroundColor: t.palette.black},
]}> ]}>
<View <View style={[a.absolute, a.inset_0]}>
style={[
a.absolute,
/*
* Aspect ratio was being clipped weirdly on web -esb
*/
{
top: -2,
bottom: -2,
left: -2,
right: -2,
},
]}>
<MediaInsetBorder /> <MediaInsetBorder />
<GifPresentationControls <GifPresentationControls
onPress={onPress} onPress={onPress}
@@ -1,18 +1,13 @@
import { import {ActivityIndicator, View} from 'react-native'
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {HITSLOP_20} from '#/lib/constants' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {Fill} from '#/components/Fill' import {Fill} from '#/components/Fill'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
@@ -29,6 +24,7 @@ export function GifPresentationControls({
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const largeBadge = useLargeAltBadgeEnabled()
return ( return (
<> <>
@@ -64,74 +60,41 @@ export function GifPresentationControls({
]} ]}
/> />
)} )}
<View style={styles.gifBadgeContainer}> <View
<Text style={[{color: 'white'}, a.font_bold, a.text_xs]}> style={[
a.absolute,
a.flex_row,
a.z_10,
{
bottom: a.p_xs.padding,
right: a.p_xs.padding,
gap: 3,
},
largeBadge && {
gap: 4,
},
]}>
<View
accessible={false}
style={[
a.justify_center,
a.rounded_sm,
a.p_xs,
a.z_10,
t.atoms.bg_contrast_25,
largeBadge && {
padding: 6,
},
{
opacity: 0.8,
},
]}>
<Text style={[a.font_bold, largeBadge ? a.text_xs : {fontSize: 8}]}>
<Trans>GIF</Trans> <Trans>GIF</Trans>
</Text> </Text>
</View> </View>
{altText && <AltBadge text={altText} />} {altText && <AltBadgeWithDialog text={altText} />}
</View>
</> </>
) )
} }
function AltBadge({text}: {text: string}) {
const control = Prompt.usePromptControl()
const {_} = useLingui()
return (
<>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Show alt text`)}
accessibilityHint=""
hitSlop={HITSLOP_20}
onPress={control.open}
style={styles.altBadgeContainer}>
<Text
style={[{color: 'white'}, a.font_bold, a.text_xs]}
accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<Prompt.Outer control={control}>
<Prompt.Content>
<Prompt.TitleText>
<Trans>Alt Text</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Action
onPress={() => control.close()}
cta={_(msg`Close`)}
color="secondary"
/>
</Prompt.Actions>
</Prompt.Outer>
</>
)
}
const styles = StyleSheet.create({
gifBadgeContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: 4,
paddingVertical: 3,
position: 'absolute',
left: 6,
bottom: 6,
zIndex: 2,
},
altBadgeContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: 4,
paddingVertical: 3,
position: 'absolute',
right: 6,
bottom: 6,
zIndex: 2,
},
})
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
import {HITSLOP_30} from '#/lib/constants' import {HITSLOP_30} from '#/lib/constants'
import {useAutoplayDisabled} from '#/state/preferences' import {useAutoplayDisabled} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {useIsWithinMessage} from '#/components/dms/MessageContext' import {useIsWithinMessage} from '#/components/dms/MessageContext'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
@@ -98,6 +99,7 @@ export function VideoEmbedInnerNative({
altText={embed.alt} altText={embed.alt}
/> />
) : ( ) : (
<>
<VideoPresentationControls <VideoPresentationControls
enterFullscreen={() => { enterFullscreen={() => {
videoRef.current?.enterFullscreen(true) videoRef.current?.enterFullscreen(true)
@@ -111,6 +113,10 @@ export function VideoEmbedInnerNative({
isPlaying={isPlaying} isPlaying={isPlaying}
timeRemaining={timeRemaining} timeRemaining={timeRemaining}
/> />
{embed.alt && (
<AltBadgeWithDialog text={embed.alt} position="top-right" />
)}
</>
)} )}
<MediaInsetBorder /> <MediaInsetBorder />
<KeepAwake enabled={isPlaying} /> <KeepAwake enabled={isPlaying} />
@@ -7,6 +7,8 @@ import type * as HlsTypes from 'hls.js'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {useFullscreen} from '#/components/hooks/useFullscreen'
import * as BandwidthEstimate from './bandwidth-estimate' import * as BandwidthEstimate from './bandwidth-estimate'
import {Controls} from './web-controls/VideoControls' import {Controls} from './web-controls/VideoControls'
@@ -30,6 +32,8 @@ export function VideoEmbedInnerWeb({
const [hlsLoading, setHlsLoading] = useState(false) const [hlsLoading, setHlsLoading] = useState(false)
const figId = useId() const figId = useId()
const {_} = useLingui() const {_} = useLingui()
const [isFullscreen] = useFullscreen(containerRef)
const isGif = embed.presentation === 'gif'
// send error up to error boundary // send error up to error boundary
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
@@ -77,6 +81,9 @@ export function VideoEmbedInnerWeb({
</figcaption> </figcaption>
)} )}
</figure> </figure>
{!isFullscreen && !isGif && embed.alt && (
<AltBadgeWithDialog text={embed.alt} position="top-right" />
)}
<Controls <Controls
videoRef={videoRef} videoRef={videoRef}
hlsRef={hlsRef} hlsRef={hlsRef}
@@ -88,7 +95,7 @@ export function VideoEmbedInnerWeb({
onScreen={onScreen} onScreen={onScreen}
fullscreenRef={containerRef} fullscreenRef={containerRef}
hasSubtitleTrack={hasSubtitleTrack} hasSubtitleTrack={hasSubtitleTrack}
isGif={embed.presentation === 'gif'} isGif={isGif}
altText={embed.alt} altText={embed.alt}
updateCuePositions={updateCuePositions} updateCuePositions={updateCuePositions}
/> />
+2 -6
View File
@@ -40,11 +40,7 @@ import {ModeratedFeedEmbed} from './FeedEmbed'
import {ImageEmbed} from './ImageEmbed' import {ImageEmbed} from './ImageEmbed'
import {ModeratedListEmbed} from './ListEmbed' import {ModeratedListEmbed} from './ListEmbed'
import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder' import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder'
import { import {type CommonProps, type EmbedProps, PostEmbedViewContext} from './types'
type CommonProps,
type EmbedProps,
type PostEmbedViewContext,
} from './types'
import {VideoEmbed} from './VideoEmbed' import {VideoEmbed} from './VideoEmbed'
export {PostEmbedViewContext} from './types' export {PostEmbedViewContext} from './types'
@@ -356,7 +352,7 @@ export function QuoteEmbed({
return ( return (
<GalleryBleed> <GalleryBleed>
<View <View
style={[a.mt_sm]} style={[viewContext !== PostEmbedViewContext.ChatMessage && a.mt_sm]}
onPointerEnter={linkDisabled ? undefined : onPointerEnter} onPointerEnter={linkDisabled ? undefined : onPointerEnter}
onPointerLeave={linkDisabled ? undefined : onPointerLeave}> onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
<ContentHider <ContentHider
@@ -139,8 +139,7 @@ export function WizardProfileCard({
const isTarget = profile.did === targetProfileDid const isTarget = profile.did === targetProfileDid
const included = isTarget || state.profiles.some(p => p.did === profile.did) const included = isTarget || state.profiles.some(p => p.did === profile.did)
const disabled = const disabled =
isTarget || isTarget || (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE)
(!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1)
const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar') const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
const displayName = profile.displayName const displayName = profile.displayName
? sanitizeDisplayName(profile.displayName) ? sanitizeDisplayName(profile.displayName)
+1
View File
@@ -8,6 +8,7 @@ export const KWS_SUPPORTED_LANGS = [
{value: 'en', label: 'English'}, {value: 'en', label: 'English'},
{value: 'ar', label: 'العربية'}, {value: 'ar', label: 'العربية'},
{value: 'zh-Hans', label: '简体中文'}, {value: 'zh-Hans', label: '简体中文'},
{value: 'cs', label: 'Čeština'},
{value: 'nl', label: 'Nederlands'}, {value: 'nl', label: 'Nederlands'},
{value: 'tl', label: 'Filipino'}, {value: 'tl', label: 'Filipino'},
{value: 'fr', label: 'Français'}, {value: 'fr', label: 'Français'},
@@ -1,8 +1,12 @@
import {View} from 'react-native' import {View} from 'react-native'
import {type AppBskyNotificationDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings' import {
isChatPreferenceName,
type NotificationSettingsPreferenceName,
useChatNotificationSettingsQuery,
useNotificationSettingsQuery,
} from '#/state/queries/notifications/settings'
import * as SettingsList from '#/screens/Settings/components/SettingsList' import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {PreferenceControls} from '#/screens/Settings/NotificationSettings/components/PreferenceControls' import {PreferenceControls} from '#/screens/Settings/NotificationSettings/components/PreferenceControls'
import {atoms as a, useTheme, web} from '#/alf' import {atoms as a, useTheme, web} from '#/alf'
@@ -15,8 +19,8 @@ import {IS_NATIVE} from '#/env'
type NotificationSettingsDialogProps = { type NotificationSettingsDialogProps = {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'> name: NotificationSettingsPreferenceName
syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[] syncOthers?: NotificationSettingsPreferenceName[]
icon: React.ComponentType<SVGIconProps> icon: React.ComponentType<SVGIconProps>
titleText: React.ReactNode titleText: React.ReactNode
subtitleText: React.ReactNode subtitleText: React.ReactNode
@@ -55,7 +59,11 @@ function NotificationSettingsDialogInner({
}: Omit<NotificationSettingsDialogProps, 'icon'>) { }: Omit<NotificationSettingsDialogProps, 'icon'>) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const {data: preferences, isError} = useNotificationSettingsQuery() const isChat = isChatPreferenceName(name)
const appQuery = useNotificationSettingsQuery({enabled: !isChat})
const chatQuery = useChatNotificationSettingsQuery({enabled: isChat})
const isError = isChat ? chatQuery.isError : appQuery.isError
const preference = isChat ? chatQuery.data?.[name] : appQuery.data?.[name]
return ( return (
<> <>
@@ -81,7 +89,7 @@ function NotificationSettingsDialogInner({
<PreferenceControls <PreferenceControls
name={name} name={name}
syncOthers={syncOthers} syncOthers={syncOthers}
preference={preferences?.[name]} preference={preference}
allowDisableInApp={allowDisableInApp} allowDisableInApp={allowDisableInApp}
/> />
)} )}
@@ -89,7 +97,7 @@ function NotificationSettingsDialogInner({
<Dialog.Close /> <Dialog.Close />
{IS_NATIVE && ( {IS_NATIVE && (
<Button <Button
color="secondary" color="primary"
size="large" size="large"
label={l`Close dialog`} label={l`Close dialog`}
onPress={() => control.close()} onPress={() => control.close()}
@@ -168,7 +168,7 @@ export function GroupChatsAnnouncement() {
a.font_medium, a.font_medium,
{color: t.palette.primary_500}, {color: t.palette.primary_500},
]}> ]}>
<Trans>New</Trans> <Trans context="nux-description">New</Trans>
</Text> </Text>
</View> </View>
<Text <Text
+17 -2
View File
@@ -60,6 +60,20 @@ export let MessageContextMenu = ({
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const translate = useGoogleTranslate() const translate = useGoogleTranslate()
const onReply = useCallback(() => {
setReply(message)
}, [setReply, message])
// On web, the menu is a Radix dropdown that restores focus to the trigger on
// close. When Reply moves focus to the composer, don't let Radix steal it
// back. Checking activeElement (rather than tracking reply intent) also
// handles re-replying to the same message, where the composer's focus effect
// bails on an unchanged reply target and focus should stay on the trigger.
const onCloseAutoFocus = useCallback((event: Event) => {
if (document.activeElement && document.activeElement !== document.body) {
event.preventDefault()
}
}, [])
const isFromSelf = message.sender?.did === currentAccount?.did const isFromSelf = message.sender?.did === currentAccount?.did
const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable) const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable)
@@ -161,11 +175,12 @@ export let MessageContextMenu = ({
label={l`Sent at ${i18n.date(new Date(message.sentAt), { label={l`Sent at ${i18n.date(new Date(message.sentAt), {
timeStyle: 'short', timeStyle: 'short',
})}`} })}`}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}> style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}
onCloseAutoFocus={onCloseAutoFocus}>
<ContextMenu.Item <ContextMenu.Item
testID="messageDropdownReplyBtn" testID="messageDropdownReplyBtn"
label={l`Reply`} label={l`Reply`}
onPress={() => setReply(message)}> onPress={onReply}>
<ContextMenu.ItemIcon icon={ReplyIcon} position="left" /> <ContextMenu.ItemIcon icon={ReplyIcon} position="left" />
<ContextMenu.ItemText>{l`Reply`}</ContextMenu.ItemText> <ContextMenu.ItemText>{l`Reply`}</ContextMenu.ItemText>
</ContextMenu.Item> </ContextMenu.Item>
+11 -14
View File
@@ -44,12 +44,13 @@ import {useProfileBlockMutationQueue} from '#/state/queries/profile'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, platform, useTheme, utils} from '#/alf' import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography' import {isOnlyEmoji} from '#/alf/typography'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {useMessageDialogs} from '#/components/dms/MessageOverlays' import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {useMessageReplies} from '#/components/dms/MessageReplies' import {useMessageReplies} from '#/components/dms/MessageReplies'
import {useReplyPreviewText} from '#/components/dms/replyPreview'
import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight' import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
@@ -834,6 +835,7 @@ function ReplyQuote({
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const getReplyPreviewText = useReplyPreviewText()
const senderProfile = useMaybeProfileShadow( const senderProfile = useMaybeProfileShadow(
relatedProfiles.get(replyTo.sender.did), relatedProfiles.get(replyTo.sender.did),
@@ -857,22 +859,15 @@ function ReplyQuote({
let text: string let text: string
let subtle = false let subtle = false
if (isBlocked) { if (isBlocked) {
text = l`Blocked message hidden` text = l({
message: '(blocked message hidden)',
comment: 'A reply summary in chat',
})
subtle = true subtle = true
} else if (ChatBskyConvoDefs.isMessageView(replyTo)) { } else if (ChatBskyConvoDefs.isMessageView(replyTo)) {
text = replyTo.text ;({text, subtle} = getReplyPreviewText(replyTo))
if (!text.trim()) {
subtle = true
if (ChatBskyEmbedJoinLink.isView(replyTo.embed)) {
text = l`(chat invite link)`
} else if (AppBskyEmbedRecord.isView(replyTo.embed)) {
text = l`(contains embedded content)`
} else { } else {
text = l`No text` text = l({message: '(deleted message)', comment: 'A reply summary in chat'})
}
}
} else {
text = l`Deleted message`
subtle = true subtle = true
} }
@@ -888,6 +883,8 @@ function ReplyQuote({
a.mb_xs, a.mb_xs,
a.rounded_md, a.rounded_md,
a.p_sm, a.p_sm,
// The padding above is a little loose, so we tighten it up here.
{paddingTop: tokens.space.sm - 2},
a.flex_col, a.flex_col,
a.align_start, a.align_start,
a.border, a.border,
-3
View File
@@ -77,9 +77,6 @@ let MessageItemEmbed = ({
minWidth: 280, minWidth: 280,
maxWidth: 360, maxWidth: 360,
}), }),
// Cancel out the embed's internal a.mt_sm so the container's
// CLUSTERED_MESSAGE_GAP (2px) is the only spacing applied
{marginTop: -a.mt_sm.marginTop},
]}> ]}>
<Animated.View <Animated.View
style={[a.rounded_xl, a.overflow_hidden, radiiStyle, highlightStyle]}> style={[a.rounded_xl, a.overflow_hidden, radiiStyle, highlightStyle]}>
+112
View File
@@ -0,0 +1,112 @@
import {
AppBskyEmbedExternal,
AppBskyEmbedRecord,
type ChatBskyConvoDefs,
ChatBskyEmbedJoinLink,
ChatBskyGroupDefs,
} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {BSKY_APP_HOST, toShortUrl} from '#/lib/strings/url-helpers'
/**
* Describes the embed of a quoted message that has no text of its own, so the
* reply preview can show what was shared instead of a generic placeholder. For
* a link card we surface the URI directly; otherwise we classify the quoted
* record (post/feed/list/etc.) so the caller can render a translated label.
*/
type ReplyEmbedSummary =
| {type: 'external'; uri: string}
| {type: 'post'}
| {type: 'unknown'}
function summarizeReplyEmbed(
embed: ChatBskyConvoDefs.MessageView['embed'],
): ReplyEmbedSummary {
if (!AppBskyEmbedRecord.isView(embed)) return {type: 'unknown'}
const {record} = embed
if (AppBskyEmbedRecord.isViewRecord(record)) {
const inner = record.embeds?.[0]
if (AppBskyEmbedExternal.isView(inner)) {
return {type: 'external', uri: inner.external.uri}
}
return {type: 'post'}
}
return {type: 'unknown'}
}
/**
* Returns a formatter that computes the preview text for a message being quoted
* in a reply, shared between the staged-reply composer and the sent reply
* bubble so the two stay in sync. When the message has its own text we use it
* verbatim; otherwise we summarize the embed.
*
* `subtle` indicates the text is a placeholder (not real message content), so
* callers can render it in a muted/italic style.
*/
export function useReplyPreviewText(): (
message: ChatBskyConvoDefs.MessageView,
) => {text: string; subtle: boolean} {
const {t: l} = useLingui()
return (message: ChatBskyConvoDefs.MessageView) => {
const text = message.text
if (text.trim()) {
return {text, subtle: false}
}
if (ChatBskyEmbedJoinLink.isView(message.embed)) {
const {joinLinkPreview} = message.embed
if (ChatBskyGroupDefs.isJoinLinkPreviewView(joinLinkPreview)) {
return {
text: `${BSKY_APP_HOST}/chat/${joinLinkPreview.code}`,
subtle: true,
}
}
if (ChatBskyGroupDefs.isDisabledJoinLinkPreviewView(joinLinkPreview)) {
return {
text: l({
message: '(disabled chat invite link)',
comment: 'A reply summary in chat',
}),
subtle: true,
}
}
if (ChatBskyGroupDefs.isInvalidJoinLinkPreviewView(joinLinkPreview)) {
return {
text: l({
message: '(invalid chat invite link)',
comment: 'A reply summary in chat',
}),
subtle: true,
}
}
return {
text: l({
message: '(chat invite link)',
comment: 'A reply summary in chat',
}),
subtle: true,
}
}
const summary = summarizeReplyEmbed(message.embed)
switch (summary.type) {
case 'external':
return {text: toShortUrl(summary.uri), subtle: true}
case 'post':
return {
text: l({
message: '(quoted post)',
comment: 'A reply summary in chat',
}),
subtle: true,
}
default:
return {
text: l({message: '(no text)', comment: 'A reply summary in chat'}),
subtle: true,
}
}
}
}
@@ -16,24 +16,41 @@ export function DateField({
value, value,
inputRef, inputRef,
onChangeDate, onChangeDate,
onConfirm,
placeholder,
label, label,
isInvalid, isInvalid,
testID, testID,
accessibilityHint, accessibilityHint,
maximumDate, maximumDate,
minimumDate,
}: DateFieldProps) { }: DateFieldProps) {
const {i18n} = useLingui() const {i18n} = useLingui()
const t = useTheme() const t = useTheme()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
/*
* The picker requires a valid date, so when value is empty we open at
* maximumDate (if set) or today. Normalize through toSimpleDateString so a
* date-only value is parsed as UTC midnight, consistent with the picker's
* timeZoneOffsetInMinutes={0} and the maximumDate below.
*/
const initialDate =
value === ''
? maximumDate
? new Date(toSimpleDateString(maximumDate))
: new Date()
: new Date(toSimpleDateString(value))
const onChangeInternal = useCallback( const onChangeInternal = useCallback(
(date: Date) => { (date: Date) => {
setOpen(false) setOpen(false)
const formatted = toSimpleDateString(date) const formatted = toSimpleDateString(date)
onChangeDate(formatted) onChangeDate(formatted)
onConfirm?.(formatted)
}, },
[onChangeDate, setOpen], [onChangeDate, onConfirm, setOpen],
) )
useImperativeHandle( useImperativeHandle(
@@ -63,6 +80,7 @@ export function DateField({
<DateFieldButton <DateFieldButton
label={label} label={label}
value={value} value={value}
placeholder={placeholder}
onPress={onPress} onPress={onPress}
isInvalid={isInvalid} isInvalid={isInvalid}
accessibilityHint={accessibilityHint} accessibilityHint={accessibilityHint}
@@ -77,7 +95,7 @@ export function DateField({
theme={t.scheme} theme={t.scheme}
// @ts-ignore TODO // @ts-ignore TODO
buttonColor={t.name === 'light' ? '#000000' : '#ffffff'} buttonColor={t.name === 'light' ? '#000000' : '#ffffff'}
date={new Date(value)} date={initialDate}
onConfirm={onChangeInternal} onConfirm={onChangeInternal}
onCancel={onCancel} onCancel={onCancel}
mode="date" mode="date"
@@ -90,6 +108,9 @@ export function DateField({
maximumDate={ maximumDate={
maximumDate ? new Date(toSimpleDateString(maximumDate)) : undefined maximumDate ? new Date(toSimpleDateString(maximumDate)) : undefined
} }
minimumDate={
minimumDate ? new Date(toSimpleDateString(minimumDate)) : undefined
}
/> />
)} )}
</> </>
@@ -14,12 +14,14 @@ import {Text} from '#/components/Typography'
export function DateFieldButton({ export function DateFieldButton({
label, label,
value, value,
placeholder,
onPress, onPress,
isInvalid, isInvalid,
accessibilityHint, accessibilityHint,
}: { }: {
label: string label: string
value: string | Date value: string | Date
placeholder?: string
onPress: () => void onPress: () => void
isInvalid?: boolean isInvalid?: boolean
accessibilityHint?: string accessibilityHint?: string
@@ -78,20 +80,18 @@ export function DateFieldButton({
a.align_center, a.align_center,
hovered ? chromeHover : {}, hovered ? chromeHover : {},
focused || pressed ? chromeFocus : {}, focused || pressed ? chromeFocus : {},
isInvalid || isInvalid ? chromeError : {}, isInvalid ? chromeError : {},
(isInvalid || isInvalid) && (hovered || focused) isInvalid && (hovered || focused) ? chromeErrorHover : {},
? chromeErrorHover
: {},
]}> ]}>
<TextField.Icon icon={CalendarDays} /> <TextField.Icon icon={CalendarDays} />
<Text <Text
style={[ style={[
a.text_md, a.text_md,
a.pl_xs, a.pl_xs,
t.atoms.text, value === '' ? t.atoms.text_contrast_low : t.atoms.text,
{lineHeight: a.text_md.fontSize * 1.1875}, {lineHeight: a.text_md.fontSize * 1.1875},
]}> ]}>
{i18n.date(value, {timeZone: 'UTC'})} {value === '' ? placeholder : i18n.date(value, {timeZone: 'UTC'})}
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
+39 -4
View File
@@ -1,4 +1,4 @@
import {useCallback, useImperativeHandle} from 'react' import {useCallback, useImperativeHandle, useState} from 'react'
import {Keyboard, View} from 'react-native' import {Keyboard, View} from 'react-native'
import DatePicker from 'react-native-date-picker' import DatePicker from 'react-native-date-picker'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
@@ -28,20 +28,36 @@ export function DateField({
value, value,
inputRef, inputRef,
onChangeDate, onChangeDate,
onConfirm,
placeholder,
testID, testID,
label, label,
isInvalid, isInvalid,
accessibilityHint, accessibilityHint,
maximumDate, maximumDate,
minimumDate,
}: DateFieldProps) { }: DateFieldProps) {
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
const t = useTheme() const t = useTheme()
const control = Dialog.useDialogControl() const control = Dialog.useDialogControl()
/*
* The picker requires a valid date, so when value is empty we fall back to
* maximumDate (if set) or today. Draft state lets the picker scroll even when
* the parent does not echo value back (e.g. a clearable field).
*/
const fallbackDate = maximumDate
? toSimpleDateString(maximumDate)
: toSimpleDateString(new Date())
const [draft, setDraft] = useState(() =>
value === '' ? fallbackDate : toSimpleDateString(value),
)
const onChangeInternal = useCallback( const onChangeInternal = useCallback(
(date: Date | undefined) => { (date: Date | undefined) => {
if (date) { if (date) {
const formatted = toSimpleDateString(date) const formatted = toSimpleDateString(date)
setDraft(formatted)
onChangeDate(formatted) onChangeDate(formatted)
} }
}, },
@@ -53,13 +69,14 @@ export function DateField({
() => ({ () => ({
focus: () => { focus: () => {
Keyboard.dismiss() Keyboard.dismiss()
setDraft(value === '' ? fallbackDate : toSimpleDateString(value))
control.open() control.open()
}, },
blur: () => { blur: () => {
control.close() control.close()
}, },
}), }),
[control], [control, value, fallbackDate],
) )
return ( return (
@@ -67,8 +84,10 @@ export function DateField({
<DateFieldButton <DateFieldButton
label={label} label={label}
value={value} value={value}
placeholder={placeholder}
onPress={() => { onPress={() => {
Keyboard.dismiss() Keyboard.dismiss()
setDraft(value === '' ? fallbackDate : toSimpleDateString(value))
control.open() control.open()
}} }}
isInvalid={isInvalid} isInvalid={isInvalid}
@@ -85,7 +104,7 @@ export function DateField({
<DatePicker <DatePicker
timeZoneOffsetInMinutes={0} timeZoneOffsetInMinutes={0}
theme={t.scheme} theme={t.scheme}
date={new Date(toSimpleDateString(value))} date={new Date(draft)}
onDateChange={onChangeInternal} onDateChange={onChangeInternal}
mode="date" mode="date"
locale={i18n.locale} locale={i18n.locale}
@@ -98,11 +117,27 @@ export function DateField({
? new Date(toSimpleDateString(maximumDate)) ? new Date(toSimpleDateString(maximumDate))
: undefined : undefined
} }
minimumDate={
minimumDate
? new Date(toSimpleDateString(minimumDate))
: undefined
}
/> />
</View> </View>
<Button <Button
label={_(msg`Done`)} label={_(msg`Done`)}
onPress={() => control.close()} onPress={() => {
/*
* Commit the currently shown date even if the user never
* scrolled (onDateChange only fires on scroll). This keeps
* onChangeDate firing alongside onConfirm, matching Android and
* web, so an empty field confirmed without scrolling does not
* report a date via onConfirm while onChangeDate stays silent.
*/
onChangeDate(draft)
onConfirm?.(draft)
control.close()
}}
size="large" size="large"
color="primary" color="primary"
variant="solid"> variant="solid">
+6 -2
View File
@@ -36,11 +36,13 @@ export function DateField({
value, value,
inputRef, inputRef,
onChangeDate, onChangeDate,
onConfirm,
label, label,
isInvalid, isInvalid,
testID, testID,
accessibilityHint, accessibilityHint,
maximumDate, maximumDate,
minimumDate,
}: DateFieldProps) { }: DateFieldProps) {
const handleOnChange = useCallback( const handleOnChange = useCallback(
(e: any) => { (e: any) => {
@@ -49,16 +51,17 @@ export function DateField({
if (date) { if (date) {
const formatted = toSimpleDateString(date) const formatted = toSimpleDateString(date)
onChangeDate(formatted) onChangeDate(formatted)
onConfirm?.(formatted)
} }
}, },
[onChangeDate], [onChangeDate, onConfirm],
) )
return ( return (
<TextField.Root isInvalid={isInvalid}> <TextField.Root isInvalid={isInvalid}>
<TextField.Icon icon={CalendarDays} /> <TextField.Icon icon={CalendarDays} />
<Input <Input
value={toSimpleDateString(value)} value={value === '' ? '' : toSimpleDateString(value)}
inputRef={inputRef as React.Ref<TextInput>} inputRef={inputRef as React.Ref<TextInput>}
label={label} label={label}
onChange={handleOnChange} onChange={handleOnChange}
@@ -66,6 +69,7 @@ export function DateField({
accessibilityHint={accessibilityHint} accessibilityHint={accessibilityHint}
// @ts-expect-error not typed as <input type="date"> even though it is one // @ts-expect-error not typed as <input type="date"> even though it is one
max={maximumDate ? toSimpleDateString(maximumDate) : undefined} max={maximumDate ? toSimpleDateString(maximumDate) : undefined}
min={minimumDate ? toSimpleDateString(minimumDate) : undefined}
/> />
</TextField.Root> </TextField.Root>
) )
+16
View File
@@ -3,12 +3,28 @@ export type DateFieldRef = {
blur: () => void blur: () => void
} }
export type DateFieldProps = { export type DateFieldProps = {
/**
* An empty string renders the placeholder and opens the picker at today (or
* maximumDate, if earlier).
*/
value: string | Date value: string | Date
onChangeDate: (date: string) => void onChangeDate: (date: string) => void
/**
* Fired when the user commits a date: iOS "Done", Android confirm, or web
* input change. Distinct from onChangeDate, which on iOS fires on every
* scroll tick.
*/
onConfirm?: (date: string) => void
/**
* Shown on native when value is empty. Web uses the browser's native date
* placeholder.
*/
placeholder?: string
label: string label: string
inputRef?: React.Ref<DateFieldRef> inputRef?: React.Ref<DateFieldRef>
isInvalid?: boolean isInvalid?: boolean
testID?: string testID?: string
accessibilityHint?: string accessibilityHint?: string
maximumDate?: string | Date maximumDate?: string | Date
minimumDate?: string | Date
} }
+2 -2
View File
@@ -362,14 +362,14 @@ function MutualGroupChat({
<Button <Button
color="negative_subtle" color="negative_subtle"
disabled={isRemovePending} disabled={isRemovePending}
label={l`Kick member`} label={l`Remove member`}
size="small" size="small"
onPress={() => { onPress={() => {
onOptimisticallyRemoveConvo(view.id) onOptimisticallyRemoveConvo(view.id)
removeMembers({members: [profileDid]}) removeMembers({members: [profileDid]})
}}> }}>
<ButtonText> <ButtonText>
<Trans>Kick member</Trans> <Trans>Remove member</Trans>
</ButtonText> </ButtonText>
{isRemovePending ? <ButtonIcon icon={Loader} /> : null} {isRemovePending ? <ButtonIcon icon={Loader} /> : null}
</Button> </Button>
+7 -13
View File
@@ -2,7 +2,7 @@ import {type StyleProp, View, type ViewStyle} from 'react-native'
import {type AppBskyFeedDefs, type ComAtprotoLabelDefs} from '@atproto/api' import {type AppBskyFeedDefs, type ComAtprotoLabelDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro' import {Plural} from '@lingui/react/macro'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
@@ -60,23 +60,17 @@ export function LabelsOnMe({
<ButtonIcon position="left" icon={CircleInfo} /> <ButtonIcon position="left" icon={CircleInfo} />
<ButtonText style={[a.leading_snug]}> <ButtonText style={[a.leading_snug]}>
{type === 'account' ? ( {type === 'account' ? (
<Trans>
<Plural <Plural
value={labels.length} value={labels.length}
one="# label has" one="# account label"
other="# labels have" other="# account labels"
/>{' '} />
been placed on this account
</Trans>
) : ( ) : (
<Trans>
<Plural <Plural
value={labels.length} value={labels.length}
one="# label has" one="# content label"
other="# labels have" other="# content labels"
/>{' '} />
been placed on this content
</Trans>
)} )}
</ButtonText> </ButtonText>
</Button> </Button>
@@ -138,7 +138,7 @@ export function InviteFriendsDialogInner({
return ( return (
<Dialog.ScrollableInner <Dialog.ScrollableInner
label={l`Invite friends`} label={l`Share Profile`}
contentContainerStyle={[a.pt_0, a.px_0]} contentContainerStyle={[a.pt_0, a.px_0]}
header={ header={
<Dialog.Header <Dialog.Header
@@ -153,7 +153,7 @@ export function InviteFriendsDialogInner({
<ButtonText style={[a.text_md]}>{l`Done`}</ButtonText> <ButtonText style={[a.text_md]}>{l`Done`}</ButtonText>
</Button> </Button>
)}> )}>
<Dialog.HeaderText>{l`Invite Friends`}</Dialog.HeaderText> <Dialog.HeaderText>{l`Share Profile`}</Dialog.HeaderText>
</Dialog.Header> </Dialog.Header>
}> }>
<View style={[a.align_center, a.pt_xl, a.px_xl]}> <View style={[a.align_center, a.pt_xl, a.px_xl]}>
@@ -26,8 +26,10 @@ export function ThemePicker({
const labels: Record<InviteThemeKey, string> = { const labels: Record<InviteThemeKey, string> = {
dawn: l`Dawn`, dawn: l`Dawn`,
sunlight: l`Sunlight`,
day: l`Day`, day: l`Day`,
dusk: l`Dusk`, dusk: l`Dusk`,
twilight: l`Twilight`,
night: l`Night`, night: l`Night`,
} }
+9 -2
View File
@@ -3,8 +3,15 @@ import {describe, expect, it} from '@jest/globals'
import {getInviteTheme, INVITE_THEME_KEYS, INVITE_THEMES} from './themes' import {getInviteTheme, INVITE_THEME_KEYS, INVITE_THEMES} from './themes'
describe('invite themes', () => { describe('invite themes', () => {
it('exposes four themes in canonical order', () => { it('exposes six themes in canonical order', () => {
expect(INVITE_THEME_KEYS).toEqual(['dawn', 'day', 'dusk', 'night']) expect(INVITE_THEME_KEYS).toEqual([
'dawn',
'sunlight',
'day',
'dusk',
'twilight',
'night',
])
}) })
it('has light and dark variants for every theme', () => { it('has light and dark variants for every theme', () => {
+45 -1
View File
@@ -5,7 +5,13 @@
* matching primary color; the QR data uses qrPrimary; the avatar handle text * matching primary color; the QR data uses qrPrimary; the avatar handle text
* follows handleColor. * follows handleColor.
*/ */
export type InviteThemeKey = 'dawn' | 'day' | 'dusk' | 'night' export type InviteThemeKey =
| 'dawn'
| 'sunlight'
| 'day'
| 'dusk'
| 'twilight'
| 'night'
export type InviteThemeVariant = { export type InviteThemeVariant = {
/** QR data + eye color */ /** QR data + eye color */
@@ -30,8 +36,10 @@ export type InviteTheme = {
export const INVITE_THEME_KEYS: readonly InviteThemeKey[] = [ export const INVITE_THEME_KEYS: readonly InviteThemeKey[] = [
'dawn', 'dawn',
'sunlight',
'day', 'day',
'dusk', 'dusk',
'twilight',
'night', 'night',
] as const ] as const
@@ -54,6 +62,24 @@ export const INVITE_THEMES: Record<InviteThemeKey, InviteTheme> = {
handleColor: '#ffffff', handleColor: '#ffffff',
}, },
}, },
sunlight: {
key: 'sunlight',
swatch: '#ff8159',
light: {
qrPrimary: '#ff8159',
gradientFrom: '#ffc785',
gradientTo: '#ff8159',
shadowColor: '#ff8159',
handleColor: '#ffffff',
},
dark: {
qrPrimary: '#ff8159',
gradientFrom: '#ffc785',
gradientTo: '#ff8159',
shadowColor: '#ff8159',
handleColor: '#ffffff',
},
},
day: { day: {
key: 'day', key: 'day',
swatch: '#006aff', swatch: '#006aff',
@@ -90,6 +116,24 @@ export const INVITE_THEMES: Record<InviteThemeKey, InviteTheme> = {
handleColor: '#ffffff', handleColor: '#ffffff',
}, },
}, },
twilight: {
key: 'twilight',
swatch: '#8b60f7',
light: {
qrPrimary: '#8b60f7',
gradientFrom: '#8ec1ff',
gradientTo: '#8b60f7',
shadowColor: '#8b60f7',
handleColor: '#ffffff',
},
dark: {
qrPrimary: '#8b60f7',
gradientFrom: '#8ec1ff',
gradientTo: '#8b60f7',
shadowColor: '#8b60f7',
handleColor: '#ffffff',
},
},
night: { night: {
key: 'night', key: 'night',
swatch: '#0048ad', swatch: '#0048ad',
+38 -40
View File
@@ -1,8 +1,7 @@
import {useEffect} from 'react' import {useEffect} from 'react'
import * as Notifications from 'expo-notifications' import * as Notifications from 'expo-notifications'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {CommonActions, useNavigation} from '@react-navigation/native' import {CommonActions, useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
@@ -108,6 +107,7 @@ let lastHandledNotificationDateDedupe = 0
export function useNotificationsHandler() { export function useNotificationsHandler() {
const ax = useAnalytics() const ax = useAnalytics()
// eslint-disable-next-line react-compiler/react-compiler
const logger = ax.logger.useChild(ax.logger.Context.Notifications) const logger = ax.logger.useChild(ax.logger.Context.Notifications)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {currentAccount, accounts} = useSession() const {currentAccount, accounts} = useSession()
@@ -116,7 +116,7 @@ export function useNotificationsHandler() {
const {currentConvoId} = useCurrentConvoId() const {currentConvoId} = useCurrentConvoId()
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements() const closeAllActiveElements = useCloseAllActiveElements()
const {_} = useLingui() const {t: l} = useLingui()
// On Android, we cannot control which sound is used for a notification on Android // On Android, we cannot control which sound is used for a notification on Android
// 28 or higher. Instead, we have to configure a notification channel ahead of time // 28 or higher. Instead, we have to configure a notification channel ahead of time
@@ -129,14 +129,12 @@ export function useNotificationsHandler() {
// NOTE: I don't think that it will retroactively move them into the group // NOTE: I don't think that it will retroactively move them into the group
// if the channels already exist. no big deal imo -sfn // if the channels already exist. no big deal imo -sfn
const CHAT_GROUP = 'chat' const CHAT_GROUP = 'chat'
Notifications.setNotificationChannelGroupAsync(CHAT_GROUP, { void Notifications.setNotificationChannelGroupAsync(CHAT_GROUP, {
name: _(msg`Chat`), name: l`Chat`,
description: _( description: l`You can choose whether chat notifications have sound in the chat settings within the app`,
msg`You can choose whether chat notifications have sound in the chat settings within the app`,
),
}) })
Notifications.setNotificationChannelAsync('chat-messages', { void Notifications.setNotificationChannelAsync('chat-messages', {
name: _(msg`Chat messages - sound`), name: l`Chat messages - sound`,
groupId: CHAT_GROUP, groupId: CHAT_GROUP,
importance: Notifications.AndroidImportance.MAX, importance: Notifications.AndroidImportance.MAX,
sound: 'dm.mp3', sound: 'dm.mp3',
@@ -144,8 +142,8 @@ export function useNotificationsHandler() {
vibrationPattern: [250], vibrationPattern: [250],
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE, lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
}) })
Notifications.setNotificationChannelAsync('chat-messages-muted', { void Notifications.setNotificationChannelAsync('chat-messages-muted', {
name: _(msg`Chat messages - silent`), name: l`Chat messages - silent`,
groupId: CHAT_GROUP, groupId: CHAT_GROUP,
importance: Notifications.AndroidImportance.MAX, importance: Notifications.AndroidImportance.MAX,
sound: null, sound: null,
@@ -154,70 +152,70 @@ export function useNotificationsHandler() {
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE, lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
}) })
Notifications.setNotificationChannelAsync( void Notifications.setNotificationChannelAsync(
'like' satisfies NotificationReason, 'like' satisfies NotificationReason,
{ {
name: _(msg`Likes`), name: l`Likes`,
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( void Notifications.setNotificationChannelAsync(
'repost' satisfies NotificationReason, 'repost' satisfies NotificationReason,
{ {
name: _(msg`Reposts`), name: l`Reposts`,
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( void Notifications.setNotificationChannelAsync(
'reply' satisfies NotificationReason, 'reply' satisfies NotificationReason,
{ {
name: _(msg`Replies`), name: l`Replies`,
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( void Notifications.setNotificationChannelAsync(
'mention' satisfies NotificationReason, 'mention' satisfies NotificationReason,
{ {
name: _(msg`Mentions`), name: l`Mentions`,
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( void Notifications.setNotificationChannelAsync(
'quote' satisfies NotificationReason, 'quote' satisfies NotificationReason,
{ {
name: _(msg`Quotes`), name: l`Quotes`,
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( void Notifications.setNotificationChannelAsync(
'follow' satisfies NotificationReason, 'follow' satisfies NotificationReason,
{ {
name: _(msg`New followers`), name: l`New followers`,
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( void Notifications.setNotificationChannelAsync(
'like-via-repost' satisfies NotificationReason, 'like-via-repost' satisfies NotificationReason,
{ {
name: _(msg`Likes of your reposts`), name: l`Likes of your reposts`,
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( void Notifications.setNotificationChannelAsync(
'repost-via-repost' satisfies NotificationReason, 'repost-via-repost' satisfies NotificationReason,
{ {
name: _(msg`Reposts of your reposts`), name: l`Reposts of your reposts`,
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( void Notifications.setNotificationChannelAsync(
'subscribed-post' satisfies NotificationReason, 'subscribed-post' satisfies NotificationReason,
{ {
name: _(msg`Activity from others`), name: l`Activity from others`,
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
}, [_]) }, [l])
useEffect(() => { useEffect(() => {
const handleNotification = (payload?: NotificationPayload) => { const handleNotification = (payload?: NotificationPayload) => {
@@ -237,7 +235,7 @@ export function useNotificationsHandler() {
const account = accounts.find(a => a.did === payload.recipientDid) const account = accounts.find(a => a.did === payload.recipientDid)
if (account) { if (account) {
onPressSwitchAccount(account, 'Notification') void onPressSwitchAccount(account, 'Notification')
} else { } else {
setShowLoggedOut(true) setShowLoggedOut(true)
} }
@@ -305,10 +303,10 @@ export function useNotificationsHandler() {
} }
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
handleNotification: async e => { handleNotification: e => {
const payload = getNotificationPayload(e) const payload = getNotificationPayload(e)
if (!payload) return DEFAULT_HANDLER_OPTIONS if (!payload) return Promise.resolve(DEFAULT_HANDLER_OPTIONS)
logger.debug('useNotificationsHandler: incoming', {e, payload}) logger.debug('useNotificationsHandler: incoming', {e, payload})
@@ -323,17 +321,17 @@ export function useNotificationsHandler() {
payload.reason === 'chat-removed-from-group' || payload.reason === 'chat-removed-from-group' ||
payload.reason === 'chat-join-request-rejected' || payload.reason === 'chat-join-request-rejected' ||
payload.convoId !== currentConvoId payload.convoId !== currentConvoId
return { return Promise.resolve({
shouldShowList: shouldAlert, shouldShowList: shouldAlert,
shouldShowBanner: shouldAlert, shouldShowBanner: shouldAlert,
shouldPlaySound: false, shouldPlaySound: false,
shouldSetBadge: false, shouldSetBadge: false,
} satisfies Notifications.NotificationBehavior } satisfies Notifications.NotificationBehavior)
} }
// Any notification other than a chat message should invalidate the unread page // Any notification other than a chat message should invalidate the unread page
invalidateCachedUnreadPage() invalidateCachedUnreadPage()
return DEFAULT_HANDLER_OPTIONS return Promise.resolve(DEFAULT_HANDLER_OPTIONS)
}, },
}) })
@@ -363,14 +361,14 @@ export function useNotificationsHandler() {
}) })
invalidateCachedUnreadPage() invalidateCachedUnreadPage()
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all')) void truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
if ( if (
payload.reason === 'mention' || payload.reason === 'mention' ||
payload.reason === 'quote' || payload.reason === 'quote' ||
payload.reason === 'reply' payload.reason === 'reply'
) { ) {
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions')) void truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
} }
logger.debug('Notifications: handleNotification', { logger.debug('Notifications: handleNotification', {
@@ -379,7 +377,7 @@ export function useNotificationsHandler() {
}) })
handleNotification(payload) handleNotification(payload)
Notifications.dismissAllNotificationsAsync() void Notifications.dismissAllNotificationsAsync()
// Also clear the native `lastResponse` cache. Otherwise a subsequent // Also clear the native `lastResponse` cache. Otherwise a subsequent
// `getLastNotificationResponse()` (e.g. on an account-switch remount, // `getLastNotificationResponse()` (e.g. on an account-switch remount,
// which re-runs `handlePushNotificationEntry`) would replay this // which re-runs `handlePushNotificationEntry`) would replay this
+31 -10
View File
@@ -5,7 +5,7 @@ import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants'
import {type CompressedVideo} from './types' import {type CompressedVideo} from './types'
import {extToMime} from './util' import {extToMime} from './util'
const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb const MIN_SIZE_FOR_COMPRESSION_BYTES = 25 * 1024 * 1024 // 25mb
export async function compressVideo( export async function compressVideo(
file: ImagePickerAsset, file: ImagePickerAsset,
@@ -16,20 +16,36 @@ export async function compressVideo(
): Promise<CompressedVideo> { ): Promise<CompressedVideo> {
const {onProgress, signal} = opts || {} const {onProgress, signal} = opts || {}
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
file.mimeType as SupportedMimeTypes,
)
if (file.mimeType === 'image/gif') { if (file.mimeType === 'image/gif') {
// let's hope they're small enough that they don't need compression! // let's hope they're small enough that they don't need compression!
// this compression library doesn't support gifs // this compression library doesn't support gifs
// worst case - server rejects them. I think that's fine -sfn // worst case - server rejects them. I think that's fine -sfn
return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'} return {
uri: file.uri,
size: file.fileSize ?? -1,
mimeType: 'image/gif',
passthroughReason: 'gif',
}
} }
const minimumFileSizeForCompress = isAcceptableFormat // Pre-check the threshold ourselves so we can label the skip in telemetry.
? MIN_SIZE_FOR_COMPRESSION // rnc would do the same skip internally via minimumFileSizeForCompress, but
: 0 // that path is invisible to us.
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
file.mimeType as SupportedMimeTypes,
)
if (
isAcceptableFormat &&
file.fileSize != null &&
file.fileSize < MIN_SIZE_FOR_COMPRESSION_BYTES
) {
return {
uri: file.uri,
size: file.fileSize,
mimeType: file.mimeType ?? 'video/mp4',
passthroughReason: 'below-byte-threshold',
}
}
const compressed = await Video.compress( const compressed = await Video.compress(
file.uri, file.uri,
@@ -37,8 +53,13 @@ export async function compressVideo(
compressionMethod: 'manual', compressionMethod: 'manual',
bitrate: 3_000_000, // 3mbps bitrate: 3_000_000, // 3mbps
maxSize: 1920, maxSize: 1920,
// Force a transcode for unacceptable-format files regardless of size.
// rnc's default minimumFileSizeForCompress would otherwise pass small
// unacceptable-format files through unchanged and the server would
// reject them. Acceptable formats are already short-circuited above so
// they never reach this call.
// WARNING: this ONE SPECIFIC ARG is in MB -sfn // WARNING: this ONE SPECIFIC ARG is in MB -sfn
minimumFileSizeForCompress, minimumFileSizeForCompress: 0,
getCancellationId: id => { getCancellationId: id => {
if (signal) { if (signal) {
signal.addEventListener('abort', () => { signal.addEventListener('abort', () => {
+307 -28
View File
@@ -1,56 +1,335 @@
import {type ImagePickerAsset} from 'expo-image-picker' import {type ImagePickerAsset} from 'expo-image-picker'
import {
ALL_FORMATS,
type AudioCodec,
BlobSource,
BufferTarget,
canEncodeAudio,
canEncodeVideo,
Conversion,
Input,
Mp4OutputFormat,
Output,
type VideoCodec,
WebMOutputFormat,
} from 'mediabunny'
import {VIDEO_MAX_SIZE} from '#/lib/constants' import {VIDEO_MAX_SIZE} from '#/lib/constants'
import {VideoTooLargeError} from '#/lib/media/video/errors' import {VideoTooLargeError} from '#/lib/media/video/errors'
import {logger} from '#/logger'
import {hasWebCodecs} from '#/view/com/composer/videos/metadata'
import {
COMPRESSION_MAX_DIMENSION,
COMPRESSION_MIN_SIZE_BYTES,
COMPRESSION_TARGET_BITRATE,
} from './constants'
import {type CompressedVideo} from './types' import {type CompressedVideo} from './types'
// doesn't actually compress, converts to ArrayBuffer // Codecs to try in order of preference
// avc (H.264) is most compatible, vp9/vp8 are fallbacks for WebM
const VIDEO_CODECS: VideoCodec[] = ['avc', 'hevc', 'vp9', 'vp8']
export async function compressVideo( export async function compressVideo(
asset: ImagePickerAsset, asset: ImagePickerAsset,
_opts?: { opts?: {
signal?: AbortSignal signal?: AbortSignal
onProgress?: (progress: number) => void onProgress?: (progress: number) => void
}, },
): Promise<CompressedVideo> { ): Promise<CompressedVideo> {
const {mimeType, base64} = parseDataUrl(asset.uri) const {onProgress, signal} = opts || {}
const blob = base64ToBlob(base64, mimeType)
const uri = URL.createObjectURL(blob)
logger.debug('compress: starting', {
uri: asset.uri.slice(0, 50),
hasWebCodecs: hasWebCodecs(),
})
const response = await fetch(asset.uri)
const blob = await response.blob()
const isGif = blob.type === 'image/gif'
const hasCodecs = hasWebCodecs()
logger.debug('compress: fetched blob', {
size: blob.size,
mimeType: blob.type,
isGif,
minSizeForCompression: COMPRESSION_MIN_SIZE_BYTES,
})
// Try MediaBunny compression if WebCodecs is available and file is large enough
// Skip GIFs - MediaBunny doesn't support them
let fallbackReason: NonNullable<CompressedVideo['passthroughReason']> | null =
null
if (isGif) {
fallbackReason = 'gif'
} else if (!hasCodecs) {
fallbackReason = 'no-webcodecs'
} else if (blob.size < COMPRESSION_MIN_SIZE_BYTES) {
fallbackReason = 'below-byte-threshold'
} else {
try {
return await doCompression(blob, asset.uri, {onProgress, signal})
} catch (e) {
logger.warn('compress: MediaBunny compression failed, using original', {
safeMessage: e,
})
fallbackReason = 'compress-error-fallback'
}
}
logger.debug('compress: skipping compression', {
hasWebCodecs: hasCodecs,
blobSize: blob.size,
minSize: COMPRESSION_MIN_SIZE_BYTES,
reason: fallbackReason,
})
// No compression path - just return the blob as-is
if (blob.size > VIDEO_MAX_SIZE) { if (blob.size > VIDEO_MAX_SIZE) {
throw new VideoTooLargeError() throw new VideoTooLargeError()
} }
return { return {
uri: asset.uri,
size: blob.size, size: blob.size,
uri,
bytes: await blob.arrayBuffer(), bytes: await blob.arrayBuffer(),
mimeType: blob.type || 'video/mp4',
passthroughReason: fallbackReason,
}
}
async function findEncodableVideoCodec(
width: number,
height: number,
): Promise<{codec: VideoCodec; useWebM: boolean} | null> {
for (const codec of VIDEO_CODECS) {
const canEncode = await canEncodeVideo(codec, {
width,
height,
bitrate: COMPRESSION_TARGET_BITRATE,
})
logger.debug('compress: checking video codec', {
codec,
canEncode,
width,
height,
})
if (canEncode) {
// vp8/vp9 need WebM container, others use MP4
const useWebM = codec === 'vp8' || codec === 'vp9'
return {codec, useWebM}
}
}
return null
}
// Audio codecs to try - aac for MP4, opus for WebM
const AUDIO_CODECS_MP4: AudioCodec[] = ['aac']
const AUDIO_CODECS_WEBM: AudioCodec[] = ['opus', 'vorbis']
async function findEncodableAudioCodec(
audioTrack: Awaited<ReturnType<Input['getPrimaryAudioTrack']>>,
useWebM: boolean,
): Promise<{codec: AudioCodec} | null> {
if (!audioTrack) {
return null
}
// First check if we can decode the source audio
const canDecodeSource = await audioTrack.canDecode()
logger.debug('compress: checking audio source', {
sourceCodec: audioTrack.codec,
canDecode: canDecodeSource,
channels: audioTrack.numberOfChannels,
sampleRate: audioTrack.sampleRate,
})
if (!canDecodeSource) {
return null
}
const codecsToTry = useWebM ? AUDIO_CODECS_WEBM : AUDIO_CODECS_MP4
for (const codec of codecsToTry) {
const canEncode = await canEncodeAudio(codec, {
numberOfChannels: audioTrack.numberOfChannels,
sampleRate: audioTrack.sampleRate,
})
logger.debug('compress: checking audio encode codec', {
codec,
canEncode,
})
if (canEncode) {
return {codec}
}
}
return null
}
async function doCompression(
blob: Blob,
originalUri: string,
opts: {
onProgress?: (progress: number) => void
signal?: AbortSignal
},
): Promise<CompressedVideo> {
const {onProgress, signal} = opts
const input = new Input({
source: new BlobSource(blob),
formats: ALL_FORMATS,
})
// Get video track to determine dimensions for codec check
const videoTrack = await input.getPrimaryVideoTrack()
if (!videoTrack) {
input.dispose()
throw new Error('No video track found')
}
// Get audio track to check if we can encode it
const audioTrack = await input.getPrimaryAudioTrack()
const {width, height} = calculateDimensions(
videoTrack.displayWidth,
videoTrack.displayHeight,
COMPRESSION_MAX_DIMENSION,
)
logger.debug('compress: video dimensions', {
original: {
width: videoTrack.displayWidth,
height: videoTrack.displayHeight,
},
target: {width, height},
audioCodec: audioTrack?.codec,
})
// Find a video codec we can encode with
const codecInfo = await findEncodableVideoCodec(width, height)
if (!codecInfo) {
input.dispose()
throw new Error('No supported video codec available')
}
// Check if we can encode the audio
const audioCodecInfo = await findEncodableAudioCodec(
audioTrack,
codecInfo.useWebM,
)
logger.debug('compress: using codecs', {
video: codecInfo.codec,
audio: audioCodecInfo?.codec ?? 'none',
useWebM: codecInfo.useWebM,
})
const target = new BufferTarget()
const output = new Output({
format: codecInfo.useWebM ? new WebMOutputFormat() : new Mp4OutputFormat(),
target,
})
// If we have audio but can't encode it, bail out and use the original
if (audioTrack && !audioCodecInfo) {
input.dispose()
throw new Error(
`Cannot encode audio codec: ${audioTrack.codec ?? 'unknown'}`,
)
}
const conversion = await Conversion.init({
input,
output,
video: {
codec: codecInfo.codec,
bitrate: COMPRESSION_TARGET_BITRATE,
width,
height,
fit: 'contain',
},
audio: audioCodecInfo ? {codec: audioCodecInfo.codec} : undefined,
})
if (onProgress) {
conversion.onProgress = onProgress
}
if (signal) {
signal.addEventListener(
'abort',
() => {
logger.debug('compress: cancelled')
void conversion.cancel()
},
{once: true},
)
}
logger.debug('compress: starting conversion')
const startTime = performance.now()
try {
await conversion.execute()
} finally {
input.dispose()
}
const elapsed = performance.now() - startTime
const bytes = target.buffer
if (!bytes) {
// mediabunny's BufferTarget reports a null buffer after a successful
// execute(). Should not happen in normal use; recoverable here because
// the outer compressVideo() catches and falls back to the original blob,
// but worth flagging in Sentry so we can chase the root cause.
const err = new Error('Compression produced empty output')
logger.error(err, {})
throw err
}
const mimeType = codecInfo.useWebM ? 'video/webm' : 'video/mp4'
const savedBytes = blob.size - bytes.byteLength
const savedPercent = ((savedBytes / blob.size) * 100).toFixed(1)
logger.debug('compress: completed', {
from: blob.type,
to: mimeType,
originalSize: blob.size,
compressedSize: bytes.byteLength,
savedBytes,
savedPercent: `${savedPercent}%`,
elapsedMs: Math.round(elapsed),
})
if (bytes.byteLength > VIDEO_MAX_SIZE) {
throw new VideoTooLargeError()
}
return {
uri: originalUri,
size: bytes.byteLength,
bytes,
mimeType, mimeType,
} }
} }
function parseDataUrl(dataUrl: string) { function calculateDimensions(
const [mimeType, base64] = dataUrl.slice('data:'.length).split(';base64,') width: number,
if (!mimeType || !base64) { height: number,
throw new Error('Invalid data URL') maxDimension: number,
} ): {width: number; height: number} {
return {mimeType, base64} const maxSide = Math.max(width, height)
if (maxSide <= maxDimension) {
return {width, height}
} }
function base64ToBlob(base64: string, mimeType: string) { const scale = maxDimension / maxSide
const byteCharacters = atob(base64) return {
const byteArrays = [] width: Math.round(width * scale),
height: Math.round(height * scale),
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i)
} }
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
}
return new Blob(byteArrays, {type: mimeType})
} }
+8
View File
@@ -0,0 +1,8 @@
// Shared compression knobs. Mirrored between native (compress.ts) and web
// (compress.web.ts) so both platforms produce videos with the same target.
export const COMPRESSION_TARGET_BITRATE = 3_000_000 // 3 Mbps
export const COMPRESSION_MAX_DIMENSION = 1920
// Web skips compression entirely for files under this size; native applies its
// own threshold logic inside expo-bluesky-video-compress's probe step.
export const COMPRESSION_MIN_SIZE_BYTES = 25_000_000
+271
View File
@@ -0,0 +1,271 @@
import {Platform} from 'react-native'
import {type ImagePickerAsset} from 'expo-image-picker'
import {nanoid} from 'nanoid/non-secure'
import {type VideoCompressSkipReason} from '#/lib/media/video/types'
import {Sentry} from '#/logger/sentry/lib'
import {type Metrics} from '#/analytics/metrics'
type MetricFn = <E extends keyof Metrics>(event: E, payload: Metrics[E]) => void
// Identifies the active compression engine. Bumped when the engine swaps.
// Versions are intentionally hardcoded so a dependency bump shows up in
// analytics as a label change.
const COMPRESS_ENGINE =
Platform.OS === 'web'
? 'web:mediabunny@1.25.3'
: 'native:react-native-compressor@1.13.0'
type Phase = 'compress' | 'upload' | 'processing'
function errorClass(e: unknown): string {
if (e instanceof Error) return e.name || 'Error'
return 'Unknown'
}
export type VideoTelemetry = {
readonly uploadId: string
readonly engine: string
picked: () => void
compressStarted: () => void
compressSkipped: (video: {
size: number
mimeType: string
skipReason: VideoCompressSkipReason
}) => void
compressCompleted: (video: {size: number; mimeType: string}) => void
compressFailed: (e: unknown) => void
uploadStarted: (bytes: number) => void
uploadCompleted: (jobId: string) => void
uploadFailed: (e: unknown) => void
processingStarted: (jobId: string) => void
processingCompleted: () => void
processingFailed: (e: unknown) => void
published: () => void
}
export function createVideoTelemetry({
asset,
signal,
metric,
}: {
asset: ImagePickerAsset
signal: AbortSignal
metric: MetricFn
}): VideoTelemetry {
const uploadId = nanoid()
const engine = COMPRESS_ENGINE
const startedAt = Date.now()
let phase: Phase | undefined
let phaseStartedAt = startedAt
let jobId: string | undefined
let uploadBytes: number | undefined
let txnEnded = false
let abortBound = true
// Parent span: full selection->ready arc. Inactive so phase spans can be
// attached as children regardless of the current async context.
const txn = Sentry.startInactiveSpan({
name: 'video.upload',
op: 'video.upload',
attributes: {
uploadId,
engine,
'video.source.mime': asset.mimeType ?? 'unknown',
'video.source.bytes': asset.fileSize ?? 0,
'video.source.durationMs': asset.duration ?? 0,
'video.source.width': asset.width ?? 0,
'video.source.height': asset.height ?? 0,
},
})
let phaseSpan: ReturnType<typeof Sentry.startInactiveSpan> | undefined
function endPhaseSpan() {
if (!phaseSpan) return
phaseSpan.end()
phaseSpan = undefined
}
function enterPhase(next: Phase, spanName: string) {
endPhaseSpan()
phase = next
phaseStartedAt = Date.now()
phaseSpan = Sentry.withActiveSpan(txn, () =>
Sentry.startInactiveSpan({
name: spanName,
op: spanName,
attributes: {uploadId, engine},
}),
)
}
function endTxn(outcome: 'ok' | 'error' | 'cancelled') {
if (txnEnded) return
txnEnded = true
endPhaseSpan()
txn.setAttribute('outcome', outcome)
txn.end()
}
function detachAbort() {
if (!abortBound) return
abortBound = false
signal.removeEventListener('abort', onAbort)
}
function onAbort() {
if (phase) {
metric('video:upload:abandoned', {
uploadId,
engine,
phase,
jobId,
elapsedInPhaseMs: Date.now() - phaseStartedAt,
})
}
endTxn('cancelled')
abortBound = false
}
signal.addEventListener('abort', onAbort, {once: true})
return {
uploadId,
engine,
picked() {
metric('video:upload:picked', {
uploadId,
engine,
sourceMimeType: asset.mimeType,
sourceBytes: asset.fileSize,
sourceDurationMs: asset.duration ?? undefined,
sourceWidth: asset.width,
sourceHeight: asset.height,
})
},
compressStarted() {
enterPhase('compress', 'video.compress')
metric('video:upload:compressStarted', {
uploadId,
engine,
sourceBytes: asset.fileSize,
})
},
compressSkipped({size, mimeType, skipReason}) {
metric('video:upload:compressSkipped', {
uploadId,
engine,
skipReason,
bytes: size,
mimeType,
elapsedMs: Date.now() - phaseStartedAt,
})
endPhaseSpan()
phase = undefined
},
compressCompleted({size, mimeType}) {
metric('video:upload:compressCompleted', {
uploadId,
engine,
bytesIn: asset.fileSize,
bytesOut: size,
outputMimeType: mimeType,
elapsedMs: Date.now() - phaseStartedAt,
})
endPhaseSpan()
phase = undefined
},
compressFailed(e) {
metric('video:upload:compressFailed', {
uploadId,
engine,
errorClass: errorClass(e),
elapsedMs: Date.now() - phaseStartedAt,
})
endTxn('error')
detachAbort()
},
uploadStarted(bytes) {
uploadBytes = bytes
enterPhase('upload', 'video.upload.transfer')
metric('video:upload:uploadStarted', {uploadId, engine, bytes})
},
uploadCompleted(id) {
jobId = id
const elapsedMs = Date.now() - phaseStartedAt
const bytes = uploadBytes ?? 0
metric('video:upload:uploadCompleted', {
uploadId,
engine,
jobId: id,
bytes,
elapsedMs,
throughputBytesPerSec:
elapsedMs > 0 ? Math.round((bytes * 1000) / elapsedMs) : 0,
})
endPhaseSpan()
phase = undefined
},
uploadFailed(e) {
metric('video:upload:uploadFailed', {
uploadId,
engine,
bytes: uploadBytes ?? 0,
errorClass: errorClass(e),
elapsedMs: Date.now() - phaseStartedAt,
})
endTxn('error')
detachAbort()
},
processingStarted(id) {
jobId = id
enterPhase('processing', 'video.processing')
metric('video:upload:processingStarted', {uploadId, engine, jobId: id})
},
processingCompleted() {
metric('video:upload:processingCompleted', {
uploadId,
engine,
jobId: jobId ?? '',
elapsedMs: Date.now() - phaseStartedAt,
})
// Upload pipeline is done; publish is a separate user action that
// fires its own event. Releases the parent span so its duration
// measures upload work, not idle composer time.
endTxn('ok')
detachAbort()
},
processingFailed(e) {
metric('video:upload:processingFailed', {
uploadId,
engine,
jobId: jobId ?? '',
errorClass: errorClass(e),
elapsedMs: Date.now() - phaseStartedAt,
})
endTxn('error')
detachAbort()
},
published() {
metric('video:upload:published', {
uploadId,
engine,
jobId: jobId ?? '',
totalElapsedMs: Date.now() - startedAt,
})
},
}
}
+13
View File
@@ -1,7 +1,20 @@
// Why the compress engine returned the input unchanged. Used both as the
// reason on `CompressedVideo.passthroughReason` and as the `skipReason` field
// on the `video:upload:compressSkipped` analytics event, so the two stay in
// sync.
export type VideoCompressSkipReason =
| 'gif'
| 'below-byte-threshold'
| 'no-webcodecs'
| 'compress-error-fallback'
export type CompressedVideo = { export type CompressedVideo = {
uri: string uri: string
mimeType: string mimeType: string
size: number size: number
// web only, can fall back to uri if missing // web only, can fall back to uri if missing
bytes?: ArrayBuffer bytes?: ArrayBuffer
// Set when the engine returned the input unchanged. Undefined means the
// bytes were actually re-encoded.
passthroughReason?: VideoCompressSkipReason
} }
+19 -5
View File
@@ -1,10 +1,24 @@
import {type NavigationState, type PartialState} from '@react-navigation/native' import {type NavigationState, type PartialState} from '@react-navigation/native'
import {type NativeStackNavigationProp} from '@react-navigation/native-stack' import {type NativeStackNavigationProp} from '@react-navigation/native-stack'
import {type SearchFilters} from '#/screens/Search/searchParams'
import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types' import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types'
export type {NativeStackScreenProps} from '@react-navigation/native-stack' export type {NativeStackScreenProps} from '@react-navigation/native-stack'
/**
* The advanced-search filter params are owned by searchParams.ts (the param
* model, serialization, and helpers all live there). Re-export the type so the
* route params stay in sync with it automatically rather than being a second
* hand-maintained copy.
*/
export type SearchFilterParams = SearchFilters
export type SearchParams = {
q?: string
tab?: 'user' | 'profile' | 'feed' | 'latest'
} & SearchFilterParams
export type CommonNavigatorParams = { export type CommonNavigatorParams = {
NotFound: undefined NotFound: undefined
Lists: undefined Lists: undefined
@@ -19,7 +33,7 @@ export type CommonNavigatorParams = {
ProfileFollowers: {name: string} ProfileFollowers: {name: string}
ProfileFollows: {name: string} ProfileFollows: {name: string}
ProfileKnownFollowers: {name: string} ProfileKnownFollowers: {name: string}
ProfileSearch: {name: string; q?: string} ProfileSearch: {name: string} & SearchParams
ProfileList: {name: string; rkey: string} ProfileList: {name: string; rkey: string}
PostThread: {name: string; rkey: string} PostThread: {name: string; rkey: string}
PostLikedBy: {name: string; rkey: string} PostLikedBy: {name: string; rkey: string}
@@ -60,7 +74,7 @@ export type CommonNavigatorParams = {
AppIconSettings: undefined AppIconSettings: undefined
FindContactsSettings: undefined FindContactsSettings: undefined
InviteScanner: undefined InviteScanner: undefined
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'} Search: SearchParams
Hashtag: {tag: string; author?: string} Hashtag: {tag: string; author?: string}
Topic: {topic: string} Topic: {topic: string}
MessagesConversation: {conversation: string; embed?: string; accept?: true} MessagesConversation: {conversation: string; embed?: string; accept?: true}
@@ -98,7 +112,7 @@ export type HomeTabNavigatorParams = CommonNavigatorParams & {
} }
export type SearchTabNavigatorParams = CommonNavigatorParams & { export type SearchTabNavigatorParams = CommonNavigatorParams & {
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'} Search: SearchParams
} }
export type NotificationsTabNavigatorParams = CommonNavigatorParams & { export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
@@ -119,7 +133,7 @@ export type MessagesTabNavigatorParams = CommonNavigatorParams & {
export type FlatNavigatorParams = CommonNavigatorParams & { export type FlatNavigatorParams = CommonNavigatorParams & {
Home: undefined Home: undefined
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'} Search: SearchParams
Feeds: undefined Feeds: undefined
Notifications: undefined Notifications: undefined
Messages: { Messages: {
@@ -133,7 +147,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
HomeTab: undefined HomeTab: undefined
Home: undefined Home: undefined
SearchTab: undefined SearchTab: undefined
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'} Search: SearchParams
Feeds: undefined Feeds: undefined
NotificationsTab: undefined NotificationsTab: undefined
Notifications: undefined Notifications: undefined
+2
View File
@@ -169,6 +169,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
return AppLanguage.ast return AppLanguage.ast
case 'ca': case 'ca':
return AppLanguage.ca return AppLanguage.ca
case 'cs':
return AppLanguage.cs
case 'cy': case 'cy':
return AppLanguage.cy return AppLanguage.cy
case 'da': case 'da':
+11
View File
@@ -17,6 +17,7 @@ import {AppLanguage} from '#/locale/languages'
import {messages as messagesAn} from '#/locale/locales/an/messages' import {messages as messagesAn} from '#/locale/locales/an/messages'
import {messages as messagesAst} from '#/locale/locales/ast/messages' import {messages as messagesAst} from '#/locale/locales/ast/messages'
import {messages as messagesCa} from '#/locale/locales/ca/messages' import {messages as messagesCa} from '#/locale/locales/ca/messages'
import {messages as messagesCs} from '#/locale/locales/cs/messages'
import {messages as messagesCy} from '#/locale/locales/cy/messages' import {messages as messagesCy} from '#/locale/locales/cy/messages'
import {messages as messagesDa} from '#/locale/locales/da/messages' import {messages as messagesDa} from '#/locale/locales/da/messages'
import {messages as messagesDe} from '#/locale/locales/de/messages' import {messages as messagesDe} from '#/locale/locales/de/messages'
@@ -92,6 +93,16 @@ export async function dynamicActivate(locale: AppLanguage) {
]) ])
return dateLocale return dateLocale
} }
case AppLanguage.cs: {
i18n.loadAndActivate({locale, messages: messagesCs})
const [dateLocale] = await Promise.all([
import('date-fns/locale/cs').then(m => m.cs),
import('@formatjs/intl-pluralrules/locale-data/cs.js'),
import('@formatjs/intl-numberformat/locale-data/cs.js'),
import('@formatjs/intl-displaynames/locale-data/cs.js'),
])
return dateLocale
}
case AppLanguage.cy: { case AppLanguage.cy: {
i18n.loadAndActivate({locale, messages: messagesCy}) i18n.loadAndActivate({locale, messages: messagesCy})
const [dateLocale] = await Promise.all([ const [dateLocale] = await Promise.all([
+7
View File
@@ -36,6 +36,13 @@ export async function dynamicActivate(locale: AppLanguage) {
]) ])
break break
} }
case AppLanguage.cs: {
;[messages, dateLocale] = await Promise.all([
import(`./locales/cs/messages`).then(m => m.messages),
import('date-fns/locale/cs').then(m => m.cs),
])
break
}
case AppLanguage.cy: { case AppLanguage.cy: {
;[messages, dateLocale] = await Promise.all([ ;[messages, dateLocale] = await Promise.all([
import(`./locales/cy/messages`).then(m => m.messages), import(`./locales/cy/messages`).then(m => m.messages),
+2
View File
@@ -9,6 +9,7 @@ export enum AppLanguage {
an = 'an', an = 'an',
ast = 'ast', ast = 'ast',
ca = 'ca', ca = 'ca',
cs = 'cs',
cy = 'cy', cy = 'cy',
da = 'da', da = 'da',
de = 'de', de = 'de',
@@ -58,6 +59,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.an, name: 'aragonés – Aragonese'}, {code2: AppLanguage.an, name: 'aragonés – Aragonese'},
{code2: AppLanguage.ast, name: 'asturianu – Asturian'}, {code2: AppLanguage.ast, name: 'asturianu – Asturian'},
{code2: AppLanguage.ca, name: 'català – Catalan'}, {code2: AppLanguage.ca, name: 'català – Catalan'},
{code2: AppLanguage.cs, name: 'čeština – Czech'},
{code2: AppLanguage.cy, name: 'Cymraeg – Welsh'}, {code2: AppLanguage.cy, name: 'Cymraeg – Welsh'},
{code2: AppLanguage.da, name: 'dansk – Danish'}, {code2: AppLanguage.da, name: 'dansk – Danish'},
{code2: AppLanguage.de, name: 'Deutsch – German'}, {code2: AppLanguage.de, name: 'Deutsch – German'},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More