diff --git a/.github/workflows/build-and-push-bskyweb-aws.yaml b/.github/workflows/build-and-push-bskyweb-aws.yaml
index f6d7949825..4148640240 100644
--- a/.github/workflows/build-and-push-bskyweb-aws.yaml
+++ b/.github/workflows/build-and-push-bskyweb-aws.yaml
@@ -28,7 +28,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: 🔑 Log into registry ${{ env.REGISTRY }}
- uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
+ uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
diff --git a/.github/workflows/build-and-push-bskyweb-ghcr.yaml b/.github/workflows/build-and-push-bskyweb-ghcr.yaml
index 15e1da8a59..c905a92602 100644
--- a/.github/workflows/build-and-push-bskyweb-ghcr.yaml
+++ b/.github/workflows/build-and-push-bskyweb-ghcr.yaml
@@ -29,7 +29,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: 🔑 Log into registry ${{ env.REGISTRY }}
- uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
+ uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME }}
diff --git a/.github/workflows/build-and-push-embedr-aws.yaml b/.github/workflows/build-and-push-embedr-aws.yaml
index 5b7193cf4e..d5df3eda26 100644
--- a/.github/workflows/build-and-push-embedr-aws.yaml
+++ b/.github/workflows/build-and-push-embedr-aws.yaml
@@ -28,7 +28,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: 🔑 Log into registry ${{ env.REGISTRY }}
- uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
+ uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
diff --git a/.github/workflows/build-and-push-link-aws.yaml b/.github/workflows/build-and-push-link-aws.yaml
index 5f3daafd7c..04cb0c48be 100644
--- a/.github/workflows/build-and-push-link-aws.yaml
+++ b/.github/workflows/build-and-push-link-aws.yaml
@@ -28,7 +28,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: 🔑 Log into registry ${{ env.REGISTRY }}
- uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
+ uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
diff --git a/.github/workflows/build-and-push-ogcard-aws.yaml b/.github/workflows/build-and-push-ogcard-aws.yaml
index 7c7b228e49..08c49f3227 100644
--- a/.github/workflows/build-and-push-ogcard-aws.yaml
+++ b/.github/workflows/build-and-push-ogcard-aws.yaml
@@ -28,7 +28,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: 🔑 Log into registry ${{ env.REGISTRY }}
- uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
+ uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
diff --git a/.github/workflows/bundle-deploy-eas-update.yml b/.github/workflows/bundle-deploy-eas-update.yml
index 26581e597c..4bb8e7c795 100644
--- a/.github/workflows/bundle-deploy-eas-update.yml
+++ b/.github/workflows/bundle-deploy-eas-update.yml
@@ -253,7 +253,7 @@ jobs:
- name: ☁️ Configure AWS credentials (denis)
if: ${{ !steps.fingerprint.outputs.includes-changes &&
!steps.version.outputs.version-changed }}
- uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2
+ uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
role-to-assume: arn:aws:iam::007404326489:role/denis-ci-publish
aws-region: us-east-2
diff --git a/.github/workflows/claude-mention.yml b/.github/workflows/claude-mention.yml
index c4e4cbcb7d..5ddf02b157 100644
--- a/.github/workflows/claude-mention.yml
+++ b/.github/workflows/claude-mention.yml
@@ -59,13 +59,13 @@ jobs:
fetch-depth: 1
- name: ☁️ Configure AWS credentials (OIDC)
- uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2
+ uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
aws-region: us-east-2
- name: 🤖 Claude
- uses: anthropics/claude-code-action@b76a0776ae74036e77cd11018083743453d7ad35 # v1.0.179
+ uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1.0.183
with:
use_bedrock: 'true'
additional_permissions: |
diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml
index de15b75fed..bcb9a09e3c 100644
--- a/.github/workflows/claude-review.yml
+++ b/.github/workflows/claude-review.yml
@@ -45,13 +45,13 @@ jobs:
fetch-depth: 1
- name: ☁️ Configure AWS credentials (OIDC)
- uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2
+ uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
aws-region: us-east-2
- name: 🤖 Claude review
- uses: anthropics/claude-code-action@b76a0776ae74036e77cd11018083743453d7ad35 # v1.0.179
+ uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1.0.183
with:
use_bedrock: 'true'
additional_permissions: |
diff --git a/.github/workflows/nightly-update-source-languages.yaml b/.github/workflows/nightly-update-source-languages.yaml
index e11ff6b7d4..2fb3fffd78 100644
--- a/.github/workflows/nightly-update-source-languages.yaml
+++ b/.github/workflows/nightly-update-source-languages.yaml
@@ -35,7 +35,7 @@ jobs:
commit_message: Nightly source-language update
file_pattern: ./src/locale/locales/en/messages.po
- name: 🚀 Push source lang to Crowdin
- uses: crowdin/github-action@e0c8f73cdc0fafde9396e056c5038217000a32d1 # v2.16.4
+ uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
with:
upload_sources: true
upload_sources_args: "-b main"
diff --git a/.github/workflows/pull-request-commit.yml b/.github/workflows/pull-request-commit.yml
index afa3d4f46c..16566672a6 100644
--- a/.github/workflows/pull-request-commit.yml
+++ b/.github/workflows/pull-request-commit.yml
@@ -185,7 +185,7 @@ jobs:
- name: 📷 Check fingerprint and install dependencies
id: fingerprint
timeout-minutes: 5
- uses: bluesky-social/github-actions/fingerprint-native@b5556913e4aef3964cfd5936d0add3fc0d809bdb # v0.2.0
+ uses: bluesky-social/github-actions/fingerprint-native@abc6a46eb4badf243f55bfd7d6cec42722456300 # v0.3.0
with:
profile: pull-request
@@ -275,6 +275,10 @@ jobs:
permissions:
id-token: write
contents: read
+ outputs:
+ release-version: ${{ steps.env.outputs.release-version }}
+ ios-build-number: ${{ steps.build-info.outputs.BSKY_IOS_BUILD_NUMBER }}
+ android-build-number: ${{ steps.build-info.outputs.BSKY_ANDROID_VERSION_CODE }}
steps:
- name: ⬇️ Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -308,7 +312,7 @@ jobs:
pnpm export
- name: ☁️ Configure AWS credentials (denis, PR-scoped)
- uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2
+ uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
role-to-assume: arn:aws:iam::007404326489:role/denis-ci-publish-pr
aws-region: us-east-2
@@ -342,11 +346,18 @@ jobs:
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
+ - name: 🔢 Get native build numbers
+ id: build-info
+ run: bash scripts/setGitHubOutput.sh
+
- name: 🚀 Publish OTA to denis (S3)
run: pnpm use-build-number bash scripts/denisPublish.sh
env:
RUNTIME_VERSION: ''
CHANNEL_NAME: pull-request-${{ github.event.pull_request.number }}
+ # Pin the publish to the same values exposed in the install link.
+ BSKY_IOS_BUILD_NUMBER: ${{ steps.build-info.outputs.BSKY_IOS_BUILD_NUMBER }}
+ BSKY_ANDROID_VERSION_CODE: ${{ steps.build-info.outputs.BSKY_ANDROID_VERSION_CODE }}
comment-pr-ota:
name: Comment PR OTA install link
@@ -362,6 +373,6 @@ jobs:
message: |
The OTA deployment for this PR was successful! You may now apply it by either scanning the QR code or opening the deep link below in your browser:
-
+
- `bluesky://intent/apply-ota?channel=pull-request-${{ github.event.pull_request.number }}`
+ `bluesky://intent/apply-ota?channel=pull-request-${{ github.event.pull_request.number }}&releaseVersion=${{ needs.publish-pr-ota.outputs.release-version }}&iosBuildNumber=${{ needs.publish-pr-ota.outputs.ios-build-number }}&androidBuildNumber=${{ needs.publish-pr-ota.outputs.android-build-number }}`
diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml
index 12ff6ed2c7..dac6025ee2 100644
--- a/.github/workflows/zizmor.yml
+++ b/.github/workflows/zizmor.yml
@@ -26,7 +26,7 @@ jobs:
persist-credentials: false
- name: 🛡️ Run zizmor
- uses: zizmorcore/zizmor-action@6599ee8b7a49aef6a770f63d261d214911a7ce02 # v0.6.0
+ uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
with:
# Annotate the PR directly instead of uploading SARIF to the
# security tab, and fail the check on any finding
diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json
index 996b647227..2bc0ab246f 100644
--- a/oxlint-suppressions.json
+++ b/oxlint-suppressions.json
@@ -707,7 +707,7 @@
"count": 1
},
"typescript/no-misused-promises": {
- "count": 4
+ "count": 3
}
},
"src/lib/hooks/usePermissions.ts": {
@@ -933,17 +933,6 @@
"count": 1
}
},
- "src/screens/Onboarding/StepInterests/index.tsx": {
- "typescript/no-explicit-any": {
- "count": 1
- },
- "typescript/no-misused-promises": {
- "count": 1
- },
- "typescript/require-await": {
- "count": 1
- }
- },
"src/screens/Onboarding/StepProfile/index.tsx": {
"typescript/no-floating-promises": {
"count": 2
@@ -1216,11 +1205,6 @@
"count": 1
}
},
- "src/screens/StarterPack/Wizard/StepProfiles.tsx": {
- "typescript/no-misused-promises": {
- "count": 1
- }
- },
"src/screens/StarterPack/Wizard/index.tsx": {
"typescript/no-floating-promises": {
"count": 2
@@ -1643,31 +1627,6 @@
"count": 2
}
},
- "src/view/com/notifications/NotificationFeed.tsx": {
- "typescript/no-explicit-any": {
- "count": 2
- },
- "typescript/no-floating-promises": {
- "count": 1
- },
- "typescript/no-misused-promises": {
- "count": 2
- },
- "typescript/no-unsafe-member-access": {
- "count": 6
- }
- },
- "src/view/com/notifications/NotificationFeedItem.tsx": {
- "typescript/no-explicit-any": {
- "count": 3
- },
- "typescript/no-misused-promises": {
- "count": 3
- },
- "typescript/no-unsafe-member-access": {
- "count": 2
- }
- },
"src/view/com/pager/Pager.tsx": {
"typescript/no-explicit-any": {
"count": 4
diff --git a/package.json b/package.json
index 54feea61ef..f49c831c97 100644
--- a/package.json
+++ b/package.json
@@ -40,7 +40,7 @@
"prepare": "is-ci || husky",
"postinstall": "pnpm intl:compile-if-needed",
"prebuild": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean",
- "android": "expo run:android",
+ "android": "expo run:android --variant debugOptimized",
"android:prod": "expo run:android --variant release",
"android:profile": "BSKY_PROFILE=1 expo run:android --variant release",
"ios": "expo run:ios",
@@ -96,7 +96,7 @@
"prettier": "prettier --check ."
},
"dependencies": {
- "@atproto/api": "0.20.33",
+ "@atproto/api": "0.20.37",
"@atproto/common-web": "0.5.6",
"@atproto/syntax": "0.7.2",
"@bitdrift/react-native": "^0.6.8",
@@ -107,7 +107,7 @@
"@bsky.app/expo-image-crop-tool": "^0.5.1",
"@bsky.app/expo-scroll-edge-effect": "^0.1.9",
"@bsky.app/expo-translate-text": "^0.2.9",
- "@bsky.app/peek-menu": "^0.3.1",
+ "@bsky.app/peek-menu": "^0.3.2",
"@bsky.app/react-native-uitextview": "^2.5.0",
"@bsky.app/sift": "^0.3.9",
"@bsky.app/tapper": "^0.6.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 86c3430c04..7952820f4d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -242,8 +242,8 @@ importers:
.:
dependencies:
'@atproto/api':
- specifier: 0.20.33
- version: 0.20.33
+ specifier: 0.20.37
+ version: 0.20.37
'@atproto/common-web':
specifier: 0.5.6
version: 0.5.6
@@ -275,8 +275,8 @@ importers:
specifier: ^0.2.9
version: 0.2.9(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/peek-menu':
- specifier: ^0.3.1
- version: 0.3.1(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
+ specifier: ^0.3.2
+ version: 0.3.2(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/react-native-uitextview':
specifier: ^2.5.0
version: 2.5.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -871,32 +871,48 @@ packages:
graphql:
optional: true
- '@atproto/api@0.20.33':
- resolution: {integrity: sha512-3YpnBVMieQFWetLvqibn2yG6vhNZ7ozSi/EUujuGcvYXt9g/NrvmHufaUtrXCasp2mHDI7t7UG5sLEBg6YNEJw==}
+ '@atproto/api@0.20.37':
+ resolution: {integrity: sha512-TEt8oKMbuC+1GvqyLKDBLqmptnk1mh52FdHPQEomlPIWxh88YcNnk0g0iKNVBevaeeLtWZF1a7Am/t/q255u5Q==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.6':
resolution: {integrity: sha512-5Y4MIK9dpkJPiKiE6u7iEHitxj+g3aAU2GfGL686JlKE2zDKD4y18BJb+uVek6nXQKb5XOdNVvw+7BHarpn8Fw==}
engines: {node: '>=22'}
+ '@atproto/common-web@0.5.8':
+ resolution: {integrity: sha512-GiYY2Jgbg1aWe9QGT3TswC/wZNicipBDz0nFo4FrP6XqENEJJRxGKAf/zjvqbesfIQFhHXi1VbSFY7GStB8v8w==}
+ engines: {node: '>=22'}
+
'@atproto/lex-data@0.1.5':
resolution: {integrity: sha512-TEM6GHuYpNm4O90LjNgbYq1Gmcr875S+BHrDxkg4PB5w/nlsz4HlbkGQG/WP/xbIV5O8TF5nNHDpCZ5ezTRrFA==}
engines: {node: '>=22'}
+ '@atproto/lex-data@0.1.7':
+ resolution: {integrity: sha512-kW/dPLqo/WgCLV+XESR4JKwV6c1rZWJGOfuPupZGTjEDAKoBbKXdaEzX9/1vKQYbZ9U3j0DS/n7OFFK7wBugyQ==}
+ engines: {node: '>=22'}
+
'@atproto/lex-json@0.1.4':
resolution: {integrity: sha512-ENR2cWkVrES+UL6TovbCRdX9BJOyHHJUS8jYx3Lxp3j4vEphjn/u+DW7bWloST2O8ID2OuVlt6+28ftNEEjmQQ==}
engines: {node: '>=22'}
- '@atproto/lexicon@0.7.7':
- resolution: {integrity: sha512-92VH2oEsJdrIVNy7WY8rGn99ANNVglyUffoN7GJc0mxKi+fXN5iVlJ2cOyTfYABhr2ldSiptZWXEPEdBaIR9/A==}
+ '@atproto/lex-json@0.1.6':
+ resolution: {integrity: sha512-mvrAd0lbyuecIHjyld8QN6MN6CBf4j0GCxLzegsvLh0SvDf+GbYWklkcQqmITL44yFQOwmA/QNIQj0Uvh7+R/g==}
+ engines: {node: '>=22'}
+
+ '@atproto/lexicon@0.7.10':
+ resolution: {integrity: sha512-HH/3d3z5Qt0JoJ9iOuLP/YT++6lb5wWMQsvgQ2TGfgkbuzDQ6AX6X1H5uJ9luPci4/LaADfpwLZfeqyy/Hjfaw==}
engines: {node: '>=22'}
'@atproto/syntax@0.7.2':
resolution: {integrity: sha512-tZ1Tr0R9pK4bI4Zs69t29cjMlCFQvRNBeNkqJC5pGeNuCt64D0eoF7s/AlqeVmYppClmQ0xJquggGgsMDP6j7w==}
engines: {node: '>=22'}
- '@atproto/xrpc@0.8.6':
- resolution: {integrity: sha512-yVfKrlwZBBm44Ft9jDvHcTCwQ1ElqSlzXHWhT/KcqE+u24EAhWVFosfABfGbUByB/PmN8eLJ06FsWo5pUQcMNQ==}
+ '@atproto/syntax@0.7.3':
+ resolution: {integrity: sha512-7LU8Ra79L5wCqVAZZst2352VMMwkU4KNagVMm6pnURrY/uS7+HrJl3XCuA4V2Gy2dEpkt1r6DBxlsP43/VjDBw==}
+ engines: {node: '>=22'}
+
+ '@atproto/xrpc@0.8.9':
+ resolution: {integrity: sha512-3jRiAuHCqYFoT8Nv25xFRmgrfmoo84yBnB5PfVP95JoRP+szIkwHS6mq72ZETYbWmLA1tNyR5QVEHrhHZ1LQhw==}
engines: {node: '>=22'}
'@babel/code-frame@7.10.4':
@@ -1653,8 +1669,8 @@ packages:
react: '*'
react-native: '*'
- '@bsky.app/peek-menu@0.3.1':
- resolution: {integrity: sha512-ttFE7dIv1EJEmQn52WLvievzLNmPNNvPtoiz8ovl38jXLDMszL2wVPUYMnEjnZZqoq+K6HiBau9iHeCOKWwbTA==}
+ '@bsky.app/peek-menu@0.3.2':
+ resolution: {integrity: sha512-Uvr+tkE5qpbNNGiDJxbEaA3uidJnrZV1IHSJk16ltwgxrpj603d22I1u9B60o+eU4TR67fgLZMlRA0Rmhtqm/w==}
peerDependencies:
expo: '*'
react: '*'
@@ -9389,12 +9405,12 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {}
- '@atproto/api@0.20.33':
+ '@atproto/api@0.20.37':
dependencies:
- '@atproto/common-web': 0.5.6
- '@atproto/lexicon': 0.7.7
- '@atproto/syntax': 0.7.2
- '@atproto/xrpc': 0.8.6
+ '@atproto/common-web': 0.5.8
+ '@atproto/lexicon': 0.7.10
+ '@atproto/syntax': 0.7.3
+ '@atproto/xrpc': 0.8.9
await-lock: 3.0.0
multiformats: 13.4.2
tlds: 1.261.0
@@ -9407,21 +9423,39 @@ snapshots:
'@atproto/syntax': 0.7.2
zod: 3.25.76
+ '@atproto/common-web@0.5.8':
+ dependencies:
+ '@atproto/lex-data': 0.1.7
+ '@atproto/lex-json': 0.1.6
+ '@atproto/syntax': 0.7.3
+ zod: 3.25.76
+
'@atproto/lex-data@0.1.5':
dependencies:
multiformats: 13.4.2
tslib: 2.8.1
unicode-segmenter: 0.14.5
+ '@atproto/lex-data@0.1.7':
+ dependencies:
+ multiformats: 13.4.2
+ tslib: 2.8.1
+ unicode-segmenter: 0.14.5
+
'@atproto/lex-json@0.1.4':
dependencies:
'@atproto/lex-data': 0.1.5
tslib: 2.8.1
- '@atproto/lexicon@0.7.7':
+ '@atproto/lex-json@0.1.6':
dependencies:
- '@atproto/common-web': 0.5.6
- '@atproto/syntax': 0.7.2
+ '@atproto/lex-data': 0.1.7
+ tslib: 2.8.1
+
+ '@atproto/lexicon@0.7.10':
+ dependencies:
+ '@atproto/common-web': 0.5.8
+ '@atproto/syntax': 0.7.3
multiformats: 13.4.2
zod: 3.25.76
@@ -9430,9 +9464,14 @@ snapshots:
iso-datestring-validator: 2.2.2
tslib: 2.8.1
- '@atproto/xrpc@0.8.6':
+ '@atproto/syntax@0.7.3':
dependencies:
- '@atproto/lexicon': 0.7.7
+ iso-datestring-validator: 2.2.2
+ tslib: 2.8.1
+
+ '@atproto/xrpc@0.8.9':
+ dependencies:
+ '@atproto/lexicon': 0.7.10
zod: 3.25.76
'@babel/code-frame@7.10.4':
@@ -10375,7 +10414,7 @@ snapshots:
react: 19.1.0
react-native: 0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)
- '@bsky.app/peek-menu@0.3.1(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
+ '@bsky.app/peek-menu@0.3.2(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
expo: 54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react: 19.1.0
diff --git a/src/analytics/features/index.ts b/src/analytics/features/index.ts
index 67ea7bb4ad..b83ac9e33b 100644
--- a/src/analytics/features/index.ts
+++ b/src/analytics/features/index.ts
@@ -40,6 +40,7 @@ const TIMEOUT_PREFER_FRESH_GATES = 1500
export const features = new GrowthBook({
apiHost: env.GROWTHBOOK_API_HOST,
clientKey: env.GROWTHBOOK_CLIENT_KEY,
+ enableDevMode: env.IS_INTERNAL,
})
/**
@@ -59,8 +60,7 @@ export const init = features.init({timeout: TIMEOUT_INIT}).then(res => {
})
/**
- * Refresh feature gates from GrowthBook. Updates attributes based on the
- * provided account, if any.
+ * Refresh feature gates from GrowthBook.
*/
export async function refresh({strategy}: {strategy: FeatureFetchStrategy}) {
await features.refreshFeatures({
diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts
index 16cbe3a105..62fe079b7b 100644
--- a/src/analytics/features/types.ts
+++ b/src/analytics/features/types.ts
@@ -19,8 +19,15 @@ export enum Features {
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable',
CustomLogoJapanEnable = 'custom_logo:japan:enable',
+ VideoAllow10MinuteEnable = 'video:allow-10-minute:enable',
+ VideoMultipartUploadEnable = 'video:multipart_upload:enable',
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
FollowSortEnable = 'follow_sort:enable',
+ OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable',
+
+ // values
+ TrendingDiscoverValues = 'trending_discover:values',
+ TrendingExploreTopicsCountValue = 'trending_explore_topics_count:value',
AATest = 'aa-test',
}
diff --git a/src/analytics/index.tsx b/src/analytics/index.tsx
index 20f919f501..51438c2d7b 100644
--- a/src/analytics/index.tsx
+++ b/src/analytics/index.tsx
@@ -6,7 +6,7 @@ import {
useSyncExternalStore,
} from 'react'
import {Platform} from 'react-native'
-import {type Result} from '@growthbook/growthbook-react'
+import {type Result, type WidenPrimitives} from '@growthbook/growthbook-react'
import {Logger} from '#/logger'
import {
@@ -67,6 +67,7 @@ export type AnalyticsContextType = {
) => void
features: typeof Features & {
enabled(feature: Features): boolean
+ getValue(feature: Features, defaultValue: T): WidenPrimitives
}
}
export type AnalyticsBaseContextType = Omit
@@ -83,6 +84,7 @@ function createLogger(
warn: logger.warn.bind(logger),
error: logger.error.bind(logger),
useChild: (context: Exclude) => {
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => createLogger(context, metadata), [context, metadata])
},
Context: Logger.Context,
@@ -314,6 +316,7 @@ export function AnalyticsFeaturesContext({
...parentContext,
features: {
enabled: feats.isOn.bind(feats),
+ getValue: feats.getFeatureValue.bind(feats),
...Features,
},
}
diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts
index bd268603a0..592f9a563d 100644
--- a/src/analytics/metrics/types.ts
+++ b/src/analytics/metrics/types.ts
@@ -5,7 +5,10 @@
import {type Platform} from 'react-native'
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
-import {type VideoCompressSkipReason} from '#/lib/media/video/types'
+import {
+ type VideoCompressSkipReason,
+ type VideoUploadTransport,
+} from '#/lib/media/video/types'
import {type NotificationType} from '#/state/queries/notifications/types'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
@@ -140,6 +143,7 @@ export type Events = {
selectedInterests: string[]
selectedInterestsLength: number
}
+ 'onboarding:interests:disabledNextPressed': {}
'onboarding:suggestedAccounts:tabPressed': {
tab: string
}
@@ -753,10 +757,14 @@ export type Events = {
'trendingTopic:seen': {
context: 'sidebar' | 'interstitial' | 'explore'
recId?: string
+ rank: number
+ feedSliceIndex?: number
}
'trendingTopic:click': {
context: 'sidebar' | 'interstitial' | 'explore'
recId?: string
+ rank: number
+ feedSliceIndex?: number
}
'trendingVideos:show': {
context: 'settings'
@@ -1451,6 +1459,7 @@ export type Events = {
bytes: number
elapsedMs: number
throughputBytesPerSec: number
+ transport: VideoUploadTransport
}
'video:upload:uploadFailed': {
uploadId: string
@@ -1458,6 +1467,7 @@ export type Events = {
bytes: number
errorClass: string
elapsedMs: number
+ transport: VideoUploadTransport
}
'video:upload:processingStarted': {
uploadId: string
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts
index d7c44b0d91..9184ccf37c 100644
--- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts
@@ -27,8 +27,22 @@ export class VideoNotFoundError extends Error {
*/
export class HLSFatalError extends Error {
detail: string
- constructor(detail: string, cause: Error) {
+ type: string
+ diagnostics: Record
+ constructor({
+ detail,
+ type,
+ cause,
+ diagnostics,
+ }: {
+ detail: string
+ type: string
+ cause: Error
+ diagnostics: Record
+ }) {
super(cause.message, {cause})
this.detail = detail
+ this.type = type
+ this.diagnostics = diagnostics
}
}
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx
index 6ece8f5a9b..0599240816 100644
--- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx
@@ -308,7 +308,53 @@ function useHLS({
) {
setError(new VideoNotFoundError())
} else {
- setError(new HLSFatalError(data.details, data.error))
+ const video = videoRef.current
+ const mediaError = video?.error
+ setError(
+ new HLSFatalError({
+ detail: data.details,
+ type: data.type,
+ cause: data.error,
+ diagnostics: {
+ hlsError: {
+ detail: data.details,
+ type: data.type,
+ sourceBufferName: data.sourceBufferName,
+ parent: data.parent,
+ reason: data.reason,
+ errorName: data.error.name,
+ errorCode: (data.error as DOMException).code,
+ },
+ fragment: data.frag
+ ? {
+ sn: data.frag.sn,
+ level: data.frag.level,
+ type: data.frag.type,
+ start: data.frag.start,
+ duration: data.frag.duration,
+ cc: data.frag.cc,
+ }
+ : undefined,
+ media: video
+ ? {
+ errorCode: mediaError?.code,
+ errorMessage: mediaError?.message,
+ readyState: video.readyState,
+ networkState: video.networkState,
+ currentTime: video.currentTime,
+ paused: video.paused,
+ ended: video.ended,
+ seeking: video.seeking,
+ }
+ : undefined,
+ lifecycle: {
+ documentVisibility: document.visibilityState,
+ hlsIsCurrent: hlsRef.current === hls,
+ },
+ playlist,
+ },
+ }),
+ )
}
} else {
console.error(data.error)
diff --git a/src/components/Post/Embed/VideoEmbed/index.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx
index ee44ba33e8..0eb622bad7 100644
--- a/src/components/Post/Embed/VideoEmbed/index.web.tsx
+++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx
@@ -82,6 +82,16 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
),
[key, embed],
)
+ const getErrorMetadata = useCallback((error: Error) => {
+ if (!(error instanceof HLSFatalError)) return {}
+ return {
+ tags: {
+ hls_error_detail: error.detail,
+ hls_error_type: error.type,
+ },
+ hls: error.diagnostics,
+ }
+ }, [])
let aspectRatio: number | undefined
const dims = embed.aspectRatio
@@ -158,7 +168,10 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
/>
>
)}
-
+
) {
const topic = useTopic(raw)
- useTrendingTopicSeen(metricContext, recId)
+ useTrendingTopicSeen(metricContext, rank, recId)
return (
{
- ax.metric('trendingTopic:seen', {context, recId})
+ ax.metric('trendingTopic:seen', {
+ context,
+ rank,
+ feedSliceIndex,
+ recId,
+ })
})
useEffect(() => {
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index 6e3c71bbc8..2512403ec3 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -267,7 +267,16 @@ export function createInput(Component: typeof TextInput) {
ctx.onBlur()
onBlur?.(e)
}}
- placeholder={placeholder === null ? undefined : placeholder || label}
+ /*
+ * Android sizes an empty input from the font's bounding box instead
+ * of `lineHeight`, so a field with no placeholder shrinks on the
+ * first keystroke.
+ */
+ placeholder={
+ placeholder === null
+ ? platform({android: ' '})
+ : placeholder || label
+ }
placeholderTextColor={t.palette.contrast_500}
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
style={flattened}
diff --git a/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts b/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts
index 6ac2c0086f..eea54b5ed9 100644
--- a/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts
+++ b/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts
@@ -88,6 +88,7 @@ export function maybeApplyGalleryOffsetStyles(
if (!isPostGalleryEmbedEnabled) return
// one image, not a gallery
if (embed.media.images.length === 1) return
+ hasImages = true
}
if (
bsky.dangerousIsType(
@@ -97,8 +98,8 @@ export function maybeApplyGalleryOffsetStyles(
) {
// single (or empty) gallery - no offset needed
if (embed.media.items.length <= 1) return
+ hasImages = true
}
- hasImages = true
}
if (!hasImages) return
diff --git a/src/components/interstitials/FeedTrendingTopics.tsx b/src/components/interstitials/FeedTrendingTopics.tsx
index ac68de94ea..4d45001f1c 100644
--- a/src/components/interstitials/FeedTrendingTopics.tsx
+++ b/src/components/interstitials/FeedTrendingTopics.tsx
@@ -32,15 +32,21 @@ import {useAnalytics} from '#/analytics'
const TOPIC_COUNT = 3
-export function FeedTrendingTopicsInterstitial() {
+export function FeedTrendingTopicsInterstitial({
+ feedSliceIndex,
+}: {
+ feedSliceIndex: number
+}) {
const {enabled} = useTrendingConfig()
const {trendingDisabled} = useTrendingSettings()
const {rightNavVisible} = useLayoutBreakpoints()
- return enabled && !trendingDisabled && !rightNavVisible ? : null
+ return enabled && !trendingDisabled && !rightNavVisible ? (
+
+ ) : null
}
-function Inner() {
+function Inner({feedSliceIndex}: {feedSliceIndex: number}) {
const t = useTheme()
const {t: l} = useLingui()
const gutters = useGutters([0, 'base'])
@@ -132,20 +138,26 @@ function Inner() {
? Array.from({length: TOPIC_COUNT}).map((_, i) => (
))
- : trending?.trends?.map((trend, index) => (
- {
- ax.metric('trendingTopic:click', {
- context: 'interstitial',
- recId: trending.recId,
- })
- }}
- />
- ))}
+ : trending?.trends?.map((trend, index) => {
+ const rank = index + 1
+ return (
+ {
+ ax.metric('trendingTopic:click', {
+ context: 'interstitial',
+ rank,
+ feedSliceIndex,
+ recId: trending.recId,
+ })
+ }}
+ />
+ )
+ })}
@@ -166,11 +178,13 @@ function Inner() {
function TrendRow({
trend,
rank,
+ feedSliceIndex,
recId,
onPress,
}: ViewStyleProp & {
trend: AppBskyUnspeccedDefs.TrendView
rank: number
+ feedSliceIndex: number
recId?: string
children?: React.ReactNode
onPress?: () => void
@@ -180,7 +194,7 @@ function TrendRow({
const actors = useModerateTrendingActors(trend.actors)
const formattedPostCount = formatCount(i18n, trend.postCount)
- useTrendingTopicSeen('interstitial', recId)
+ useTrendingTopicSeen('interstitial', rank, recId, feedSliceIndex)
return (
-
+
{trend.displayName}
diff --git a/src/components/interstitials/Trending.tsx b/src/components/interstitials/Trending.tsx
index 3b88109043..0b94064dac 100644
--- a/src/components/interstitials/Trending.tsx
+++ b/src/components/interstitials/Trending.tsx
@@ -98,30 +98,35 @@ export function Inner() {
) : !trending?.trends ? null : (
<>
- {trending.trends.map(topic => (
- {
- ax.metric('trendingTopic:click', {
- context: 'interstitial',
- recId: trending.recId,
- })
- }}>
-
-
- {topic.topic}
-
-
-
- ))}
+ {trending.trends.map((topic, index) => {
+ const rank = index + 1
+ return (
+ {
+ ax.metric('trendingTopic:click', {
+ context: 'interstitial',
+ rank,
+ recId: trending.recId,
+ })
+ }}>
+
+
+ {topic.topic}
+
+
+
+ )
+ })}
)
diff --git a/src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx b/src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx
index abcaf59285..835cc41c6f 100644
--- a/src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx
+++ b/src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx
@@ -20,7 +20,10 @@ export function FromDropdown({
const options: {value: FromFilter; label: string}[] = [
{value: 'anyone', label: l`No author filter`},
- {value: 'following', label: l`People I follow`},
+ {
+ value: 'following',
+ label: l({context: 'display posts made by', message: 'People I follow'}),
+ },
{value: 'me', label: l`Me`},
]
const currentLabel =
diff --git a/src/screens/Search/modules/ExploreTrendingTopics.tsx b/src/screens/Search/modules/ExploreTrendingTopics.tsx
index fab8fff816..38c8cac0a8 100644
--- a/src/screens/Search/modules/ExploreTrendingTopics.tsx
+++ b/src/screens/Search/modules/ExploreTrendingTopics.tsx
@@ -13,7 +13,10 @@ import {
useTrendingSettings,
useTrendingSettingsApi,
} from '#/state/preferences/trending'
-import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
+import {
+ DEFAULT_LIMIT,
+ useGetTrendsQuery,
+} from '#/state/queries/trending/useGetTrendsQuery'
import {useTrendingConfig} from '#/state/service-config'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {formatCount} from '#/view/com/util/numeric/format'
@@ -29,8 +32,6 @@ import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import * as ModuleHeader from '../components/ModuleHeader'
-const TOPIC_COUNT = 5
-
const IMAGE_SIZE = 56
export function ExploreTrendingTopics() {
@@ -42,9 +43,20 @@ export function ExploreTrendingTopics() {
function Inner() {
const ax = useAnalytics()
const {t: l} = useLingui()
+
+ const topicCount = ax.features.getValue(
+ ax.features.TrendingExploreTopicsCountValue,
+ DEFAULT_LIMIT,
+ )
+
const trendingPrompt = Prompt.usePromptControl()
const {setTrendingDisabled} = useTrendingSettingsApi()
- const {data: trending, error, isLoading, isRefetching} = useGetTrendsQuery()
+ const {
+ data: trending,
+ error,
+ isLoading,
+ isRefetching,
+ } = useGetTrendsQuery({limit: topicCount})
const noTopics = !isLoading && !error && !trending?.trends?.length
const showLoading = isLoading || isRefetching
@@ -64,23 +76,27 @@ function Inner() {
/>
{showLoading
- ? Array.from({length: TOPIC_COUNT}).map((__, i) => (
+ ? Array.from({length: topicCount}).map((__, i) => (
))
- : trending?.trends.map((trend, index) => (
- {
- ax.metric('trendingTopic:click', {
- context: 'explore',
- recId: trending.recId,
- })
- }}
- />
- ))}
+ : trending?.trends.map((trend, index) => {
+ const rank = index + 1
+ return (
+ {
+ ax.metric('trendingTopic:click', {
+ context: 'explore',
+ rank,
+ recId: trending.recId,
+ })
+ }}
+ />
+ )
+ })}
{
if (!trend.description) return
@@ -143,7 +159,6 @@ export function TrendRow({
style={[
a.text_md,
a.font_medium,
-
t.atoms.text_contrast_low,
{
fontVariant: ['tabular-nums'],
@@ -156,7 +171,7 @@ export function TrendRow({
+ numberOfLines={2}>
{trend.displayName}
{description ? (
diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx
index a58b1bf3dd..37d0e9b5fa 100644
--- a/src/screens/Settings/Settings.tsx
+++ b/src/screens/Settings/Settings.tsx
@@ -27,6 +27,7 @@ import {useOnboardingDispatch} from '#/state/shell'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {GrowthbookDialog} from '#/screens/Settings/components/GrowthbookDialog'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf'
import {AgeAssuranceDismissibleNotice} from '#/components/ageAssurance/AgeAssuranceDismissibleNotice'
@@ -398,6 +399,7 @@ function DevOptions() {
currentChannel,
} = useApplyPullRequestOTAUpdate()
const [actyNotifNudged, setActyNotifNudged] = useActivitySubscriptionsNudged()
+ const growthbookControl = useDialogControl()
const resetOnboarding = () => {
navigation.navigate('Home')
@@ -458,6 +460,14 @@ function DevOptions() {
System log
+ growthbookControl.open()}
+ label={l`View GrowthBook information`}>
+
+ GrowthBook
+
+
+
navigation.navigate('Debug')}
label={l`Open storybook page`}>
@@ -474,7 +484,7 @@ function DevOptions() {
deleteChatDeclarationRecord()}
- label={l`Open storybook page`}>
+ label={l`Delete chat declaration record`}>
Delete chat declaration record
diff --git a/src/screens/Settings/components/GrowthbookDialog.tsx b/src/screens/Settings/components/GrowthbookDialog.tsx
new file mode 100644
index 0000000000..4ba8bfc582
--- /dev/null
+++ b/src/screens/Settings/components/GrowthbookDialog.tsx
@@ -0,0 +1,215 @@
+import {useState} from 'react'
+import {Pressable, View} from 'react-native'
+import * as Clipboard from 'expo-clipboard'
+
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useProfileQuery} from '#/state/queries/profile'
+import {useSession} from '#/state/session'
+import {atoms as a, native, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {ArrowRotateClockwise_Stroke2_Corner0_Rounded as ArrowRotate} from '#/components/icons/ArrowRotate'
+import {Loader} from '#/components/Loader'
+import * as ProfileCard from '#/components/ProfileCard'
+import * as Toast from '#/components/Toast'
+import {Text} from '#/components/Typography'
+import {Features, features, refresh} from '#/analytics/features'
+
+export function GrowthbookDialog({
+ control,
+}: {
+ control: Dialog.DialogControlProps
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function GrowthbookDialogInner() {
+ const t = useTheme()
+
+ const [isRefreshing, setIsRefreshing] = useState(false)
+
+ return (
+
+ GrowthBook
+
+
+ }>
+
+
+
+
+
+
+
+ {Object.entries(Features).map(([name, key]) => (
+
+ ))}
+
+
+ )
+}
+
+function RefreshButton({
+ isRefreshing,
+ setIsRefreshing,
+}: {
+ isRefreshing: boolean
+ setIsRefreshing: React.Dispatch>
+}) {
+ const onPress = () => {
+ setIsRefreshing(true)
+ refresh({strategy: 'prefer-fresh-gates'})
+ .then(() => {
+ Toast.show('Refreshed feature flags', {type: 'success'})
+ })
+ .catch(() => {
+ Toast.show('Failed to refresh feature flags', {type: 'error'})
+ })
+ .finally(() => {
+ setIsRefreshing(false)
+ })
+ }
+
+ return (
+
+
+ Refresh feature flags
+
+ )
+}
+
+function FeatureRow({
+ name,
+ featureKey,
+ isRefreshing,
+}: {
+ name: string
+ featureKey: string
+ isRefreshing: boolean
+}) {
+ const t = useTheme()
+ const value = features.evalFeature(featureKey).value
+
+ const onPress = () => {
+ void Clipboard.setStringAsync(featureKey)
+ Toast.show('Copied feature flag key to clipboard', {type: 'success'})
+ }
+
+ return (
+
+
+ {name}
+
+ {featureKey}
+
+
+ {isRefreshing ? : }
+
+ )
+}
+
+function CurrentProfile() {
+ const t = useTheme()
+ const {currentAccount} = useSession()
+ const moderationOpts = useModerationOpts()
+ const {data: profile} = useProfileQuery({did: currentAccount?.did})
+
+ if (!currentAccount) {
+ return (
+
+ No active account
+
+ )
+ }
+
+ const onPressDid = () => {
+ void Clipboard.setStringAsync(currentAccount.did)
+ Toast.show('Copied did to clipboard', {type: 'success'})
+ }
+
+ return profile && moderationOpts ? (
+
+
+
+
+
+
+ ) : null
+}
+
+function FeatureValue({value}: {value: unknown}) {
+ const t = useTheme()
+
+ let label: string
+ let color: string
+ if (value === true) {
+ label = 'true'
+ color = t.palette.positive_500
+ } else if (value === false) {
+ label = 'false'
+ color = t.palette.negative_500
+ } else if (value === null || value === undefined) {
+ label = 'null'
+ color = t.palette.contrast_500
+ } else {
+ label = JSON.stringify(value)
+ color = t.palette.contrast_700
+ }
+
+ return {label}
+}
diff --git a/src/screens/Settings/components/OTAInfo.tsx b/src/screens/Settings/components/OTAInfo.tsx
index 9ab1962b97..25b2c828d9 100644
--- a/src/screens/Settings/components/OTAInfo.tsx
+++ b/src/screens/Settings/components/OTAInfo.tsx
@@ -4,6 +4,8 @@ import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useMutation, useQuery} from '@tanstack/react-query'
+import {splash} from '#/lib/hooks/useOTAUpdates'
+import {useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes'
@@ -13,6 +15,7 @@ import * as SettingsList from '../components/SettingsList'
export function OTAInfo() {
const {_} = useLingui()
+ const t = useTheme()
const {
data: isAvailable,
isPending: isPendingInfo,
@@ -31,7 +34,9 @@ export function OTAInfo() {
useMutation({
mutationFn: async () => {
await Updates.fetchUpdateAsync()
- await Updates.reloadAsync()
+ await Updates.reloadAsync({
+ reloadScreenOptions: splash(t.scheme),
+ })
},
onError: error =>
Toast.show(`Failed to update: ${error.message}`, {
diff --git a/src/screens/StarterPack/Wizard/StepProfiles.tsx b/src/screens/StarterPack/Wizard/StepProfiles.tsx
index 4aef058b27..9f3b7076fd 100644
--- a/src/screens/StarterPack/Wizard/StepProfiles.tsx
+++ b/src/screens/StarterPack/Wizard/StepProfiles.tsx
@@ -87,7 +87,9 @@ export function StepProfiles({
sideBorders={false}
style={[a.flex_1]}
onEndReached={
- !query && !screenReaderEnabled ? () => fetchNextPage() : undefined
+ !query && !screenReaderEnabled
+ ? () => void fetchNextPage()
+ : undefined
}
onEndReachedThreshold={IS_NATIVE ? 2 : 0.25}
keyboardDismissMode="on-drag"
@@ -104,7 +106,11 @@ export function StepProfiles({
a.mt_lg,
a.leading_snug,
]}>
- Nobody was found. Try searching for someone else.
+ {query ? (
+
+ Nobody was found. Try searching for someone else.
+
+ ) : null}
)}
diff --git a/src/state/queries/notifications/__tests__/util.test.ts b/src/state/queries/notifications/__tests__/util.test.ts
new file mode 100644
index 0000000000..3122770a40
--- /dev/null
+++ b/src/state/queries/notifications/__tests__/util.test.ts
@@ -0,0 +1,79 @@
+import {type AppBskyNotificationListNotifications} from '@atproto/api'
+import {describe, expect, it, jest} from '@jest/globals'
+
+import {groupNotifications} from '../util'
+
+jest.mock('#/state/queries/profile', () => ({precacheProfile: jest.fn()}))
+
+type Notification = AppBskyNotificationListNotifications.Notification
+
+function makeFollowNotification(
+ did: string,
+ starterPackUri?: string,
+): Notification {
+ return {
+ uri: `at://${did}/app.bsky.graph.follow/follow`,
+ cid: `cid-${did}`,
+ author: {
+ did,
+ handle: `${did}.test`,
+ displayName: did,
+ avatar: undefined,
+ associated: undefined,
+ viewer: {},
+ labels: [],
+ createdAt: '2026-07-28T12:00:00.000Z',
+ },
+ reason: 'follow',
+ record: {},
+ starterPack: starterPackUri
+ ? ({uri: starterPackUri} as Notification['starterPack'])
+ : undefined,
+ isRead: false,
+ indexedAt: '2026-07-28T12:00:00.000Z',
+ }
+}
+
+describe('groupNotifications', () => {
+ it('does not group a starter pack follow with an organic follow', () => {
+ const pack = 'at://did:plc:alice/app.bsky.graph.starterpack/a'
+
+ const grouped = groupNotifications([
+ makeFollowNotification('did:plc:a'),
+ makeFollowNotification('did:plc:b', pack),
+ ])
+
+ expect(grouped).toHaveLength(2)
+ expect(grouped[0].notification.author.did).toBe('did:plc:a')
+ expect(grouped[0].notification.starterPack).toBeUndefined()
+ expect(grouped[0].additional).toBeUndefined()
+ expect(grouped[1].notification.author.did).toBe('did:plc:b')
+ expect(grouped[1].notification.starterPack?.uri).toBe(pack)
+ expect(grouped[1].additional).toBeUndefined()
+ })
+
+ it('groups follows by starter pack', () => {
+ const packA = 'at://did:plc:alice/app.bsky.graph.starterpack/a'
+ const packB = 'at://did:plc:bob/app.bsky.graph.starterpack/b'
+
+ const grouped = groupNotifications([
+ makeFollowNotification('did:plc:a', packA),
+ makeFollowNotification('did:plc:b', packB),
+ makeFollowNotification('did:plc:c', packA),
+ makeFollowNotification('did:plc:d'),
+ makeFollowNotification('did:plc:e', packB),
+ makeFollowNotification('did:plc:f'),
+ ])
+
+ expect(
+ grouped.map(item => [
+ item.notification.author.did,
+ ...(item.additional ?? []).map(notification => notification.author.did),
+ ]),
+ ).toEqual([
+ ['did:plc:a', 'did:plc:c'],
+ ['did:plc:b', 'did:plc:e'],
+ ['did:plc:d', 'did:plc:f'],
+ ])
+ })
+})
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index ded66fb62e..e5d1d81885 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -163,6 +163,9 @@ export function groupNotifications(
Math.abs(ts2 - ts) < MS_2DAY &&
notif.reason === groupedNotif.notification.reason &&
notif.reasonSubject === groupedNotif.notification.reasonSubject &&
+ (notif.reason !== 'follow' ||
+ notif.starterPack?.uri ===
+ groupedNotif.notification.starterPack?.uri) &&
(notif.author.did !== groupedNotif.notification.author.did ||
notif.reason === 'subscribed-post')
) {
diff --git a/src/storage/schema.ts b/src/storage/schema.ts
index 33c6bc0b56..2f4424792d 100644
--- a/src/storage/schema.ts
+++ b/src/storage/schema.ts
@@ -63,6 +63,11 @@ export type Device = {
activitySubscriptionsNudged?: boolean
threadgateNudged?: boolean
inviteFriendsFollowersPromoDismissed?: boolean
+ pendingOTAUpdate?: {
+ attemptedAt: number
+ channel: string
+ updateId: string
+ }
/**
* Selected color theme for the Invite Friends QR card.
*/
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 026697fe25..b080441230 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -70,6 +70,7 @@ import {
MAX_GRAPHEME_LENGTH,
SUPPORTED_MIME_TYPES,
type SupportedMimeTypes,
+ VIDEO_10_MINUTE_MAX_DURATION_MS,
VIDEO_MAX_DURATION_MS,
} from '#/lib/constants'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@@ -267,6 +268,12 @@ export const ComposePost = ({
const {currentAccount} = useSession()
const t = useTheme()
const ax = useAnalytics()
+ const allow10MinuteVideos = ax.features.enabled(
+ ax.features.VideoAllow10MinuteEnable,
+ )
+ const videoMaxDurationMs = allow10MinuteVideos
+ ? VIDEO_10_MINUTE_MAX_DURATION_MS
+ : VIDEO_MAX_DURATION_MS
const agent = useAgent()
const queryClient = useQueryClient()
const currentDid = currentAccount!.did
@@ -434,7 +441,7 @@ export const ComposePost = ({
* Fail early on duration so we don't spend time compressing a video the
* server would reject anyway.
*/
- if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) {
+ if (asset.duration != null && asset.duration > videoMaxDurationMs) {
composerDispatch({
type: 'update_post',
postId: postId,
@@ -442,7 +449,9 @@ export const ComposePost = ({
type: 'embed_update_video',
videoAction: {
type: 'to_error',
- error: l`Videos must be less than 3 minutes long.`,
+ error: allow10MinuteVideos
+ ? l`Videos must be 10 minutes or less.`
+ : l`Videos must be less than 3 minutes long.`,
signal: abortController.signal,
},
},
@@ -469,7 +478,16 @@ export const ComposePost = ({
telemetry,
)
},
- [l, i18n, agent, currentDid, composerDispatch, ax.metric],
+ [
+ l,
+ i18n,
+ agent,
+ currentDid,
+ composerDispatch,
+ ax.metric,
+ videoMaxDurationMs,
+ allow10MinuteVideos,
+ ],
)
const onInitVideo = useNonReactiveCallback(() => {
@@ -566,7 +584,7 @@ export const ComposePost = ({
},
})
- if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) {
+ if (asset.duration != null && asset.duration > videoMaxDurationMs) {
composerDispatch({
type: 'update_post',
postId,
@@ -574,7 +592,9 @@ export const ComposePost = ({
type: 'embed_update_video',
videoAction: {
type: 'to_error',
- error: l`Videos must be less than 3 minutes long.`,
+ error: allow10MinuteVideos
+ ? l`Videos must be 10 minutes or less.`
+ : l`Videos must be less than 3 minutes long.`,
signal: abortController.signal,
},
},
@@ -646,7 +666,16 @@ export const ComposePost = ({
})
}
},
- [l, i18n, agent, currentDid, composerDispatch, ax.metric],
+ [
+ l,
+ i18n,
+ agent,
+ currentDid,
+ composerDispatch,
+ ax.metric,
+ videoMaxDurationMs,
+ allow10MinuteVideos,
+ ],
)
const handleSelectDraft = useCallback(
diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx
index b7565e6b6d..35767a20ab 100644
--- a/src/view/com/composer/GifAltText.tsx
+++ b/src/view/com/composer/GifAltText.tsx
@@ -162,7 +162,6 @@ function AltTextInner({
onChangeText={onChange}
defaultValue={altText}
multiline
- numberOfLines={3}
autoFocus
onKeyPress={({nativeEvent}) => {
if (nativeEvent.key === 'Escape') {
diff --git a/src/view/com/composer/SelectMediaButton.tsx b/src/view/com/composer/SelectMediaButton.tsx
index d3cb92db7c..34c0101875 100644
--- a/src/view/com/composer/SelectMediaButton.tsx
+++ b/src/view/com/composer/SelectMediaButton.tsx
@@ -6,6 +6,7 @@ import {msg, plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {
+ VIDEO_10_MINUTE_MAX_DURATION_MS,
VIDEO_MAX_DURATION_MS,
VIDEO_MAX_SIZE,
VIDEO_MAX_SIZE_MB,
@@ -22,6 +23,7 @@ import {Button} from '#/components/Button'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
import * as toast from '#/components/Toast'
+import {useAnalytics} from '#/analytics'
import {IS_NATIVE, IS_WEB} from '#/env'
import {isAnimatedGif} from './videos/isAnimatedGif'
import {hasWebCodecs} from './videos/metadata'
@@ -236,9 +238,11 @@ async function processImagePickerAssets(
{
selectionCountRemaining,
allowedAssetTypes,
+ videoMaxDurationMs,
}: {
selectionCountRemaining: number
allowedAssetTypes: AssetType | undefined
+ videoMaxDurationMs: number
},
) {
/*
@@ -362,7 +366,7 @@ async function processImagePickerAssets(
supportedAssets[0].duration = supportedAssets[0].duration * 1000
}
- if (supportedAssets[0].duration > VIDEO_MAX_DURATION_MS) {
+ if (supportedAssets[0].duration > videoMaxDurationMs) {
errors.add(SelectedAssetError.VideoTooLong)
supportedAssets = []
}
@@ -393,6 +397,13 @@ export function SelectMediaButton({
autoOpen,
}: SelectMediaButtonProps) {
const {_} = useLingui()
+ const ax = useAnalytics()
+ const allow10MinuteVideos = ax.features.enabled(
+ ax.features.VideoAllow10MinuteEnable,
+ )
+ const videoMaxDurationMs = allow10MinuteVideos
+ ? VIDEO_10_MINUTE_MAX_DURATION_MS
+ : VIDEO_MAX_DURATION_MS
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
const sheetWrapper = useSheetWrapper()
@@ -412,6 +423,7 @@ export function SelectMediaButton({
} = await processImagePickerAssets(rawAssets, {
selectionCountRemaining,
allowedAssetTypes,
+ videoMaxDurationMs,
})
/*
@@ -436,9 +448,9 @@ export function SelectMediaButton({
[SelectedAssetError.MaxVideos]: _(
msg`You can only select one video at a time.`,
),
- [SelectedAssetError.VideoTooLong]: _(
- msg`Videos must be less than 3 minutes long.`,
- ),
+ [SelectedAssetError.VideoTooLong]: allow10MinuteVideos
+ ? _(msg`Videos must be 10 minutes or less.`)
+ : _(msg`Videos must be less than 3 minutes long.`),
[SelectedAssetError.MaxGIFs]: _(
msg`You can only select one GIF at a time.`,
),
@@ -458,7 +470,14 @@ export function SelectMediaButton({
errors,
})
},
- [_, onSelectAssets, selectionCountRemaining, allowedAssetTypes],
+ [
+ _,
+ onSelectAssets,
+ selectionCountRemaining,
+ allowedAssetTypes,
+ videoMaxDurationMs,
+ allow10MinuteVideos,
+ ],
)
const onPressSelectMedia = useCallback(async () => {
@@ -481,7 +500,7 @@ export function SelectMediaButton({
}
const {assets, canceled} = await sheetWrapper(
- openUnifiedPicker({selectionCountRemaining}),
+ openUnifiedPicker({selectionCountRemaining, videoMaxDurationMs}),
)
if (canceled) return
@@ -494,6 +513,7 @@ export function SelectMediaButton({
sheetWrapper,
processSelectedAssets,
selectionCountRemaining,
+ videoMaxDurationMs,
])
useEffect(() => {
diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx
index 08de8d8d99..9d00aed097 100644
--- a/src/view/com/composer/photos/ImageAltTextDialog.tsx
+++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx
@@ -131,7 +131,6 @@ const ImageAltTextInner = ({
}}
defaultValue={altText}
multiline
- numberOfLines={3}
autoFocus
/>
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index 6365765fc9..cf087b59bb 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -32,6 +32,9 @@ export function OpenCameraBtn({disabled, onAdd}: OpenCameraBtnProps) {
const img = await openCamera({
aspect: [1, 1],
})
+ if (!img) {
+ return
+ }
// If we don't have permissions it's fine, we just wont save it. The post itself will still have access to
// the image even without these permissions
diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts
index dafd28bed7..d984202d0a 100644
--- a/src/view/com/composer/state/video.ts
+++ b/src/view/com/composer/state/video.ts
@@ -20,16 +20,6 @@ import {logger} from '#/logger'
type CaptionsTrack = {lang: string; file: File}
-type VideoJobStatus = AppBskyVideoDefs.JobStatus & {
- failureCode?:
- | 'validation_failure'
- | 'encoding_failure'
- | 'pds_upload_failure'
- | 'pds_upload_unsupported_blob_size'
- | 'generic_failure'
- | (string & {})
-}
-
export type VideoAction =
| {
type: 'compressing_to_uploading'
@@ -336,6 +326,7 @@ export async function processVideo(
did,
signal,
i18n,
+ onTransport: telemetry.uploadTransport,
setProgress: p => {
dispatch({type: 'update_progress', progress: p, signal})
},
@@ -369,7 +360,7 @@ export async function processVideo(
}
const videoAgent = createVideoAgent()
- let status: VideoJobStatus | undefined
+ let status: AppBskyVideoDefs.JobStatus | undefined
let blob: BlobRef | undefined
try {
const response = await videoAgent.app.bsky.video.getJobStatus({jobId})
diff --git a/src/view/com/feeds/ComposerPrompt.tsx b/src/view/com/feeds/ComposerPrompt.tsx
index b221b0fae3..a2effdf231 100644
--- a/src/view/com/feeds/ComposerPrompt.tsx
+++ b/src/view/com/feeds/ComposerPrompt.tsx
@@ -115,6 +115,9 @@ export function ComposerPrompt() {
const image = await openCamera({
mediaTypes: 'images',
})
+ if (!image) {
+ return
+ }
const imageUris = [
{
diff --git a/src/view/com/notifications/NotificationFeed.tsx b/src/view/com/notifications/NotificationFeed.tsx
index a4c4c6a973..82b5008078 100644
--- a/src/view/com/notifications/NotificationFeed.tsx
+++ b/src/view/com/notifications/NotificationFeed.tsx
@@ -1,31 +1,37 @@
import {useCallback, useEffect, useMemo, useState} from 'react'
-import {
- ActivityIndicator,
- type ListRenderItemInfo,
- StyleSheet,
- View,
-} from 'react-native'
-import {msg} from '@lingui/core/macro'
-import {useLingui} from '@lingui/react'
+import {ActivityIndicator, type ListRenderItemInfo, View} from 'react-native'
+import {useLingui} from '@lingui/react/macro'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking'
import {cleanError} from '#/lib/strings/errors'
-import {s} from '#/lib/styles'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
+import {
+ type FeedNotification,
+ useNotificationFeedQuery,
+} from '#/state/queries/notifications/feed'
import {EmptyState} from '#/view/com/util/EmptyState'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {List, type ListProps, type ListRef} from '#/view/com/util/List'
import {NotificationFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
+import {atoms as a, platform} from '#/alf'
import {Bell_Stroke2_Corner0_Rounded as BellIcon} from '#/components/icons/Bell'
import {NotificationFeedItem} from './NotificationFeedItem'
-const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
-const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
-const LOADING_ITEM = {_reactKey: '__loading__'}
+const EMPTY_FEED_ITEM = {type: 'empty', _reactKey: '__empty__'} as const
+const LOAD_MORE_ERROR_ITEM = {
+ type: 'load-more-error',
+ _reactKey: '__load_more_error__',
+} as const
+const LOADING_ITEM = {type: 'loading', _reactKey: '__loading__'} as const
+
+type NotificationFeedListItem =
+ | FeedNotification
+ | typeof EMPTY_FEED_ITEM
+ | typeof LOAD_MORE_ERROR_ITEM
+ | typeof LOADING_ITEM
export function NotificationFeed({
filter,
@@ -46,7 +52,7 @@ export function NotificationFeed({
}) {
const initialNumToRender = useInitialNumToRender()
const [isPTRing, setIsPTRing] = useState(false)
- const {_} = useLingui()
+ const {t: l} = useLingui()
const moderationOpts = useModerationOpts()
const trackPostView = usePostViewTracking('Notifications')
const {
@@ -71,7 +77,7 @@ export function NotificationFeed({
!isFetching && !data?.pages.find(page => page.items.length > 0)
const items = useMemo(() => {
- let arr: any[] = []
+ let arr: NotificationFeedListItem[] = []
if (isFetched) {
if (isEmpty) {
arr = arr.concat([EMPTY_FEED_ITEM])
@@ -113,29 +119,27 @@ export function NotificationFeed({
}, [isFetching, hasNextPage, isError, fetchNextPage])
const onPressRetryLoadMore = useCallback(() => {
- fetchNextPage()
+ void fetchNextPage()
}, [fetchNextPage])
const renderItem = useCallback(
- ({item, index}: ListRenderItemInfo) => {
- if (item === EMPTY_FEED_ITEM) {
+ ({item, index}: ListRenderItemInfo) => {
+ if (item.type === 'empty') {
return (
)
- } else if (item === LOAD_MORE_ERROR_ITEM) {
+ } else if (item.type === 'load-more-error') {
return (
)
- } else if (item === LOADING_ITEM) {
+ } else if (item.type === 'loading') {
return
}
return (
@@ -147,13 +151,13 @@ export function NotificationFeed({
/>
)
},
- [moderationOpts, _, onPressRetryLoadMore, filter],
+ [moderationOpts, l, onPressRetryLoadMore, filter],
)
const FeedFooter = useCallback(
() =>
isFetchingNextPage ? (
-
+
) : (
@@ -169,7 +173,11 @@ export function NotificationFeed({
}, [enabled])
return (
-
+
{error && (
item._reactKey}
+ keyExtractor={(item: NotificationFeedListItem) => item._reactKey}
renderItem={renderItem}
ListHeaderComponent={ListHeaderComponent}
ListFooterComponent={FeedFooter}
refreshing={isPTRing}
- onRefresh={onRefresh}
- onEndReached={onEndReached}
+ onRefresh={() => void onRefresh()}
+ onEndReached={() => void onEndReached()}
onEndReachedThreshold={2}
onScrolledDownChange={onScrolledDownChange}
- onItemSeen={item => {
+ onItemSeen={(item: NotificationFeedListItem) => {
if (
(item.type === 'reply' ||
item.type === 'mention' ||
@@ -199,7 +207,7 @@ export function NotificationFeed({
trackPostView(item.subject)
}
}}
- contentContainerStyle={s.contentContainer}
+ contentContainerStyle={{paddingBottom: 200}}
desktopFixedHeight
initialNumToRender={initialNumToRender}
windowSize={11}
@@ -209,8 +217,3 @@ export function NotificationFeed({
)
}
-
-const styles = StyleSheet.create({
- feedFooter: {paddingTop: 20},
- emptyState: {paddingVertical: 40},
-})
diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx
index a89431938d..c8efc82678 100644
--- a/src/view/com/notifications/NotificationFeedItem.tsx
+++ b/src/view/com/notifications/NotificationFeedItem.tsx
@@ -12,16 +12,17 @@ import {
type AppBskyActorDefs,
type AppBskyFeedDefs,
AppBskyFeedPost,
+ type AppBskyGraphDefs,
AppBskyGraphFollow,
+ AppBskyGraphStarterpack,
+ AtUri,
moderateProfile,
type ModerationDecision,
type ModerationOpts,
} from '@atproto/api'
-import {AtUri} from '@atproto/api'
import {TID} from '@atproto/common-web'
-import {msg, plural} from '@lingui/core/macro'
-import {useLingui} from '@lingui/react'
-import {Plural, Trans} from '@lingui/react/macro'
+import {plural} from '@lingui/core/macro'
+import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
@@ -32,7 +33,6 @@ import {type NavigationProp} from '#/lib/routes/types'
import {forceLTR} from '#/lib/strings/bidi'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {niceDate} from '#/lib/strings/time'
-import {s} from '#/lib/styles'
import {logger} from '#/logger'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {type FeedNotification} from '#/state/queries/notifications/feed'
@@ -44,7 +44,7 @@ import {Post} from '#/view/com/post/Post'
import {formatCount} from '#/view/com/util/numeric/format'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
-import {atoms as a, platform, useTheme, web} from '#/alf'
+import {atoms as a, native, platform, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
@@ -64,7 +64,10 @@ import * as MediaPreview from '#/components/MediaPreview'
import {ProfileBadges} from '#/components/ProfileBadges'
import * as ProfileCard from '#/components/ProfileCard'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
-import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
+import {
+ Notification as StarterPackCard,
+ useStarterPackLink,
+} from '#/components/StarterPack/StarterPackCard'
import {SubtleHover} from '#/components/SubtleHover'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
@@ -93,9 +96,9 @@ let NotificationFeedItem = ({
}): React.ReactNode => {
const queryClient = useQueryClient()
const t = useTheme()
- const {_, i18n} = useLingui()
+ const {t: l, i18n} = useLingui()
const ax = useAnalytics()
- const [isAuthorsExpanded, setIsAuthorsExpanded] = useState(false)
+ const [isAuthorsExpanded, setIsAuthorsExpanded] = useState(false)
const [isHoveringAuthorsList, setIsHoveringAuthorsList] = useState(false)
const itemHref = useMemo(() => {
switch (item.type) {
@@ -247,40 +250,40 @@ let NotificationFeedItem = ({
t.atoms.text,
a.font_semi_bold,
a.text_md,
- a.leading_tight,
web({direction: 'ltr', unicodeBidi: 'isolate'}),
]}
to={firstAuthor.href}
disableMismatchWarning
emoji
- label={_(msg`Go to ${firstAuthorName}'s profile`)}>
+ label={l`Go to ${firstAuthorName}'s profile`}>
{forceLTR(firstAuthorName)}
)
const additionalAuthorsCount = authors.length - 1
const hasMultipleAuthors = additionalAuthorsCount > 0
+ const starterPack = item.notification.starterPack
+ const allFollowedViaSameStarterPack =
+ item.type === 'follow' &&
+ starterPack !== undefined &&
+ (item.additional ?? []).every(
+ notification => notification.starterPack?.uri === starterPack.uri,
+ )
+ const starterPackName =
+ allFollowedViaSameStarterPack && starterPack
+ ? getStarterPackName(starterPack)
+ : undefined
const formattedAuthorsCount = hasMultipleAuthors
? formatCount(i18n, additionalAuthorsCount)
: ''
let a11yLabel = ''
- let notificationContent: React.ReactElement
+ let notificationContent: React.ReactElement
let icon = (
{firstAuthorLink} and{' '}
-
+
{firstAuthorLink} and{' '}
-
+
{firstAuthorLink} followed you back
} else {
- a11yLabel = hasMultipleAuthors
- ? _(
- msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
+ a11yLabel = starterPackName
+ ? hasMultipleAuthors
+ ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
- })} followed you`,
- )
- : _(msg`${firstAuthorName} followed you`)
+ })} followed you via starter pack ${starterPackName}`
+ : l`${firstAuthorName} followed you via starter pack ${starterPackName}`
+ : hasMultipleAuthors
+ ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
+ one: `${formattedAuthorsCount} other`,
+ other: `${formattedAuthorsCount} others`,
+ })} followed you`
+ : l`${firstAuthorName} followed you`
notificationContent = hasMultipleAuthors ? (
{firstAuthorLink} and{' '}
-
+
} else if (item.type === 'contact-match') {
- a11yLabel = _(msg`Your contact ${firstAuthorName} is on Bluesky`)
+ a11yLabel = l`Your contact ${firstAuthorName} is on Bluesky`
notificationContent = (
Your contact {firstAuthorLink} is on Bluesky
)
@@ -384,17 +390,15 @@ let NotificationFeedItem = ({
)
} else if (item.type === 'feedgen-like') {
a11yLabel = hasMultipleAuthors
- ? _(
- msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
- one: `${formattedAuthorsCount} other`,
- other: `${formattedAuthorsCount} others`,
- })} liked your custom feed`,
- )
- : _(msg`${firstAuthorName} liked your custom feed`)
+ ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
+ one: `${formattedAuthorsCount} other`,
+ other: `${formattedAuthorsCount} others`,
+ })} liked your custom feed`
+ : l`${firstAuthorName} liked your custom feed`
notificationContent = hasMultipleAuthors ? (
{firstAuthorLink} and{' '}
-
+
{firstAuthorLink} and{' '}
-
+
{firstAuthorLink} and{' '}
-
+
} else if (item.type === 'unverified') {
a11yLabel = hasMultipleAuthors
- ? _(
- msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
- one: `${formattedAuthorsCount} other`,
- other: `${formattedAuthorsCount} others`,
- })} removed their verifications from your account`,
- )
- : _(msg`${firstAuthorName} removed their verification from your account`)
+ ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
+ one: `${formattedAuthorsCount} other`,
+ other: `${formattedAuthorsCount} others`,
+ })} removed their verifications from your account`
+ : l`${firstAuthorName} removed their verification from your account`
notificationContent = hasMultipleAuthors ? (
{firstAuthorLink} and{' '}
-
+
} else if (item.type === 'like-via-repost') {
a11yLabel = hasMultipleAuthors
- ? _(
- msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
- one: `${formattedAuthorsCount} other`,
- other: `${formattedAuthorsCount} others`,
- })} liked your repost`,
- )
- : _(msg`${firstAuthorName} liked your repost`)
+ ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
+ one: `${formattedAuthorsCount} other`,
+ other: `${formattedAuthorsCount} others`,
+ })} liked your repost`
+ : l`${firstAuthorName} liked your repost`
notificationContent = hasMultipleAuthors ? (
{firstAuthorLink} and{' '}
-
+
{firstAuthorLink} and{' '}
-
+
New posts from {firstAuthorLink} and{' '}
-
+
@@ -686,6 +673,9 @@ let NotificationFeedItem = ({
+ {allFollowedViaSameStarterPack && starterPack ? (
+
+ ) : null}
{(item.type === 'follow' && !hasMultipleAuthors && !isFollowBack) ||
(item.type === 'contact-match' &&
!item.notification.author.viewer?.following) ? (
@@ -737,6 +727,53 @@ let NotificationFeedItem = ({
NotificationFeedItem = memo(NotificationFeedItem)
export {NotificationFeedItem}
+function FollowedViaStarterPack({
+ starterPack,
+}: {
+ starterPack: AppBskyGraphDefs.StarterPackViewBasic
+}) {
+ const t = useTheme()
+ const link = useStarterPackLink({view: starterPack})
+
+ const starterPackName = getStarterPackName(starterPack)
+
+ if (!starterPackName) {
+ return null
+ }
+
+ return (
+
+
+ via starter pack{' '}
+
+
+ {starterPackName}
+
+
+
+ )
+}
+
+function getStarterPackName(
+ starterPack: AppBskyGraphDefs.StarterPackViewBasic,
+) {
+ return bsky.dangerousIsType(
+ starterPack.record,
+ AppBskyGraphStarterpack.isRecord,
+ )
+ ? starterPack.record.name
+ : undefined
+}
+
function ExpandListPressable({
hasMultipleAuthors,
children,
@@ -767,7 +804,7 @@ function ExpandListPressable({
}
function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) {
- const {_} = useLingui()
+ const {t: l} = useLingui()
const {currentAccount, hasSession} = useSession()
const profileShadow = useProfileShadow(profile)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
@@ -787,15 +824,14 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) {
try {
await queueFollow()
Toast.show(
- _(
- msg`Following ${sanitizeDisplayName(
- profile.displayName || profile.handle,
- )}`,
- ),
+ l`Following ${sanitizeDisplayName(
+ profile.displayName || profile.handle,
+ )}`,
)
- } catch (err: any) {
+ } catch (error) {
+ const err = error as Error
if (err?.name !== 'AbortError') {
- Toast.show(_(msg`An issue occurred, please try again.`), {
+ Toast.show(l`An issue occurred, please try again.`, {
type: 'error',
})
}
@@ -809,15 +845,14 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) {
try {
await queueUnfollow()
Toast.show(
- _(
- msg`No longer following ${sanitizeDisplayName(
- profile.displayName || profile.handle,
- )}`,
- ),
+ l`No longer following ${sanitizeDisplayName(
+ profile.displayName || profile.handle,
+ )}`,
)
- } catch (err: any) {
+ } catch (error) {
+ const err = error as Error
if (err?.name !== 'AbortError') {
- Toast.show(_(msg`An issue occurred, please try again.`), {
+ Toast.show(l`An issue occurred, please try again.`, {
type: 'error',
})
}
@@ -838,12 +873,10 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) {
const isFollowing = profileShadow.viewer.following
const isFollowedBy = profileShadow.viewer.followedBy
- const followingLabel = _(
- msg({
- message: 'Following',
- comment: 'User is following this account, click to unfollow',
- }),
- )
+ const followingLabel = l({
+ message: 'Following',
+ comment: 'User is following this account, click to unfollow',
+ })
return (
@@ -853,7 +886,7 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) {
color="secondary"
size="small"
style={[a.self_start]}
- onPress={onPressUnfollow}>
+ onPress={(e: GestureResponderEvent) => void onPressUnfollow(e)}>
Following
@@ -861,11 +894,11 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) {
) : (
+ onPress={(e: GestureResponderEvent) => void onPressFollow(e)}>
{isFollowedBy ? Follow back : Follow}
@@ -877,11 +910,30 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) {
}
function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileView}) {
- const {_} = useLingui()
+ const {t: l} = useLingui()
const agent = useAgent()
const navigation = useNavigation()
const [isLoading, setIsLoading] = useState(false)
+ const onPress = async () => {
+ try {
+ setIsLoading(true)
+ const res = await agent.api.chat.bsky.convo.getConvoForMembers(
+ {
+ members: [profile.did, agent.session!.did],
+ },
+ {headers: DM_SERVICE_HEADERS},
+ )
+ navigation.navigate('MessagesConversation', {
+ conversation: res.data.convo.id,
+ })
+ } catch (e) {
+ logger.error('Failed to get conversation', {safeMessage: e})
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
if (
profile.associated?.chat?.allowIncoming === 'none' ||
(profile.associated?.chat?.allowIncoming === 'following' &&
@@ -892,30 +944,13 @@ function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileView}) {
return (
{
- try {
- setIsLoading(true)
- const res = await agent.api.chat.bsky.convo.getConvoForMembers(
- {
- members: [profile.did, agent.session!.did],
- },
- {headers: DM_SERVICE_HEADERS},
- )
- navigation.navigate('MessagesConversation', {
- conversation: res.data.convo.id,
- })
- } catch (e) {
- logger.error('Failed to get conversation', {safeMessage: e})
- } finally {
- setIsLoading(false)
- }
- }}>
+ onPress={() => void onPress()}>
Say hello!
@@ -935,7 +970,7 @@ function CondensedAuthorsList({
showDmButton?: boolean
}) {
const t = useTheme()
- const {_} = useLingui()
+ const {t: l} = useLingui()
if (!visible) {
return (
@@ -944,10 +979,8 @@ function CondensedAuthorsList({
style={styles.expandedAuthorsCloseBtn}
onPress={onToggleAuthorsExpanded}
accessibilityRole="button"
- accessibilityLabel={_(msg`Hide user list`)}
- accessibilityHint={_(
- msg`Collapses list of users for a given notification`,
- )}>
+ accessibilityLabel={l`Hide user list`}
+ accessibilityHint={l`Collapses list of users for a given notification`}>
{authors.slice(0, MAX_AUTHORS).map(author => (
-
+
0 && (
{text}
diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx
index 0100856307..eea2453739 100644
--- a/src/view/com/posts/PostFeed.tsx
+++ b/src/view/com/posts/PostFeed.tsx
@@ -159,6 +159,7 @@ type FeedRow =
| {
type: 'interstitialFeedTrendingTopics'
key: string
+ feedSliceIndex: number
}
| {
type: 'interstitialTrendingVideos'
@@ -211,10 +212,6 @@ export type PostFeedRef = {
// const REFRESH_AFTER = STALE.HOURS.ONE
const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY
-const TRENDING_TOPICS_INDEX = 5
-const TRENDING_VIDEO_INDEX = 30
-const SUGGESTED_FOR_YOU_INDEX = 15
-
let PostFeed = ({
feed,
description,
@@ -280,6 +277,15 @@ let PostFeed = ({
const {rightNavVisible} = useLayoutBreakpoints()
const areVideoFeedsEnabled = IS_NATIVE
+ const trendingIndices = ax.features.getValue(
+ ax.features.TrendingDiscoverValues,
+ {
+ topics: 5,
+ accounts: 15,
+ videos: 30,
+ },
+ )
+
const [hasPressedShowLessUris, setHasPressedShowLessUris] = useState(
() => new Set(),
)
@@ -574,19 +580,20 @@ let PostFeed = ({
key: 'composerPrompt-' + sliceIndex,
})
}
- } else if (sliceIndex === TRENDING_TOPICS_INDEX) {
+ } else if (sliceIndex === trendingIndices.topics) {
arr.push({
type: 'interstitialFeedTrendingTopics',
key: 'interstitialFeedTrendingTopics-' + sliceIndex,
+ feedSliceIndex: sliceIndex,
})
- } else if (sliceIndex === TRENDING_VIDEO_INDEX) {
+ } else if (sliceIndex === trendingIndices.videos) {
if (areVideoFeedsEnabled && !trendingVideoDisabled) {
arr.push({
type: 'interstitialTrendingVideos',
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
})
}
- } else if (sliceIndex === SUGGESTED_FOR_YOU_INDEX) {
+ } else if (sliceIndex === trendingIndices.accounts) {
arr.push({
type: 'interstitialFollows',
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
@@ -739,6 +746,7 @@ let PostFeed = ({
ageAssuranceBannerState,
isCurrentFeedAtStartupSelected,
blockedOrMutedAuthors,
+ trendingIndices,
])
// events
@@ -852,7 +860,9 @@ let PostFeed = ({
} else if (row.type === 'interstitialTrending') {
return
} else if (row.type === 'interstitialFeedTrendingTopics') {
- return
+ return (
+
+ )
} else if (row.type === 'liveEventFeedsAndTrendingBanner') {
return
} else if (row.type === 'composerPrompt') {
diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx
index 712473c77f..0ef9ac4d95 100644
--- a/src/view/com/util/ErrorBoundary.tsx
+++ b/src/view/com/util/ErrorBoundary.tsx
@@ -4,12 +4,14 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
+import {type Metadata} from '#/logger/types'
import {ErrorScreen} from './error/ErrorScreen'
import {CenteredView} from './Views'
interface Props {
children?: ReactNode
renderError?: (error: any) => ReactNode
+ getErrorMetadata?: (error: Error) => Metadata
style?: StyleProp
}
@@ -29,7 +31,10 @@ export class ErrorBoundary extends Component {
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
- logger.error(error, {errorInfo})
+ logger.error(error, {
+ errorInfo,
+ ...this.props.getErrorMetadata?.(error),
+ })
}
public render() {
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index f139542857..98819bf8d6 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -391,14 +391,14 @@ let EditableUserAvatar = ({
return
}
- onSelectNewAvatar(
- await compressIfNeeded(
- await openCamera({
- aspect: [1, 1],
- }),
- IMAGE_SIZE_CONFIG_2K_1MB,
- ),
- )
+ const image = await openCamera({
+ aspect: [1, 1],
+ })
+ if (!image) {
+ return
+ }
+
+ onSelectNewAvatar(await compressIfNeeded(image, IMAGE_SIZE_CONFIG_2K_1MB))
}, [onSelectNewAvatar, requestCameraAccessIfNeeded])
const onOpenLibrary = useCallback(async () => {
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index ff4b6631bd..964cc2ba57 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -58,14 +58,14 @@ export function UserBanner({
if (!(await requestCameraAccessIfNeeded())) {
return
}
- onSelectNewBanner?.(
- await compressIfNeeded(
- await openCamera({
- aspect: [3, 1],
- }),
- IMAGE_SIZE_CONFIG_2K_1MB,
- ),
- )
+ const image = await openCamera({
+ aspect: [3, 1],
+ })
+ if (!image) {
+ return
+ }
+
+ onSelectNewBanner?.(await compressIfNeeded(image, IMAGE_SIZE_CONFIG_2K_1MB))
}, [onSelectNewBanner, requestCameraAccessIfNeeded])
const onOpenLibrary = useCallback(async () => {
diff --git a/src/view/shell/desktop/SidebarTrendingTopics.tsx b/src/view/shell/desktop/SidebarTrendingTopics.tsx
index 7a8e45208a..7d0c60fbc1 100644
--- a/src/view/shell/desktop/SidebarTrendingTopics.tsx
+++ b/src/view/shell/desktop/SidebarTrendingTopics.tsx
@@ -5,19 +5,21 @@ import {
useTrendingSettings,
useTrendingSettingsApi,
} from '#/state/preferences/trending'
-import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
+import {
+ DEFAULT_LIMIT,
+ useGetTrendsQuery,
+} from '#/state/queries/trending/useGetTrendsQuery'
import {useTrendingConfig} from '#/state/service-config'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending'
+import {Link} from '#/components/Link'
import * as Prompt from '#/components/Prompt'
import {TrendingTopicLink} from '#/components/TrendingTopics'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
-const TRENDING_LIMIT = 5
-
export function SidebarTrendingTopics() {
const {enabled} = useTrendingConfig()
const {trendingDisabled} = useTrendingSettings()
@@ -28,6 +30,12 @@ function Inner() {
const t = useTheme()
const {t: l} = useLingui()
const ax = useAnalytics()
+
+ const exploreTopicCount = ax.features.getValue(
+ ax.features.TrendingExploreTopicsCountValue,
+ DEFAULT_LIMIT,
+ )
+
const trendingPrompt = Prompt.usePromptControl()
const {setTrendingDisabled} = useTrendingSettingsApi()
const {
@@ -35,6 +43,7 @@ function Inner() {
error,
isLoading,
} = useGetTrendsQuery({
+ limit: DEFAULT_LIMIT,
refetchOnWindowFocus: true,
})
const noTopics = !isLoading && !error && !trending?.trends?.length
@@ -53,6 +62,26 @@ function Inner() {
Trending
+ {exploreTopicCount > DEFAULT_LIMIT ? (
+
+ {({hovered, pressed}) => (
+
+ See more
+
+ )}
+
+ ) : null}
{isLoading ? (
- Array(TRENDING_LIMIT)
+ Array(DEFAULT_LIMIT)
.fill(0)
.map((_n, i) => (
@@ -90,44 +119,49 @@ function Inner() {
))
) : !trending?.trends ? null : (
<>
- {trending.trends.slice(0, TRENDING_LIMIT).map((topic, i) => (
- {
- ax.metric('trendingTopic:click', {
- context: 'sidebar',
- recId: trending.recId,
- })
- }}>
- {({hovered}) => (
-
-
- {i + 1}.
-
-
- {topic.displayName ?? topic.topic}
-
-
- )}
-
- ))}
+ {trending.trends.slice(0, DEFAULT_LIMIT).map((topic, i) => {
+ const rank = i + 1
+ return (
+ {
+ ax.metric('trendingTopic:click', {
+ context: 'sidebar',
+ rank,
+ recId: trending.recId,
+ })
+ }}>
+ {({hovered}) => (
+
+
+ {rank}.
+
+
+ {topic.displayName ?? topic.topic}
+
+
+ )}
+
+ )
+ })}
>
)}
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 126ce485b6..9603236d4d 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -9,6 +9,7 @@ import {useNavigation, useNavigationState} from '@react-navigation/native'
import {useDedupe} from '#/lib/hooks/useDedupe'
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
import {useNotificationsHandler} from '#/lib/hooks/useNotificationHandler'
+import {useOTAUpdateRecovery} from '#/lib/hooks/useOTAUpdates'
import {useNotificationsRegistration} from '#/lib/notifications/notifications'
import {isStateAtTabRoot} from '#/lib/routes/helpers'
import {useDialogFullyExpandedCountContext} from '#/state/dialogs'
@@ -216,6 +217,7 @@ export function Shell() {
const fullyExpandedCount = useDialogFullyExpandedCountContext()
useIntentHandler()
+ useOTAUpdateRecovery()
useEffect(() => {
setSystemUITheme('theme', t)