Merge main into app-2773
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+62
-23
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]}>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() {},
|
||||
}
|
||||
}
|
||||
@@ -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'}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
+346
-246
File diff suppressed because it is too large
Load Diff
@@ -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', () => {
|
||||
|
||||
@@ -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} : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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'],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
) {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -162,7 +162,6 @@ function AltTextInner({
|
||||
onChangeText={onChange}
|
||||
defaultValue={altText}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
autoFocus
|
||||
onKeyPress={({nativeEvent}) => {
|
||||
if (nativeEvent.key === 'Escape') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -115,6 +115,9 @@ export function ComposerPrompt() {
|
||||
const image = await openCamera({
|
||||
mediaTypes: 'images',
|
||||
})
|
||||
if (!image) {
|
||||
return
|
||||
}
|
||||
|
||||
const imageUris = [
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user