Merge main into app-2773

This commit is contained in:
vineyardbovines
2026-08-05 10:20:35 -04:00
89 changed files with 4057 additions and 802 deletions
@@ -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}}
@@ -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 }}
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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
+2 -2
View File
@@ -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: |
+2 -2
View File
@@ -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: |
@@ -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"
+15 -4
View File
@@ -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:
<img src="https://bsky-qr.vercel.app?channel=pull-request-${{ github.event.pull_request.number }}" width="300" height="300" alt="QR code for the PR OTA deployment">
<img src="https://bsky-qr.vercel.app?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 }}" width="300" height="300" alt="QR code for the PR OTA deployment">
`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 }}`
+1 -1
View File
@@ -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
+1 -42
View File
@@ -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
+3 -3
View File
@@ -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",
+62 -23
View File
@@ -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
+2 -2
View File
@@ -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({
+7
View File
@@ -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',
}
+4 -1
View File
@@ -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<T>(feature: Features, defaultValue: T): WidenPrimitives<T>
}
}
export type AnalyticsBaseContextType = Omit<AnalyticsContextType, 'features'>
@@ -83,6 +84,7 @@ function createLogger(
warn: logger.warn.bind(logger),
error: logger.error.bind(logger),
useChild: (context: Exclude<Logger['context'], undefined>) => {
// 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,
},
}
+11 -1
View File
@@ -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
@@ -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<string, unknown>
constructor({
detail,
type,
cause,
diagnostics,
}: {
detail: string
type: string
cause: Error
diagnostics: Record<string, unknown>
}) {
super(cause.message, {cause})
this.detail = detail
this.type = type
this.diagnostics = diagnostics
}
}
@@ -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)
@@ -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}) {
/>
</>
)}
<ErrorBoundary renderError={renderError} key={key}>
<ErrorBoundary
renderError={renderError}
getErrorMetadata={getErrorMetadata}
key={key}>
<OnlyNearScreen>
<VideoEmbedInnerWeb
embed={embed}
+11 -2
View File
@@ -13,16 +13,18 @@ import {type Metrics, useAnalytics} from '#/analytics'
export function TrendingTopicLink({
topic: raw,
metricContext,
rank,
recId,
children,
...rest
}: {
topic: AppBskyUnspeccedDefs.TrendView
metricContext: Metrics['trendingTopic:seen']['context']
rank: number
recId?: string
} & Omit<LinkProps, 'to' | 'label'>) {
const topic = useTopic(raw)
useTrendingTopicSeen(metricContext, recId)
useTrendingTopicSeen(metricContext, rank, recId)
return (
<InternalLink
@@ -37,11 +39,18 @@ export function TrendingTopicLink({
export function useTrendingTopicSeen(
context: Metrics['trendingTopic:seen']['context'],
rank: number,
recId?: string,
feedSliceIndex?: number,
) {
const ax = useAnalytics()
const trackSeen = useCallOnce(() => {
ax.metric('trendingTopic:seen', {context, recId})
ax.metric('trendingTopic:seen', {
context,
rank,
feedSliceIndex,
recId,
})
})
useEffect(() => {
+10 -1
View File
@@ -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}
@@ -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<AppBskyEmbedGallery.Main>(
@@ -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
@@ -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 ? <Inner /> : null
return enabled && !trendingDisabled && !rightNavVisible ? (
<Inner feedSliceIndex={feedSliceIndex} />
) : 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) => (
<TrendingTopicRowSkeleton key={i} rank={i + 1} />
))
: trending?.trends?.map((trend, index) => (
<TrendRow
key={trend.link}
trend={trend}
rank={index + 1}
recId={trending.recId}
onPress={() => {
ax.metric('trendingTopic:click', {
context: 'interstitial',
recId: trending.recId,
})
}}
/>
))}
: trending?.trends?.map((trend, index) => {
const rank = index + 1
return (
<TrendRow
key={trend.link}
trend={trend}
rank={rank}
feedSliceIndex={feedSliceIndex}
recId={trending.recId}
onPress={() => {
ax.metric('trendingTopic:click', {
context: 'interstitial',
rank,
feedSliceIndex,
recId: trending.recId,
})
}}
/>
)
})}
</View>
</View>
</View>
@@ -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 (
<Link
@@ -218,7 +232,7 @@ function TrendRow({
</Trans>
</Text>
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.text_md, a.font_medium]} numberOfLines={1}>
<Text style={[a.text_md, a.font_medium]} numberOfLines={2}>
{trend.displayName}
</Text>
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
+29 -24
View File
@@ -98,30 +98,35 @@ export function Inner() {
</View>
) : !trending?.trends ? null : (
<>
{trending.trends.map(topic => (
<TrendingTopicLink
key={topic.link}
topic={topic}
metricContext="interstitial"
recId={trending.recId}
onPress={() => {
ax.metric('trendingTopic:click', {
context: 'interstitial',
recId: trending.recId,
})
}}>
<View style={[a.py_lg]}>
<Text
style={[
t.atoms.text_contrast_medium,
a.text_sm,
a.font_semi_bold,
]}>
{topic.topic}
</Text>
</View>
</TrendingTopicLink>
))}
{trending.trends.map((topic, index) => {
const rank = index + 1
return (
<TrendingTopicLink
key={topic.link}
topic={topic}
metricContext="interstitial"
rank={rank}
recId={trending.recId}
onPress={() => {
ax.metric('trendingTopic:click', {
context: 'interstitial',
rank,
recId: trending.recId,
})
}}>
<View style={[a.py_lg]}>
<Text
style={[
t.atoms.text_contrast_medium,
a.text_sm,
a.font_semi_bold,
]}>
{topic.topic}
</Text>
</View>
</TrendingTopicLink>
)
})}
<Button
label={l`Hide trending topics`}
size="tiny"
@@ -7,6 +7,7 @@ import {type ParsedReportSubject} from '#/components/moderation/ReportDialog/typ
export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
export const SUPPORT_PAGE = 'https://bsky.social/about/support'
export const NCII_FORM = 'https://forms.bsky.app/f/ncii'
export const NEW_TO_OLD_REASON_MAPPING: Record<string, string> = {}
@@ -0,0 +1,107 @@
import {XRPCError} from '@atproto/api'
import {classifyReportError} from './errors'
describe('classifyReportError', () => {
it('treats account takedown as an expected rejection', () => {
const result = classifyReportError(
new XRPCError(
403,
'AccountTakedown',
'Report not accepted from takendown account',
),
)
expect(result).toMatchObject({
kind: 'account-takedown',
shouldReport: false,
fingerprint: ['{{ default }}', 'report-dialog:account-takedown'],
tags: {
report_error_kind: 'account-takedown',
report_error_bucket: 'account-takedown',
report_xrpc_error: 'AccountTakedown',
report_http_status: 403,
},
})
})
it.each([
{
error: new XRPCError(
502,
'InternalServerError',
'Failed to perform upstream request',
),
bucket: 'upstream-fetch',
},
{
error: new XRPCError(502, 'UpstreamFailure', 'Internal Server Error'),
bucket: 'upstream-internal',
},
{
error: new XRPCError(
502,
'UpstreamFailure',
'Upstream server responded with a 502 error',
),
bucket: 'upstream-http-502',
},
{
error: new XRPCError(
504,
'UpstreamTimeout',
'Upstream server responded with a 504 error',
),
bucket: 'upstream-http-504',
},
])('classifies $bucket as unavailable', ({error, bucket}) => {
expect(classifyReportError(error)).toMatchObject({
kind: 'service-unavailable',
shouldReport: true,
fingerprint: ['{{ default }}', `report-dialog:${bucket}`],
})
})
it('classifies an invalid reason type separately', () => {
const result = classifyReportError(
new XRPCError(
400,
'InvalidRequest',
'Invalid reason type: tools.ozone.report.defs#reasonOther',
),
)
expect(result).toMatchObject({
kind: 'invalid-reason-type',
shouldReport: true,
fingerprint: ['{{ default }}', 'report-dialog:invalid-reason-type'],
})
})
it.each([400, 404])(
'separates a non-retryable upstream %i without calling it temporary',
status => {
const result = classifyReportError(
new XRPCError(
502,
'UpstreamFailure',
`Upstream server responded with a ${status} error`,
),
)
expect(result).toMatchObject({
kind: 'unexpected',
shouldReport: true,
fingerprint: ['{{ default }}', `report-dialog:upstream-http-${status}`],
})
},
)
it('classifies non-XRPC errors as unexpected', () => {
expect(classifyReportError(new Error('boom'))).toMatchObject({
kind: 'unexpected',
shouldReport: true,
fingerprint: ['{{ default }}', 'report-dialog:unexpected'],
})
})
})
@@ -0,0 +1,111 @@
import {XRPCError} from '@atproto/api'
import {isRetryableHttpStatus, shouldRetryError} from '#/lib/strings/errors'
export type ReportErrorKind =
| 'account-takedown'
| 'invalid-reason-type'
| 'service-unavailable'
| 'unexpected'
export type ReportErrorClassification = {
kind: ReportErrorKind
shouldReport: boolean
fingerprint: string[]
tags: Record<string, string | number>
}
export function classifyReportError(error: unknown): ReportErrorClassification {
if (!(error instanceof XRPCError)) {
return classification('unexpected', 'unexpected', true)
}
const xrpcTags = {
report_xrpc_error: error.error,
report_http_status: error.status,
}
if (error.error === 'AccountTakedown') {
return classification(
'account-takedown',
'account-takedown',
false,
xrpcTags,
)
}
if (error.message.startsWith('Invalid reason type')) {
return classification(
'invalid-reason-type',
'invalid-reason-type',
true,
xrpcTags,
)
}
if (error.message === 'Failed to perform upstream request') {
return classification(
'service-unavailable',
'upstream-fetch',
true,
xrpcTags,
)
}
if (error.message === 'Internal Server Error') {
return classification(
'service-unavailable',
'upstream-internal',
true,
xrpcTags,
)
}
const upstreamStatus = error.message.match(
/^Upstream server responded with a (\d{3}) error$/,
)?.[1]
if (upstreamStatus) {
return classification(
isRetryableHttpStatus(Number(upstreamStatus))
? 'service-unavailable'
: 'unexpected',
`upstream-http-${upstreamStatus}`,
true,
xrpcTags,
)
}
if (shouldRetryError(error)) {
return classification(
'service-unavailable',
`xrpc-retryable-${error.status}`,
true,
xrpcTags,
)
}
return classification(
'unexpected',
`xrpc-other-${error.status}`,
true,
xrpcTags,
)
}
function classification(
kind: ReportErrorKind,
bucket: string,
shouldReport: boolean,
tags: Record<string, string | number> = {},
): ReportErrorClassification {
return {
kind,
shouldReport,
fingerprint: ['{{ default }}', `report-dialog:${bucket}`],
tags: {
report_error_kind: kind,
report_error_bucket: bucket,
...tags,
},
}
}
+180 -22
View File
@@ -40,11 +40,19 @@ import {useSubmitReportMutation} from './action'
import {
BSKY_LABELER_ONLY_REPORT_REASONS,
BSKY_LABELER_ONLY_SUBJECT_TYPES,
NCII_FORM,
NEW_TO_OLD_REASONS_MAP,
SUPPORT_PAGE,
} from './const'
import {useCopyForSubject} from './copy'
import {initialState, reducer} from './state'
import {classifyReportError} from './errors'
import {
getNciiQualificationOutcome,
initialState,
type NciiQualification as NciiQualificationState,
reducer,
type ReportAction,
} from './state'
import {type ReportDialogProps, type ReportSubject} from './types'
import {parseReportSubject} from './utils/parseReportSubject'
import {
@@ -237,14 +245,39 @@ function Inner(props: ReportDialogProps) {
})
}, 1e3)
} catch (err) {
const e = err as Error
const e = err instanceof Error ? err : new Error(String(err))
const classification = classifyReportError(e)
const tags = {
...classification.tags,
report_subject_type: props.subject.type,
report_labeler: state.selectedLabeler?.creator.did,
report_reason: state.selectedOption?.reason,
}
ax.metric('reportDialog:failure', {})
logger.error(e, {
source: 'ReportDialog',
})
if (classification.shouldReport) {
logger.error(e, {
source: 'ReportDialog',
fingerprint: classification.fingerprint,
tags,
})
} else {
logger.warn('Report rejected for taken down account', {tags})
}
let error = l`Something went wrong. Please try again.`
if (classification.kind === 'account-takedown') {
error = l`Your account cannot submit reports while it is taken down.`
} else if (classification.kind === 'invalid-reason-type') {
error = l`This moderation service does not support that report reason. Please choose a different reason or moderation service.`
} else if (classification.kind === 'service-unavailable') {
error = l`The moderation service is temporarily unavailable. Please try again later.`
}
dispatch({
type: 'setError',
error: l`Something went wrong. Please try again.`,
error,
})
} finally {
setIsPending(false)
@@ -381,23 +414,28 @@ function Inner(props: ReportDialogProps) {
activeIndex1={state.activeStepIndex1}
/>
{state.selectedOption ? (
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<View style={[a.flex_1]}>
<OptionCard option={state.selectedOption} />
<>
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<View style={[a.flex_1]}>
<OptionCard option={state.selectedOption} />
</View>
<Button
testID="report:clearReportOption"
label={l`Change report reason`}
size="tiny"
variant="solid"
color="secondary"
shape="round"
onPress={() => {
dispatch({type: 'clearOption'})
}}>
<ButtonIcon icon={X} />
</Button>
</View>
<Button
testID="report:clearReportOption"
label={l`Change report reason`}
size="tiny"
variant="solid"
color="secondary"
shape="round"
onPress={() => {
dispatch({type: 'clearOption'})
}}>
<ButtonIcon icon={X} />
</Button>
</View>
{state.ncii && (
<NciiQualification ncii={state.ncii} dispatch={dispatch} />
)}
</>
) : state.selectedCategory ? (
<View style={[a.gap_sm]}>
{getCategory(state.selectedCategory.key).options.map(o => (
@@ -780,6 +818,126 @@ function OptionCard({
)
}
/**
* Qualifying question shown when the NCII reason is selected. The depicted
* person (or their authorized representative) is directed to the external
* NCII report form; everyone else continues with the normal in-app
* submission.
*/
function NciiQualification({
ncii,
dispatch,
}: {
ncii: NciiQualificationState
dispatch: React.Dispatch<ReportAction>
}) {
const t = useTheme()
const {t: l} = useLingui()
const outcome = getNciiQualificationOutcome(ncii)
return (
<View style={[a.gap_md]}>
<YesNoQuestion
testID="report:ncii:isDepicted"
question={l`Are you the person depicted, or an authorized representative acting on behalf of the person depicted?`}
value={ncii.isDepicted}
onAnswer={answer => {
dispatch({
type: 'answerNciiQuestion',
question: 'isDepicted',
answer,
})
}}
/>
{outcome === 'externalForm' && (
<Link
to={NCII_FORM}
label={l({
message:
'Submit your report through the Report non-consensual intimate imagery (NCII) form',
context: 'english-only-resource',
})}>
{({hovered, pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
a.w_full,
a.px_md,
a.py_sm,
a.rounded_sm,
a.border,
hovered || pressed
? [t.atoms.border_contrast_high]
: [t.atoms.border_contrast_low],
]}>
<Text style={[a.flex_1, a.italic, a.leading_snug]}>
<Trans context="english-only-resource">
Please submit your report through the Report non-consensual
intimate imagery (NCII) form.
</Trans>
</Text>
<SquareArrowTopRight size="sm" fill={t.atoms.text.color} />
</View>
)}
</Link>
)}
</View>
)
}
function YesNoQuestion({
question,
value,
onAnswer,
testID,
}: {
question: string
value?: boolean
onAnswer: (answer: boolean) => void
testID?: string
}) {
const {t: l} = useLingui()
return (
<View style={[a.gap_sm]}>
<Text style={[a.text_sm, a.leading_snug]}>{question}</Text>
<View style={[a.flex_row, a.gap_sm]}>
<View style={[a.flex_1]}>
<Button
testID={testID ? `${testID}:yes` : undefined}
label={l({
message: 'Yes',
context: 'Answer to a yes/no question',
})}
accessibilityHint={question}
size="small"
color={value === true ? 'primary' : 'secondary'}
onPress={() => onAnswer(true)}>
<ButtonText>
<Trans context="Answer to a yes/no question">Yes</Trans>
</ButtonText>
</Button>
</View>
<View style={[a.flex_1]}>
<Button
testID={testID ? `${testID}:no` : undefined}
label={l({
message: 'No',
context: 'Answer to a yes/no question',
})}
accessibilityHint={question}
size="small"
color={value === false ? 'primary' : 'secondary'}
onPress={() => onAnswer(false)}>
<ButtonText>
<Trans context="Answer to a yes/no question">No</Trans>
</ButtonText>
</Button>
</View>
</View>
</View>
)
}
function OptionCardSkeleton() {
const t = useTheme()
return (
@@ -0,0 +1,110 @@
import {
type AppBskyLabelerDefs,
ToolsOzoneReportDefs as OzoneReportDefs,
} from '@atproto/api'
import {
getNciiQualificationOutcome,
initialState,
reducer,
type ReportState,
} from './state'
const nciiOption = {
title: 'Non-consensual intimate imagery',
reason: OzoneReportDefs.REASONSEXUALNCII,
}
const otherOption = {
title: 'Unlabeled adult content',
reason: OzoneReportDefs.REASONSEXUALUNLABELED,
}
function selectNciiOption(state: ReportState = initialState) {
return reducer(state, {type: 'selectOption', option: nciiOption})
}
describe('getNciiQualificationOutcome', () => {
it('returns undefined when not an NCII report', () => {
expect(getNciiQualificationOutcome(undefined)).toBeUndefined()
})
it('is pending until the question is answered', () => {
expect(getNciiQualificationOutcome({})).toBe('pending')
})
it('directs the depicted person to the external form', () => {
expect(getNciiQualificationOutcome({isDepicted: true})).toBe('externalForm')
})
it('directs everyone else to in-app submission', () => {
expect(getNciiQualificationOutcome({isDepicted: false})).toBe('inApp')
})
})
describe('reducer NCII qualification', () => {
it('holds at step 2 when the NCII reason is selected', () => {
const state = selectNciiOption()
expect(state.activeStepIndex1).toBe(2)
expect(state.ncii).toEqual({})
})
it('does not gate non-NCII reasons', () => {
const state = reducer(initialState, {
type: 'selectOption',
option: otherOption,
})
expect(state.activeStepIndex1).toBe(3)
expect(state.ncii).toBeUndefined()
})
it('holds at step 2 for the depicted person (external form)', () => {
let state = selectNciiOption()
state = reducer(state, {
type: 'answerNciiQuestion',
question: 'isDepicted',
answer: true,
})
expect(getNciiQualificationOutcome(state.ncii)).toBe('externalForm')
expect(state.activeStepIndex1).toBe(2)
})
it('advances to step 3 when not the depicted person', () => {
let state = selectNciiOption()
state = reducer(state, {
type: 'answerNciiQuestion',
question: 'isDepicted',
answer: false,
})
expect(state.activeStepIndex1).toBe(3)
})
it('does not advance past a pending question when a labeler is auto-selected', () => {
let state = selectNciiOption()
state = reducer(state, {
type: 'selectLabeler',
labeler: {} as AppBskyLabelerDefs.LabelerViewDetailed,
})
expect(state.activeStepIndex1).toBe(2)
})
it('skips to step 4 when the answer resolves after labeler auto-selection', () => {
let state = selectNciiOption()
state = reducer(state, {
type: 'selectLabeler',
labeler: {} as AppBskyLabelerDefs.LabelerViewDetailed,
})
state = reducer(state, {
type: 'answerNciiQuestion',
question: 'isDepicted',
answer: false,
})
expect(state.activeStepIndex1).toBe(4)
})
it('clears NCII state when the reason or category is cleared', () => {
const state = selectNciiOption()
expect(reducer(state, {type: 'clearOption'}).ncii).toBeUndefined()
expect(reducer(state, {type: 'clearCategory'}).ncii).toBeUndefined()
})
})
@@ -1,4 +1,7 @@
import {type AppBskyLabelerDefs} from '@atproto/api'
import {
type AppBskyLabelerDefs,
ToolsOzoneReportDefs as OzoneReportDefs,
} from '@atproto/api'
import {OTHER_REPORT_REASONS} from '#/components/moderation/ReportDialog/const'
import {
@@ -6,6 +9,10 @@ import {
type ReportOption,
} from '#/components/moderation/ReportDialog/utils/useReportOptions'
export type NciiQualification = {
isDepicted?: boolean
}
export type ReportState = {
selectedCategory?: ReportCategoryConfig
selectedOption?: ReportOption
@@ -14,6 +21,26 @@ export type ReportState = {
detailsOpen: boolean
activeStepIndex1: number
error?: string
/**
* Present while the selected reason is NCII. Tracks the answer to the
* qualifying question that determines whether the report should go through
* the external NCII report form instead of in-app submission.
*/
ncii?: NciiQualification
}
/**
* Resolves the NCII qualifying question into an outcome. The depicted person
* (or their authorized representative) is directed to the external NCII
* report form; everyone else proceeds with the normal in-app submission.
*/
export function getNciiQualificationOutcome(
ncii?: NciiQualification,
): 'pending' | 'externalForm' | 'inApp' | undefined {
if (!ncii) return undefined
if (ncii.isDepicted === true) return 'externalForm'
if (ncii.isDepicted === false) return 'inApp'
return 'pending'
}
export type ReportAction =
@@ -32,6 +59,11 @@ export type ReportAction =
| {
type: 'clearOption'
}
| {
type: 'answerNciiQuestion'
question: keyof NciiQualification
answer: boolean
}
| {
type: 'selectLabeler'
labeler: AppBskyLabelerDefs.LabelerViewDetailed
@@ -81,14 +113,19 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
selectedLabeler: undefined,
activeStepIndex1: 1,
detailsOpen: false,
ncii: undefined,
}
case 'selectOption':
case 'selectOption': {
const isNcii = action.option.reason === OzoneReportDefs.REASONSEXUALNCII
return {
...state,
selectedOption: action.option,
activeStepIndex1: 3,
// NCII reports require answering qualifying questions before moving on
activeStepIndex1: isNcii ? 2 : 3,
detailsOpen: OTHER_REPORT_REASONS.has(action.option.reason),
ncii: isNcii ? {} : undefined,
}
}
case 'clearOption':
return {
...state,
@@ -96,12 +133,33 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
selectedLabeler: undefined,
activeStepIndex1: 2,
detailsOpen: false,
ncii: undefined,
}
case 'answerNciiQuestion': {
const ncii = {...state.ncii, [action.question]: action.answer}
return {
...state,
ncii,
activeStepIndex1:
getNciiQualificationOutcome(ncii) === 'inApp'
? state.selectedLabeler
? 4
: 3
: 2,
}
}
case 'selectLabeler':
return {
...state,
selectedLabeler: action.labeler,
activeStepIndex1: 4,
/*
* Labelers may be auto-selected (e.g. chat reports only go to
* Bluesky), so don't advance past pending NCII qualifying questions.
*/
activeStepIndex1:
getNciiQualificationOutcome(state.ncii) === 'inApp' || !state.ncii
? 4
: 2,
detailsOpen: state.selectedOption
? OTHER_REPORT_REASONS.has(state.selectedOption?.reason)
: false,
+1
View File
@@ -194,6 +194,7 @@ export const VIDEO_SERVICE = 'https://video.bsky.app'
export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app'
export const VIDEO_MAX_DURATION_MS = 3 * 60 * 1000 // 3 minutes in milliseconds
export const VIDEO_10_MINUTE_MAX_DURATION_MS = 10 * 60 * 1000
/**
* Maximum size of a video in megabytes, _not_ mebibytes. Backend uses
* ISO megabytes.
+10 -2
View File
@@ -84,11 +84,19 @@ export function useIntentHandler() {
}
case 'apply-ota': {
const channel = params.get('channel')
const releaseVersion = params.get('releaseVersion')
const buildNumber = params.get(
IS_IOS ? 'iosBuildNumber' : 'androidBuildNumber',
)
const appVersion =
releaseVersion && buildNumber
? `${releaseVersion}.${buildNumber}`
: null
if (!channel) {
Alert.alert('Error', 'No channel provided to look for.')
} else {
tryApplyUpdate(channel)
return
}
tryApplyUpdate(channel, appVersion)
return
}
default: {
+354
View File
@@ -0,0 +1,354 @@
import {Alert} from 'react-native'
import {
checkForUpdateAsync,
fetchUpdateAsync,
reloadAsync,
setExtraParamAsync,
UpdateCheckResultNotAvailableReason,
useUpdates,
} from 'expo-updates'
import {act, renderHook, waitFor} from '@testing-library/react-native'
import {logger} from '#/logger'
import {APP_VERSION} from '#/env'
import {device} from '#/storage'
import {
useApplyPullRequestOTAUpdate,
useOTAUpdateRecovery,
} from './useOTAUpdates'
jest.mock('expo-updates', () => ({
checkForUpdateAsync: jest.fn(),
fetchUpdateAsync: jest.fn(),
isEnabled: true,
reloadAsync: jest.fn(),
setExtraParamAsync: jest.fn(),
UpdateCheckResultNotAvailableReason: {
NO_UPDATE_AVAILABLE_ON_SERVER: 'noUpdateAvailableOnServer',
UPDATE_PREVIOUSLY_FAILED: 'updatePreviouslyFailed',
},
useUpdates: jest.fn(),
}))
jest.mock('#/logger', () => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
},
}))
jest.mock('#/storage', () => ({
device: {
get: jest.fn(),
remove: jest.fn(),
set: jest.fn(),
},
}))
jest.mock('#/alf', () => ({
useTheme: jest.fn().mockImplementation(() => ({
scheme: 'light',
})),
}))
/**
* `channel` here is the build-time constant baked into the native build, not the
* channel of the running bundle. `channel` is passed as the manifest metadata
* channel our update server stamps into every published update - omit it to
* simulate an embedded launch, which has no server manifest.
*/
function mockCurrentlyRunning({
buildChannel = 'testflight',
channel,
updateId = 'current-update',
}: {
buildChannel?: string
channel?: string
updateId?: string
} = {}) {
const currentlyRunning = {
channel: buildChannel,
emergencyLaunchReason: null,
isEmbeddedLaunch: !channel,
isEmergencyLaunch: false,
updateId,
manifest: channel ? {id: updateId, metadata: {channel}} : undefined,
}
jest.mocked(useUpdates).mockReturnValue({
currentlyRunning,
} as ReturnType<typeof useUpdates>)
return currentlyRunning
}
const currentUpdate = {updateId: 'current-update'}
beforeEach(() => {
jest.clearAllMocks()
mockCurrentlyRunning()
jest.mocked(setExtraParamAsync).mockResolvedValue(undefined)
jest.mocked(reloadAsync).mockResolvedValue(undefined)
jest.spyOn(Alert, 'alert').mockImplementation(() => {})
})
describe('useApplyPullRequestOTAUpdate', () => {
it('detects a running PR deployment from the manifest metadata', () => {
mockCurrentlyRunning({
buildChannel: 'testflight',
channel: 'pull-request-123',
})
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
expect(result.current.currentChannel).toBe('pull-request-123')
expect(result.current.isCurrentlyRunningPullRequestDeployment).toBe(true)
expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(true)
})
it('treats a standard downloaded update as a standard channel', () => {
mockCurrentlyRunning({buildChannel: 'testflight', channel: 'testflight'})
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
expect(result.current.currentChannel).toBe('testflight')
expect(result.current.isCurrentlyRunningPullRequestDeployment).toBe(false)
expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(false)
})
it('falls back to the build channel for an embedded launch', () => {
mockCurrentlyRunning({buildChannel: 'testflight'})
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
expect(result.current.currentChannel).toBe('testflight')
expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(false)
})
it('reports no channel when updates are disabled', () => {
mockCurrentlyRunning({buildChannel: ''})
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
expect(result.current.currentChannel).toBeUndefined()
expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(false)
})
it('stays quiet when already running the latest of the requested channel', async () => {
mockCurrentlyRunning({
buildChannel: 'testflight',
channel: 'pull-request-123',
})
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: false,
reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-123'))
expect(Alert.alert).not.toHaveBeenCalled()
})
it('warns when no deployment is available for a different channel', async () => {
mockCurrentlyRunning({
buildChannel: 'testflight',
channel: 'pull-request-123',
})
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: false,
reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-456'))
expect(Alert.alert).toHaveBeenCalledWith(
'No Deployment Available',
expect.stringContaining('pull-request-456'),
)
})
it('stays silent on a re-fired intent even when the app version differs', async () => {
mockCurrentlyRunning({
buildChannel: 'testflight',
channel: 'pull-request-123',
})
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: false,
reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0'))
expect(Alert.alert).not.toHaveBeenCalled()
expect(fetchUpdateAsync).not.toHaveBeenCalled()
})
it('prompts to apply an available update when the app version matches', async () => {
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: true,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() =>
result.current.tryApplyUpdate('pull-request-123', APP_VERSION),
)
expect(Alert.alert).toHaveBeenCalledWith(
'Apply update from PR #123?',
expect.stringContaining('relaunch'),
expect.arrayContaining([expect.objectContaining({text: 'Apply'})]),
)
})
it('warns before applying an OTA built for a different app version', async () => {
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: true,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
jest.mocked(fetchUpdateAsync).mockResolvedValue({
isNew: true,
isRollBackToEmbedded: false,
manifest: {id: 'mismatched-update'},
} as Awaited<ReturnType<typeof fetchUpdateAsync>>)
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0'))
expect(Alert.alert).toHaveBeenCalledWith(
'App Version Mismatch',
expect.stringContaining('Applying it anyway may cause'),
expect.arrayContaining([expect.objectContaining({text: 'Apply Anyway'})]),
)
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
act(() => buttons?.[1].onPress?.())
await waitFor(() => expect(reloadAsync).toHaveBeenCalled())
expect(device.set).toHaveBeenCalledWith(['pendingOTAUpdate'], {
attemptedAt: expect.any(Number),
channel: 'pull-request-123',
updateId: 'mismatched-update',
})
})
it('informs the user when checking for an OTA fails', async () => {
jest.mocked(checkForUpdateAsync).mockRejectedValue(new Error('offline'))
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-123'))
expect(Alert.alert).toHaveBeenCalledWith(
'Update Check Failed',
expect.stringContaining('Error: offline'),
)
expect(result.current.pending).toBe(false)
})
it('informs the user when downloading an OTA fails', async () => {
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: true,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
jest
.mocked(fetchUpdateAsync)
.mockRejectedValue(new Error('download failed'))
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-123'))
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
act(() => buttons?.[1].onPress?.())
await waitFor(() =>
expect(Alert.alert).toHaveBeenLastCalledWith(
'Update Failed',
expect.stringContaining('Error: download failed'),
),
)
expect(device.set).not.toHaveBeenCalled()
expect(result.current.pending).toBe(false)
})
it('clears the recovery marker and informs the user when reloading fails', async () => {
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: true,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
jest.mocked(fetchUpdateAsync).mockResolvedValue({
isNew: true,
isRollBackToEmbedded: false,
manifest: {id: 'new-update'},
} as Awaited<ReturnType<typeof fetchUpdateAsync>>)
jest.mocked(reloadAsync).mockRejectedValue(new Error('reload failed'))
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-123'))
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
act(() => buttons?.[1].onPress?.())
await waitFor(() =>
expect(Alert.alert).toHaveBeenLastCalledWith(
'Update Failed',
expect.stringContaining('Error: reload failed'),
),
)
expect(device.set).toHaveBeenCalledWith(['pendingOTAUpdate'], {
attemptedAt: expect.any(Number),
channel: 'pull-request-123',
updateId: 'new-update',
})
expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate'])
expect(result.current.pending).toBe(false)
})
})
describe('useOTAUpdateRecovery', () => {
it('informs the user when Expo fell back from the attempted OTA', async () => {
jest.mocked(device.get).mockReturnValue({
attemptedAt: Date.now(),
channel: 'pull-request-123',
updateId: 'failed-update',
})
renderHook(() => useOTAUpdateRecovery())
await waitFor(() =>
expect(Alert.alert).toHaveBeenCalledWith(
'Update Failed',
expect.stringContaining('PR #123 deployment could not start'),
),
)
expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate'])
expect(logger.error).toHaveBeenCalledWith(
'Custom OTA Update Failed to Launch',
expect.objectContaining({attemptedUpdateId: 'failed-update'}),
)
})
it('recognizes a launched OTA when the update ID casing differs', () => {
jest.mocked(device.get).mockReturnValue({
attemptedAt: Date.now(),
channel: 'pull-request-123',
updateId: currentUpdate.updateId.toUpperCase(),
})
renderHook(() => useOTAUpdateRecovery())
expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate'])
expect(Alert.alert).not.toHaveBeenCalled()
expect(logger.error).not.toHaveBeenCalled()
})
it('silently clears a stale marker from an older OTA bundle', () => {
jest.mocked(device.get).mockReturnValue({
attemptedAt: Date.now() - 10 * 60e3,
channel: 'pull-request-123',
updateId: 'older-update',
})
renderHook(() => useOTAUpdateRecovery())
expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate'])
expect(Alert.alert).not.toHaveBeenCalled()
expect(logger.error).not.toHaveBeenCalled()
})
})
+235 -27
View File
@@ -1,20 +1,31 @@
import {useCallback, useEffect, useRef, useState} from 'react'
import {Alert, AppState, type AppStateStatus} from 'react-native'
import {
Alert,
AppState,
type AppStateStatus,
Image as RNImage,
} from 'react-native'
import {nativeBuildVersion} from 'expo-application'
import {
checkForUpdateAsync,
type CurrentlyRunningInfo,
fetchUpdateAsync,
isEnabled,
reloadAsync,
type ReloadScreenOptions,
setExtraParamAsync,
UpdateCheckResultNotAvailableReason,
useUpdates,
} from 'expo-updates'
import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {IS_IOS, IS_TESTFLIGHT} from '#/env'
import {useTheme} from '#/alf'
import {APP_VERSION, IS_IOS, IS_TESTFLIGHT} from '#/env'
import {device} from '#/storage'
const MINIMUM_MINIMIZE_TIME = 15 * 60e3
const OTA_RECOVERY_WINDOW = 5 * 60e3
/**
* The channel this native build is expected to receive updates from. Anything
@@ -28,6 +39,37 @@ const DEFAULT_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
*/
const STANDARD_CHANNELS = ['production', 'testflight', 'development']
function getDeploymentName(channel: string) {
const pullRequestNumber = channel.match(/^pull-request-(\d+)$/)?.[1]
return pullRequestNumber ? `PR #${pullRequestNumber}` : channel
}
/**
* The channel of the update bundle that is actually running. The
* `currentlyRunning.channel` constant only reflects the channel baked into the
* native build config, so a manually applied deployment (e.g. a pull request
* channel) must be detected from the manifest metadata our update server stamps
* into every published update. Embedded launches have no server manifest and
* fall back to the build constant.
*/
function getRunningChannel(
currentlyRunning: CurrentlyRunningInfo | undefined,
): string | undefined {
/*
* `metadata` is typed as a bare `object` by expo-manifests, and is absent
* entirely from embedded manifests, so narrow it ourselves.
*/
const manifest = currentlyRunning?.manifest as
| {metadata?: {channel?: unknown}}
| undefined
const channel = manifest?.metadata?.channel
if (typeof channel === 'string' && channel) {
return channel
}
// The build constant is an empty string rather than null when unconfigured.
return currentlyRunning?.channel || undefined
}
async function setExtraParams() {
await setExtraParamAsync(
IS_IOS ? 'ios-build-number' : 'android-build-number',
@@ -48,7 +90,7 @@ async function setExtraParamsPullRequest(channel: string) {
await setExtraParamAsync('channel', channel)
}
async function updateTestflight() {
async function updateTestflight(scheme: 'light' | 'dark') {
await setExtraParams()
const res = await checkForUpdateAsync()
@@ -66,7 +108,9 @@ async function updateTestflight() {
text: 'Relaunch',
style: 'default',
onPress: async () => {
await reloadAsync()
await reloadAsync({
reloadScreenOptions: splash(scheme),
})
},
},
],
@@ -75,50 +119,145 @@ async function updateTestflight() {
}
export function useApplyPullRequestOTAUpdate() {
const t = useTheme()
const {currentlyRunning} = useUpdates()
const [pending, setPending] = useState(false)
const currentChannel = currentlyRunning?.channel
const currentChannel = getRunningChannel(currentlyRunning)
const isCurrentlyRunningPullRequestDeployment =
currentChannel?.startsWith('pull-request')
/*
* Covers pull request deployments as well as any other channel we manually
* applied an update from. Note that `channel` is null when updates are
* applied an update from. Note that the channel is undefined when updates are
* disabled (e.g. in dev), in which case there's nothing to restore.
*/
const isCurrentlyRunningNonStandardChannel = Boolean(
currentChannel && !STANDARD_CHANNELS.includes(currentChannel),
)
const tryApplyUpdate = async (channel: string) => {
const tryApplyUpdate = async (
channel: string,
declaredAppVersion?: string | null,
) => {
const deploymentName = getDeploymentName(channel)
const checkForDeployment = async () => {
await setExtraParamsPullRequest(channel)
const res = await checkForUpdateAsync()
if (!res.isAvailable) {
if (
res.reason ===
UpdateCheckResultNotAvailableReason.UPDATE_PREVIOUSLY_FAILED
) {
Alert.alert(
'Deployment Blocked',
`The ${deploymentName} deployment previously failed to start on this device, so the app will not try to apply it again.`,
)
} else if (currentChannel !== channel) {
Alert.alert(
'No Deployment Available',
`No new deployments of ${channel} are currently available for your current native build.`,
)
}
}
return res.isAvailable
}
const applyUpdate = () => {
setPending(true)
void (async () => {
try {
if (!(await checkForDeployment())) return
const fetchedUpdate = await fetchUpdateAsync()
if (!fetchedUpdate.isNew) {
throw new Error('Expo did not download a new update.')
}
device.set(['pendingOTAUpdate'], {
attemptedAt: Date.now(),
channel,
updateId: fetchedUpdate.manifest.id,
})
try {
/*
* TODO: once expo-linking is upgraded to >= 57, enable this so the
* re-delivered initial URL doesn't trigger a redundant silent check
* after the reload.
*/
// Linking.clearInitialURL()
await reloadAsync({
reloadScreenOptions: splash(t.scheme),
})
} catch (e) {
device.remove(['pendingOTAUpdate'])
throw e
}
} catch (e: unknown) {
const error = String(e)
logger.error('Internal OTA Update Error', {error})
Alert.alert(
'Update Failed',
`Could not apply the ${deploymentName} deployment: ${error}`,
)
} finally {
setPending(false)
}
})()
}
/*
* Check before prompting about anything, so that re-running this while
* already on the newest update of `channel` stays silent. Reloading into an
* update re-delivers the deep link that triggered it, and the same link may
* also just be tapped again.
*/
setPending(true)
await setExtraParamsPullRequest(channel)
const res = await checkForUpdateAsync()
if (res.isAvailable) {
try {
if (!(await checkForDeployment())) return
if (declaredAppVersion && declaredAppVersion !== APP_VERSION) {
Alert.alert(
'App Version Mismatch',
`This OTA update was built for a different version of the app.\n\nCurrent app version: ${APP_VERSION}\nOTA app version: ${declaredAppVersion}\n\nApplying it anyway may cause the app to stop working and require a reinstall.`,
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Apply Anyway',
style: 'destructive',
onPress: applyUpdate,
},
],
)
return
}
Alert.alert(
'Deployment Available',
`A deployment of ${channel} is availalble. Applying this deployment may result in a bricked installation, in which case you will need to reinstall the app and may lose local data. Are you sure you want to proceed?`,
`Apply update from ${deploymentName}?`,
'The app will relaunch after the update is applied.',
[
{
text: 'No',
text: 'Cancel',
style: 'cancel',
},
{
text: 'Relaunch',
text: 'Apply',
style: 'default',
onPress: async () => {
await fetchUpdateAsync()
await reloadAsync()
},
onPress: applyUpdate,
},
],
)
} else {
} catch (e: unknown) {
const error = String(e)
logger.error('Internal OTA Update Error', {error})
Alert.alert(
'No Deployment Available',
`No new deployments of ${channel} are currently available for your current native build.`,
'Update Check Failed',
`Could not check the ${deploymentName} deployment: ${error}`,
)
} finally {
setPending(false)
}
setPending(false)
}
/**
@@ -161,15 +300,60 @@ export function useApplyPullRequestOTAUpdate() {
}
}
/**
* Reports when expo-updates recovered from a custom OTA that failed to launch.
* The attempted update ID is persisted before reload so the previous bundle can
* distinguish a successful relaunch from an automatic fallback.
*/
export function useOTAUpdateRecovery() {
const {currentlyRunning} = useUpdates()
useEffect(() => {
const pendingUpdate = device.get(['pendingOTAUpdate'])
if (!pendingUpdate || !currentlyRunning) return
device.remove(['pendingOTAUpdate'])
if (
pendingUpdate.updateId.toLowerCase() ===
currentlyRunning.updateId?.toLowerCase()
) {
return
}
// A fallback relaunch is immediate. A stale marker can be left by a
// successful runtime-compatible bundle that predates this hook.
if (
typeof pendingUpdate.attemptedAt !== 'number' ||
Date.now() - pendingUpdate.attemptedAt >= OTA_RECOVERY_WINDOW
) {
return
}
const deploymentName = getDeploymentName(pendingUpdate.channel)
logger.error('Custom OTA Update Failed to Launch', {
channel: pendingUpdate.channel,
attemptedUpdateId: pendingUpdate.updateId,
currentUpdateId: currentlyRunning.updateId,
isEmergencyLaunch: currentlyRunning.isEmergencyLaunch,
emergencyLaunchReason: currentlyRunning.emergencyLaunchReason,
})
Alert.alert(
'Update Failed',
`The ${deploymentName} deployment could not start. The app recovered by loading a working version instead.`,
)
}, [currentlyRunning])
}
export function useOTAUpdates() {
const shouldReceiveUpdates = isEnabled && !__DEV__
const t = useTheme()
const appState = useRef<AppStateStatus>('active')
const lastMinimize = useRef(0)
const ranInitialCheck = useRef(false)
const timeout = useRef<NodeJS.Timeout>(undefined)
const {currentlyRunning, isUpdatePending} = useUpdates()
const currentChannel = currentlyRunning?.channel
const currentChannel = getRunningChannel(currentlyRunning)
const setCheckTimeout = useCallback(() => {
timeout.current = setTimeout(async () => {
@@ -195,13 +379,13 @@ export function useOTAUpdates() {
const onIsTestFlight = useCallback(async () => {
try {
await updateTestflight()
await updateTestflight(t.scheme)
} catch (err: any) {
if (!isNetworkError(err)) {
logger.error('Internal OTA Update Error', {safeMessage: err})
}
}
}, [])
}, [t.scheme])
useEffect(() => {
// We don't need to check anything if the current update is a PR update
@@ -243,7 +427,9 @@ export function useOTAUpdates() {
// chances are that there isn't anything important going on in the current session.
if (lastMinimize.current <= Date.now() - MINIMUM_MINIMIZE_TIME) {
if (isUpdatePending) {
await reloadAsync()
await reloadAsync({
reloadScreenOptions: splash(t.scheme),
})
} else {
setCheckTimeout()
}
@@ -260,5 +446,27 @@ export function useOTAUpdates() {
clearTimeout(timeout.current)
subscription.remove()
}
}, [isUpdatePending, currentChannel, setCheckTimeout])
}, [isUpdatePending, currentChannel, setCheckTimeout, t.scheme])
}
/**
* Splash screen for while the app is updating
*/
export const splash = (scheme: 'light' | 'dark') => {
const source =
scheme === 'light'
? require('../../../assets/splash/splash.png')
: require('../../../assets/splash/splash-dark.png')
return {
image: RNImage.resolveAssetSource(source).uri,
imageFullScreen: true,
imageResizeMode: 'cover',
backgroundColor: scheme === 'light' ? '#006AFF' : '#002861',
spinner: {
enabled: true,
color: '#ffffff',
size: 'large',
},
} satisfies ReloadScreenOptions
}
+5 -1
View File
@@ -1,7 +1,11 @@
export function useOTAUpdates() {}
export function useOTAUpdateRecovery() {}
export function useApplyPullRequestOTAUpdate() {
return {
tryApplyUpdate: async (_channel: string) => {},
tryApplyUpdate: async (
_channel: string,
_declaredAppVersion?: string | null,
) => {},
restoreDefaultChannel: async () => {},
isCurrentlyRunningPullRequestDeployment: false,
isCurrentlyRunningNonStandardChannel: false,
+3 -1
View File
@@ -47,8 +47,10 @@ export async function openPicker(opts?: ImagePickerOptions) {
export async function openUnifiedPicker({
selectionCountRemaining,
videoMaxDurationMs = VIDEO_MAX_DURATION_MS,
}: {
selectionCountRemaining: number
videoMaxDurationMs?: number
}) {
return await launchImageLibraryAsync({
exif: false,
@@ -61,6 +63,6 @@ export async function openUnifiedPicker({
preferredAssetRepresentationMode:
UIImagePickerPreferredAssetRepresentationMode.Automatic,
videoExportPreset: VideoExportPreset.Passthrough,
videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000,
videoMaxDuration: videoMaxDurationMs / 1000,
})
}
+3 -3
View File
@@ -17,11 +17,11 @@ export async function openCamera(customOpts: ImagePickerOptions) {
}
const res = await launchCameraAsync(opts)
if (!res || !res.assets) {
throw new Error('Camera was closed before taking a photo')
if (res.canceled) {
return
}
const asset = res?.assets[0]
const asset = res.assets[0]
return {
path: asset.uri,
@@ -0,0 +1,37 @@
import {createProgressAggregator} from './aggregateProgress'
describe('createProgressAggregator', () => {
it('sums bytes across parts against the total', () => {
const progress: number[] = []
const report = createProgressAggregator(100, p => progress.push(p))
report(1, 50)
report(2, 25)
expect(progress).toEqual([0.5, 0.75])
})
it('overwrites a part running count rather than adding it', () => {
const progress: number[] = []
const report = createProgressAggregator(100, p => progress.push(p))
report(1, 20)
report(1, 40)
expect(progress).toEqual([0.2, 0.4])
})
it('clamps to 1', () => {
const progress: number[] = []
const report = createProgressAggregator(100, p => progress.push(p))
report(1, 150)
expect(progress).toEqual([1])
})
it('reports 0 when the total is 0', () => {
const progress: number[] = []
const report = createProgressAggregator(0, p => progress.push(p))
report(1, 10)
expect(progress).toEqual([0])
})
})
@@ -0,0 +1,19 @@
/**
* Tracks bytes sent per part and reports overall progress (0..1) to the
* existing single-value progress callback. Parts upload concurrently, so each
* part reports its own running byte count and this sums them against the total.
*/
export function createProgressAggregator(
totalBytes: number,
setProgress: (progress: number) => void,
) {
const sentByPart = new Map<number, number>()
return function reportPartProgress(partNumber: number, bytesSent: number) {
sentByPart.set(partNumber, bytesSent)
let sum = 0
for (const value of sentByPart.values()) {
sum += value
}
setProgress(totalBytes > 0 ? Math.min(sum / totalBytes, 1) : 0)
}
}
+139
View File
@@ -0,0 +1,139 @@
import {AbortError} from '#/lib/async/cancelable'
import {createVideoEndpointUrl} from '#/lib/media/video/util'
import {
type AbortUploadResponse,
type FinishUploadResponse,
type StartUploadResponse,
type UploadStatusResponse,
} from './types'
export class MultipartUploadError extends Error {
constructor(
message: string,
public error?: string,
public status?: number,
) {
super(message)
this.name = 'MultipartUploadError'
}
}
async function request<T>({
route,
token,
signal,
method = 'POST',
body,
params,
}: {
route: string
token: string
signal?: AbortSignal
method?: 'GET' | 'POST'
body?: object
params?: Record<string, string>
}): Promise<T> {
if (signal?.aborted) throw new AbortError()
let res: Response
try {
res = await fetch(createVideoEndpointUrl(route, params), {
method,
headers: {
Authorization: `Bearer ${token}`,
...(body ? {'Content-Type': 'application/json'} : {}),
},
body: body ? JSON.stringify(body) : undefined,
signal,
})
} catch (err) {
if (signal?.aborted) throw new AbortError()
throw err
}
const text = await res.text()
let data: unknown
try {
data = text ? JSON.parse(text) : undefined
} catch {}
if (!res.ok) {
const xrpc = data as {error?: string; message?: string} | undefined
throw new MultipartUploadError(
xrpc?.message || xrpc?.error || `Video service returned ${res.status}`,
xrpc?.error,
res.status,
)
}
return data as T
}
export function startUpload({
token,
video,
name,
signal,
}: {
token: string
video: {size: number; mimeType: string}
name: string
signal: AbortSignal
}) {
return request<StartUploadResponse>({
route: '/xrpc/app.bsky.video.startUpload',
token,
signal,
body: {sizeBytes: video.size, mimeType: video.mimeType, name},
})
}
export function finishUpload(
jobId: string,
token: string,
signal: AbortSignal,
) {
return request<FinishUploadResponse>({
route: '/xrpc/app.bsky.video.finishUpload',
token,
signal,
body: {jobId},
})
}
export function getUploadStatus(
jobId: string,
token: string,
signal?: AbortSignal,
) {
return request<UploadStatusResponse>({
route: '/xrpc/app.bsky.video.getUploadStatus',
token,
signal,
method: 'GET',
params: {jobId},
})
}
export function abortUpload(
jobId: string,
token: string,
signal?: AbortSignal,
) {
return request<AbortUploadResponse>({
route: '/xrpc/app.bsky.video.abortUpload',
token,
signal,
body: {jobId},
})
}
export function completedStatus(status: UploadStatusResponse) {
if (
status.state !== 'completed' ||
!status.completedJobId ||
!status.jobStatus
) {
return undefined
}
return {
completedJobId: status.completedJobId,
jobStatus: status.jobStatus,
}
}
@@ -0,0 +1,22 @@
/*
* Multipart upload knobs. Tunable; part size must stay above the storage
* backend's minimum (R2/S3 require >= 5 MiB per part, except the last).
*/
/** Max parts uploaded concurrently. */
export const MULTIPART_CONCURRENCY = 4
/** Per-part upload attempts before the part (and the upload) fails. */
export const MULTIPART_MAX_ATTEMPTS = 5
/** Maximum time to wait for an individual part request to settle. */
export const MULTIPART_PART_TIMEOUT_MS = 120_000
/** Maximum time to wait for each best-effort abort request. */
export const MULTIPART_ABORT_TIMEOUT_MS = 10_000
/** Attempts to release a failed multipart upload reservation. */
export const MULTIPART_ABORT_ATTEMPTS = 3
/** Attempts to begin/continue server-side finalization before checking state. */
export const MULTIPART_FINISH_ATTEMPTS = 3
@@ -0,0 +1,47 @@
import {getMissingParts, planParts} from './planParts'
describe('planParts', () => {
it('splits an evenly divisible size into full parts', () => {
expect(planParts(20, 10)).toEqual([
{partNumber: 1, offset: 0, size: 10},
{partNumber: 2, offset: 10, size: 10},
])
})
it('puts the remainder in the last part', () => {
expect(planParts(25, 10)).toEqual([
{partNumber: 1, offset: 0, size: 10},
{partNumber: 2, offset: 10, size: 10},
{partNumber: 3, offset: 20, size: 5},
])
})
it('returns a single part when the file is smaller than a part', () => {
expect(planParts(5, 10)).toEqual([{partNumber: 1, offset: 0, size: 5}])
})
it('returns no parts for a non-positive size', () => {
expect(planParts(0, 10)).toEqual([])
})
it('covers the whole file with no gaps or overlaps', () => {
const parts = planParts(1000, 128)
expect(parts[0].offset).toBe(0)
for (let i = 1; i < parts.length; i++) {
expect(parts[i].offset).toBe(parts[i - 1].offset + parts[i - 1].size)
}
const last = parts[parts.length - 1]
expect(last.offset + last.size).toBe(1000)
})
it('throws for a non-positive part size', () => {
expect(() => planParts(100, 0)).toThrow()
})
it('selects only parts the server has not received', () => {
const parts = planParts(25, 10)
expect(getMissingParts(parts, [1, 3])).toEqual([
{partNumber: 2, offset: 10, size: 10},
])
})
})
@@ -0,0 +1,30 @@
import {type PartPlan} from './types'
/**
* Splits a file of `totalSize` bytes into parts of at most `partSize` bytes.
* The last part carries the remainder. Part numbers are 1-indexed. Returns an
* empty list for a non-positive size.
*/
export function planParts(totalSize: number, partSize: number): PartPlan[] {
if (partSize <= 0) {
throw new Error('partSize must be positive')
}
const parts: PartPlan[] = []
let offset = 0
let partNumber = 1
while (offset < totalSize) {
const size = Math.min(partSize, totalSize - offset)
parts.push({partNumber, offset, size})
offset += size
partNumber += 1
}
return parts
}
export function getMissingParts(
parts: PartPlan[],
receivedPartNumbers: number[],
): PartPlan[] {
const received = new Set(receivedPartNumbers)
return parts.filter(part => !received.has(part.partNumber))
}
@@ -0,0 +1,21 @@
import {File} from 'expo-file-system'
import {type CompressedVideo} from '#/lib/media/video/types'
import {type ChunkReader} from './types'
/**
* Native chunk reader. Opens one file handle and seeks per read, so the video
* bytes are never all held in JS memory. Call `close` when the upload finishes.
*/
export function createChunkReader(video: CompressedVideo): ChunkReader {
const handle = new File(video.uri).open()
return {
read(offset, size) {
handle.offset = offset
return Promise.resolve(handle.readBytes(size))
},
close() {
handle.close()
},
}
}
@@ -0,0 +1,27 @@
import {type CompressedVideo} from '#/lib/media/video/types'
import {type ChunkReader} from './types'
/**
* Web chunk reader. Web compression already produces the full buffer, so this
* slices it in memory. Falls back to fetching the uri once if `bytes` is
* missing. `close` is a no-op.
*/
export function createChunkReader(video: CompressedVideo): ChunkReader {
let bytesPromise: Promise<ArrayBuffer> | null = null
const getBytes = () => {
if (video.bytes) {
return Promise.resolve(video.bytes)
}
if (!bytesPromise) {
bytesPromise = fetch(video.uri).then(res => res.arrayBuffer())
}
return bytesPromise
}
return {
async read(offset, size) {
const buffer = await getBytes()
return new Uint8Array(buffer, offset, size)
},
close() {},
}
}
+72
View File
@@ -0,0 +1,72 @@
/**
* One part of a multipart upload. `partNumber` is 1-indexed to match the S3
* convention the backend uses.
*/
export type PartPlan = {
partNumber: number
offset: number
size: number
}
/** Receipt returned by the video service after recording a part. */
export type PartUploadResult = {
partNumber: number
sizeBytes: number
}
/**
* Reads a byte range off the compressed video. Native opens a file handle and
* seeks; web slices an in-memory buffer. `close` releases the native handle and
* is a no-op on web.
*/
export type ChunkReader = {
read: (offset: number, size: number) => Promise<Uint8Array>
close: () => void
}
/**
* Uploads one part through the video service's first-party proxy.
*/
export type UploadPartFn = (args: {
part: PartPlan
chunk: Uint8Array
onProgress: (bytesSent: number) => void
signal: AbortSignal
}) => Promise<PartUploadResult>
export type StartUploadResponse = {
jobId: string
partSizeBytes: number
partCount: number
expiresAt: string
}
export type UploadState =
| 'created'
| 'finishing'
| 'completed'
| 'failed'
| 'aborted'
| 'expired'
export type UploadStatusResponse = {
jobId: string
partSizeBytes: number
partCount: number
receivedParts: number[]
expiresAt: string
state: UploadState
completedJobId?: string
jobStatus?: import('@atproto/api').AppBskyVideoDefs.JobStatus
failureReason?: string
}
export type FinishUploadResponse = {
completedJobId: string
jobStatus: import('@atproto/api').AppBskyVideoDefs.JobStatus
}
export type AbortUploadResponse = Pick<
UploadStatusResponse,
'completedJobId' | 'failureReason'
> & {state: 'aborted' | 'completed' | 'failed' | 'expired'}
+318
View File
@@ -0,0 +1,318 @@
import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api'
import {nanoid} from 'nanoid/non-secure'
import {AbortError} from '#/lib/async/cancelable'
import {type CompressedVideo} from '#/lib/media/video/types'
import {shouldRetryError} from '#/lib/strings/errors'
import {getServiceAuthToken} from '../upload.shared'
import {mimeToExt} from '../util'
import {
abortUpload,
completedStatus,
finishUpload,
getUploadStatus,
MultipartUploadError,
startUpload,
} from './api'
import {
MULTIPART_ABORT_ATTEMPTS,
MULTIPART_ABORT_TIMEOUT_MS,
MULTIPART_FINISH_ATTEMPTS,
} from './constants'
import {getMissingParts, planParts} from './planParts'
import {createChunkReader} from './readChunk'
import {createUploadPart} from './uploadPart'
import {uploadParts} from './uploadParts'
import {delay, isRetryableMultipartError, retryDelayMs} from './utils'
export class MultipartFallbackError extends Error {}
export async function uploadVideoMultipart({
video,
agent,
setProgress,
signal,
onStarted,
}: {
video: CompressedVideo
agent: AtpAgent
setProgress: (progress: number) => void
signal: AbortSignal
onStarted?: () => void
}): Promise<AppBskyVideoDefs.JobStatus> {
throwIfAborted(signal)
const tokenProvider = createTokenProvider(agent, signal)
const token = await tokenProvider.get()
const name = `${nanoid(12)}.${mimeToExt(video.mimeType)}`
let session
try {
session = await startUpload({token, video, name, signal})
} catch (err) {
if (signal.aborted) throw new AbortError()
// A server without multipart support, or one with the kill switch active,
// leaves no reservation behind. The legacy path remains authoritative.
throw new MultipartFallbackError(
err instanceof Error ? err.message : 'Multipart upload unavailable',
)
}
onStarted?.()
const {jobId} = session
const abortOnCancel = () => {
void tokenProvider
.get()
.then(currentToken => abortUpload(jobId, currentToken))
.catch(() => {})
}
signal.addEventListener('abort', abortOnCancel, {once: true})
let reader: ReturnType<typeof createChunkReader> | undefined
// Kept outside the upload try block for finish-time missing-part recovery.
let parts: ReturnType<typeof planParts> = []
try {
try {
reader = createChunkReader(video)
parts = planParts(video.size, session.partSizeBytes)
if (parts.length !== session.partCount) {
throw new Error('Video service returned an invalid multipart plan')
}
await uploadParts({
parts,
reader,
uploadPart: createUploadPart(jobId, tokenProvider.get),
totalBytes: video.size,
setProgress,
signal,
})
} catch (err) {
if (signal.aborted) throw new AbortError()
return await abortThenFallbackOrResolve(
jobId,
await tokenProvider.get(),
err,
)
}
// Preserve TypeScript's narrowing inside the recovery callback.
const activeReader = reader
if (!activeReader) throw new Error('Video chunk reader is unavailable')
return await finishAndRecover({
jobId,
getToken: tokenProvider.get,
signal,
resendMissingParts: async receivedPartNumbers => {
const missing = getMissingParts(parts, receivedPartNumbers)
if (missing.length === 0) return false
const missingBytes = missing.reduce((sum, part) => sum + part.size, 0)
const completedBytes = video.size - missingBytes
await uploadParts({
parts: missing,
reader: activeReader,
uploadPart: createUploadPart(jobId, tokenProvider.get),
totalBytes: missingBytes,
setProgress: progress =>
setProgress(
(completedBytes + progress * missingBytes) / video.size,
),
signal,
})
return true
},
})
} finally {
reader?.close()
signal.removeEventListener('abort', abortOnCancel)
}
}
async function finishAndRecover({
jobId,
getToken,
signal,
resendMissingParts,
}: {
jobId: string
getToken: (forceRefresh?: boolean) => Promise<string>
signal: AbortSignal
resendMissingParts: (receivedPartNumbers: number[]) => Promise<boolean>
}): Promise<AppBskyVideoDefs.JobStatus> {
let createdFailures = 0
let forceTokenRefresh = true
while (true) {
throwIfAborted(signal)
// Finish stores this credential for the later PDS blob upload. Refresh it
// once after part transfer, then reuse it while polling/recovering.
const token = await getToken(forceTokenRefresh)
forceTokenRefresh = false
try {
const result = await finishUpload(jobId, token, signal)
return result.jobStatus
} catch (finishError) {
throwIfAborted(signal)
const status = await getUploadStatusWithRetry(jobId, token, signal)
const completed = completedStatus(status)
if (completed) return completed.jobStatus
switch (status.state) {
case 'created':
try {
const resentParts = await resendMissingParts(status.receivedParts)
if (resentParts) {
createdFailures = 0
continue
}
} catch (err) {
throwIfAborted(signal)
return await abortThenFallbackOrResolve(jobId, token, err)
}
createdFailures++
if (createdFailures < MULTIPART_FINISH_ATTEMPTS) {
await delay(500 * 2 ** (createdFailures - 1), signal)
continue
}
return await abortThenFallbackOrResolve(jobId, token, finishError)
case 'finishing':
// The service may have assembled the upload even though the finish
// request failed. Poll and retry instead of starting a second upload.
await delay(1000, signal)
continue
case 'failed':
throw new MultipartUploadError(
status.failureReason || 'Multipart upload failed',
'UploadFailed',
)
case 'aborted':
case 'expired':
throw new MultipartUploadError(
`Multipart upload ${status.state}`,
status.state === 'aborted' ? 'UploadAborted' : 'UploadExpired',
)
case 'completed':
throw new MultipartUploadError(
'Multipart upload completed without a job status',
'InvalidUploadStatus',
)
default:
throw new MultipartUploadError(
'Multipart upload returned an unknown status',
'InvalidUploadStatus',
)
}
}
}
}
async function getUploadStatusWithRetry(
jobId: string,
token: string,
signal: AbortSignal,
) {
let lastError: unknown
for (let attempt = 1; attempt <= 3; attempt++) {
try {
return await getUploadStatus(jobId, token, signal)
} catch (err) {
throwIfAborted(signal)
if (!isRetryableMultipartError(err)) throw err
lastError = err
if (attempt < 3) await delay(500 * 2 ** (attempt - 1), signal)
}
}
throw lastError
}
async function abortThenFallbackOrResolve(
jobId: string,
token: string,
cause: unknown,
): Promise<AppBskyVideoDefs.JobStatus> {
const result = await abortUploadWithRetry(jobId, token)
if (result.state === 'aborted') {
throw new MultipartFallbackError(
cause instanceof Error ? cause.message : 'Multipart upload failed',
)
}
if (result.state === 'completed' && result.completedJobId) {
const status = await getUploadStatus(jobId, token)
const completed = completedStatus(status)
if (completed) return completed.jobStatus
}
throw new MultipartUploadError(
result.failureReason || `Multipart upload ${result.state}`,
result.state === 'failed' ? 'UploadFailed' : undefined,
)
}
async function abortUploadWithRetry(jobId: string, token: string) {
let lastError: unknown
for (let attempt = 1; attempt <= MULTIPART_ABORT_ATTEMPTS; attempt++) {
const controller = new AbortController()
const timer = setTimeout(
() => controller.abort(),
MULTIPART_ABORT_TIMEOUT_MS,
)
try {
return await abortUpload(jobId, token, controller.signal)
} catch (err) {
lastError = err
if (attempt < MULTIPART_ABORT_ATTEMPTS) {
await delay(retryDelayMs(attempt), new AbortController().signal)
}
} finally {
clearTimeout(timer)
}
}
throw lastError
}
function createTokenProvider(agent: AtpAgent, signal: AbortSignal) {
let token: string | undefined
let expiresAt = 0
let refresh: Promise<string> | undefined
async function get(forceRefresh = false) {
if (!forceRefresh && token && Date.now() < expiresAt - 60_000) return token
if (!refresh) {
const exp = Math.floor(Date.now() / 1000) + 60 * 30
refresh = getServiceAuthTokenWithRetry(agent, exp, signal)
.then(nextToken => {
token = nextToken
expiresAt = exp * 1000
return nextToken
})
.finally(() => {
refresh = undefined
})
}
return refresh
}
return {get}
}
async function getServiceAuthTokenWithRetry(
agent: AtpAgent,
exp: number,
signal: AbortSignal,
) {
let lastError: unknown
for (let attempt = 1; attempt <= 3; attempt++) {
throwIfAborted(signal)
try {
return await getServiceAuthToken({
agent,
lxm: 'com.atproto.repo.uploadBlob',
exp,
})
} catch (err) {
throwIfAborted(signal)
if (!(err instanceof TypeError) && !shouldRetryError(err)) throw err
lastError = err
if (attempt < 3) await delay(500 * 2 ** (attempt - 1), signal)
}
}
throw lastError
}
function throwIfAborted(signal: AbortSignal) {
if (signal.aborted) throw new AbortError()
}
+131
View File
@@ -0,0 +1,131 @@
import {AbortError} from '#/lib/async/cancelable'
import {createVideoEndpointUrl} from '#/lib/media/video/util'
import {MultipartUploadError} from './api'
import {MULTIPART_PART_TIMEOUT_MS} from './constants'
import {type UploadPartFn} from './types'
export function createUploadPart(
jobId: string,
getToken: (forceRefresh?: boolean) => Promise<string>,
): UploadPartFn {
return async args => {
try {
return await sendPart(jobId, await getToken(), args)
} catch (err) {
if (
err instanceof MultipartUploadError &&
(err.status === 401 || err.error === 'AuthRequired')
) {
args.onProgress(0)
return await sendPart(jobId, await getToken(true), args)
}
throw err
}
}
}
function sendPart(
jobId: string,
token: string,
{part, chunk, onProgress, signal}: Parameters<UploadPartFn>[0],
) {
return new Promise<Awaited<ReturnType<UploadPartFn>>>((resolve, reject) => {
if (signal.aborted) {
reject(new AbortError())
return
}
const xhr = new XMLHttpRequest()
xhr.timeout = MULTIPART_PART_TIMEOUT_MS
const abort = () => xhr.abort()
signal.addEventListener('abort', abort, {once: true})
let settled = false
const cleanup = () => {
signal.removeEventListener('abort', abort)
xhr.onreadystatechange = null
}
const rejectOnce = (err: Error) => {
if (settled) return
settled = true
cleanup()
reject(err)
}
const resolveOnce = (result: Awaited<ReturnType<UploadPartFn>>) => {
if (settled) return
settled = true
cleanup()
resolve(result)
}
xhr.upload.addEventListener('progress', event => {
onProgress(event.loaded)
})
xhr.onerror = () => {
rejectOnce(new TypeError('Network request failed'))
}
xhr.ontimeout = () => {
rejectOnce(new TypeError('Multipart part upload timed out'))
}
xhr.onabort = () => {
rejectOnce(new AbortError())
}
xhr.onload = () => {
let data: {
partNumber?: number
sizeBytes?: number
error?: string
message?: string
}
try {
data = JSON.parse(xhr.responseText)
} catch {
data = {}
}
if (xhr.status < 200 || xhr.status >= 300) {
rejectOnce(
new MultipartUploadError(
data.message ||
data.error ||
`Video service returned ${xhr.status}`,
data.error,
xhr.status,
),
)
} else {
onProgress(part.size)
resolveOnce({
partNumber: data.partNumber ?? part.partNumber,
sizeBytes: data.sizeBytes ?? part.size,
})
}
}
xhr.onreadystatechange = () => {
if (
xhr.readyState === XMLHttpRequest.HEADERS_RECEIVED &&
xhr.status >= 400
) {
// React Native does not dispatch `load` until the response body has
// completed. Reject from the headers so a stalled 5xx response body
// cannot prevent the retry loop (or eventual abortUpload) from running.
const status = xhr.status
rejectOnce(
new MultipartUploadError(
`Video service returned ${status}`,
undefined,
status,
),
)
xhr.abort()
}
}
xhr.open(
'POST',
createVideoEndpointUrl('/xrpc/app.bsky.video.uploadPart', {
jobId,
partNumber: String(part.partNumber),
}),
)
xhr.setRequestHeader('Content-Type', 'application/octet-stream')
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.send(chunk as XMLHttpRequestBodyInit)
})
}
@@ -0,0 +1,237 @@
import {AbortError} from '#/lib/async/cancelable'
import {MultipartUploadError} from './api'
import {type ChunkReader, type UploadPartFn} from './types'
import {uploadParts} from './uploadParts'
function fakeReader(): ChunkReader {
return {
read: (_offset, size) => Promise.resolve(new Uint8Array(size)),
close: () => {},
}
}
const parts = [
{partNumber: 1, offset: 0, size: 10},
{partNumber: 2, offset: 10, size: 10},
{partNumber: 3, offset: 20, size: 5},
]
describe('uploadParts', () => {
it('uploads every part and returns results ordered by part number', async () => {
const uploadPart: UploadPartFn = ({part}) =>
Promise.resolve({
partNumber: part.partNumber,
sizeBytes: part.size,
})
const results = await uploadParts({
parts,
reader: fakeReader(),
uploadPart,
totalBytes: 25,
setProgress: () => {},
signal: new AbortController().signal,
})
expect(results.map(r => r.partNumber)).toEqual([1, 2, 3])
expect(results.map(r => r.sizeBytes)).toEqual([10, 10, 5])
})
it('respects the concurrency cap', async () => {
let active = 0
let maxActive = 0
const uploadPart: UploadPartFn = async ({part}) => {
active++
maxActive = Math.max(maxActive, active)
await new Promise(r => setTimeout(r, 5))
active--
return {partNumber: part.partNumber, sizeBytes: part.size}
}
await uploadParts({
parts,
reader: fakeReader(),
uploadPart,
totalBytes: 25,
setProgress: () => {},
signal: new AbortController().signal,
concurrency: 2,
})
expect(maxActive).toBeLessThanOrEqual(2)
})
it('retries a failing part and succeeds', async () => {
const attemptsByPart = new Map<number, number>()
const uploadPart: UploadPartFn = ({part}) => {
const n = (attemptsByPart.get(part.partNumber) ?? 0) + 1
attemptsByPart.set(part.partNumber, n)
if (part.partNumber === 2 && n === 1) {
return Promise.reject(new TypeError('transient network error'))
}
return Promise.resolve({
partNumber: part.partNumber,
sizeBytes: part.size,
})
}
const results = await uploadParts({
parts,
reader: fakeReader(),
uploadPart,
totalBytes: 25,
setProgress: () => {},
signal: new AbortController().signal,
})
expect(attemptsByPart.get(2)).toBe(2)
expect(results).toHaveLength(3)
})
it('retries rate-limited parts', async () => {
let attempts = 0
const uploadPart: UploadPartFn = ({part}) => {
attempts++
if (attempts === 1) {
return Promise.reject(
new MultipartUploadError('rate limited', 'RateLimitExceeded', 429),
)
}
return Promise.resolve({
partNumber: part.partNumber,
sizeBytes: part.size,
})
}
await uploadParts({
parts: parts.slice(0, 1),
reader: fakeReader(),
uploadPart,
totalBytes: 10,
setProgress: () => {},
signal: new AbortController().signal,
})
expect(attempts).toBe(2)
})
it('retries service-unavailable parts', async () => {
let attempts = 0
const uploadPart: UploadPartFn = ({part}) => {
attempts++
if (attempts === 1) {
return Promise.reject(
new MultipartUploadError(
'failed to upload multipart part',
'ServiceUnavailable',
503,
),
)
}
return Promise.resolve({
partNumber: part.partNumber,
sizeBytes: part.size,
})
}
await uploadParts({
parts: parts.slice(0, 1),
reader: fakeReader(),
uploadPart,
totalBytes: 10,
setProgress: () => {},
signal: new AbortController().signal,
})
expect(attempts).toBe(2)
})
it('does not retry a non-retryable response', async () => {
const uploadPart = jest.fn<
ReturnType<UploadPartFn>,
Parameters<UploadPartFn>
>(() =>
Promise.reject(
new MultipartUploadError('bad request', 'InvalidRequest', 400),
),
)
await expect(
uploadParts({
parts: parts.slice(0, 1),
reader: fakeReader(),
uploadPart,
totalBytes: 10,
setProgress: () => {},
signal: new AbortController().signal,
}),
).rejects.toThrow('bad request')
expect(uploadPart).toHaveBeenCalledTimes(1)
})
it('throws after exhausting attempts', async () => {
const uploadPart: UploadPartFn = () =>
Promise.reject(new TypeError('always fails'))
await expect(
uploadParts({
parts,
reader: fakeReader(),
uploadPart,
totalBytes: 25,
setProgress: () => {},
signal: new AbortController().signal,
maxAttempts: 2,
}),
).rejects.toThrow('always fails')
})
it('preserves the originating error when sibling workers abort', async () => {
const uploadPart: UploadPartFn = ({part, signal}) => {
if (part.partNumber === 1) {
return new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(new AbortError()), {
once: true,
})
})
}
return Promise.reject(new Error('part upload failed'))
}
await expect(
uploadParts({
parts: parts.slice(0, 2),
reader: fakeReader(),
uploadPart,
totalBytes: 20,
setProgress: () => {},
signal: new AbortController().signal,
concurrency: 2,
maxAttempts: 1,
}),
).rejects.toThrow('part upload failed')
})
it('reports progress that reaches 1 when all parts complete', async () => {
const progress: number[] = []
const uploadPart: UploadPartFn = ({part, chunk, onProgress}) => {
onProgress(chunk.byteLength)
return Promise.resolve({
partNumber: part.partNumber,
sizeBytes: part.size,
})
}
await uploadParts({
parts,
reader: fakeReader(),
uploadPart,
totalBytes: 25,
setProgress: p => progress.push(p),
signal: new AbortController().signal,
concurrency: 1,
})
expect(progress[progress.length - 1]).toBe(1)
})
})
@@ -0,0 +1,132 @@
import {AbortError} from '#/lib/async/cancelable'
import {createProgressAggregator} from './aggregateProgress'
import {MULTIPART_CONCURRENCY, MULTIPART_MAX_ATTEMPTS} from './constants'
import {
type ChunkReader,
type PartPlan,
type PartUploadResult,
type UploadPartFn,
} from './types'
import {delay, isRetryableMultipartError, retryDelayMs} from './utils'
/**
* Uploads every part with a concurrency cap and per-part retry, aggregating
* byte progress into `setProgress`. Reads each chunk lazily just before its
* upload so only `concurrency` chunks are in memory at once. Resolves with the
* part results ordered by part number.
*/
export async function uploadParts({
parts,
reader,
uploadPart,
totalBytes,
setProgress,
signal,
concurrency = MULTIPART_CONCURRENCY,
maxAttempts = MULTIPART_MAX_ATTEMPTS,
}: {
parts: PartPlan[]
reader: ChunkReader
uploadPart: UploadPartFn
totalBytes: number
setProgress: (progress: number) => void
signal: AbortSignal
concurrency?: number
maxAttempts?: number
}): Promise<PartUploadResult[]> {
const reportPartProgress = createProgressAggregator(totalBytes, setProgress)
const results: PartUploadResult[] = new Array(parts.length)
const workerController = new AbortController()
const abortWorkers = () => workerController.abort()
signal.addEventListener('abort', abortWorkers, {once: true})
const workerSignal = workerController.signal
let nextIndex = 0
async function worker() {
while (true) {
if (workerSignal.aborted) {
throw new AbortError()
}
const index = nextIndex++
if (index >= parts.length) {
return
}
const part = parts[index]
const chunk = await reader.read(part.offset, part.size)
results[index] = await uploadPartWithRetry({
part,
chunk,
uploadPart,
maxAttempts,
signal: workerSignal,
onProgress: bytesSent => reportPartProgress(part.partNumber, bytesSent),
})
}
}
const workers = Array.from(
{length: Math.min(concurrency, parts.length)},
() => worker(),
)
const settled = await Promise.allSettled(
workers.map(async workerPromise => {
try {
await workerPromise
} catch (err) {
workerController.abort()
throw err
}
}),
)
signal.removeEventListener('abort', abortWorkers)
const failures = settled.filter(
(result): result is PromiseRejectedResult => result.status === 'rejected',
)
if (signal.aborted) throw new AbortError()
// A sibling worker aborted after the first failure can settle earlier in
// array order. Preserve the originating error for fallback and telemetry.
const failure =
failures.find(result => !(result.reason instanceof AbortError)) ??
failures[0]
if (failure) throw failure.reason
return results
}
async function uploadPartWithRetry({
part,
chunk,
uploadPart,
maxAttempts,
signal,
onProgress,
}: {
part: PartPlan
chunk: Uint8Array
uploadPart: UploadPartFn
maxAttempts: number
signal: AbortSignal
onProgress: (bytesSent: number) => void
}): Promise<PartUploadResult> {
let lastError: unknown
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (signal.aborted) {
throw new AbortError()
}
try {
return await uploadPart({part, chunk, onProgress, signal})
} catch (err) {
if (signal.aborted) {
throw new AbortError()
}
lastError = err
if (!isRetryableMultipartError(err)) throw err
if (attempt < maxAttempts) {
// XHR progress starts over on a retry, so remove bytes reported by the
// failed attempt from the aggregate while backing off.
onProgress(0)
await delay(retryDelayMs(attempt), signal)
}
}
}
throw lastError
}
+33
View File
@@ -0,0 +1,33 @@
import {AbortError} from '#/lib/async/cancelable'
import {isRetryableHttpStatus} from '#/lib/strings/errors'
import {MultipartUploadError} from './api'
export function isRetryableMultipartError(err: unknown) {
return (
err instanceof TypeError ||
(err instanceof MultipartUploadError &&
(err.error === 'ServiceOverloaded' ||
err.status === undefined ||
isRetryableHttpStatus(err.status)))
)
}
export function delay(ms: number, signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, ms)
function onAbort() {
clearTimeout(timer)
reject(new AbortError())
}
signal.addEventListener('abort', onAbort, {once: true})
})
}
/** Exponential backoff with 50-100% jitter to avoid synchronized retries. */
export function retryDelayMs(attempt: number) {
const ceiling = Math.min(500 * 2 ** (attempt - 1), 8_000)
return ceiling * (0.5 + Math.random() * 0.5)
}
+10
View File
@@ -5,6 +5,7 @@ import {nanoid} from 'nanoid/non-secure'
import {
type ProbedMetadata,
type VideoCompressSkipReason,
type VideoUploadTransport,
} from '#/lib/media/video/types'
import {Sentry} from '#/logger/sentry/lib'
import {type Metrics} from '#/analytics/metrics'
@@ -45,6 +46,7 @@ export type VideoTelemetry = {
compressCompleted: (video: {size: number; mimeType: string}) => void
compressFailed: (e: unknown) => void
uploadStarted: (bytes: number) => void
uploadTransport: (transport: VideoUploadTransport) => void
uploadCompleted: (jobId: string) => void
uploadFailed: (e: unknown) => void
processingStarted: (jobId: string) => void
@@ -70,6 +72,7 @@ export function createVideoTelemetry({
let phaseStartedAt = startedAt
let jobId: string | undefined
let uploadBytes: number | undefined
let uploadTransport: VideoUploadTransport = 'legacy'
let txnEnded = false
let abortBound = true
@@ -226,6 +229,11 @@ export function createVideoTelemetry({
metric('video:upload:uploadStarted', {uploadId, engine, bytes})
},
uploadTransport(transport) {
uploadTransport = transport
phaseSpan?.setAttribute('video.upload.transport', transport)
},
uploadCompleted(id) {
jobId = id
const elapsedMs = Date.now() - phaseStartedAt
@@ -238,6 +246,7 @@ export function createVideoTelemetry({
elapsedMs,
throughputBytesPerSec:
elapsedMs > 0 ? Math.round((bytes * 1000) / elapsedMs) : 0,
transport: uploadTransport,
})
endPhaseSpan()
phase = undefined
@@ -250,6 +259,7 @@ export function createVideoTelemetry({
bytes: uploadBytes ?? 0,
errorClass: errorClass(e),
elapsedMs: Date.now() - phaseStartedAt,
transport: uploadTransport,
})
endTxn('error')
detachAbort()
+2
View File
@@ -8,6 +8,8 @@ export type VideoCompressSkipReason =
| 'no-webcodecs'
| 'compress-error-fallback'
export type VideoUploadTransport = 'multipart' | 'legacy' | 'legacy-fallback'
export type CompressedVideo = {
uri: string
mimeType: string
+26 -1
View File
@@ -6,7 +6,12 @@ import {nanoid} from 'nanoid/non-secure'
import {AbortError} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {type CompressedVideo} from '#/lib/media/video/types'
import {
type CompressedVideo,
type VideoUploadTransport,
} from '#/lib/media/video/types'
import {Features, features} from '#/analytics/features'
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
import {createVideoEndpointUrl, mimeToExt} from './util'
@@ -17,6 +22,7 @@ export async function uploadVideo({
setProgress,
signal,
i18n,
onTransport,
}: {
video: CompressedVideo
agent: AtpAgent
@@ -24,12 +30,31 @@ export async function uploadVideo({
setProgress: (progress: number) => void
signal: AbortSignal
i18n: I18n
onTransport?: (transport: VideoUploadTransport) => void
}) {
if (signal.aborted) {
throw new AbortError()
}
await getVideoUploadLimits(agent, i18n)
if (features.isOn(Features.VideoMultipartUploadEnable)) {
try {
return await uploadVideoMultipart({
video,
agent,
setProgress,
signal,
onStarted: () => onTransport?.('multipart'),
})
} catch (err) {
if (!(err instanceof MultipartFallbackError)) throw err
onTransport?.('legacy-fallback')
setProgress(0)
}
} else {
onTransport?.('legacy')
}
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
+26 -1
View File
@@ -5,7 +5,12 @@ import {nanoid} from 'nanoid/non-secure'
import {AbortError} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {type CompressedVideo} from '#/lib/media/video/types'
import {
type CompressedVideo,
type VideoUploadTransport,
} from '#/lib/media/video/types'
import {Features, features} from '#/analytics/features'
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
import {createVideoEndpointUrl, mimeToExt} from './util'
@@ -16,6 +21,7 @@ export async function uploadVideo({
setProgress,
signal,
i18n,
onTransport,
}: {
video: CompressedVideo
agent: AtpAgent
@@ -23,12 +29,31 @@ export async function uploadVideo({
setProgress: (progress: number) => void
signal: AbortSignal
i18n: I18n
onTransport?: (transport: VideoUploadTransport) => void
}) {
if (signal.aborted) {
throw new AbortError()
}
await getVideoUploadLimits(agent, i18n)
if (features.isOn(Features.VideoMultipartUploadEnable)) {
try {
return await uploadVideoMultipart({
video,
agent,
setProgress,
signal,
onStarted: () => onTransport?.('multipart'),
})
} catch (err) {
if (!(err instanceof MultipartFallbackError)) throw err
onTransport?.('legacy-fallback')
setProgress(0)
}
} else {
onTransport?.('legacy')
}
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
+6 -2
View File
@@ -88,6 +88,10 @@ export function isCancelledError(e: unknown) {
// TODO Replace this with error.shouldRetry() when available. -dsb
const RETRYABLE_ERRORS = [408, 425, 429, 500, 502, 503, 504, 522, 524]
export function shouldRetryError(e: unknown) {
return e instanceof XRPCError && RETRYABLE_ERRORS.includes(e.status)
export function isRetryableHttpStatus(status: number) {
return RETRYABLE_ERRORS.includes(status)
}
export function shouldRetryError(e: unknown) {
return e instanceof XRPCError && isRetryableHttpStatus(e.status)
}
+3 -1
View File
@@ -69,7 +69,9 @@ export async function dynamicActivate(locale: AppLanguage) {
import('date-fns/locale/es').then(m => m.es),
import('@formatjs/intl-pluralrules/locale-data/an.js'),
import('@formatjs/intl-numberformat/locale-data/an.js'),
import('@formatjs/intl-displaynames/locale-data/an.js'),
// Aragonese locale data is missing
// see: https://github.com/bluesky-social/social-app/pull/11327
import('@formatjs/intl-displaynames/locale-data/es.js'),
])
return dateLocale
}
File diff suppressed because it is too large Load Diff
+14
View File
@@ -242,6 +242,20 @@ describe('general functionality', () => {
__context__: 'logger',
},
})
const fingerprint = ['{{ default }}', 'report-dialog:upstream-fetch']
sentryTransport(
LogLevel.Error,
Logger.Context.ReportDialog,
e,
{fingerprint},
timestamp,
)
expect(Sentry.captureException).toHaveBeenLastCalledWith(e, {
tags: {category: 'report-dialog'},
extra: {__context__: 'report-dialog'},
fingerprint,
})
})
test('sentryTransport serializes errors', () => {
+3 -1
View File
@@ -7,7 +7,7 @@ export const sentryTransport: Transport = (
level,
context,
message,
{type, tags, ...metadata},
{type, tags, fingerprint, ...metadata},
timestamp,
) => {
// Skip debug messages entirely for now - esb
@@ -70,6 +70,7 @@ export const sentryTransport: Transport = (
level: severity,
tags: _tags,
extra: meta,
...(fingerprint ? {fingerprint} : {}),
})
}
} else {
@@ -84,6 +85,7 @@ export const sentryTransport: Transport = (
Sentry.captureException(message, {
tags: _tags,
extra: meta,
...(fingerprint ? {fingerprint} : {}),
})
}
}
+6
View File
@@ -82,6 +82,12 @@ export type Metadata = {
[key: string]: number | string | boolean | null | undefined
}
/**
* Passed through to Sentry as a custom fingerprint. Include
* `{{ default }}` to preserve Sentry's default grouping and add dimensions.
*/
fingerprint?: string[]
/**
* Any additional data, passed through to Sentry as `extra` param on
* exceptions, or the `data` param on breadcrumbs.
+84 -23
View File
@@ -1,8 +1,6 @@
import {useCallback, useState} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Pressable, View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {interests, useInterestsDisplayNames} from '#/lib/interests'
import {capitalize} from '#/lib/strings/capitalize'
@@ -19,20 +17,36 @@ import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Toggle from '#/components/forms/Toggle'
import {Loader} from '#/components/Loader'
import * as Tooltip from '#/components/Tooltip'
import {useAnalytics} from '#/analytics'
export function StepInterests() {
const {_} = useLingui()
const {t: l} = useLingui()
const ax = useAnalytics()
const interestsDisplayNames = useInterestsDisplayNames()
const {state, dispatch} = useOnboardingInternalState()
const [saving, setSaving] = useState(false)
const [tooltipVisible, setTooltipVisible] = useState(false)
const [selectedInterests, setSelectedInterests] = useState<string[]>(
state.interestsStepResults.selectedInterests.map(i => i),
)
/*
* Behind this gate, users must choose at least one interest before they can
* continue.
*/
const interestRequired = ax.features.enabled(
ax.features.OnboardingInterestsRequiredEnable,
)
const missingRequiredInterest =
interestRequired && selectedInterests.length === 0
const saveInterests = useCallback(async () => {
const showMissingInterestTooltip = () => {
ax.metric('onboarding:interests:disabledNextPressed', {})
setTooltipVisible(true)
}
const saveInterests = useCallback(() => {
setSaving(true)
try {
@@ -46,12 +60,37 @@ export function StepInterests() {
selectedInterests,
selectedInterestsLength: selectedInterests.length,
})
} catch (e: any) {
logger.info(`onboading: error saving interests`)
} catch (error) {
const e = error as Error
logger.info(`onboarding: error saving interests`)
logger.error(e)
}
}, [ax, selectedInterests, setSaving, dispatch])
const continueButton = (
<Button
disabled={saving || missingRequiredInterest}
testID="onboardingContinue"
variant="solid"
color="primary"
size="large"
label={
missingRequiredInterest
? l`Choose an interest`
: l`Continue to next step`
}
onPress={() => void saveInterests()}>
<ButtonText style={{pointerEvents: 'none'}}>
{missingRequiredInterest ? (
<Trans>Choose an interest</Trans>
) : (
<Trans>Continue</Trans>
)}
</ButtonText>
{saving && <ButtonIcon icon={Loader} />}
</Button>
)
return (
<View style={[a.align_start, a.gap_sm]} testID="onboardingInterests">
<OnboardingPosition />
@@ -59,14 +98,21 @@ export function StepInterests() {
<Trans>What are your interests?</Trans>
</OnboardingTitleText>
<OnboardingDescriptionText>
<Trans>We'll use this to help customize your experience.</Trans>
{interestRequired ? (
<Trans>
Choose at least one. We'll use this to customize your experience.
You can change these anytime.
</Trans>
) : (
<Trans>We'll use this to help customize your experience.</Trans>
)}
</OnboardingDescriptionText>
<View style={[a.w_full, a.pt_lg]}>
<Toggle.Group
values={selectedInterests}
onChange={setSelectedInterests}
label={_(msg`Select your interests from the options below`)}>
label={l`Select your interests from the options below`}>
<View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
{interests.map(interest => (
<Toggle.Item
@@ -81,19 +127,34 @@ export function StepInterests() {
</View>
<OnboardingControls.Portal>
<Button
disabled={saving}
testID="onboardingContinue"
variant="solid"
color="primary"
size="large"
label={_(msg`Continue to next step`)}
onPress={saveInterests}>
<ButtonText>
<Trans>Continue</Trans>
</ButtonText>
{saving && <ButtonIcon icon={Loader} />}
</Button>
<View style={[a.relative]}>
{missingRequiredInterest ? (
<Tooltip.Outer
position="top"
visible={tooltipVisible}
onVisibleChange={setTooltipVisible}>
<Tooltip.Target>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Choose an interest`}
accessibilityHint={l`Choose at least one interest to continue`}
onPress={showMissingInterestTooltip}>
<View
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants">
{continueButton}
</View>
</Pressable>
</Tooltip.Target>
<Tooltip.BubbleText label={l`Choose at least one interest.`}>
<Trans>Choose at least one interest.</Trans>
</Tooltip.BubbleText>
</Tooltip.Outer>
) : (
continueButton
)}
</View>
</OnboardingControls.Portal>
</View>
)
@@ -37,6 +37,11 @@ export function ProfileHeaderDisplayName({
<View style={[a.pl_xs, {marginTop: platform({ios: 2})}]}>
<ProfileBadges profile={profile} size="lg" interactive />
</View>
{/*
* TODO: Workaround for a rounding bug in Android RN.
* Fixed upstream in RN main (facebook/react-native#56651); remove this
* once we are on a release that contains it (0.86.0 should be good).
*/}{' '}
</Text>
</View>
)
@@ -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 =
@@ -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() {
/>
</ModuleHeader.Container>
{showLoading
? Array.from({length: TOPIC_COUNT}).map((__, i) => (
? Array.from({length: topicCount}).map((__, i) => (
<TrendingTopicRowSkeleton key={i} />
))
: trending?.trends.map((trend, index) => (
<TrendRow
key={trend.link}
trend={trend}
rank={index + 1}
recId={trending.recId}
onPress={() => {
ax.metric('trendingTopic:click', {
context: 'explore',
recId: trending.recId,
})
}}
/>
))}
: trending?.trends.map((trend, index) => {
const rank = index + 1
return (
<TrendRow
key={trend.link}
trend={trend}
rank={rank}
recId={trending.recId}
onPress={() => {
ax.metric('trendingTopic:click', {
context: 'explore',
rank,
recId: trending.recId,
})
}}
/>
)
})}
</View>
<Prompt.Basic
@@ -116,7 +132,7 @@ export function TrendRow({
const actors = useModerateTrendingActors(trend.actors)
const formattedPostCount = formatCount(i18n, trend.postCount)
useTrendingTopicSeen('explore', recId)
useTrendingTopicSeen('explore', rank, recId)
const description = useMemo(() => {
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({
<View style={[a.flex_1, a.gap_2xs]}>
<Text
style={[a.text_md, a.font_semi_bold, a.leading_snug]}
numberOfLines={1}>
numberOfLines={2}>
{trend.displayName}
</Text>
{description ? (
+11 -1
View File
@@ -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() {
<Trans>System log</Trans>
</SettingsList.ItemText>
</SettingsList.PressableItem>
<SettingsList.PressableItem
onPress={() => growthbookControl.open()}
label={l`View GrowthBook information`}>
<SettingsList.ItemText>
<Trans>GrowthBook</Trans>
</SettingsList.ItemText>
</SettingsList.PressableItem>
<GrowthbookDialog control={growthbookControl} />
<SettingsList.PressableItem
onPress={() => navigation.navigate('Debug')}
label={l`Open storybook page`}>
@@ -474,7 +484,7 @@ function DevOptions() {
</SettingsList.PressableItem>
<SettingsList.PressableItem
onPress={() => deleteChatDeclarationRecord()}
label={l`Open storybook page`}>
label={l`Delete chat declaration record`}>
<SettingsList.ItemText>
<Trans>Delete chat declaration record</Trans>
</SettingsList.ItemText>
@@ -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 (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<GrowthbookDialogInner />
</Dialog.Outer>
)
}
function GrowthbookDialogInner() {
const t = useTheme()
const [isRefreshing, setIsRefreshing] = useState(false)
return (
<Dialog.ScrollableInner
label="GrowthBook features"
header={
<Dialog.Header>
<Dialog.HeaderText>GrowthBook</Dialog.HeaderText>
<Dialog.Close />
</Dialog.Header>
}>
<View
style={[
a.gap_sm,
a.pb_lg,
a.mb_lg,
native(a.pt_lg),
a.border_b,
t.atoms.border_contrast_low,
]}>
<CurrentProfile />
</View>
<View style={[a.pb_lg, a.mb_lg, a.border_b, t.atoms.border_contrast_low]}>
<RefreshButton
isRefreshing={isRefreshing}
setIsRefreshing={setIsRefreshing}
/>
</View>
<View style={[a.gap_md]}>
{Object.entries(Features).map(([name, key]) => (
<FeatureRow
key={key}
name={name}
featureKey={key}
isRefreshing={isRefreshing}
/>
))}
</View>
</Dialog.ScrollableInner>
)
}
function RefreshButton({
isRefreshing,
setIsRefreshing,
}: {
isRefreshing: boolean
setIsRefreshing: React.Dispatch<React.SetStateAction<boolean>>
}) {
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 (
<Button
label="Refresh feature flags"
onPress={onPress}
disabled={isRefreshing}
color="secondary"
size="small">
<ButtonIcon icon={isRefreshing ? Loader : ArrowRotate} />
<ButtonText>Refresh feature flags</ButtonText>
</Button>
)
}
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 (
<Pressable
accessibilityRole="button"
accessibilityLabel="Copy feature flag key"
accessibilityHint="Copies the feature flag key to the clipboard"
onPress={onPress}
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.gap_md,
a.pb_md,
a.border_b,
t.atoms.border_contrast_low,
]}>
<View style={[a.flex_1]}>
<Text style={[a.text_sm, a.font_bold]}>{name}</Text>
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
{featureKey}
</Text>
</View>
{isRefreshing ? <Loader size="sm" /> : <FeatureValue value={value} />}
</Pressable>
)
}
function CurrentProfile() {
const t = useTheme()
const {currentAccount} = useSession()
const moderationOpts = useModerationOpts()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
if (!currentAccount) {
return (
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
No active account
</Text>
)
}
const onPressDid = () => {
void Clipboard.setStringAsync(currentAccount.did)
Toast.show('Copied did to clipboard', {type: 'success'})
}
return profile && moderationOpts ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Copy DID"
accessibilityHint="Copies your DID to the clipboard"
onPress={onPressDid}
hitSlop={native({top: 8, bottom: 8, left: 8, right: 8})}
style={[a.gap_sm]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
disabledPreview
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
</ProfileCard.Header>
</Pressable>
) : 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 <Text style={[a.text_sm, a.font_bold, {color}]}>{label}</Text>
}
+6 -1
View File
@@ -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}`, {
@@ -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,
]}>
<Trans>Nobody was found. Try searching for someone else.</Trans>
{query ? (
<Trans>
Nobody was found. Try searching for someone else.
</Trans>
) : null}
</Text>
)}
</View>
@@ -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'],
])
})
})
+3
View File
@@ -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')
) {
+5
View File
@@ -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.
*/
+35 -6
View File
@@ -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(
-1
View File
@@ -162,7 +162,6 @@ function AltTextInner({
onChangeText={onChange}
defaultValue={altText}
multiline
numberOfLines={3}
autoFocus
onKeyPress={({nativeEvent}) => {
if (nativeEvent.key === 'Escape') {
+26 -6
View File
@@ -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(() => {
@@ -131,7 +131,6 @@ const ImageAltTextInner = ({
}}
defaultValue={altText}
multiline
numberOfLines={3}
autoFocus
/>
</TextField.Root>
@@ -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
+2 -11
View File
@@ -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})
+3
View File
@@ -115,6 +115,9 @@ export function ComposerPrompt() {
const image = await openCamera({
mediaTypes: 'images',
})
if (!image) {
return
}
const imageUris = [
{
+41 -38
View File
@@ -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<any>) => {
if (item === EMPTY_FEED_ITEM) {
({item, index}: ListRenderItemInfo<NotificationFeedListItem>) => {
if (item.type === 'empty') {
return (
<EmptyState
icon={BellIcon}
message={_(msg`No notifications yet!`)}
style={styles.emptyState}
message={l`No notifications yet!`}
style={[a.py_5xl]}
/>
)
} else if (item === LOAD_MORE_ERROR_ITEM) {
} else if (item.type === 'load-more-error') {
return (
<LoadMoreRetryBtn
label={_(
msg`There was an issue fetching notifications. Tap here to try again.`,
)}
label={l`There was an issue fetching notifications. Tap here to try again.`}
onPress={onPressRetryLoadMore}
/>
)
} else if (item === LOADING_ITEM) {
} else if (item.type === 'loading') {
return <NotificationFeedLoadingPlaceholder />
}
return (
@@ -147,13 +151,13 @@ export function NotificationFeed({
/>
)
},
[moderationOpts, _, onPressRetryLoadMore, filter],
[moderationOpts, l, onPressRetryLoadMore, filter],
)
const FeedFooter = useCallback(
() =>
isFetchingNextPage ? (
<View style={styles.feedFooter}>
<View style={[a.pt_xl]}>
<ActivityIndicator />
</View>
) : (
@@ -169,7 +173,11 @@ export function NotificationFeed({
}, [enabled])
return (
<View style={s.hContentRegion}>
<View
style={platform({
web: {minHeight: '100%'},
default: {height: '100%'},
})}>
{error && (
<ErrorMessage
message={cleanError(error)}
@@ -180,16 +188,16 @@ export function NotificationFeed({
testID="notifsFeed"
ref={scrollElRef}
data={items}
keyExtractor={item => 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({
</View>
)
}
const styles = StyleSheet.create({
feedFooter: {paddingTop: 20},
emptyState: {paddingVertical: 40},
})
@@ -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<boolean>(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)}
<ProfileBadges
profile={firstAuthor.profile}
size="md"
style={[
a.relative,
{
// weird stuff here
paddingTop: platform({android: 2}),
marginBottom: platform({ios: -6}),
top: platform({web: 2}),
paddingLeft: 3,
paddingRight: 2,
},
]}
size="sm"
style={[a.px_2xs, {transform: [{translateY: 1}]}]}
/>
</InlineLinkText>
</ProfileHoverCard>
)
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<any>
let notificationContent: React.ReactElement
let icon = (
<HeartIconFilled
size="xl"
@@ -293,17 +296,15 @@ let NotificationFeedItem = ({
if (item.type === 'post-like') {
a11yLabel = hasMultipleAuthors
? _(
msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} liked your post`,
)
: _(msg`${firstAuthorName} liked your post`)
? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} liked your post`
: l`${firstAuthorName} liked your post`
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
@@ -317,17 +318,15 @@ let NotificationFeedItem = ({
)
} else if (item.type === 'repost') {
a11yLabel = hasMultipleAuthors
? _(
msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} reposted your post`,
)
: _(msg`${firstAuthorName} reposted your post`)
? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} reposted your post`
: l`${firstAuthorName} reposted your post`
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
@@ -346,21 +345,28 @@ let NotificationFeedItem = ({
* Follow-backs are ungrouped, grouped follow-backs not supported atm,
* see `src/state/queries/notifications/util.ts`
*/
a11yLabel = _(msg`${firstAuthorName} followed you back`)
a11yLabel = starterPackName
? l`${firstAuthorName} followed you back via starter pack ${starterPackName}`
: l`${firstAuthorName} followed you back`
notificationContent = <Trans>{firstAuthorLink} followed you back</Trans>
} 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 ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
@@ -375,7 +381,7 @@ let NotificationFeedItem = ({
}
icon = <PersonPlusIcon size="xl" style={{color: t.palette.primary_500}} />
} else if (item.type === 'contact-match') {
a11yLabel = _(msg`Your contact ${firstAuthorName} is on Bluesky`)
a11yLabel = l`Your contact ${firstAuthorName} is on Bluesky`
notificationContent = (
<Trans>Your contact {firstAuthorLink} is on Bluesky</Trans>
)
@@ -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 ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
@@ -408,17 +412,15 @@ let NotificationFeedItem = ({
)
} else if (item.type === 'starterpack-joined') {
a11yLabel = hasMultipleAuthors
? _(
msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} signed up with your starter pack`,
)
: _(msg`${firstAuthorName} signed up with your starter pack`)
? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} signed up with your starter pack`
: l`${firstAuthorName} signed up with your starter pack`
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
@@ -437,17 +439,15 @@ let NotificationFeedItem = ({
)
} else if (item.type === 'verified') {
a11yLabel = hasMultipleAuthors
? _(
msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} verified you`,
)
: _(msg`${firstAuthorName} verified you`)
? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} verified you`
: l`${firstAuthorName} verified you`
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
@@ -462,17 +462,15 @@ let NotificationFeedItem = ({
icon = <VerifiedCheck size="xl" />
} 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 ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
@@ -489,17 +487,15 @@ let NotificationFeedItem = ({
icon = <VerifiedCheck size="xl" fill={t.palette.contrast_500} />
} 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 ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
@@ -513,17 +509,15 @@ let NotificationFeedItem = ({
)
} else if (item.type === 'repost-via-repost') {
a11yLabel = hasMultipleAuthors
? _(
msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} reposted your repost`,
)
: _(msg`${firstAuthorName} reposted your repost`)
? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} reposted your repost`
: l`${firstAuthorName} reposted your repost`
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
@@ -539,25 +533,21 @@ let NotificationFeedItem = ({
} else if (item.type === 'subscribed-post') {
const postsCount = 1 + (item.additional?.length || 0)
a11yLabel = hasMultipleAuthors
? _(
msg`New posts from ${firstAuthorName} and ${plural(
additionalAuthorsCount,
{
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
},
)}`,
)
: _(
msg`New ${plural(postsCount, {
one: 'post',
other: 'posts',
})} from ${firstAuthorName}`,
)
? l`New posts from ${firstAuthorName} and ${plural(
additionalAuthorsCount,
{
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
},
)}`
: l`New ${plural(postsCount, {
one: 'post',
other: 'posts',
})} from ${firstAuthorName}`
notificationContent = hasMultipleAuthors ? (
<Trans>
New posts from {firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
@@ -608,18 +598,16 @@ let NotificationFeedItem = ({
{
name: 'toggleAuthorsExpanded',
label: isAuthorsExpanded
? _(msg`Collapse list of users`)
: _(msg`Expand list of users`),
? l`Collapse list of users`
: l`Expand list of users`,
},
]
: [
{
name: 'viewProfile',
label: _(
msg`View ${
authors[0].profile.displayName || authors[0].profile.handle
}'s profile`,
),
label: l`View ${
authors[0].profile.displayName || authors[0].profile.handle
}'s profile`,
},
]
}
@@ -663,7 +651,6 @@ let NotificationFeedItem = ({
{paddingTop: 6},
a.self_start,
a.text_md,
a.leading_snug,
]}
accessibilityHint=""
accessibilityLabel={a11yLabel}>
@@ -686,6 +673,9 @@ let NotificationFeedItem = ({
</TimeElapsed>
</Text>
</ExpandListPressable>
{allFollowedViaSameStarterPack && starterPack ? (
<FollowedViaStarterPack starterPack={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 (
<Text style={[native(a.pt_xs), t.atoms.text_contrast_medium]}>
<Trans comment="When the source of a follow is a starter pack, i.e., 'via starter pack {starterPackName}'.">
via starter pack{' '}
<StarterPack
size="sm"
gradient="sky"
style={[native(a.mr_2xs), {transform: [{translateY: 4}]}]}
/>
<InlineLinkText
to={link.to}
label={link.label}
onPress={link.precache}
onMouseEnter={link.precache}
style={[a.font_semi_bold, t.atoms.text]}>
{starterPackName}
</InlineLinkText>
</Trans>
</Text>
)
}
function getStarterPackName(
starterPack: AppBskyGraphDefs.StarterPackViewBasic,
) {
return bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
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 (
<View style={[a.pt_sm]}>
@@ -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)}>
<ButtonIcon icon={CheckIcon} />
<ButtonText>
<Trans>Following</Trans>
@@ -861,11 +894,11 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) {
</Button>
) : (
<Button
label={isFollowedBy ? _(msg`Follow back`) : _(msg`Follow`)}
label={isFollowedBy ? l`Follow back` : l`Follow`}
color="primary"
size="small"
style={[a.self_start]}
onPress={onPressFollow}>
onPress={(e: GestureResponderEvent) => void onPressFollow(e)}>
<ButtonIcon icon={PlusIcon} />
<ButtonText>
{isFollowedBy ? <Trans>Follow back</Trans> : <Trans>Follow</Trans>}
@@ -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<NavigationProp>()
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 (
<Button
label={_(msg`Say hello!`)}
label={l`Say hello!`}
variant="ghost"
color="primary"
size="small"
style={[a.self_center, {marginLeft: 'auto'}]}
disabled={isLoading}
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)
}
}}>
onPress={() => void onPress()}>
<ButtonText>
<Trans>Say hello!</Trans>
</ButtonText>
@@ -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`}>
<ChevronUpIcon
size="md"
style={[a.ml_xs, a.mr_md, t.atoms.text_contrast_high]}
@@ -978,7 +1011,7 @@ function CondensedAuthorsList({
onPress={onToggleAuthorsExpanded}>
<View style={[a.flex_row, a.align_center]}>
{authors.slice(0, MAX_AUTHORS).map(author => (
<View key={author.href} style={s.mr5}>
<View key={author.href} style={{marginRight: 5}}>
<PreviewableUserAvatar
size={35}
profile={author.profile}
@@ -1126,7 +1159,7 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
{text?.length > 0 && (
<Text
emoji
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}
style={[a.text_sm, t.atoms.text_contrast_medium]}
numberOfLines={MAX_POST_LINES}>
{text}
</Text>
+18 -8
View File
@@ -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<string>(),
)
@@ -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 <TrendingInterstitial />
} else if (row.type === 'interstitialFeedTrendingTopics') {
return <FeedTrendingTopicsInterstitial />
return (
<FeedTrendingTopicsInterstitial feedSliceIndex={row.feedSliceIndex} />
)
} else if (row.type === 'liveEventFeedsAndTrendingBanner') {
return <DiscoverFeedLiveEventFeedsAndTrendingBanner />
} else if (row.type === 'composerPrompt') {
+6 -1
View File
@@ -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<ViewStyle>
}
@@ -29,7 +31,10 @@ export class ErrorBoundary extends Component<Props, State> {
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
logger.error(error, {errorInfo})
logger.error(error, {
errorInfo,
...this.props.getErrorMetadata?.(error),
})
}
public render() {
+8 -8
View File
@@ -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 () => {
+8 -8
View File
@@ -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 () => {
@@ -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() {
<Text style={[a.flex_1, a.text_md, a.font_semi_bold]}>
<Trans>Trending</Trans>
</Text>
{exploreTopicCount > DEFAULT_LIMIT ? (
<Link label={l`See more trending topics`} to="/search">
{({hovered, pressed}) => (
<Text
style={[
a.text_sm,
a.font_medium,
{
color:
hovered || pressed
? t.palette.contrast_800
: t.palette.contrast_500,
},
]}
numberOfLines={1}>
<Trans>See more</Trans>
</Text>
)}
</Link>
) : null}
<Button
variant="ghost"
size="tiny"
@@ -67,7 +96,7 @@ function Inner() {
<View style={[a.gap_xs]}>
{isLoading ? (
Array(TRENDING_LIMIT)
Array(DEFAULT_LIMIT)
.fill(0)
.map((_n, i) => (
<View key={i} style={[a.flex_row, a.align_center, a.gap_sm]}>
@@ -90,44 +119,49 @@ function Inner() {
))
) : !trending?.trends ? null : (
<>
{trending.trends.slice(0, TRENDING_LIMIT).map((topic, i) => (
<TrendingTopicLink
key={topic.link}
topic={topic}
metricContext="sidebar"
recId={trending.recId}
onPress={() => {
ax.metric('trendingTopic:click', {
context: 'sidebar',
recId: trending.recId,
})
}}>
{({hovered}) => (
<View style={[a.flex_1, a.flex_row, a.gap_xs]}>
<Text
style={[
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_low,
{minWidth: 16},
]}>
{i + 1}.
</Text>
<Text
style={[
a.flex_1,
a.text_sm,
a.leading_snug,
hovered
? [t.atoms.text, a.underline]
: t.atoms.text_contrast_medium,
]}>
{topic.displayName ?? topic.topic}
</Text>
</View>
)}
</TrendingTopicLink>
))}
{trending.trends.slice(0, DEFAULT_LIMIT).map((topic, i) => {
const rank = i + 1
return (
<TrendingTopicLink
key={topic.link}
topic={topic}
metricContext="sidebar"
rank={rank}
recId={trending.recId}
onPress={() => {
ax.metric('trendingTopic:click', {
context: 'sidebar',
rank,
recId: trending.recId,
})
}}>
{({hovered}) => (
<View style={[a.flex_1, a.flex_row, a.gap_xs]}>
<Text
style={[
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_low,
{minWidth: 16},
]}>
{rank}.
</Text>
<Text
style={[
a.flex_1,
a.text_sm,
a.leading_snug,
hovered
? [t.atoms.text, a.underline]
: t.atoms.text_contrast_medium,
]}>
{topic.displayName ?? topic.topic}
</Text>
</View>
)}
</TrendingTopicLink>
)
})}
</>
)}
</View>
+2
View File
@@ -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)