diff --git a/.github/workflows/build-and-push-bskyweb-aws.yaml b/.github/workflows/build-and-push-bskyweb-aws.yaml index f6d7949825..4148640240 100644 --- a/.github/workflows/build-and-push-bskyweb-aws.yaml +++ b/.github/workflows/build-and-push-bskyweb-aws.yaml @@ -28,7 +28,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: 🔑 Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: registry: ${{ env.REGISTRY }} username: ${{ env.USERNAME}} diff --git a/.github/workflows/build-and-push-bskyweb-ghcr.yaml b/.github/workflows/build-and-push-bskyweb-ghcr.yaml index 15e1da8a59..c905a92602 100644 --- a/.github/workflows/build-and-push-bskyweb-ghcr.yaml +++ b/.github/workflows/build-and-push-bskyweb-ghcr.yaml @@ -29,7 +29,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: 🔑 Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: registry: ${{ env.REGISTRY }} username: ${{ env.USERNAME }} diff --git a/.github/workflows/build-and-push-embedr-aws.yaml b/.github/workflows/build-and-push-embedr-aws.yaml index 5b7193cf4e..d5df3eda26 100644 --- a/.github/workflows/build-and-push-embedr-aws.yaml +++ b/.github/workflows/build-and-push-embedr-aws.yaml @@ -28,7 +28,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: 🔑 Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: registry: ${{ env.REGISTRY }} username: ${{ env.USERNAME}} diff --git a/.github/workflows/build-and-push-link-aws.yaml b/.github/workflows/build-and-push-link-aws.yaml index 5f3daafd7c..04cb0c48be 100644 --- a/.github/workflows/build-and-push-link-aws.yaml +++ b/.github/workflows/build-and-push-link-aws.yaml @@ -28,7 +28,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: 🔑 Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: registry: ${{ env.REGISTRY }} username: ${{ env.USERNAME}} diff --git a/.github/workflows/build-and-push-ogcard-aws.yaml b/.github/workflows/build-and-push-ogcard-aws.yaml index 7c7b228e49..08c49f3227 100644 --- a/.github/workflows/build-and-push-ogcard-aws.yaml +++ b/.github/workflows/build-and-push-ogcard-aws.yaml @@ -28,7 +28,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: 🔑 Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: registry: ${{ env.REGISTRY }} username: ${{ env.USERNAME}} diff --git a/.github/workflows/bundle-deploy-eas-update.yml b/.github/workflows/bundle-deploy-eas-update.yml index 26581e597c..4bb8e7c795 100644 --- a/.github/workflows/bundle-deploy-eas-update.yml +++ b/.github/workflows/bundle-deploy-eas-update.yml @@ -253,7 +253,7 @@ jobs: - name: ☁️ Configure AWS credentials (denis) if: ${{ !steps.fingerprint.outputs.includes-changes && !steps.version.outputs.version-changed }} - uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 with: role-to-assume: arn:aws:iam::007404326489:role/denis-ci-publish aws-region: us-east-2 diff --git a/.github/workflows/claude-mention.yml b/.github/workflows/claude-mention.yml index c4e4cbcb7d..5ddf02b157 100644 --- a/.github/workflows/claude-mention.yml +++ b/.github/workflows/claude-mention.yml @@ -59,13 +59,13 @@ jobs: fetch-depth: 1 - name: ☁️ Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 with: role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }} aws-region: us-east-2 - name: 🤖 Claude - uses: anthropics/claude-code-action@b76a0776ae74036e77cd11018083743453d7ad35 # v1.0.179 + uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1.0.183 with: use_bedrock: 'true' additional_permissions: | diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index de15b75fed..bcb9a09e3c 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -45,13 +45,13 @@ jobs: fetch-depth: 1 - name: ☁️ Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 with: role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }} aws-region: us-east-2 - name: 🤖 Claude review - uses: anthropics/claude-code-action@b76a0776ae74036e77cd11018083743453d7ad35 # v1.0.179 + uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1.0.183 with: use_bedrock: 'true' additional_permissions: | diff --git a/.github/workflows/nightly-update-source-languages.yaml b/.github/workflows/nightly-update-source-languages.yaml index e11ff6b7d4..2fb3fffd78 100644 --- a/.github/workflows/nightly-update-source-languages.yaml +++ b/.github/workflows/nightly-update-source-languages.yaml @@ -35,7 +35,7 @@ jobs: commit_message: Nightly source-language update file_pattern: ./src/locale/locales/en/messages.po - name: 🚀 Push source lang to Crowdin - uses: crowdin/github-action@e0c8f73cdc0fafde9396e056c5038217000a32d1 # v2.16.4 + uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0 with: upload_sources: true upload_sources_args: "-b main" diff --git a/.github/workflows/pull-request-commit.yml b/.github/workflows/pull-request-commit.yml index afa3d4f46c..16566672a6 100644 --- a/.github/workflows/pull-request-commit.yml +++ b/.github/workflows/pull-request-commit.yml @@ -185,7 +185,7 @@ jobs: - name: 📷 Check fingerprint and install dependencies id: fingerprint timeout-minutes: 5 - uses: bluesky-social/github-actions/fingerprint-native@b5556913e4aef3964cfd5936d0add3fc0d809bdb # v0.2.0 + uses: bluesky-social/github-actions/fingerprint-native@abc6a46eb4badf243f55bfd7d6cec42722456300 # v0.3.0 with: profile: pull-request @@ -275,6 +275,10 @@ jobs: permissions: id-token: write contents: read + outputs: + release-version: ${{ steps.env.outputs.release-version }} + ios-build-number: ${{ steps.build-info.outputs.BSKY_IOS_BUILD_NUMBER }} + android-build-number: ${{ steps.build-info.outputs.BSKY_ANDROID_VERSION_CODE }} steps: - name: ⬇️ Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -308,7 +312,7 @@ jobs: pnpm export - name: ☁️ Configure AWS credentials (denis, PR-scoped) - uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 with: role-to-assume: arn:aws:iam::007404326489:role/denis-ci-publish-pr aws-region: us-east-2 @@ -342,11 +346,18 @@ jobs: app-id: ${{ vars.SYNC_INTERNAL_APP_ID }} private-key: ${{ secrets.SYNC_INTERNAL_PK }} + - name: 🔢 Get native build numbers + id: build-info + run: bash scripts/setGitHubOutput.sh + - name: 🚀 Publish OTA to denis (S3) run: pnpm use-build-number bash scripts/denisPublish.sh env: RUNTIME_VERSION: '' CHANNEL_NAME: pull-request-${{ github.event.pull_request.number }} + # Pin the publish to the same values exposed in the install link. + BSKY_IOS_BUILD_NUMBER: ${{ steps.build-info.outputs.BSKY_IOS_BUILD_NUMBER }} + BSKY_ANDROID_VERSION_CODE: ${{ steps.build-info.outputs.BSKY_ANDROID_VERSION_CODE }} comment-pr-ota: name: Comment PR OTA install link @@ -362,6 +373,6 @@ jobs: message: | The OTA deployment for this PR was successful! You may now apply it by either scanning the QR code or opening the deep link below in your browser: - QR code for the PR OTA deployment + 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 }}` diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 12ff6ed2c7..dac6025ee2 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -26,7 +26,7 @@ jobs: persist-credentials: false - name: 🛡️ Run zizmor - uses: zizmorcore/zizmor-action@6599ee8b7a49aef6a770f63d261d214911a7ce02 # v0.6.0 + uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 with: # Annotate the PR directly instead of uploading SARIF to the # security tab, and fail the check on any finding diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 996b647227..2bc0ab246f 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -707,7 +707,7 @@ "count": 1 }, "typescript/no-misused-promises": { - "count": 4 + "count": 3 } }, "src/lib/hooks/usePermissions.ts": { @@ -933,17 +933,6 @@ "count": 1 } }, - "src/screens/Onboarding/StepInterests/index.tsx": { - "typescript/no-explicit-any": { - "count": 1 - }, - "typescript/no-misused-promises": { - "count": 1 - }, - "typescript/require-await": { - "count": 1 - } - }, "src/screens/Onboarding/StepProfile/index.tsx": { "typescript/no-floating-promises": { "count": 2 @@ -1216,11 +1205,6 @@ "count": 1 } }, - "src/screens/StarterPack/Wizard/StepProfiles.tsx": { - "typescript/no-misused-promises": { - "count": 1 - } - }, "src/screens/StarterPack/Wizard/index.tsx": { "typescript/no-floating-promises": { "count": 2 @@ -1643,31 +1627,6 @@ "count": 2 } }, - "src/view/com/notifications/NotificationFeed.tsx": { - "typescript/no-explicit-any": { - "count": 2 - }, - "typescript/no-floating-promises": { - "count": 1 - }, - "typescript/no-misused-promises": { - "count": 2 - }, - "typescript/no-unsafe-member-access": { - "count": 6 - } - }, - "src/view/com/notifications/NotificationFeedItem.tsx": { - "typescript/no-explicit-any": { - "count": 3 - }, - "typescript/no-misused-promises": { - "count": 3 - }, - "typescript/no-unsafe-member-access": { - "count": 2 - } - }, "src/view/com/pager/Pager.tsx": { "typescript/no-explicit-any": { "count": 4 diff --git a/package.json b/package.json index 54feea61ef..f49c831c97 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "prepare": "is-ci || husky", "postinstall": "pnpm intl:compile-if-needed", "prebuild": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean", - "android": "expo run:android", + "android": "expo run:android --variant debugOptimized", "android:prod": "expo run:android --variant release", "android:profile": "BSKY_PROFILE=1 expo run:android --variant release", "ios": "expo run:ios", @@ -96,7 +96,7 @@ "prettier": "prettier --check ." }, "dependencies": { - "@atproto/api": "0.20.33", + "@atproto/api": "0.20.37", "@atproto/common-web": "0.5.6", "@atproto/syntax": "0.7.2", "@bitdrift/react-native": "^0.6.8", @@ -107,7 +107,7 @@ "@bsky.app/expo-image-crop-tool": "^0.5.1", "@bsky.app/expo-scroll-edge-effect": "^0.1.9", "@bsky.app/expo-translate-text": "^0.2.9", - "@bsky.app/peek-menu": "^0.3.1", + "@bsky.app/peek-menu": "^0.3.2", "@bsky.app/react-native-uitextview": "^2.5.0", "@bsky.app/sift": "^0.3.9", "@bsky.app/tapper": "^0.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86c3430c04..7952820f4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,8 +242,8 @@ importers: .: dependencies: '@atproto/api': - specifier: 0.20.33 - version: 0.20.33 + specifier: 0.20.37 + version: 0.20.37 '@atproto/common-web': specifier: 0.5.6 version: 0.5.6 @@ -275,8 +275,8 @@ importers: specifier: ^0.2.9 version: 0.2.9(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@bsky.app/peek-menu': - specifier: ^0.3.1 - version: 0.3.1(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + specifier: ^0.3.2 + version: 0.3.2(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@bsky.app/react-native-uitextview': specifier: ^2.5.0 version: 2.5.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -871,32 +871,48 @@ packages: graphql: optional: true - '@atproto/api@0.20.33': - resolution: {integrity: sha512-3YpnBVMieQFWetLvqibn2yG6vhNZ7ozSi/EUujuGcvYXt9g/NrvmHufaUtrXCasp2mHDI7t7UG5sLEBg6YNEJw==} + '@atproto/api@0.20.37': + resolution: {integrity: sha512-TEt8oKMbuC+1GvqyLKDBLqmptnk1mh52FdHPQEomlPIWxh88YcNnk0g0iKNVBevaeeLtWZF1a7Am/t/q255u5Q==} engines: {node: '>=22'} '@atproto/common-web@0.5.6': resolution: {integrity: sha512-5Y4MIK9dpkJPiKiE6u7iEHitxj+g3aAU2GfGL686JlKE2zDKD4y18BJb+uVek6nXQKb5XOdNVvw+7BHarpn8Fw==} engines: {node: '>=22'} + '@atproto/common-web@0.5.8': + resolution: {integrity: sha512-GiYY2Jgbg1aWe9QGT3TswC/wZNicipBDz0nFo4FrP6XqENEJJRxGKAf/zjvqbesfIQFhHXi1VbSFY7GStB8v8w==} + engines: {node: '>=22'} + '@atproto/lex-data@0.1.5': resolution: {integrity: sha512-TEM6GHuYpNm4O90LjNgbYq1Gmcr875S+BHrDxkg4PB5w/nlsz4HlbkGQG/WP/xbIV5O8TF5nNHDpCZ5ezTRrFA==} engines: {node: '>=22'} + '@atproto/lex-data@0.1.7': + resolution: {integrity: sha512-kW/dPLqo/WgCLV+XESR4JKwV6c1rZWJGOfuPupZGTjEDAKoBbKXdaEzX9/1vKQYbZ9U3j0DS/n7OFFK7wBugyQ==} + engines: {node: '>=22'} + '@atproto/lex-json@0.1.4': resolution: {integrity: sha512-ENR2cWkVrES+UL6TovbCRdX9BJOyHHJUS8jYx3Lxp3j4vEphjn/u+DW7bWloST2O8ID2OuVlt6+28ftNEEjmQQ==} engines: {node: '>=22'} - '@atproto/lexicon@0.7.7': - resolution: {integrity: sha512-92VH2oEsJdrIVNy7WY8rGn99ANNVglyUffoN7GJc0mxKi+fXN5iVlJ2cOyTfYABhr2ldSiptZWXEPEdBaIR9/A==} + '@atproto/lex-json@0.1.6': + resolution: {integrity: sha512-mvrAd0lbyuecIHjyld8QN6MN6CBf4j0GCxLzegsvLh0SvDf+GbYWklkcQqmITL44yFQOwmA/QNIQj0Uvh7+R/g==} + engines: {node: '>=22'} + + '@atproto/lexicon@0.7.10': + resolution: {integrity: sha512-HH/3d3z5Qt0JoJ9iOuLP/YT++6lb5wWMQsvgQ2TGfgkbuzDQ6AX6X1H5uJ9luPci4/LaADfpwLZfeqyy/Hjfaw==} engines: {node: '>=22'} '@atproto/syntax@0.7.2': resolution: {integrity: sha512-tZ1Tr0R9pK4bI4Zs69t29cjMlCFQvRNBeNkqJC5pGeNuCt64D0eoF7s/AlqeVmYppClmQ0xJquggGgsMDP6j7w==} engines: {node: '>=22'} - '@atproto/xrpc@0.8.6': - resolution: {integrity: sha512-yVfKrlwZBBm44Ft9jDvHcTCwQ1ElqSlzXHWhT/KcqE+u24EAhWVFosfABfGbUByB/PmN8eLJ06FsWo5pUQcMNQ==} + '@atproto/syntax@0.7.3': + resolution: {integrity: sha512-7LU8Ra79L5wCqVAZZst2352VMMwkU4KNagVMm6pnURrY/uS7+HrJl3XCuA4V2Gy2dEpkt1r6DBxlsP43/VjDBw==} + engines: {node: '>=22'} + + '@atproto/xrpc@0.8.9': + resolution: {integrity: sha512-3jRiAuHCqYFoT8Nv25xFRmgrfmoo84yBnB5PfVP95JoRP+szIkwHS6mq72ZETYbWmLA1tNyR5QVEHrhHZ1LQhw==} engines: {node: '>=22'} '@babel/code-frame@7.10.4': @@ -1653,8 +1669,8 @@ packages: react: '*' react-native: '*' - '@bsky.app/peek-menu@0.3.1': - resolution: {integrity: sha512-ttFE7dIv1EJEmQn52WLvievzLNmPNNvPtoiz8ovl38jXLDMszL2wVPUYMnEjnZZqoq+K6HiBau9iHeCOKWwbTA==} + '@bsky.app/peek-menu@0.3.2': + resolution: {integrity: sha512-Uvr+tkE5qpbNNGiDJxbEaA3uidJnrZV1IHSJk16ltwgxrpj603d22I1u9B60o+eU4TR67fgLZMlRA0Rmhtqm/w==} peerDependencies: expo: '*' react: '*' @@ -9389,12 +9405,12 @@ snapshots: '@0no-co/graphql.web@1.2.0': {} - '@atproto/api@0.20.33': + '@atproto/api@0.20.37': dependencies: - '@atproto/common-web': 0.5.6 - '@atproto/lexicon': 0.7.7 - '@atproto/syntax': 0.7.2 - '@atproto/xrpc': 0.8.6 + '@atproto/common-web': 0.5.8 + '@atproto/lexicon': 0.7.10 + '@atproto/syntax': 0.7.3 + '@atproto/xrpc': 0.8.9 await-lock: 3.0.0 multiformats: 13.4.2 tlds: 1.261.0 @@ -9407,21 +9423,39 @@ snapshots: '@atproto/syntax': 0.7.2 zod: 3.25.76 + '@atproto/common-web@0.5.8': + dependencies: + '@atproto/lex-data': 0.1.7 + '@atproto/lex-json': 0.1.6 + '@atproto/syntax': 0.7.3 + zod: 3.25.76 + '@atproto/lex-data@0.1.5': dependencies: multiformats: 13.4.2 tslib: 2.8.1 unicode-segmenter: 0.14.5 + '@atproto/lex-data@0.1.7': + dependencies: + multiformats: 13.4.2 + tslib: 2.8.1 + unicode-segmenter: 0.14.5 + '@atproto/lex-json@0.1.4': dependencies: '@atproto/lex-data': 0.1.5 tslib: 2.8.1 - '@atproto/lexicon@0.7.7': + '@atproto/lex-json@0.1.6': dependencies: - '@atproto/common-web': 0.5.6 - '@atproto/syntax': 0.7.2 + '@atproto/lex-data': 0.1.7 + tslib: 2.8.1 + + '@atproto/lexicon@0.7.10': + dependencies: + '@atproto/common-web': 0.5.8 + '@atproto/syntax': 0.7.3 multiformats: 13.4.2 zod: 3.25.76 @@ -9430,9 +9464,14 @@ snapshots: iso-datestring-validator: 2.2.2 tslib: 2.8.1 - '@atproto/xrpc@0.8.6': + '@atproto/syntax@0.7.3': dependencies: - '@atproto/lexicon': 0.7.7 + iso-datestring-validator: 2.2.2 + tslib: 2.8.1 + + '@atproto/xrpc@0.8.9': + dependencies: + '@atproto/lexicon': 0.7.10 zod: 3.25.76 '@babel/code-frame@7.10.4': @@ -10375,7 +10414,7 @@ snapshots: react: 19.1.0 react-native: 0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) - '@bsky.app/peek-menu@0.3.1(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + '@bsky.app/peek-menu@0.3.2(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: expo: 54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react: 19.1.0 diff --git a/src/analytics/features/index.ts b/src/analytics/features/index.ts index 67ea7bb4ad..b83ac9e33b 100644 --- a/src/analytics/features/index.ts +++ b/src/analytics/features/index.ts @@ -40,6 +40,7 @@ const TIMEOUT_PREFER_FRESH_GATES = 1500 export const features = new GrowthBook({ apiHost: env.GROWTHBOOK_API_HOST, clientKey: env.GROWTHBOOK_CLIENT_KEY, + enableDevMode: env.IS_INTERNAL, }) /** @@ -59,8 +60,7 @@ export const init = features.init({timeout: TIMEOUT_INIT}).then(res => { }) /** - * Refresh feature gates from GrowthBook. Updates attributes based on the - * provided account, if any. + * Refresh feature gates from GrowthBook. */ export async function refresh({strategy}: {strategy: FeatureFetchStrategy}) { await features.refreshFeatures({ diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index 16cbe3a105..62fe079b7b 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -19,8 +19,15 @@ export enum Features { PostThreadKnownLikersEnable = 'post_thread:known_likers:enable', PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable', CustomLogoJapanEnable = 'custom_logo:japan:enable', + VideoAllow10MinuteEnable = 'video:allow-10-minute:enable', + VideoMultipartUploadEnable = 'video:multipart_upload:enable', SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable', FollowSortEnable = 'follow_sort:enable', + OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable', + + // values + TrendingDiscoverValues = 'trending_discover:values', + TrendingExploreTopicsCountValue = 'trending_explore_topics_count:value', AATest = 'aa-test', } diff --git a/src/analytics/index.tsx b/src/analytics/index.tsx index 20f919f501..51438c2d7b 100644 --- a/src/analytics/index.tsx +++ b/src/analytics/index.tsx @@ -6,7 +6,7 @@ import { useSyncExternalStore, } from 'react' import {Platform} from 'react-native' -import {type Result} from '@growthbook/growthbook-react' +import {type Result, type WidenPrimitives} from '@growthbook/growthbook-react' import {Logger} from '#/logger' import { @@ -67,6 +67,7 @@ export type AnalyticsContextType = { ) => void features: typeof Features & { enabled(feature: Features): boolean + getValue(feature: Features, defaultValue: T): WidenPrimitives } } export type AnalyticsBaseContextType = Omit @@ -83,6 +84,7 @@ function createLogger( warn: logger.warn.bind(logger), error: logger.error.bind(logger), useChild: (context: Exclude) => { + // oxlint-disable-next-line react-hooks/exhaustive-deps return useMemo(() => createLogger(context, metadata), [context, metadata]) }, Context: Logger.Context, @@ -314,6 +316,7 @@ export function AnalyticsFeaturesContext({ ...parentContext, features: { enabled: feats.isOn.bind(feats), + getValue: feats.getFeatureValue.bind(feats), ...Features, }, } diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index bd268603a0..592f9a563d 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -5,7 +5,10 @@ import {type Platform} from 'react-native' import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' -import {type VideoCompressSkipReason} from '#/lib/media/video/types' +import { + type VideoCompressSkipReason, + type VideoUploadTransport, +} from '#/lib/media/video/types' import {type NotificationType} from '#/state/queries/notifications/types' import {type FeedDescriptor} from '#/state/queries/post-feed' import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types' @@ -140,6 +143,7 @@ export type Events = { selectedInterests: string[] selectedInterestsLength: number } + 'onboarding:interests:disabledNextPressed': {} 'onboarding:suggestedAccounts:tabPressed': { tab: string } @@ -753,10 +757,14 @@ export type Events = { 'trendingTopic:seen': { context: 'sidebar' | 'interstitial' | 'explore' recId?: string + rank: number + feedSliceIndex?: number } 'trendingTopic:click': { context: 'sidebar' | 'interstitial' | 'explore' recId?: string + rank: number + feedSliceIndex?: number } 'trendingVideos:show': { context: 'settings' @@ -1451,6 +1459,7 @@ export type Events = { bytes: number elapsedMs: number throughputBytesPerSec: number + transport: VideoUploadTransport } 'video:upload:uploadFailed': { uploadId: string @@ -1458,6 +1467,7 @@ export type Events = { bytes: number errorClass: string elapsedMs: number + transport: VideoUploadTransport } 'video:upload:processingStarted': { uploadId: string diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts index d7c44b0d91..9184ccf37c 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts @@ -27,8 +27,22 @@ export class VideoNotFoundError extends Error { */ export class HLSFatalError extends Error { detail: string - constructor(detail: string, cause: Error) { + type: string + diagnostics: Record + constructor({ + detail, + type, + cause, + diagnostics, + }: { + detail: string + type: string + cause: Error + diagnostics: Record + }) { super(cause.message, {cause}) this.detail = detail + this.type = type + this.diagnostics = diagnostics } } diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx index 6ece8f5a9b..0599240816 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -308,7 +308,53 @@ function useHLS({ ) { setError(new VideoNotFoundError()) } else { - setError(new HLSFatalError(data.details, data.error)) + const video = videoRef.current + const mediaError = video?.error + setError( + new HLSFatalError({ + detail: data.details, + type: data.type, + cause: data.error, + diagnostics: { + hlsError: { + detail: data.details, + type: data.type, + sourceBufferName: data.sourceBufferName, + parent: data.parent, + reason: data.reason, + errorName: data.error.name, + errorCode: (data.error as DOMException).code, + }, + fragment: data.frag + ? { + sn: data.frag.sn, + level: data.frag.level, + type: data.frag.type, + start: data.frag.start, + duration: data.frag.duration, + cc: data.frag.cc, + } + : undefined, + media: video + ? { + errorCode: mediaError?.code, + errorMessage: mediaError?.message, + readyState: video.readyState, + networkState: video.networkState, + currentTime: video.currentTime, + paused: video.paused, + ended: video.ended, + seeking: video.seeking, + } + : undefined, + lifecycle: { + documentVisibility: document.visibilityState, + hlsIsCurrent: hlsRef.current === hls, + }, + playlist, + }, + }), + ) } } else { console.error(data.error) diff --git a/src/components/Post/Embed/VideoEmbed/index.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx index ee44ba33e8..0eb622bad7 100644 --- a/src/components/Post/Embed/VideoEmbed/index.web.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx @@ -82,6 +82,16 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { ), [key, embed], ) + const getErrorMetadata = useCallback((error: Error) => { + if (!(error instanceof HLSFatalError)) return {} + return { + tags: { + hls_error_detail: error.detail, + hls_error_type: error.type, + }, + hls: error.diagnostics, + } + }, []) let aspectRatio: number | undefined const dims = embed.aspectRatio @@ -158,7 +168,10 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { /> )} - + ) { const topic = useTopic(raw) - useTrendingTopicSeen(metricContext, recId) + useTrendingTopicSeen(metricContext, rank, recId) return ( { - ax.metric('trendingTopic:seen', {context, recId}) + ax.metric('trendingTopic:seen', { + context, + rank, + feedSliceIndex, + recId, + }) }) useEffect(() => { diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index 6e3c71bbc8..2512403ec3 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -267,7 +267,16 @@ export function createInput(Component: typeof TextInput) { ctx.onBlur() onBlur?.(e) }} - placeholder={placeholder === null ? undefined : placeholder || label} + /* + * Android sizes an empty input from the font's bounding box instead + * of `lineHeight`, so a field with no placeholder shrinks on the + * first keystroke. + */ + placeholder={ + placeholder === null + ? platform({android: ' '}) + : placeholder || label + } placeholderTextColor={t.palette.contrast_500} keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} style={flattened} diff --git a/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts b/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts index 6ac2c0086f..eea54b5ed9 100644 --- a/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts +++ b/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts @@ -88,6 +88,7 @@ export function maybeApplyGalleryOffsetStyles( if (!isPostGalleryEmbedEnabled) return // one image, not a gallery if (embed.media.images.length === 1) return + hasImages = true } if ( bsky.dangerousIsType( @@ -97,8 +98,8 @@ export function maybeApplyGalleryOffsetStyles( ) { // single (or empty) gallery - no offset needed if (embed.media.items.length <= 1) return + hasImages = true } - hasImages = true } if (!hasImages) return diff --git a/src/components/interstitials/FeedTrendingTopics.tsx b/src/components/interstitials/FeedTrendingTopics.tsx index ac68de94ea..4d45001f1c 100644 --- a/src/components/interstitials/FeedTrendingTopics.tsx +++ b/src/components/interstitials/FeedTrendingTopics.tsx @@ -32,15 +32,21 @@ import {useAnalytics} from '#/analytics' const TOPIC_COUNT = 3 -export function FeedTrendingTopicsInterstitial() { +export function FeedTrendingTopicsInterstitial({ + feedSliceIndex, +}: { + feedSliceIndex: number +}) { const {enabled} = useTrendingConfig() const {trendingDisabled} = useTrendingSettings() const {rightNavVisible} = useLayoutBreakpoints() - return enabled && !trendingDisabled && !rightNavVisible ? : null + return enabled && !trendingDisabled && !rightNavVisible ? ( + + ) : null } -function Inner() { +function Inner({feedSliceIndex}: {feedSliceIndex: number}) { const t = useTheme() const {t: l} = useLingui() const gutters = useGutters([0, 'base']) @@ -132,20 +138,26 @@ function Inner() { ? Array.from({length: TOPIC_COUNT}).map((_, i) => ( )) - : trending?.trends?.map((trend, index) => ( - { - ax.metric('trendingTopic:click', { - context: 'interstitial', - recId: trending.recId, - }) - }} - /> - ))} + : trending?.trends?.map((trend, index) => { + const rank = index + 1 + return ( + { + ax.metric('trendingTopic:click', { + context: 'interstitial', + rank, + feedSliceIndex, + recId: trending.recId, + }) + }} + /> + ) + })} @@ -166,11 +178,13 @@ function Inner() { function TrendRow({ trend, rank, + feedSliceIndex, recId, onPress, }: ViewStyleProp & { trend: AppBskyUnspeccedDefs.TrendView rank: number + feedSliceIndex: number recId?: string children?: React.ReactNode onPress?: () => void @@ -180,7 +194,7 @@ function TrendRow({ const actors = useModerateTrendingActors(trend.actors) const formattedPostCount = formatCount(i18n, trend.postCount) - useTrendingTopicSeen('interstitial', recId) + useTrendingTopicSeen('interstitial', rank, recId, feedSliceIndex) return ( - + {trend.displayName} diff --git a/src/components/interstitials/Trending.tsx b/src/components/interstitials/Trending.tsx index 3b88109043..0b94064dac 100644 --- a/src/components/interstitials/Trending.tsx +++ b/src/components/interstitials/Trending.tsx @@ -98,30 +98,35 @@ export function Inner() { ) : !trending?.trends ? null : ( <> - {trending.trends.map(topic => ( - { - ax.metric('trendingTopic:click', { - context: 'interstitial', - recId: trending.recId, - }) - }}> - - - {topic.topic} - - - - ))} + {trending.trends.map((topic, index) => { + const rank = index + 1 + return ( + { + ax.metric('trendingTopic:click', { + context: 'interstitial', + rank, + recId: trending.recId, + }) + }}> + + + {topic.topic} + + + + ) + })} - - + {state.ncii && ( + + )} + ) : state.selectedCategory ? ( {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 +}) { + const t = useTheme() + const {t: l} = useLingui() + const outcome = getNciiQualificationOutcome(ncii) + return ( + + { + dispatch({ + type: 'answerNciiQuestion', + question: 'isDepicted', + answer, + }) + }} + /> + {outcome === 'externalForm' && ( + + {({hovered, pressed}) => ( + + + + Please submit your report through the Report non-consensual + intimate imagery (NCII) form. + + + + + )} + + )} + + ) +} + +function YesNoQuestion({ + question, + value, + onAnswer, + testID, +}: { + question: string + value?: boolean + onAnswer: (answer: boolean) => void + testID?: string +}) { + const {t: l} = useLingui() + return ( + + {question} + + + + + + + + + + ) +} + function OptionCardSkeleton() { const t = useTheme() return ( diff --git a/src/components/moderation/ReportDialog/state.test.ts b/src/components/moderation/ReportDialog/state.test.ts new file mode 100644 index 0000000000..6c08fe3d5c --- /dev/null +++ b/src/components/moderation/ReportDialog/state.test.ts @@ -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() + }) +}) diff --git a/src/components/moderation/ReportDialog/state.ts b/src/components/moderation/ReportDialog/state.ts index 7b15e174b2..d86b5e28b9 100644 --- a/src/components/moderation/ReportDialog/state.ts +++ b/src/components/moderation/ReportDialog/state.ts @@ -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, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 80d8639ff2..bc079c7af5 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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. diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index eaa6482830..71a3437ad2 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -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: { diff --git a/src/lib/hooks/useOTAUpdates.test.ts b/src/lib/hooks/useOTAUpdates.test.ts new file mode 100644 index 0000000000..871cac0e63 --- /dev/null +++ b/src/lib/hooks/useOTAUpdates.test.ts @@ -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) + 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>) + 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>) + 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>) + 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>) + 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>) + jest.mocked(fetchUpdateAsync).mockResolvedValue({ + isNew: true, + isRollBackToEmbedded: false, + manifest: {id: 'mismatched-update'}, + } as Awaited>) + 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>) + 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>) + jest.mocked(fetchUpdateAsync).mockResolvedValue({ + isNew: true, + isRollBackToEmbedded: false, + manifest: {id: 'new-update'}, + } as Awaited>) + 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() + }) +}) diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts index 069367dd33..180102c2cd 100644 --- a/src/lib/hooks/useOTAUpdates.ts +++ b/src/lib/hooks/useOTAUpdates.ts @@ -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('active') const lastMinimize = useRef(0) const ranInitialCheck = useRef(false) const timeout = useRef(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 } diff --git a/src/lib/hooks/useOTAUpdates.web.ts b/src/lib/hooks/useOTAUpdates.web.ts index 6938a5b003..970deebf1b 100644 --- a/src/lib/hooks/useOTAUpdates.web.ts +++ b/src/lib/hooks/useOTAUpdates.web.ts @@ -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, diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts index d7d8dab440..63e2a1e06c 100644 --- a/src/lib/media/picker.shared.ts +++ b/src/lib/media/picker.shared.ts @@ -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, }) } diff --git a/src/lib/media/picker.tsx b/src/lib/media/picker.tsx index c9a52b8bad..926b2e9560 100644 --- a/src/lib/media/picker.tsx +++ b/src/lib/media/picker.tsx @@ -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, diff --git a/src/lib/media/video/multipart/aggregateProgress.test.ts b/src/lib/media/video/multipart/aggregateProgress.test.ts new file mode 100644 index 0000000000..a92273cbdb --- /dev/null +++ b/src/lib/media/video/multipart/aggregateProgress.test.ts @@ -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]) + }) +}) diff --git a/src/lib/media/video/multipart/aggregateProgress.ts b/src/lib/media/video/multipart/aggregateProgress.ts new file mode 100644 index 0000000000..c9e0b1914c --- /dev/null +++ b/src/lib/media/video/multipart/aggregateProgress.ts @@ -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() + 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) + } +} diff --git a/src/lib/media/video/multipart/api.ts b/src/lib/media/video/multipart/api.ts new file mode 100644 index 0000000000..c6a268ffe1 --- /dev/null +++ b/src/lib/media/video/multipart/api.ts @@ -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({ + route, + token, + signal, + method = 'POST', + body, + params, +}: { + route: string + token: string + signal?: AbortSignal + method?: 'GET' | 'POST' + body?: object + params?: Record +}): Promise { + 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({ + 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({ + route: '/xrpc/app.bsky.video.finishUpload', + token, + signal, + body: {jobId}, + }) +} + +export function getUploadStatus( + jobId: string, + token: string, + signal?: AbortSignal, +) { + return request({ + route: '/xrpc/app.bsky.video.getUploadStatus', + token, + signal, + method: 'GET', + params: {jobId}, + }) +} + +export function abortUpload( + jobId: string, + token: string, + signal?: AbortSignal, +) { + return request({ + 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, + } +} diff --git a/src/lib/media/video/multipart/constants.ts b/src/lib/media/video/multipart/constants.ts new file mode 100644 index 0000000000..2592a9d7db --- /dev/null +++ b/src/lib/media/video/multipart/constants.ts @@ -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 diff --git a/src/lib/media/video/multipart/planParts.test.ts b/src/lib/media/video/multipart/planParts.test.ts new file mode 100644 index 0000000000..fb7365fc74 --- /dev/null +++ b/src/lib/media/video/multipart/planParts.test.ts @@ -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}, + ]) + }) +}) diff --git a/src/lib/media/video/multipart/planParts.ts b/src/lib/media/video/multipart/planParts.ts new file mode 100644 index 0000000000..6dfaf29f1b --- /dev/null +++ b/src/lib/media/video/multipart/planParts.ts @@ -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)) +} diff --git a/src/lib/media/video/multipart/readChunk.ts b/src/lib/media/video/multipart/readChunk.ts new file mode 100644 index 0000000000..9a8aaefe32 --- /dev/null +++ b/src/lib/media/video/multipart/readChunk.ts @@ -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() + }, + } +} diff --git a/src/lib/media/video/multipart/readChunk.web.ts b/src/lib/media/video/multipart/readChunk.web.ts new file mode 100644 index 0000000000..8f3767f26c --- /dev/null +++ b/src/lib/media/video/multipart/readChunk.web.ts @@ -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 | 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() {}, + } +} diff --git a/src/lib/media/video/multipart/types.ts b/src/lib/media/video/multipart/types.ts new file mode 100644 index 0000000000..16817778c3 --- /dev/null +++ b/src/lib/media/video/multipart/types.ts @@ -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 + 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 + +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'} diff --git a/src/lib/media/video/multipart/upload.ts b/src/lib/media/video/multipart/upload.ts new file mode 100644 index 0000000000..2b45e6d2c6 --- /dev/null +++ b/src/lib/media/video/multipart/upload.ts @@ -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 { + 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 | undefined + // Kept outside the upload try block for finish-time missing-part recovery. + let parts: ReturnType = [] + 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 + signal: AbortSignal + resendMissingParts: (receivedPartNumbers: number[]) => Promise +}): Promise { + 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 { + 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 | 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() +} diff --git a/src/lib/media/video/multipart/uploadPart.ts b/src/lib/media/video/multipart/uploadPart.ts new file mode 100644 index 0000000000..d0a910682c --- /dev/null +++ b/src/lib/media/video/multipart/uploadPart.ts @@ -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, +): 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[0], +) { + return new Promise>>((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>) => { + 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) + }) +} diff --git a/src/lib/media/video/multipart/uploadParts.test.ts b/src/lib/media/video/multipart/uploadParts.test.ts new file mode 100644 index 0000000000..b580f14de2 --- /dev/null +++ b/src/lib/media/video/multipart/uploadParts.test.ts @@ -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() + 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, + Parameters + >(() => + 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) + }) +}) diff --git a/src/lib/media/video/multipart/uploadParts.ts b/src/lib/media/video/multipart/uploadParts.ts new file mode 100644 index 0000000000..041ecff1b9 --- /dev/null +++ b/src/lib/media/video/multipart/uploadParts.ts @@ -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 { + 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 { + 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 +} diff --git a/src/lib/media/video/multipart/utils.ts b/src/lib/media/video/multipart/utils.ts new file mode 100644 index 0000000000..05c42858f9 --- /dev/null +++ b/src/lib/media/video/multipart/utils.ts @@ -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((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) +} diff --git a/src/lib/media/video/telemetry.ts b/src/lib/media/video/telemetry.ts index 67ff77c58e..9181ec002b 100644 --- a/src/lib/media/video/telemetry.ts +++ b/src/lib/media/video/telemetry.ts @@ -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() diff --git a/src/lib/media/video/types.ts b/src/lib/media/video/types.ts index 6825f03ba3..1d2062ce00 100644 --- a/src/lib/media/video/types.ts +++ b/src/lib/media/video/types.ts @@ -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 diff --git a/src/lib/media/video/upload.ts b/src/lib/media/video/upload.ts index 721ee7f94f..b91ad7a153 100644 --- a/src/lib/media/video/upload.ts +++ b/src/lib/media/video/upload.ts @@ -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)}`, diff --git a/src/lib/media/video/upload.web.ts b/src/lib/media/video/upload.web.ts index e88a04707c..cfefbd797d 100644 --- a/src/lib/media/video/upload.web.ts +++ b/src/lib/media/video/upload.web.ts @@ -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)}`, diff --git a/src/lib/strings/errors.ts b/src/lib/strings/errors.ts index 994c91515b..11e7c11362 100644 --- a/src/lib/strings/errors.ts +++ b/src/lib/strings/errors.ts @@ -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) } diff --git a/src/locale/i18n.ts b/src/locale/i18n.ts index 42041ca9a4..3238cf5468 100644 --- a/src/locale/i18n.ts +++ b/src/locale/i18n.ts @@ -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 } diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index fd9f45c3a5..f82413f9f7 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -483,35 +483,35 @@ msgstr "" msgid "{filterCount, plural, one {+# filter} other {+# filters}}" msgstr "{filterCount, plural, one {+# filter} other {+# filters}}" -#: src/view/com/notifications/NotificationFeedItem.tsx:361 +#: src/view/com/notifications/NotificationFeedItem.tsx:367 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} followed you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:395 +#: src/view/com/notifications/NotificationFeedItem.tsx:399 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your custom feed" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:304 +#: src/view/com/notifications/NotificationFeedItem.tsx:305 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:500 +#: src/view/com/notifications/NotificationFeedItem.tsx:496 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:473 +#: src/view/com/notifications/NotificationFeedItem.tsx:471 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} removed their verifications from your account" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:328 +#: src/view/com/notifications/NotificationFeedItem.tsx:327 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} reposted your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:524 +#: src/view/com/notifications/NotificationFeedItem.tsx:518 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} reposted your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:419 +#: src/view/com/notifications/NotificationFeedItem.tsx:421 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} signed up with your starter pack" msgstr "" @@ -519,39 +519,39 @@ msgstr "" msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} verified you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:373 +#: src/view/com/notifications/NotificationFeedItem.tsx:379 msgid "{firstAuthorLink} followed you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:350 +#: src/view/com/notifications/NotificationFeedItem.tsx:351 msgid "{firstAuthorLink} followed you back" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:407 +#: src/view/com/notifications/NotificationFeedItem.tsx:411 msgid "{firstAuthorLink} liked your custom feed" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:316 +#: src/view/com/notifications/NotificationFeedItem.tsx:317 msgid "{firstAuthorLink} liked your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:512 +#: src/view/com/notifications/NotificationFeedItem.tsx:508 msgid "{firstAuthorLink} liked your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:485 +#: src/view/com/notifications/NotificationFeedItem.tsx:483 msgid "{firstAuthorLink} removed their verification from your account" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:340 +#: src/view/com/notifications/NotificationFeedItem.tsx:339 msgid "{firstAuthorLink} reposted your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:536 +#: src/view/com/notifications/NotificationFeedItem.tsx:530 msgid "{firstAuthorLink} reposted your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:431 +#: src/view/com/notifications/NotificationFeedItem.tsx:433 msgid "{firstAuthorLink} signed up with your starter pack" msgstr "" @@ -559,23 +559,27 @@ msgstr "" msgid "{firstAuthorLink} verified you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:354 +#: src/view/com/notifications/NotificationFeedItem.tsx:361 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} followed you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:388 +#: src/view/com/notifications/NotificationFeedItem.tsx:355 +msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} followed you via starter pack {starterPackName}" +msgstr "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} followed you via starter pack {starterPackName}" + +#: src/view/com/notifications/NotificationFeedItem.tsx:393 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your custom feed" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:297 +#: src/view/com/notifications/NotificationFeedItem.tsx:299 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:493 +#: src/view/com/notifications/NotificationFeedItem.tsx:490 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:466 +#: src/view/com/notifications/NotificationFeedItem.tsx:465 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} removed their verifications from your account" msgstr "" @@ -583,51 +587,59 @@ msgstr "" msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} reposted your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:517 +#: src/view/com/notifications/NotificationFeedItem.tsx:512 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} reposted your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:412 +#: src/view/com/notifications/NotificationFeedItem.tsx:415 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} signed up with your starter pack" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:441 +#: src/view/com/notifications/NotificationFeedItem.tsx:442 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} verified you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:359 +#: src/view/com/notifications/NotificationFeedItem.tsx:365 msgid "{firstAuthorName} followed you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:349 +#: src/view/com/notifications/NotificationFeedItem.tsx:350 msgid "{firstAuthorName} followed you back" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:393 +#: src/view/com/notifications/NotificationFeedItem.tsx:349 +msgid "{firstAuthorName} followed you back via starter pack {starterPackName}" +msgstr "{firstAuthorName} followed you back via starter pack {starterPackName}" + +#: src/view/com/notifications/NotificationFeedItem.tsx:359 +msgid "{firstAuthorName} followed you via starter pack {starterPackName}" +msgstr "{firstAuthorName} followed you via starter pack {starterPackName}" + +#: src/view/com/notifications/NotificationFeedItem.tsx:397 msgid "{firstAuthorName} liked your custom feed" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:302 +#: src/view/com/notifications/NotificationFeedItem.tsx:303 msgid "{firstAuthorName} liked your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:498 +#: src/view/com/notifications/NotificationFeedItem.tsx:494 msgid "{firstAuthorName} liked your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:471 +#: src/view/com/notifications/NotificationFeedItem.tsx:469 msgid "{firstAuthorName} removed their verification from your account" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:326 +#: src/view/com/notifications/NotificationFeedItem.tsx:325 msgid "{firstAuthorName} reposted your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:522 +#: src/view/com/notifications/NotificationFeedItem.tsx:516 msgid "{firstAuthorName} reposted your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:417 +#: src/view/com/notifications/NotificationFeedItem.tsx:419 msgid "{firstAuthorName} signed up with your starter pack" msgstr "" @@ -641,8 +653,8 @@ msgstr "" #. '{postCount} {posts}', e.g., '1.2K posts' #. '{postCount} {posts}', e.g., '1.2K posts' -#: src/components/interstitials/FeedTrendingTopics.tsx:231 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:177 +#: src/components/interstitials/FeedTrendingTopics.tsx:245 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:192 msgid "{formattedPostCount} {postCount, plural, one {post} other {posts}}" msgstr "{formattedPostCount} {postCount, plural, one {post} other {posts}}" @@ -743,8 +755,8 @@ msgstr "" #. The trending topic rank, i.e. "1. March Madness", "2. The Bachelor" #. The trending topic rank, i.e. "1. March Madness", "2. The Bachelor" -#: src/components/interstitials/FeedTrendingTopics.tsx:216 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:152 +#: src/components/interstitials/FeedTrendingTopics.tsx:230 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:167 msgid "{rank}." msgstr "" @@ -984,8 +996,8 @@ msgstr "A selected recipient is not followed by the sender." #: src/Navigation.tsx:483 #: src/screens/Settings/AboutSettings.tsx:85 -#: src/screens/Settings/Settings.tsx:264 -#: src/screens/Settings/Settings.tsx:267 +#: src/screens/Settings/Settings.tsx:265 +#: src/screens/Settings/Settings.tsx:268 msgid "About" msgstr "" @@ -1026,8 +1038,8 @@ msgid "Access requested! The group owner will review your request." msgstr "Access requested! The group owner will review your request." #: src/screens/Settings/AccessibilitySettings.tsx:45 -#: src/screens/Settings/Settings.tsx:234 -#: src/screens/Settings/Settings.tsx:237 +#: src/screens/Settings/Settings.tsx:235 +#: src/screens/Settings/Settings.tsx:238 msgid "Accessibility" msgstr "" @@ -1037,8 +1049,8 @@ msgstr "" #: src/Navigation.tsx:406 #: src/screens/Settings/AccountSettings.tsx:54 -#: src/screens/Settings/Settings.tsx:174 -#: src/screens/Settings/Settings.tsx:177 +#: src/screens/Settings/Settings.tsx:175 +#: src/screens/Settings/Settings.tsx:178 msgid "Account" msgstr "" @@ -1078,11 +1090,11 @@ msgstr "" msgid "Account Muted by List" msgstr "" -#: src/screens/Settings/Settings.tsx:646 +#: src/screens/Settings/Settings.tsx:656 msgid "Account options" msgstr "" -#: src/screens/Settings/Settings.tsx:681 +#: src/screens/Settings/Settings.tsx:691 msgid "Account removed from quick access" msgstr "" @@ -1159,7 +1171,7 @@ msgstr "" #: src/view/com/composer/GifAltText.tsx:76 #: src/view/com/composer/GifAltText.tsx:150 -#: src/view/com/composer/GifAltText.tsx:217 +#: src/view/com/composer/GifAltText.tsx:216 #: src/view/com/composer/photos/Gallery.tsx:201 #: src/view/com/composer/photos/Gallery.tsx:236 #: src/view/com/composer/photos/ImageAltTextDialog.tsx:95 @@ -1171,8 +1183,8 @@ msgstr "" msgid "Add alt text (optional)" msgstr "" -#: src/screens/Settings/Settings.tsx:584 -#: src/screens/Settings/Settings.tsx:587 +#: src/screens/Settings/Settings.tsx:594 +#: src/screens/Settings/Settings.tsx:597 #: src/view/shell/desktop/LeftNav.tsx:276 #: src/view/shell/desktop/LeftNav.tsx:279 msgid "Add another account" @@ -1216,7 +1228,7 @@ msgstr "Add filter" msgid "Add group chat members" msgstr "Add group chat members" -#: src/view/com/feeds/ComposerPrompt.tsx:223 +#: src/view/com/feeds/ComposerPrompt.tsx:226 msgid "Add image" msgstr "" @@ -1231,8 +1243,8 @@ msgstr "" msgid "Add members" msgstr "Add members" -#: src/components/moderation/ReportDialog/index.tsx:528 -#: src/components/moderation/ReportDialog/index.tsx:532 +#: src/components/moderation/ReportDialog/index.tsx:566 +#: src/components/moderation/ReportDialog/index.tsx:570 msgid "Add more details (optional)" msgstr "" @@ -1338,7 +1350,7 @@ msgstr "" msgid "Additional details (limit 1000 characters)" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:546 +#: src/components/moderation/ReportDialog/index.tsx:584 msgid "Additional details (limit 300 characters)" msgstr "" @@ -1544,8 +1556,8 @@ msgid "Alt text describes images for blind and low-vision users, and helps give msgstr "" #. placeholder {0}: i18n.number(MAX_ALT_TEXT) -#: src/view/com/composer/GifAltText.tsx:185 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:149 +#: src/view/com/composer/GifAltText.tsx:184 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:148 msgid "Alt text will be truncated. {MAX_ALT_TEXT, plural, other {Limit: {0} characters.}}" msgstr "" @@ -1564,7 +1576,7 @@ msgstr "" msgid "An error occurred" msgstr "" -#: src/view/com/composer/state/video.ts:433 +#: src/view/com/composer/state/video.ts:447 msgid "An error occurred while compressing the video." msgstr "" @@ -1610,11 +1622,11 @@ msgstr "" msgid "An error occurred while trying to follow all" msgstr "" -#: src/view/com/composer/state/video.ts:485 +#: src/view/com/composer/state/video.ts:499 msgid "An error occurred while uploading the video. {message}" msgstr "" -#: src/view/com/composer/state/video.ts:477 +#: src/view/com/composer/state/video.ts:491 msgid "An error occurred while uploading the video. Please check your internet connection and try again." msgstr "" @@ -1665,8 +1677,8 @@ msgstr "An issue occurred starting the group chat, please try again." #: src/components/hooks/useFollowMethods.ts:52 #: src/components/ProfileCard.tsx:510 #: src/components/ProfileCard.tsx:532 -#: src/view/com/notifications/NotificationFeedItem.tsx:798 -#: src/view/com/notifications/NotificationFeedItem.tsx:820 +#: src/view/com/notifications/NotificationFeedItem.tsx:834 +#: src/view/com/notifications/NotificationFeedItem.tsx:855 msgid "An issue occurred, please try again." msgstr "" @@ -1835,8 +1847,8 @@ msgstr "Appeal this label" #: src/Navigation.tsx:398 #: src/screens/Settings/AppearanceSettings.tsx:71 -#: src/screens/Settings/Settings.tsx:226 -#: src/screens/Settings/Settings.tsx:229 +#: src/screens/Settings/Settings.tsx:227 +#: src/screens/Settings/Settings.tsx:230 msgid "Appearance" msgstr "" @@ -1845,8 +1857,8 @@ msgstr "" msgid "Apply default recommended feeds" msgstr "" -#: src/screens/Settings/Settings.tsx:515 -#: src/screens/Settings/Settings.tsx:517 +#: src/screens/Settings/Settings.tsx:525 +#: src/screens/Settings/Settings.tsx:527 msgid "Apply Pull Request" msgstr "" @@ -1930,6 +1942,10 @@ msgstr "" msgid "Are you sure?" msgstr "" +#: src/components/moderation/ReportDialog/index.tsx:841 +msgid "Are you the person depicted, or an authorized representative acting on behalf of the person depicted?" +msgstr "Are you the person depicted, or an authorized representative acting on behalf of the person depicted?" + #: src/view/com/composer/select-language/SuggestedLanguage.tsx:349 msgid "Are you writing in <0>{suggestedLanguageName}?" msgstr "" @@ -2113,8 +2129,8 @@ msgstr "" #: src/Navigation.tsx:414 #: src/screens/Settings/BetaFeaturesSettings.tsx:108 -#: src/screens/Settings/Settings.tsx:248 -#: src/screens/Settings/Settings.tsx:251 +#: src/screens/Settings/Settings.tsx:249 +#: src/screens/Settings/Settings.tsx:252 msgid "Beta features" msgstr "Beta features" @@ -2383,25 +2399,25 @@ msgstr "" msgid "Browse other feeds" msgstr "" -#: src/components/TrendingTopics.tsx:78 +#: src/components/TrendingTopics.tsx:87 msgid "Browse posts about {displayName}" msgstr "" -#: src/components/TrendingTopics.tsx:86 +#: src/components/TrendingTopics.tsx:95 msgid "Browse posts tagged with {displayName}" msgstr "" -#: src/components/TrendingTopics.tsx:95 +#: src/components/TrendingTopics.tsx:104 msgid "Browse starter pack {displayName}" msgstr "" #. placeholder {0}: trend.displayName -#: src/components/interstitials/FeedTrendingTopics.tsx:188 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:133 +#: src/components/interstitials/FeedTrendingTopics.tsx:202 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:149 msgid "Browse topic {0}" msgstr "" -#: src/components/TrendingTopics.tsx:132 +#: src/components/TrendingTopics.tsx:141 msgid "Browse topic {displayName}" msgstr "" @@ -2418,7 +2434,7 @@ msgstr "" #. placeholder {0}: sanitizeHandle(handle, '@') #. placeholder {0}: sanitizeHandle(item.feed.creator.handle, '@') #: src/components/LabelingServiceCard/index.tsx:62 -#: src/components/moderation/ReportDialog/index.tsx:847 +#: src/components/moderation/ReportDialog/index.tsx:1005 #: src/screens/Messages/JoinRequest.tsx:177 #: src/screens/Search/components/StarterPackCard.tsx:112 #: src/screens/Search/Explore.tsx:960 @@ -2467,7 +2483,7 @@ msgstr "" msgid "By you" msgstr "" -#: src/view/com/composer/photos/OpenCameraBtn.tsx:65 +#: src/view/com/composer/photos/OpenCameraBtn.tsx:68 msgid "Camera" msgstr "" @@ -2521,7 +2537,7 @@ msgstr "Camera access needed" #: src/screens/Settings/components/ChangeHandleDialog.tsx:87 #: src/screens/Settings/components/ChangePasswordDialog.tsx:248 #: src/screens/Settings/components/ChangePasswordDialog.tsx:254 -#: src/screens/Settings/Settings.tsx:309 +#: src/screens/Settings/Settings.tsx:310 #: src/screens/Takendown.tsx:102 #: src/screens/Takendown.tsx:105 #: src/view/com/composer/Composer.tsx:1820 @@ -2610,7 +2626,7 @@ msgstr "" msgid "Change hosting provider" msgstr "Change hosting provider" -#: src/components/moderation/ReportDialog/index.tsx:445 +#: src/components/moderation/ReportDialog/index.tsx:483 msgid "Change moderation service" msgstr "" @@ -2623,11 +2639,11 @@ msgstr "" msgid "Change password dialog" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:312 +#: src/components/moderation/ReportDialog/index.tsx:345 msgid "Change report category" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:390 +#: src/components/moderation/ReportDialog/index.tsx:424 msgid "Change report reason" msgstr "" @@ -2789,6 +2805,25 @@ msgstr "" msgid "Child Sexual Abuse Material (CSAM)" msgstr "" +#: src/screens/Onboarding/StepInterests/index.tsx:79 +#: src/screens/Onboarding/StepInterests/index.tsx:85 +#: src/screens/Onboarding/StepInterests/index.tsx:139 +msgid "Choose an interest" +msgstr "Choose an interest" + +#: src/screens/Onboarding/StepInterests/index.tsx:140 +msgid "Choose at least one interest to continue" +msgstr "Choose at least one interest to continue" + +#: src/screens/Onboarding/StepInterests/index.tsx:150 +#: src/screens/Onboarding/StepInterests/index.tsx:151 +msgid "Choose at least one interest." +msgstr "Choose at least one interest." + +#: src/screens/Onboarding/StepInterests/index.tsx:102 +msgid "Choose at least one. We'll use this to customize your experience. You can change these anytime." +msgstr "Choose at least one. We'll use this to customize your experience. You can change these anytime." + #: src/screens/Settings/components/ChangeHandleDialog.tsx:401 msgid "Choose domain verification method" msgstr "" @@ -2849,11 +2884,11 @@ msgstr "" msgid "Clear" msgstr "Clear" -#: src/screens/Settings/Settings.tsx:507 +#: src/screens/Settings/Settings.tsx:517 msgid "Clear all storage data" msgstr "" -#: src/screens/Settings/Settings.tsx:509 +#: src/screens/Settings/Settings.tsx:519 msgid "Clear all storage data (restart after this)" msgstr "" @@ -3062,11 +3097,11 @@ msgstr "" msgid "Closes post composer and discards post draft" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:611 +#: src/view/com/notifications/NotificationFeedItem.tsx:601 msgid "Collapse list of users" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:949 +#: src/view/com/notifications/NotificationFeedItem.tsx:983 msgid "Collapses list of users for a given notification" msgstr "" @@ -3106,7 +3141,7 @@ msgid "Complete the challenge" msgstr "" #: src/lib/hotkeys/index.tsx:66 -#: src/view/com/feeds/ComposerPrompt.tsx:147 +#: src/view/com/feeds/ComposerPrompt.tsx:150 #: src/view/shell/desktop/LeftNav.tsx:587 msgid "Compose new post" msgstr "" @@ -3208,8 +3243,8 @@ msgstr "" msgid "Content & Media" msgstr "" -#: src/screens/Settings/Settings.tsx:206 -#: src/screens/Settings/Settings.tsx:209 +#: src/screens/Settings/Settings.tsx:207 +#: src/screens/Settings/Settings.tsx:210 msgid "Content and media" msgstr "" @@ -3257,7 +3292,7 @@ msgstr "" #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:171 #: src/screens/Login/components/ConfirmHostingProviderDialog.tsx:149 #: src/screens/Login/components/ConfirmHostingProviderDialog.tsx:152 -#: src/screens/Onboarding/StepInterests/index.tsx:93 +#: src/screens/Onboarding/StepInterests/index.tsx:87 #: src/screens/Onboarding/StepProfile/index.tsx:301 #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:305 #: src/screens/Onboarding/StepSuggestedStarterpacks/index.tsx:117 @@ -3286,7 +3321,7 @@ msgstr "" msgid "Continue to group name" msgstr "Continue to group name" -#: src/screens/Onboarding/StepInterests/index.tsx:90 +#: src/screens/Onboarding/StepInterests/index.tsx:80 #: src/screens/Onboarding/StepProfile/index.tsx:298 #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:302 #: src/screens/Onboarding/StepSuggestedStarterpacks/index.tsx:114 @@ -3664,8 +3699,8 @@ msgstr "Create or modify an invite link for this group chat" #. Accessibility label for button to create a moderation report for the selected option #. placeholder {0}: option.title -#: src/components/moderation/ReportDialog/index.tsx:709 -#: src/components/moderation/ReportDialog/index.tsx:754 +#: src/components/moderation/ReportDialog/index.tsx:747 +#: src/components/moderation/ReportDialog/index.tsx:792 msgid "Create report for {0}" msgstr "" @@ -3758,7 +3793,7 @@ msgstr "Day" msgid "Deactivate account" msgstr "" -#: src/screens/Settings/Settings.tsx:472 +#: src/screens/Settings/Settings.tsx:482 msgid "Debug Moderation" msgstr "" @@ -3811,7 +3846,8 @@ msgstr "" msgid "Delete chat" msgstr "" -#: src/screens/Settings/Settings.tsx:479 +#: src/screens/Settings/Settings.tsx:487 +#: src/screens/Settings/Settings.tsx:489 msgid "Delete chat declaration record" msgstr "" @@ -3927,8 +3963,8 @@ msgctxt "toast" msgid "Developer mode enabled" msgstr "" -#: src/screens/Settings/Settings.tsx:291 -#: src/screens/Settings/Settings.tsx:294 +#: src/screens/Settings/Settings.tsx:292 +#: src/screens/Settings/Settings.tsx:295 msgid "Developer options" msgstr "" @@ -4613,7 +4649,7 @@ msgstr "" msgid "Enters full screen" msgstr "" -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:231 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:246 msgid "Entertainment" msgstr "" @@ -4710,7 +4746,7 @@ msgstr "" msgid "Expand alt text" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:612 +#: src/view/com/notifications/NotificationFeedItem.tsx:602 msgid "Expand list of users" msgstr "" @@ -5132,10 +5168,10 @@ msgstr "" msgid "Failed to update settings" msgstr "" -#: src/lib/media/video/upload.ts:73 -#: src/lib/media/video/upload.web.ts:73 -#: src/lib/media/video/upload.web.ts:77 -#: src/lib/media/video/upload.web.ts:87 +#: src/lib/media/video/upload.ts:98 +#: src/lib/media/video/upload.web.ts:98 +#: src/lib/media/video/upload.web.ts:102 +#: src/lib/media/video/upload.web.ts:112 msgid "Failed to upload video" msgstr "" @@ -5224,7 +5260,7 @@ msgctxt "toast" msgid "Feeds updated!" msgstr "" -#: src/screens/Settings/components/OTAInfo.tsx:61 +#: src/screens/Settings/components/OTAInfo.tsx:66 msgid "Fetch update" msgstr "" @@ -5233,7 +5269,7 @@ msgstr "" msgid "File saved successfully!" msgstr "" -#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:31 +#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:34 msgid "Filter by author (currently: {currentLabel})" msgstr "Filter by author (currently: {currentLabel})" @@ -5295,8 +5331,8 @@ msgstr "" #: src/features/inviteFriends/components/FollowersPromoBanner.tsx:49 #: src/screens/Settings/FindContactsSettings.tsx:81 -#: src/screens/Settings/Settings.tsx:217 -#: src/screens/Settings/Settings.tsx:220 +#: src/screens/Settings/Settings.tsx:218 +#: src/screens/Settings/Settings.tsx:221 msgid "Find and invite friends" msgstr "Find and invite friends" @@ -5378,8 +5414,8 @@ msgstr "Focus the search field" #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:157 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:409 #: src/screens/VideoFeed/index.tsx:937 -#: src/view/com/notifications/NotificationFeedItem.tsx:864 -#: src/view/com/notifications/NotificationFeedItem.tsx:871 +#: src/view/com/notifications/NotificationFeedItem.tsx:897 +#: src/view/com/notifications/NotificationFeedItem.tsx:904 msgid "Follow" msgstr "" @@ -5434,8 +5470,8 @@ msgstr "" #: src/components/ProfileCard.tsx:544 #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:155 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:407 -#: src/view/com/notifications/NotificationFeedItem.tsx:864 -#: src/view/com/notifications/NotificationFeedItem.tsx:871 +#: src/view/com/notifications/NotificationFeedItem.tsx:897 +#: src/view/com/notifications/NotificationFeedItem.tsx:904 msgid "Follow back" msgstr "" @@ -5493,8 +5529,8 @@ msgstr "" #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:160 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:405 #: src/screens/VideoFeed/index.tsx:935 -#: src/view/com/notifications/NotificationFeedItem.tsx:842 -#: src/view/com/notifications/NotificationFeedItem.tsx:859 +#: src/view/com/notifications/NotificationFeedItem.tsx:876 +#: src/view/com/notifications/NotificationFeedItem.tsx:892 msgid "Following" msgstr "" @@ -5505,11 +5541,11 @@ msgid "Following" msgstr "" #. placeholder {0}: sanitizeDisplayName( profile.displayName || profile.handle, moderation.ui('displayName'), ) -#. placeholder {0}: sanitizeDisplayName( profile.displayName || profile.handle, ) #. placeholder {0}: sanitizeDisplayName( profile.displayName || profile.handle, moderation.ui('displayName'), ) +#. placeholder {0}: sanitizeDisplayName( profile.displayName || profile.handle, ) #: src/components/ProfileCard.tsx:500 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:247 -#: src/view/com/notifications/NotificationFeedItem.tsx:791 +#: src/view/com/notifications/NotificationFeedItem.tsx:827 msgid "Following {0}" msgstr "" @@ -5828,7 +5864,7 @@ msgstr "" msgid "Go to {0}'s profile" msgstr "Go to {0}'s profile" -#: src/view/com/notifications/NotificationFeedItem.tsx:256 +#: src/view/com/notifications/NotificationFeedItem.tsx:258 msgid "Go to {firstAuthorName}'s profile" msgstr "" @@ -5973,6 +6009,10 @@ msgstr "Group name" msgid "Group name is too long. {MAX_GROUP_NAME_GRAPHEME_LENGTH, plural, other {The maximum number of characters is #.}}" msgstr "Group name is too long. {MAX_GROUP_NAME_GRAPHEME_LENGTH, plural, other {The maximum number of characters is #.}}" +#: src/screens/Settings/Settings.tsx:467 +msgid "GrowthBook" +msgstr "GrowthBook" + #: src/components/moderation/ReportDialog/utils/useReportOptions.ts:219 msgid "Hacking or system attacks" msgstr "" @@ -6055,8 +6095,8 @@ msgstr "" msgid "Held by Bluesky for 7 days to prevent abuse, then deleted" msgstr "" -#: src/screens/Settings/Settings.tsx:256 -#: src/screens/Settings/Settings.tsx:260 +#: src/screens/Settings/Settings.tsx:257 +#: src/screens/Settings/Settings.tsx:261 #: src/view/shell/desktop/RightNav.tsx:130 #: src/view/shell/desktop/RightNav.tsx:133 #: src/view/shell/Drawer.tsx:443 @@ -6097,8 +6137,8 @@ msgstr "" msgid "Hidden list" msgstr "" -#: src/components/interstitials/FeedTrendingTopics.tsx:156 -#: src/components/interstitials/Trending.tsx:144 +#: src/components/interstitials/FeedTrendingTopics.tsx:168 +#: src/components/interstitials/Trending.tsx:149 #: src/components/interstitials/TrendingVideos.tsx:138 #: src/components/moderation/ContentHider.tsx:217 #: src/components/moderation/LabelPreference.tsx:141 @@ -6108,12 +6148,12 @@ msgstr "" #: src/lib/moderation/useLabelBehaviorDescription.ts:23 #: src/lib/moderation/useLabelBehaviorDescription.ts:28 #: src/lib/moderation/useLabelBehaviorDescription.ts:33 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:90 -#: src/view/shell/desktop/SidebarTrendingTopics.tsx:139 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:106 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:173 msgid "Hide" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:956 +#: src/view/com/notifications/NotificationFeedItem.tsx:989 msgctxt "action" msgid "Hide" msgstr "" @@ -6169,14 +6209,14 @@ msgstr "" msgid "Hide translation" msgstr "Hide translation" -#: src/components/interstitials/Trending.tsx:126 +#: src/components/interstitials/Trending.tsx:131 msgid "Hide trending topics" msgstr "" -#: src/components/interstitials/FeedTrendingTopics.tsx:154 -#: src/components/interstitials/Trending.tsx:142 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:88 -#: src/view/shell/desktop/SidebarTrendingTopics.tsx:137 +#: src/components/interstitials/FeedTrendingTopics.tsx:166 +#: src/components/interstitials/Trending.tsx:147 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:104 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:171 msgid "Hide trending topics?" msgstr "" @@ -6184,7 +6224,7 @@ msgstr "" msgid "Hide trending videos?" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:947 +#: src/view/com/notifications/NotificationFeedItem.tsx:982 msgid "Hide user list" msgstr "" @@ -6242,7 +6282,7 @@ msgstr "" msgid "Hmmmm, we couldn't load that moderation service." msgstr "" -#: src/view/com/composer/state/video.ts:447 +#: src/view/com/composer/state/video.ts:461 msgid "Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!" msgstr "" @@ -6603,7 +6643,7 @@ msgstr "" msgid "Invalid phone number" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:100 +#: src/components/moderation/ReportDialog/index.tsx:108 msgid "Invalid report subject" msgstr "" @@ -6814,8 +6854,8 @@ msgid "Language Settings" msgstr "" #: src/screens/Settings/LanguageSettings.tsx:98 -#: src/screens/Settings/Settings.tsx:240 -#: src/screens/Settings/Settings.tsx:243 +#: src/screens/Settings/Settings.tsx:241 +#: src/screens/Settings/Settings.tsx:244 msgid "Languages" msgstr "" @@ -7419,7 +7459,7 @@ msgstr "Marked all requests as read" msgid "Maybe later" msgstr "" -#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:24 +#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:27 msgid "Me" msgstr "Me" @@ -7564,8 +7604,8 @@ msgstr "" msgid "Moderation" msgstr "" -#: src/screens/Settings/Settings.tsx:190 -#: src/screens/Settings/Settings.tsx:193 +#: src/screens/Settings/Settings.tsx:191 +#: src/screens/Settings/Settings.tsx:194 msgid "Moderation and content filters" msgstr "Moderation and content filters" @@ -7825,8 +7865,8 @@ msgstr "" msgid "Navigates to your profile" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:342 -#: src/components/moderation/ReportDialog/index.tsx:358 +#: src/components/moderation/ReportDialog/index.tsx:375 +#: src/components/moderation/ReportDialog/index.tsx:391 msgid "Need to report a copyright violation, legal request, or regulatory compliance issue?" msgstr "" @@ -7849,11 +7889,11 @@ msgctxt "nux-description" msgid "New" msgstr "New" -#: src/view/com/notifications/NotificationFeedItem.tsx:569 +#: src/view/com/notifications/NotificationFeedItem.tsx:559 msgid "New {postsCount, plural, one {post} other {posts}} from {firstAuthorLink}" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:552 +#: src/view/com/notifications/NotificationFeedItem.tsx:543 msgid "New {postsCount, plural, one {post} other {posts}} from {firstAuthorName}" msgstr "" @@ -7963,11 +8003,11 @@ msgctxt "action" msgid "New post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:558 +#: src/view/com/notifications/NotificationFeedItem.tsx:548 msgid "New posts from {firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} " msgstr "New posts from {firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} " -#: src/view/com/notifications/NotificationFeedItem.tsx:543 +#: src/view/com/notifications/NotificationFeedItem.tsx:536 msgid "New posts from {firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}}" msgstr "" @@ -7991,7 +8031,7 @@ msgid "Newest replies first" msgstr "" #: src/lib/interests.ts:67 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:233 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:248 msgid "News" msgstr "" @@ -8023,6 +8063,12 @@ msgstr "" msgid "Night" msgstr "Night" +#: src/components/moderation/ReportDialog/index.tsx:923 +#: src/components/moderation/ReportDialog/index.tsx:932 +msgctxt "Answer to a yes/no question" +msgid "No" +msgstr "No" + #: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:42 msgid "No ads, no invasive tracking, no engagement traps. Bluesky respects your time and attention." msgstr "" @@ -8032,7 +8078,7 @@ msgid "No app passwords yet" msgstr "" #: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:22 -#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:27 +#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:30 msgid "No author filter" msgstr "No author filter" @@ -8104,11 +8150,11 @@ msgid "No lists" msgstr "" #. placeholder {0}: sanitizeDisplayName( profile.displayName || profile.handle, moderation.ui('displayName'), ) -#. placeholder {0}: sanitizeDisplayName( profile.displayName || profile.handle, ) #. placeholder {0}: sanitizeDisplayName( profile.displayName || profile.handle, moderation.ui('displayName'), ) +#. placeholder {0}: sanitizeDisplayName( profile.displayName || profile.handle, ) #: src/components/ProfileCard.tsx:523 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:273 -#: src/view/com/notifications/NotificationFeedItem.tsx:813 +#: src/view/com/notifications/NotificationFeedItem.tsx:848 msgid "No longer following {0}" msgstr "" @@ -8133,7 +8179,7 @@ msgstr "" msgid "No name" msgstr "" -#: src/view/com/notifications/NotificationFeed.tsx:125 +#: src/view/com/notifications/NotificationFeed.tsx:131 msgid "No notifications yet!" msgstr "" @@ -8269,7 +8315,7 @@ msgstr "" msgid "Nobody has reposted this yet. Maybe you should be the first!" msgstr "" -#: src/screens/StarterPack/Wizard/StepProfiles.tsx:107 +#: src/screens/StarterPack/Wizard/StepProfiles.tsx:110 msgid "Nobody was found. Try searching for someone else." msgstr "" @@ -8350,8 +8396,8 @@ msgstr "" #: src/screens/Notifications/ActivityList.tsx:31 #: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:93 #: src/screens/Settings/NotificationSettings/index.tsx:125 -#: src/screens/Settings/Settings.tsx:198 -#: src/screens/Settings/Settings.tsx:201 +#: src/screens/Settings/Settings.tsx:199 +#: src/screens/Settings/Settings.tsx:202 #: src/view/screens/Notifications.tsx:128 #: src/view/shell/bottom-bar/BottomBar.tsx:279 #: src/view/shell/desktop/LeftNav.tsx:687 @@ -8418,7 +8464,7 @@ msgstr "On" msgid "on<0><1/><2><3/>" msgstr "" -#: src/screens/Settings/Settings.tsx:405 +#: src/screens/Settings/Settings.tsx:407 msgid "Onboarding reset" msgstr "" @@ -8546,7 +8592,7 @@ msgstr "Open advanced search options" msgid "Open avatar creator" msgstr "" -#: src/view/com/feeds/ComposerPrompt.tsx:201 +#: src/view/com/feeds/ComposerPrompt.tsx:204 msgid "Open camera" msgstr "" @@ -8618,7 +8664,7 @@ msgstr "" msgid "Open message options" msgstr "" -#: src/screens/Settings/Settings.tsx:470 +#: src/screens/Settings/Settings.tsx:480 msgid "Open moderation debug page" msgstr "" @@ -8656,12 +8702,11 @@ msgstr "" msgid "Open starter pack menu" msgstr "" -#: src/screens/Settings/Settings.tsx:463 -#: src/screens/Settings/Settings.tsx:477 +#: src/screens/Settings/Settings.tsx:473 msgid "Open storybook page" msgstr "" -#: src/screens/Settings/Settings.tsx:456 +#: src/screens/Settings/Settings.tsx:458 msgid "Open system log" msgstr "" @@ -8698,7 +8743,7 @@ msgstr "" msgid "Opens alt text dialog" msgstr "" -#: src/view/com/composer/photos/OpenCameraBtn.tsx:66 +#: src/view/com/composer/photos/OpenCameraBtn.tsx:69 msgid "Opens camera on device" msgstr "" @@ -8714,7 +8759,7 @@ msgstr "" msgid "Opens composer" msgstr "" -#: src/view/com/feeds/ComposerPrompt.tsx:202 +#: src/view/com/feeds/ComposerPrompt.tsx:205 msgid "Opens device camera" msgstr "" @@ -8739,11 +8784,11 @@ msgstr "" msgid "Opens full image" msgstr "Opens full image" -#: src/screens/Settings/Settings.tsx:257 +#: src/screens/Settings/Settings.tsx:258 msgid "Opens helpdesk in browser" msgstr "" -#: src/view/com/feeds/ComposerPrompt.tsx:224 +#: src/view/com/feeds/ComposerPrompt.tsx:227 msgid "Opens image picker" msgstr "" @@ -8777,7 +8822,7 @@ msgstr "Opens the GIF picker dialog" msgid "Opens the invite friends sheet to share your profile" msgstr "Opens the invite friends sheet to share your profile" -#: src/view/com/feeds/ComposerPrompt.tsx:148 +#: src/view/com/feeds/ComposerPrompt.tsx:151 msgid "Opens the post composer" msgstr "" @@ -8801,19 +8846,19 @@ msgstr "" msgid "Or, sign in to one of your other accounts." msgstr "" -#: src/screens/Settings/components/OTAInfo.tsx:55 +#: src/screens/Settings/components/OTAInfo.tsx:60 msgid "OTA status: ..." msgstr "" -#: src/screens/Settings/components/OTAInfo.tsx:51 +#: src/screens/Settings/components/OTAInfo.tsx:56 msgid "OTA status: Available!" msgstr "" -#: src/screens/Settings/components/OTAInfo.tsx:53 +#: src/screens/Settings/components/OTAInfo.tsx:58 msgid "OTA status: Error fetching update" msgstr "" -#: src/screens/Settings/components/OTAInfo.tsx:57 +#: src/screens/Settings/components/OTAInfo.tsx:62 msgid "OTA status: None available" msgstr "" @@ -8952,7 +8997,6 @@ msgstr "" msgid "People following @{0}" msgstr "" -#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:23 #: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:180 #: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:184 msgid "People I follow" @@ -8968,6 +9012,11 @@ msgctxt "allow messages from" msgid "People I follow" msgstr "People I follow" +#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:25 +msgctxt "display posts made by" +msgid "People I follow" +msgstr "People I follow" + #: src/screens/Messages/components/InviteLinkDialog.tsx:163 msgid "People I follow can join instantly" msgstr "People I follow can join instantly" @@ -9125,7 +9174,7 @@ msgstr "" msgid "Please complete the verification captcha." msgstr "" -#: src/view/com/composer/state/video.ts:471 +#: src/view/com/composer/state/video.ts:485 msgid "Please confirm your email address to upload videos." msgstr "" @@ -9239,6 +9288,11 @@ msgstr "" msgid "Please sign in as @{0}" msgstr "" +#: src/components/moderation/ReportDialog/index.tsx:874 +msgctxt "english-only-resource" +msgid "Please submit your report through the Report non-consensual intimate imagery (NCII) form." +msgstr "Please submit your report through the Report non-consensual intimate imagery (NCII) form." + #: src/features/inviteFriends/InviteScannerScreen.web.tsx:40 msgid "Please use the Bluesky mobile app to scan a QR code." msgstr "Please use the Bluesky mobile app to scan a QR code." @@ -9260,7 +9314,7 @@ msgid "Please write your message below:" msgstr "" #: src/lib/interests.ts:70 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:227 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:242 msgid "Politics" msgstr "" @@ -9445,8 +9499,8 @@ msgstr "" msgid "Privacy" msgstr "" -#: src/screens/Settings/Settings.tsx:182 -#: src/screens/Settings/Settings.tsx:185 +#: src/screens/Settings/Settings.tsx:183 +#: src/screens/Settings/Settings.tsx:186 msgid "Privacy and security" msgstr "" @@ -9764,7 +9818,7 @@ msgstr "" #: src/screens/Messages/ConversationSettings/Member.tsx:162 #: src/screens/Messages/ConversationSettings/prompts.tsx:178 #: src/screens/Moderation/index.tsx:477 -#: src/screens/Settings/Settings.tsx:683 +#: src/screens/Settings/Settings.tsx:693 #: src/view/com/posts/PostFeedErrorMessage.tsx:221 msgid "Remove" msgstr "" @@ -9790,8 +9844,8 @@ msgstr "Remove {displayName}?" msgid "Remove {q}" msgstr "Remove {q}" -#: src/screens/Settings/Settings.tsx:664 -#: src/screens/Settings/Settings.tsx:667 +#: src/screens/Settings/Settings.tsx:674 +#: src/screens/Settings/Settings.tsx:677 msgid "Remove account" msgstr "" @@ -9851,7 +9905,7 @@ msgstr "Remove from chat" msgid "Remove from my feeds" msgstr "" -#: src/screens/Settings/Settings.tsx:677 +#: src/screens/Settings/Settings.tsx:687 msgid "Remove from quick access?" msgstr "" @@ -10092,8 +10146,8 @@ msgstr "" msgid "Report conversation" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:98 -#: src/components/moderation/ReportDialog/index.tsx:263 +#: src/components/moderation/ReportDialog/index.tsx:106 +#: src/components/moderation/ReportDialog/index.tsx:296 msgid "Report dialog" msgstr "" @@ -10312,8 +10366,8 @@ msgstr "" msgid "Resend Verification Email" msgstr "" -#: src/screens/Settings/Settings.tsx:499 -#: src/screens/Settings/Settings.tsx:501 +#: src/screens/Settings/Settings.tsx:509 +#: src/screens/Settings/Settings.tsx:511 msgid "Reset activity subscription nudge" msgstr "" @@ -10321,8 +10375,8 @@ msgstr "" msgid "Reset code" msgstr "" -#: src/screens/Settings/Settings.tsx:484 -#: src/screens/Settings/Settings.tsx:486 +#: src/screens/Settings/Settings.tsx:494 +#: src/screens/Settings/Settings.tsx:496 msgid "Reset onboarding state" msgstr "" @@ -10362,7 +10416,7 @@ msgstr "" #: src/components/contacts/screens/VerifyNumber.tsx:355 #: src/components/Error.tsx:60 #: src/components/Lists.tsx:115 -#: src/components/moderation/ReportDialog/index.tsx:297 +#: src/components/moderation/ReportDialog/index.tsx:330 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:56 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:59 #: src/components/StarterPack/ProfileStarterPacks.tsx:380 @@ -10385,7 +10439,7 @@ msgstr "" msgid "Retry" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:294 +#: src/components/moderation/ReportDialog/index.tsx:327 #: src/view/screens/Storybook/Admonitions.tsx:61 msgid "Retry loading report options" msgstr "" @@ -10434,12 +10488,12 @@ msgstr "Sad GIFs" #: src/screens/SavedFeeds.tsx:124 #: src/screens/SavedFeeds.tsx:315 #: src/screens/Settings/components/ChangeHandleDialog.tsx:269 -#: src/view/com/composer/GifAltText.tsx:199 -#: src/view/com/composer/GifAltText.tsx:208 +#: src/view/com/composer/GifAltText.tsx:198 +#: src/view/com/composer/GifAltText.tsx:207 #: src/view/com/composer/photos/EditImageDialog.web.tsx:63 #: src/view/com/composer/photos/EditImageDialog.web.tsx:76 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:163 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:173 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:162 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:172 msgid "Save" msgstr "" @@ -10522,8 +10576,8 @@ msgid "Saved to your feeds" msgstr "" #: src/components/NewskieDialog.tsx:141 -#: src/view/com/notifications/NotificationFeedItem.tsx:895 -#: src/view/com/notifications/NotificationFeedItem.tsx:920 +#: src/view/com/notifications/NotificationFeedItem.tsx:947 +#: src/view/com/notifications/NotificationFeedItem.tsx:955 msgid "Say hello!" msgstr "" @@ -10716,7 +10770,8 @@ msgstr "" #: src/components/FeedInterstitials.tsx:495 #: src/components/FeedInterstitials.tsx:554 -#: src/components/interstitials/FeedTrendingTopics.tsx:106 +#: src/components/interstitials/FeedTrendingTopics.tsx:112 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:80 msgid "See more" msgstr "" @@ -10724,7 +10779,8 @@ msgstr "" msgid "See more suggested profiles" msgstr "" -#: src/components/interstitials/FeedTrendingTopics.tsx:92 +#: src/components/interstitials/FeedTrendingTopics.tsx:98 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:66 msgid "See more trending topics" msgstr "See more trending topics" @@ -10764,7 +10820,7 @@ msgstr "" msgid "Select a color" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:380 +#: src/components/moderation/ReportDialog/index.tsx:413 msgid "Select a reason" msgstr "" @@ -10861,7 +10917,7 @@ msgstr "" msgid "Select languages" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:430 +#: src/components/moderation/ReportDialog/index.tsx:468 msgid "Select moderation service" msgstr "" @@ -10906,7 +10962,7 @@ msgstr "" msgid "Select your date of birth" msgstr "" -#: src/screens/Onboarding/StepInterests/index.tsx:69 +#: src/screens/Onboarding/StepInterests/index.tsx:115 #: src/screens/Settings/InterestsSettings.tsx:178 msgid "Select your interests from the options below" msgstr "" @@ -10963,7 +11019,7 @@ msgstr "" msgid "Send post to..." msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:818 +#: src/components/moderation/ReportDialog/index.tsx:976 msgid "Send report to {title}" msgstr "" @@ -11036,7 +11092,7 @@ msgid "Sets email for password reset" msgstr "" #: src/Navigation.tsx:215 -#: src/screens/Settings/Settings.tsx:99 +#: src/screens/Settings/Settings.tsx:100 #: src/view/shell/desktop/LeftNav.tsx:755 #: src/view/shell/Drawer.tsx:671 msgid "Settings" @@ -11351,7 +11407,7 @@ msgstr "" msgid "Shows information about when this post was created" msgstr "" -#: src/screens/Settings/Settings.tsx:124 +#: src/screens/Settings/Settings.tsx:125 msgid "Shows other accounts you can switch to" msgstr "" @@ -11425,9 +11481,9 @@ msgstr "Sign in to request access to this group chat." msgid "Sign in to view post" msgstr "" -#: src/screens/Settings/Settings.tsx:274 -#: src/screens/Settings/Settings.tsx:276 -#: src/screens/Settings/Settings.tsx:308 +#: src/screens/Settings/Settings.tsx:275 +#: src/screens/Settings/Settings.tsx:277 +#: src/screens/Settings/Settings.tsx:309 #: src/screens/SignupQueued.tsx:94 #: src/screens/SignupQueued.tsx:97 #: src/screens/Takendown.tsx:88 @@ -11441,7 +11497,7 @@ msgstr "" msgid "Sign Out" msgstr "" -#: src/screens/Settings/Settings.tsx:305 +#: src/screens/Settings/Settings.tsx:306 #: src/view/shell/desktop/LeftNav.tsx:224 msgid "Sign out?" msgstr "" @@ -11588,7 +11644,7 @@ msgstr "Someone was removed" msgid "Someone was removed from the group" msgstr "Someone was removed from the group" -#: src/components/moderation/ReportDialog/index.tsx:103 +#: src/components/moderation/ReportDialog/index.tsx:111 msgid "Something wasn't quite right with the data you're trying to report. Please contact support." msgstr "" @@ -11599,7 +11655,7 @@ msgid "Something went wrong" msgstr "" #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:139 -#: src/components/moderation/ReportDialog/index.tsx:289 +#: src/components/moderation/ReportDialog/index.tsx:322 #: src/screens/Deactivated.tsx:86 #: src/screens/Settings/components/DeactivateAccountDialog.tsx:59 #: src/view/screens/Storybook/Admonitions.tsx:56 @@ -11621,7 +11677,7 @@ msgstr "" msgid "Something went wrong. Please try again in a moment." msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:247 +#: src/components/moderation/ReportDialog/index.tsx:269 msgid "Something went wrong. Please try again." msgstr "" @@ -11664,7 +11720,7 @@ msgid "Spam or other inauthentic behavior or deception" msgstr "" #: src/lib/interests.ts:72 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:225 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:240 msgid "Sports" msgstr "" @@ -11764,7 +11820,7 @@ msgstr "" msgid "Step {0} of {1}" msgstr "" -#: src/screens/Settings/Settings.tsx:410 +#: src/screens/Settings/Settings.tsx:412 msgid "Storage cleared, you need to restart the app now." msgstr "" @@ -11773,7 +11829,7 @@ msgid "Stored as part of a secure code for matching with others" msgstr "" #: src/Navigation.tsx:308 -#: src/screens/Settings/Settings.tsx:465 +#: src/screens/Settings/Settings.tsx:475 msgid "Storybook" msgstr "" @@ -11807,12 +11863,17 @@ msgstr "" msgid "Submit feedback" msgstr "Submit feedback" -#: src/components/moderation/ReportDialog/index.tsx:508 -#: src/components/moderation/ReportDialog/index.tsx:569 -#: src/components/moderation/ReportDialog/index.tsx:576 +#: src/components/moderation/ReportDialog/index.tsx:546 +#: src/components/moderation/ReportDialog/index.tsx:607 +#: src/components/moderation/ReportDialog/index.tsx:614 msgid "Submit report" msgstr "" +#: src/components/moderation/ReportDialog/index.tsx:854 +msgctxt "english-only-resource" +msgid "Submit your report through the Report non-consensual intimate imagery (NCII) form" +msgstr "Submit your report through the Report non-consensual intimate imagery (NCII) form" + #: src/screens/ProfileList/components/SubscribeMenu.tsx:75 msgid "Subscribe" msgstr "" @@ -11923,9 +11984,9 @@ msgstr "Suspended accounts cannot participate in chat." #: src/components/dialogs/SwitchAccount.tsx:47 #: src/components/dialogs/SwitchAccount.tsx:50 -#: src/screens/Settings/Settings.tsx:123 -#: src/screens/Settings/Settings.tsx:135 -#: src/screens/Settings/Settings.tsx:624 +#: src/screens/Settings/Settings.tsx:124 +#: src/screens/Settings/Settings.tsx:136 +#: src/screens/Settings/Settings.tsx:634 #: src/view/shell/desktop/LeftNav.tsx:262 msgid "Switch account" msgstr "" @@ -11949,7 +12010,7 @@ msgstr "" #: src/screens/Log.tsx:49 #: src/screens/Settings/AboutSettings.tsx:117 #: src/screens/Settings/AboutSettings.tsx:120 -#: src/screens/Settings/Settings.tsx:458 +#: src/screens/Settings/Settings.tsx:460 msgid "System log" msgstr "" @@ -12214,6 +12275,10 @@ msgstr "" msgid "The member limit has been reached." msgstr "The member limit has been reached." +#: src/components/moderation/ReportDialog/index.tsx:275 +msgid "The moderation service is temporarily unavailable. Please try again later." +msgstr "The moderation service is temporarily unavailable. Please try again later." + #: src/view/com/composer/select-language/SuggestedLanguage.tsx:400 msgid "The post you’re replying to was marked as being written in {suggestedLanguageName} by its author. Would you like to reply in <0>{suggestedLanguageName}?" msgstr "The post you’re replying to was marked as being written in {suggestedLanguageName} by its author. Would you like to reply in <0>{suggestedLanguageName}?" @@ -12223,10 +12288,22 @@ msgid "The Privacy Policy has been moved to <0/>" msgstr "" #: src/view/com/composer/state/video.ts:429 -#: src/view/com/composer/state/video.ts:468 +msgid "The selected video has an unsupported aspect ratio." +msgstr "The selected video has an unsupported aspect ratio." + +#: src/view/com/composer/state/video.ts:443 +#: src/view/com/composer/state/video.ts:482 msgid "The selected video is larger than {VIDEO_MAX_SIZE_MB} MB. Please try again with a smaller file." msgstr "The selected video is larger than {VIDEO_MAX_SIZE_MB} MB. Please try again with a smaller file." +#: src/view/com/composer/state/video.ts:427 +msgid "The selected video is too long." +msgstr "The selected video is too long." + +#: src/view/com/composer/state/video.ts:431 +msgid "The selected video uses an unsupported format." +msgstr "The selected video uses an unsupported format." + #: src/lib/hooks/useCleanError.ts:41 #: src/lib/strings/errors.ts:18 msgid "The server appears to be experiencing issues. Please try again in a few moments." @@ -12299,12 +12376,12 @@ msgstr "" msgid "There was an issue contacting the server, please check your internet connection and try again." msgstr "" -#: src/view/com/notifications/NotificationFeed.tsx:133 +#: src/view/com/notifications/NotificationFeed.tsx:138 msgid "There was an issue fetching notifications. Tap here to try again." msgstr "" #: src/screens/Search/Explore.tsx:1015 -#: src/view/com/posts/PostFeed.tsx:826 +#: src/view/com/posts/PostFeed.tsx:834 msgid "There was an issue fetching posts. Tap here to try again." msgstr "" @@ -12655,6 +12732,10 @@ msgstr "This message is hidden because this user is blocking you." msgid "This message is hidden because you are blocking this user." msgstr "This message is hidden because you are blocking this user." +#: src/components/moderation/ReportDialog/index.tsx:273 +msgid "This moderation service does not support that report reason. Please choose a different reason or moderation service." +msgstr "This moderation service does not support that report reason. Please choose a different reason or moderation service." + #: src/screens/Profile/ErrorState.tsx:41 msgid "This moderation service is unavailable. See below for more details. If this issue persists, contact us." msgstr "" @@ -12785,7 +12866,7 @@ msgid "This will irreversibly delete your Bluesky account <0>{currentHandle} msgstr "" #. placeholder {0}: account.handle -#: src/screens/Settings/Settings.tsx:678 +#: src/screens/Settings/Settings.tsx:688 msgid "This will remove @{0} from the quick access list." msgstr "" @@ -12919,9 +13000,9 @@ msgstr "Translation to the same language is unavailable on your device." msgid "Tree view" msgstr "" -#: src/components/interstitials/FeedTrendingTopics.tsx:88 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:59 -#: src/view/shell/desktop/SidebarTrendingTopics.tsx:54 +#: src/components/interstitials/FeedTrendingTopics.tsx:94 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:71 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:63 msgid "Trending" msgstr "" @@ -12930,9 +13011,9 @@ msgstr "" msgid "Trending GIFs" msgstr "Trending GIFs" -#: src/components/interstitials/FeedTrendingTopics.tsx:115 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:62 -#: src/view/shell/desktop/SidebarTrendingTopics.tsx:61 +#: src/components/interstitials/FeedTrendingTopics.tsx:121 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:74 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:90 msgid "Trending options" msgstr "" @@ -13040,11 +13121,11 @@ msgstr "Unable to find the selected recipient." msgid "Unable to resolve handle" msgstr "" -#: src/screens/Settings/Settings.tsx:524 +#: src/screens/Settings/Settings.tsx:534 msgid "Unapply Pull Request" msgstr "" -#: src/screens/Settings/Settings.tsx:526 +#: src/screens/Settings/Settings.tsx:536 msgid "Unapply Pull Request {currentChannel}" msgstr "" @@ -13140,7 +13221,7 @@ msgstr "" msgid "Unfollows the user" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:492 +#: src/components/moderation/ReportDialog/index.tsx:530 msgid "Unfortunately, none of your subscribed labelers supports this report type." msgstr "" @@ -13268,8 +13349,8 @@ msgstr "" msgid "Unpinned list" msgstr "" -#: src/screens/Settings/Settings.tsx:491 -#: src/screens/Settings/Settings.tsx:493 +#: src/screens/Settings/Settings.tsx:501 +#: src/screens/Settings/Settings.tsx:503 msgid "Unsnooze email reminder" msgstr "" @@ -13307,8 +13388,8 @@ msgstr "Until" msgid "Up to 50 people" msgstr "Up to 50 people" -#: src/screens/Settings/components/OTAInfo.tsx:61 -#: src/screens/Settings/components/OTAInfo.tsx:77 +#: src/screens/Settings/components/OTAInfo.tsx:66 +#: src/screens/Settings/components/OTAInfo.tsx:82 msgid "Update" msgstr "" @@ -13641,12 +13722,17 @@ msgstr "" msgid "Version {0}" msgstr "" +#. When the source of a follow is a starter pack, i.e., 'via starter pack {starterPackName}'. +#: src/view/com/notifications/NotificationFeedItem.tsx:746 +msgid "via starter pack <0/><1>{starterPackName}" +msgstr "via starter pack <0/><1>{starterPackName}" + #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:95 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:162 msgid "Video" msgstr "" -#: src/view/com/composer/state/video.ts:390 +#: src/view/com/composer/state/video.ts:433 msgid "Video failed to process" msgstr "" @@ -13665,7 +13751,7 @@ msgid "Video from {0}. Tap to play or pause the video" msgstr "" #: src/lib/interests.ts:62 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:229 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:244 msgid "Video Games" msgstr "" @@ -13726,7 +13812,7 @@ msgstr "" #: src/screens/Profile/components/ProfileFeedHeader.tsx:505 #: src/screens/Search/components/SearchProfileCard.tsx:37 #: src/screens/VideoFeed/index.tsx:880 -#: src/view/com/notifications/NotificationFeedItem.tsx:619 +#: src/view/com/notifications/NotificationFeedItem.tsx:608 msgid "View {0}'s profile" msgstr "" @@ -13766,6 +13852,10 @@ msgstr "" msgid "View full thread" msgstr "" +#: src/screens/Settings/Settings.tsx:465 +msgid "View GrowthBook information" +msgstr "View GrowthBook information" + #: src/screens/Messages/ConversationSettings/MembersAndRequests.tsx:48 msgid "View incoming group chat requests" msgstr "View incoming group chat requests" @@ -14002,7 +14092,7 @@ msgstr "" msgid "We sent an email to <0>{0} containing a link. Please click on it to complete the email verification process." msgstr "" -#: src/view/com/composer/state/video.ts:451 +#: src/view/com/composer/state/video.ts:465 msgid "We were unable to determine if you are allowed to upload videos. Please try again." msgstr "" @@ -14040,7 +14130,7 @@ msgstr "We’d love to hear about your experience testing beta features!" msgid "We'll send an email to <0>{0} containing a link. Please click on it to complete the email verification process." msgstr "" -#: src/screens/Onboarding/StepInterests/index.tsx:62 +#: src/screens/Onboarding/StepInterests/index.tsx:107 msgid "We'll use this to help customize your experience." msgstr "" @@ -14154,7 +14244,7 @@ msgstr "" msgid "Welcome, friend!" msgstr "" -#: src/screens/Onboarding/StepInterests/index.tsx:59 +#: src/screens/Onboarding/StepInterests/index.tsx:98 msgid "What are your interests?" msgstr "" @@ -14169,7 +14259,7 @@ msgstr "what’s up" #: src/view/com/auth/SplashScreen.web.tsx:100 #: src/view/com/composer/Composer.tsx:1607 -#: src/view/com/feeds/ComposerPrompt.tsx:192 +#: src/view/com/feeds/ComposerPrompt.tsx:195 msgid "What's up?" msgstr "" @@ -14291,6 +14381,12 @@ msgstr "Wrong kind of conversation" msgid "www.mylivestream.tv" msgstr "" +#: src/components/moderation/ReportDialog/index.tsx:907 +#: src/components/moderation/ReportDialog/index.tsx:916 +msgctxt "Answer to a yes/no question" +msgid "Yes" +msgstr "Yes" + #. Accessibility label for the icon-only pill that filters the GIF picker to affirmation/agreement GIFs. #: src/features/gifPicker/components/GifCategoryPills.tsx:95 msgid "Yes GIFs" @@ -14372,7 +14468,7 @@ msgstr "" msgid "You are no longer live" msgstr "" -#: src/view/com/composer/state/video.ts:444 +#: src/view/com/composer/state/video.ts:458 msgid "You are not allowed to upload videos." msgstr "" @@ -14473,11 +14569,11 @@ msgstr "You can read chat history but can’t send new messages." msgid "You can select up to {MAX_GALLERY_IMAGES, plural, other {# images}} in total." msgstr "You can select up to {MAX_GALLERY_IMAGES, plural, other {# images}} in total." -#: src/components/interstitials/FeedTrendingTopics.tsx:155 -#: src/components/interstitials/Trending.tsx:143 +#: src/components/interstitials/FeedTrendingTopics.tsx:167 +#: src/components/interstitials/Trending.tsx:148 #: src/components/interstitials/TrendingVideos.tsx:137 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:89 -#: src/view/shell/desktop/SidebarTrendingTopics.tsx:138 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:105 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:172 msgid "You can update this later from your settings." msgstr "" @@ -14685,7 +14781,7 @@ msgstr "You own this chat" msgid "You previously deactivated @{0}." msgstr "" -#: src/screens/Settings/Settings.tsx:421 +#: src/screens/Settings/Settings.tsx:423 msgid "You probably want to restart the app now." msgstr "" @@ -14721,7 +14817,7 @@ msgstr "You replied to yourself" msgid "You requested to join" msgstr "You requested to join" -#: src/screens/Settings/Settings.tsx:306 +#: src/screens/Settings/Settings.tsx:307 #: src/view/shell/desktop/LeftNav.tsx:225 msgid "You will be signed out of all your accounts." msgstr "" @@ -14844,11 +14940,11 @@ msgstr "You’ve reached the maximum of {MAX_FILTERS, plural, one {# filter} oth msgid "You've reached the start of the active content." msgstr "" -#: src/view/com/composer/state/video.ts:455 +#: src/view/com/composer/state/video.ts:469 msgid "You've reached your daily limit for video uploads (too many bytes)" msgstr "" -#: src/view/com/composer/state/video.ts:459 +#: src/view/com/composer/state/video.ts:473 msgid "You've reached your daily limit for video uploads (too many videos)" msgstr "" @@ -14860,6 +14956,10 @@ msgstr "" msgid "Your account" msgstr "" +#: src/components/moderation/ReportDialog/index.tsx:271 +msgid "Your account cannot submit reports while it is taken down." +msgstr "Your account cannot submit reports while it is taken down." + #: src/screens/Settings/components/DeleteAccountDialog.tsx:128 msgid "Your account has been deleted, see ya! ✌️" msgstr "" @@ -14868,7 +14968,7 @@ msgstr "" msgid "Your account has been suspended" msgstr "" -#: src/view/com/composer/state/video.ts:463 +#: src/view/com/composer/state/video.ts:477 msgid "Your account is not yet old enough to upload videos. Please try again later." msgstr "" @@ -14904,11 +15004,11 @@ msgstr "" msgid "Your choice will be remembered for future links. You can change it at any time in settings." msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:380 +#: src/view/com/notifications/NotificationFeedItem.tsx:386 msgid "Your contact {firstAuthorLink} is on Bluesky" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:378 +#: src/view/com/notifications/NotificationFeedItem.tsx:384 msgid "Your contact {firstAuthorName} is on Bluesky" msgstr "" @@ -15034,7 +15134,7 @@ msgid "Your reply was sent" msgstr "" #. placeholder {0}: state.selectedLabeler?.creator.displayName -#: src/components/moderation/ReportDialog/index.tsx:519 +#: src/components/moderation/ReportDialog/index.tsx:557 msgid "Your report will be sent to <0>{0}." msgstr "" diff --git a/src/logger/__tests__/logger.test.ts b/src/logger/__tests__/logger.test.ts index d7bda5ad23..ed46301f6e 100644 --- a/src/logger/__tests__/logger.test.ts +++ b/src/logger/__tests__/logger.test.ts @@ -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', () => { diff --git a/src/logger/transports/sentry.ts b/src/logger/transports/sentry.ts index 1764e91541..92a7b92f67 100644 --- a/src/logger/transports/sentry.ts +++ b/src/logger/transports/sentry.ts @@ -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} : {}), }) } } diff --git a/src/logger/types.ts b/src/logger/types.ts index cc700dc58b..7d3c897477 100644 --- a/src/logger/types.ts +++ b/src/logger/types.ts @@ -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. diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx index 2b9afa0630..10611c92d5 100644 --- a/src/screens/Onboarding/StepInterests/index.tsx +++ b/src/screens/Onboarding/StepInterests/index.tsx @@ -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( 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 = ( + + ) + return ( @@ -59,14 +98,21 @@ export function StepInterests() { What are your interests? - We'll use this to help customize your experience. + {interestRequired ? ( + + Choose at least one. We'll use this to customize your experience. + You can change these anytime. + + ) : ( + We'll use this to help customize your experience. + )} + label={l`Select your interests from the options below`}> {interests.map(interest => ( - + + {missingRequiredInterest ? ( + + + + + {continueButton} + + + + + Choose at least one interest. + + + ) : ( + continueButton + )} + ) diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx index 684a9fe53b..8bf50fee2d 100644 --- a/src/screens/Profile/Header/DisplayName.tsx +++ b/src/screens/Profile/Header/DisplayName.tsx @@ -37,6 +37,11 @@ export function ProfileHeaderDisplayName({ + {/* + * 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). + */}{' '} ) diff --git a/src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx b/src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx index abcaf59285..835cc41c6f 100644 --- a/src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx +++ b/src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx @@ -20,7 +20,10 @@ export function FromDropdown({ const options: {value: FromFilter; label: string}[] = [ {value: 'anyone', label: l`No author filter`}, - {value: 'following', label: l`People I follow`}, + { + value: 'following', + label: l({context: 'display posts made by', message: 'People I follow'}), + }, {value: 'me', label: l`Me`}, ] const currentLabel = diff --git a/src/screens/Search/modules/ExploreTrendingTopics.tsx b/src/screens/Search/modules/ExploreTrendingTopics.tsx index fab8fff816..38c8cac0a8 100644 --- a/src/screens/Search/modules/ExploreTrendingTopics.tsx +++ b/src/screens/Search/modules/ExploreTrendingTopics.tsx @@ -13,7 +13,10 @@ import { useTrendingSettings, useTrendingSettingsApi, } from '#/state/preferences/trending' -import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery' +import { + DEFAULT_LIMIT, + useGetTrendsQuery, +} from '#/state/queries/trending/useGetTrendsQuery' import {useTrendingConfig} from '#/state/service-config' import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {formatCount} from '#/view/com/util/numeric/format' @@ -29,8 +32,6 @@ import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import * as ModuleHeader from '../components/ModuleHeader' -const TOPIC_COUNT = 5 - const IMAGE_SIZE = 56 export function ExploreTrendingTopics() { @@ -42,9 +43,20 @@ export function ExploreTrendingTopics() { function Inner() { const ax = useAnalytics() const {t: l} = useLingui() + + const topicCount = ax.features.getValue( + ax.features.TrendingExploreTopicsCountValue, + DEFAULT_LIMIT, + ) + const trendingPrompt = Prompt.usePromptControl() const {setTrendingDisabled} = useTrendingSettingsApi() - const {data: trending, error, isLoading, isRefetching} = useGetTrendsQuery() + const { + data: trending, + error, + isLoading, + isRefetching, + } = useGetTrendsQuery({limit: topicCount}) const noTopics = !isLoading && !error && !trending?.trends?.length const showLoading = isLoading || isRefetching @@ -64,23 +76,27 @@ function Inner() { /> {showLoading - ? Array.from({length: TOPIC_COUNT}).map((__, i) => ( + ? Array.from({length: topicCount}).map((__, i) => ( )) - : trending?.trends.map((trend, index) => ( - { - ax.metric('trendingTopic:click', { - context: 'explore', - recId: trending.recId, - }) - }} - /> - ))} + : trending?.trends.map((trend, index) => { + const rank = index + 1 + return ( + { + ax.metric('trendingTopic:click', { + context: 'explore', + rank, + recId: trending.recId, + }) + }} + /> + ) + })} { if (!trend.description) return @@ -143,7 +159,6 @@ export function TrendRow({ style={[ a.text_md, a.font_medium, - t.atoms.text_contrast_low, { fontVariant: ['tabular-nums'], @@ -156,7 +171,7 @@ export function TrendRow({ + numberOfLines={2}> {trend.displayName} {description ? ( diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index a58b1bf3dd..37d0e9b5fa 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -27,6 +27,7 @@ import {useOnboardingDispatch} from '#/state/shell' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useCloseAllActiveElements} from '#/state/util' import {UserAvatar} from '#/view/com/util/UserAvatar' +import {GrowthbookDialog} from '#/screens/Settings/components/GrowthbookDialog' import * as SettingsList from '#/screens/Settings/components/SettingsList' import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf' import {AgeAssuranceDismissibleNotice} from '#/components/ageAssurance/AgeAssuranceDismissibleNotice' @@ -398,6 +399,7 @@ function DevOptions() { currentChannel, } = useApplyPullRequestOTAUpdate() const [actyNotifNudged, setActyNotifNudged] = useActivitySubscriptionsNudged() + const growthbookControl = useDialogControl() const resetOnboarding = () => { navigation.navigate('Home') @@ -458,6 +460,14 @@ function DevOptions() { System log + growthbookControl.open()} + label={l`View GrowthBook information`}> + + GrowthBook + + + navigation.navigate('Debug')} label={l`Open storybook page`}> @@ -474,7 +484,7 @@ function DevOptions() { deleteChatDeclarationRecord()} - label={l`Open storybook page`}> + label={l`Delete chat declaration record`}> Delete chat declaration record diff --git a/src/screens/Settings/components/GrowthbookDialog.tsx b/src/screens/Settings/components/GrowthbookDialog.tsx new file mode 100644 index 0000000000..4ba8bfc582 --- /dev/null +++ b/src/screens/Settings/components/GrowthbookDialog.tsx @@ -0,0 +1,215 @@ +import {useState} from 'react' +import {Pressable, View} from 'react-native' +import * as Clipboard from 'expo-clipboard' + +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useProfileQuery} from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {atoms as a, native, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {ArrowRotateClockwise_Stroke2_Corner0_Rounded as ArrowRotate} from '#/components/icons/ArrowRotate' +import {Loader} from '#/components/Loader' +import * as ProfileCard from '#/components/ProfileCard' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import {Features, features, refresh} from '#/analytics/features' + +export function GrowthbookDialog({ + control, +}: { + control: Dialog.DialogControlProps +}) { + return ( + + + + + ) +} + +function GrowthbookDialogInner() { + const t = useTheme() + + const [isRefreshing, setIsRefreshing] = useState(false) + + return ( + + GrowthBook + + + }> + + + + + + + + {Object.entries(Features).map(([name, key]) => ( + + ))} + + + ) +} + +function RefreshButton({ + isRefreshing, + setIsRefreshing, +}: { + isRefreshing: boolean + setIsRefreshing: React.Dispatch> +}) { + const onPress = () => { + setIsRefreshing(true) + refresh({strategy: 'prefer-fresh-gates'}) + .then(() => { + Toast.show('Refreshed feature flags', {type: 'success'}) + }) + .catch(() => { + Toast.show('Failed to refresh feature flags', {type: 'error'}) + }) + .finally(() => { + setIsRefreshing(false) + }) + } + + return ( + + ) +} + +function FeatureRow({ + name, + featureKey, + isRefreshing, +}: { + name: string + featureKey: string + isRefreshing: boolean +}) { + const t = useTheme() + const value = features.evalFeature(featureKey).value + + const onPress = () => { + void Clipboard.setStringAsync(featureKey) + Toast.show('Copied feature flag key to clipboard', {type: 'success'}) + } + + return ( + + + {name} + + {featureKey} + + + {isRefreshing ? : } + + ) +} + +function CurrentProfile() { + const t = useTheme() + const {currentAccount} = useSession() + const moderationOpts = useModerationOpts() + const {data: profile} = useProfileQuery({did: currentAccount?.did}) + + if (!currentAccount) { + return ( + + No active account + + ) + } + + const onPressDid = () => { + void Clipboard.setStringAsync(currentAccount.did) + Toast.show('Copied did to clipboard', {type: 'success'}) + } + + return profile && moderationOpts ? ( + + + + + + + ) : null +} + +function FeatureValue({value}: {value: unknown}) { + const t = useTheme() + + let label: string + let color: string + if (value === true) { + label = 'true' + color = t.palette.positive_500 + } else if (value === false) { + label = 'false' + color = t.palette.negative_500 + } else if (value === null || value === undefined) { + label = 'null' + color = t.palette.contrast_500 + } else { + label = JSON.stringify(value) + color = t.palette.contrast_700 + } + + return {label} +} diff --git a/src/screens/Settings/components/OTAInfo.tsx b/src/screens/Settings/components/OTAInfo.tsx index 9ab1962b97..25b2c828d9 100644 --- a/src/screens/Settings/components/OTAInfo.tsx +++ b/src/screens/Settings/components/OTAInfo.tsx @@ -4,6 +4,8 @@ import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {useMutation, useQuery} from '@tanstack/react-query' +import {splash} from '#/lib/hooks/useOTAUpdates' +import {useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate' import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes' @@ -13,6 +15,7 @@ import * as SettingsList from '../components/SettingsList' export function OTAInfo() { const {_} = useLingui() + const t = useTheme() const { data: isAvailable, isPending: isPendingInfo, @@ -31,7 +34,9 @@ export function OTAInfo() { useMutation({ mutationFn: async () => { await Updates.fetchUpdateAsync() - await Updates.reloadAsync() + await Updates.reloadAsync({ + reloadScreenOptions: splash(t.scheme), + }) }, onError: error => Toast.show(`Failed to update: ${error.message}`, { diff --git a/src/screens/StarterPack/Wizard/StepProfiles.tsx b/src/screens/StarterPack/Wizard/StepProfiles.tsx index 4aef058b27..9f3b7076fd 100644 --- a/src/screens/StarterPack/Wizard/StepProfiles.tsx +++ b/src/screens/StarterPack/Wizard/StepProfiles.tsx @@ -87,7 +87,9 @@ export function StepProfiles({ sideBorders={false} style={[a.flex_1]} onEndReached={ - !query && !screenReaderEnabled ? () => fetchNextPage() : undefined + !query && !screenReaderEnabled + ? () => void fetchNextPage() + : undefined } onEndReachedThreshold={IS_NATIVE ? 2 : 0.25} keyboardDismissMode="on-drag" @@ -104,7 +106,11 @@ export function StepProfiles({ a.mt_lg, a.leading_snug, ]}> - Nobody was found. Try searching for someone else. + {query ? ( + + Nobody was found. Try searching for someone else. + + ) : null} )} diff --git a/src/state/queries/notifications/__tests__/util.test.ts b/src/state/queries/notifications/__tests__/util.test.ts new file mode 100644 index 0000000000..3122770a40 --- /dev/null +++ b/src/state/queries/notifications/__tests__/util.test.ts @@ -0,0 +1,79 @@ +import {type AppBskyNotificationListNotifications} from '@atproto/api' +import {describe, expect, it, jest} from '@jest/globals' + +import {groupNotifications} from '../util' + +jest.mock('#/state/queries/profile', () => ({precacheProfile: jest.fn()})) + +type Notification = AppBskyNotificationListNotifications.Notification + +function makeFollowNotification( + did: string, + starterPackUri?: string, +): Notification { + return { + uri: `at://${did}/app.bsky.graph.follow/follow`, + cid: `cid-${did}`, + author: { + did, + handle: `${did}.test`, + displayName: did, + avatar: undefined, + associated: undefined, + viewer: {}, + labels: [], + createdAt: '2026-07-28T12:00:00.000Z', + }, + reason: 'follow', + record: {}, + starterPack: starterPackUri + ? ({uri: starterPackUri} as Notification['starterPack']) + : undefined, + isRead: false, + indexedAt: '2026-07-28T12:00:00.000Z', + } +} + +describe('groupNotifications', () => { + it('does not group a starter pack follow with an organic follow', () => { + const pack = 'at://did:plc:alice/app.bsky.graph.starterpack/a' + + const grouped = groupNotifications([ + makeFollowNotification('did:plc:a'), + makeFollowNotification('did:plc:b', pack), + ]) + + expect(grouped).toHaveLength(2) + expect(grouped[0].notification.author.did).toBe('did:plc:a') + expect(grouped[0].notification.starterPack).toBeUndefined() + expect(grouped[0].additional).toBeUndefined() + expect(grouped[1].notification.author.did).toBe('did:plc:b') + expect(grouped[1].notification.starterPack?.uri).toBe(pack) + expect(grouped[1].additional).toBeUndefined() + }) + + it('groups follows by starter pack', () => { + const packA = 'at://did:plc:alice/app.bsky.graph.starterpack/a' + const packB = 'at://did:plc:bob/app.bsky.graph.starterpack/b' + + const grouped = groupNotifications([ + makeFollowNotification('did:plc:a', packA), + makeFollowNotification('did:plc:b', packB), + makeFollowNotification('did:plc:c', packA), + makeFollowNotification('did:plc:d'), + makeFollowNotification('did:plc:e', packB), + makeFollowNotification('did:plc:f'), + ]) + + expect( + grouped.map(item => [ + item.notification.author.did, + ...(item.additional ?? []).map(notification => notification.author.did), + ]), + ).toEqual([ + ['did:plc:a', 'did:plc:c'], + ['did:plc:b', 'did:plc:e'], + ['did:plc:d', 'did:plc:f'], + ]) + }) +}) diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index ded66fb62e..e5d1d81885 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -163,6 +163,9 @@ export function groupNotifications( Math.abs(ts2 - ts) < MS_2DAY && notif.reason === groupedNotif.notification.reason && notif.reasonSubject === groupedNotif.notification.reasonSubject && + (notif.reason !== 'follow' || + notif.starterPack?.uri === + groupedNotif.notification.starterPack?.uri) && (notif.author.did !== groupedNotif.notification.author.did || notif.reason === 'subscribed-post') ) { diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 33c6bc0b56..2f4424792d 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -63,6 +63,11 @@ export type Device = { activitySubscriptionsNudged?: boolean threadgateNudged?: boolean inviteFriendsFollowersPromoDismissed?: boolean + pendingOTAUpdate?: { + attemptedAt: number + channel: string + updateId: string + } /** * Selected color theme for the Invite Friends QR card. */ diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 026697fe25..b080441230 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -70,6 +70,7 @@ import { MAX_GRAPHEME_LENGTH, SUPPORTED_MIME_TYPES, type SupportedMimeTypes, + VIDEO_10_MINUTE_MAX_DURATION_MS, VIDEO_MAX_DURATION_MS, } from '#/lib/constants' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -267,6 +268,12 @@ export const ComposePost = ({ const {currentAccount} = useSession() const t = useTheme() const ax = useAnalytics() + const allow10MinuteVideos = ax.features.enabled( + ax.features.VideoAllow10MinuteEnable, + ) + const videoMaxDurationMs = allow10MinuteVideos + ? VIDEO_10_MINUTE_MAX_DURATION_MS + : VIDEO_MAX_DURATION_MS const agent = useAgent() const queryClient = useQueryClient() const currentDid = currentAccount!.did @@ -434,7 +441,7 @@ export const ComposePost = ({ * Fail early on duration so we don't spend time compressing a video the * server would reject anyway. */ - if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) { + if (asset.duration != null && asset.duration > videoMaxDurationMs) { composerDispatch({ type: 'update_post', postId: postId, @@ -442,7 +449,9 @@ export const ComposePost = ({ type: 'embed_update_video', videoAction: { type: 'to_error', - error: l`Videos must be less than 3 minutes long.`, + error: allow10MinuteVideos + ? l`Videos must be 10 minutes or less.` + : l`Videos must be less than 3 minutes long.`, signal: abortController.signal, }, }, @@ -469,7 +478,16 @@ export const ComposePost = ({ telemetry, ) }, - [l, i18n, agent, currentDid, composerDispatch, ax.metric], + [ + l, + i18n, + agent, + currentDid, + composerDispatch, + ax.metric, + videoMaxDurationMs, + allow10MinuteVideos, + ], ) const onInitVideo = useNonReactiveCallback(() => { @@ -566,7 +584,7 @@ export const ComposePost = ({ }, }) - if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) { + if (asset.duration != null && asset.duration > videoMaxDurationMs) { composerDispatch({ type: 'update_post', postId, @@ -574,7 +592,9 @@ export const ComposePost = ({ type: 'embed_update_video', videoAction: { type: 'to_error', - error: l`Videos must be less than 3 minutes long.`, + error: allow10MinuteVideos + ? l`Videos must be 10 minutes or less.` + : l`Videos must be less than 3 minutes long.`, signal: abortController.signal, }, }, @@ -646,7 +666,16 @@ export const ComposePost = ({ }) } }, - [l, i18n, agent, currentDid, composerDispatch, ax.metric], + [ + l, + i18n, + agent, + currentDid, + composerDispatch, + ax.metric, + videoMaxDurationMs, + allow10MinuteVideos, + ], ) const handleSelectDraft = useCallback( diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx index b7565e6b6d..35767a20ab 100644 --- a/src/view/com/composer/GifAltText.tsx +++ b/src/view/com/composer/GifAltText.tsx @@ -162,7 +162,6 @@ function AltTextInner({ onChangeText={onChange} defaultValue={altText} multiline - numberOfLines={3} autoFocus onKeyPress={({nativeEvent}) => { if (nativeEvent.key === 'Escape') { diff --git a/src/view/com/composer/SelectMediaButton.tsx b/src/view/com/composer/SelectMediaButton.tsx index d3cb92db7c..34c0101875 100644 --- a/src/view/com/composer/SelectMediaButton.tsx +++ b/src/view/com/composer/SelectMediaButton.tsx @@ -6,6 +6,7 @@ import {msg, plural} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import { + VIDEO_10_MINUTE_MAX_DURATION_MS, VIDEO_MAX_DURATION_MS, VIDEO_MAX_SIZE, VIDEO_MAX_SIZE_MB, @@ -22,6 +23,7 @@ import {Button} from '#/components/Button' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' import * as toast from '#/components/Toast' +import {useAnalytics} from '#/analytics' import {IS_NATIVE, IS_WEB} from '#/env' import {isAnimatedGif} from './videos/isAnimatedGif' import {hasWebCodecs} from './videos/metadata' @@ -236,9 +238,11 @@ async function processImagePickerAssets( { selectionCountRemaining, allowedAssetTypes, + videoMaxDurationMs, }: { selectionCountRemaining: number allowedAssetTypes: AssetType | undefined + videoMaxDurationMs: number }, ) { /* @@ -362,7 +366,7 @@ async function processImagePickerAssets( supportedAssets[0].duration = supportedAssets[0].duration * 1000 } - if (supportedAssets[0].duration > VIDEO_MAX_DURATION_MS) { + if (supportedAssets[0].duration > videoMaxDurationMs) { errors.add(SelectedAssetError.VideoTooLong) supportedAssets = [] } @@ -393,6 +397,13 @@ export function SelectMediaButton({ autoOpen, }: SelectMediaButtonProps) { const {_} = useLingui() + const ax = useAnalytics() + const allow10MinuteVideos = ax.features.enabled( + ax.features.VideoAllow10MinuteEnable, + ) + const videoMaxDurationMs = allow10MinuteVideos + ? VIDEO_10_MINUTE_MAX_DURATION_MS + : VIDEO_MAX_DURATION_MS const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() const sheetWrapper = useSheetWrapper() @@ -412,6 +423,7 @@ export function SelectMediaButton({ } = await processImagePickerAssets(rawAssets, { selectionCountRemaining, allowedAssetTypes, + videoMaxDurationMs, }) /* @@ -436,9 +448,9 @@ export function SelectMediaButton({ [SelectedAssetError.MaxVideos]: _( msg`You can only select one video at a time.`, ), - [SelectedAssetError.VideoTooLong]: _( - msg`Videos must be less than 3 minutes long.`, - ), + [SelectedAssetError.VideoTooLong]: allow10MinuteVideos + ? _(msg`Videos must be 10 minutes or less.`) + : _(msg`Videos must be less than 3 minutes long.`), [SelectedAssetError.MaxGIFs]: _( msg`You can only select one GIF at a time.`, ), @@ -458,7 +470,14 @@ export function SelectMediaButton({ errors, }) }, - [_, onSelectAssets, selectionCountRemaining, allowedAssetTypes], + [ + _, + onSelectAssets, + selectionCountRemaining, + allowedAssetTypes, + videoMaxDurationMs, + allow10MinuteVideos, + ], ) const onPressSelectMedia = useCallback(async () => { @@ -481,7 +500,7 @@ export function SelectMediaButton({ } const {assets, canceled} = await sheetWrapper( - openUnifiedPicker({selectionCountRemaining}), + openUnifiedPicker({selectionCountRemaining, videoMaxDurationMs}), ) if (canceled) return @@ -494,6 +513,7 @@ export function SelectMediaButton({ sheetWrapper, processSelectedAssets, selectionCountRemaining, + videoMaxDurationMs, ]) useEffect(() => { diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx index 08de8d8d99..9d00aed097 100644 --- a/src/view/com/composer/photos/ImageAltTextDialog.tsx +++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx @@ -131,7 +131,6 @@ const ImageAltTextInner = ({ }} defaultValue={altText} multiline - numberOfLines={3} autoFocus /> diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index 6365765fc9..cf087b59bb 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -32,6 +32,9 @@ export function OpenCameraBtn({disabled, onAdd}: OpenCameraBtnProps) { const img = await openCamera({ aspect: [1, 1], }) + if (!img) { + return + } // If we don't have permissions it's fine, we just wont save it. The post itself will still have access to // the image even without these permissions diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts index dafd28bed7..d984202d0a 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/view/com/composer/state/video.ts @@ -20,16 +20,6 @@ import {logger} from '#/logger' type CaptionsTrack = {lang: string; file: File} -type VideoJobStatus = AppBskyVideoDefs.JobStatus & { - failureCode?: - | 'validation_failure' - | 'encoding_failure' - | 'pds_upload_failure' - | 'pds_upload_unsupported_blob_size' - | 'generic_failure' - | (string & {}) -} - export type VideoAction = | { type: 'compressing_to_uploading' @@ -336,6 +326,7 @@ export async function processVideo( did, signal, i18n, + onTransport: telemetry.uploadTransport, setProgress: p => { dispatch({type: 'update_progress', progress: p, signal}) }, @@ -369,7 +360,7 @@ export async function processVideo( } const videoAgent = createVideoAgent() - let status: VideoJobStatus | undefined + let status: AppBskyVideoDefs.JobStatus | undefined let blob: BlobRef | undefined try { const response = await videoAgent.app.bsky.video.getJobStatus({jobId}) diff --git a/src/view/com/feeds/ComposerPrompt.tsx b/src/view/com/feeds/ComposerPrompt.tsx index b221b0fae3..a2effdf231 100644 --- a/src/view/com/feeds/ComposerPrompt.tsx +++ b/src/view/com/feeds/ComposerPrompt.tsx @@ -115,6 +115,9 @@ export function ComposerPrompt() { const image = await openCamera({ mediaTypes: 'images', }) + if (!image) { + return + } const imageUris = [ { diff --git a/src/view/com/notifications/NotificationFeed.tsx b/src/view/com/notifications/NotificationFeed.tsx index a4c4c6a973..82b5008078 100644 --- a/src/view/com/notifications/NotificationFeed.tsx +++ b/src/view/com/notifications/NotificationFeed.tsx @@ -1,31 +1,37 @@ import {useCallback, useEffect, useMemo, useState} from 'react' -import { - ActivityIndicator, - type ListRenderItemInfo, - StyleSheet, - View, -} from 'react-native' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {ActivityIndicator, type ListRenderItemInfo, View} from 'react-native' +import {useLingui} from '@lingui/react/macro' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' import {cleanError} from '#/lib/strings/errors' -import {s} from '#/lib/styles' import {logger} from '#/logger' import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useNotificationFeedQuery} from '#/state/queries/notifications/feed' +import { + type FeedNotification, + useNotificationFeedQuery, +} from '#/state/queries/notifications/feed' import {EmptyState} from '#/view/com/util/EmptyState' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {List, type ListProps, type ListRef} from '#/view/com/util/List' import {NotificationFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' +import {atoms as a, platform} from '#/alf' import {Bell_Stroke2_Corner0_Rounded as BellIcon} from '#/components/icons/Bell' import {NotificationFeedItem} from './NotificationFeedItem' -const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} -const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} -const LOADING_ITEM = {_reactKey: '__loading__'} +const EMPTY_FEED_ITEM = {type: 'empty', _reactKey: '__empty__'} as const +const LOAD_MORE_ERROR_ITEM = { + type: 'load-more-error', + _reactKey: '__load_more_error__', +} as const +const LOADING_ITEM = {type: 'loading', _reactKey: '__loading__'} as const + +type NotificationFeedListItem = + | FeedNotification + | typeof EMPTY_FEED_ITEM + | typeof LOAD_MORE_ERROR_ITEM + | typeof LOADING_ITEM export function NotificationFeed({ filter, @@ -46,7 +52,7 @@ export function NotificationFeed({ }) { const initialNumToRender = useInitialNumToRender() const [isPTRing, setIsPTRing] = useState(false) - const {_} = useLingui() + const {t: l} = useLingui() const moderationOpts = useModerationOpts() const trackPostView = usePostViewTracking('Notifications') const { @@ -71,7 +77,7 @@ export function NotificationFeed({ !isFetching && !data?.pages.find(page => page.items.length > 0) const items = useMemo(() => { - let arr: any[] = [] + let arr: NotificationFeedListItem[] = [] if (isFetched) { if (isEmpty) { arr = arr.concat([EMPTY_FEED_ITEM]) @@ -113,29 +119,27 @@ export function NotificationFeed({ }, [isFetching, hasNextPage, isError, fetchNextPage]) const onPressRetryLoadMore = useCallback(() => { - fetchNextPage() + void fetchNextPage() }, [fetchNextPage]) const renderItem = useCallback( - ({item, index}: ListRenderItemInfo) => { - if (item === EMPTY_FEED_ITEM) { + ({item, index}: ListRenderItemInfo) => { + if (item.type === 'empty') { return ( ) - } else if (item === LOAD_MORE_ERROR_ITEM) { + } else if (item.type === 'load-more-error') { return ( ) - } else if (item === LOADING_ITEM) { + } else if (item.type === 'loading') { return } return ( @@ -147,13 +151,13 @@ export function NotificationFeed({ /> ) }, - [moderationOpts, _, onPressRetryLoadMore, filter], + [moderationOpts, l, onPressRetryLoadMore, filter], ) const FeedFooter = useCallback( () => isFetchingNextPage ? ( - + ) : ( @@ -169,7 +173,11 @@ export function NotificationFeed({ }, [enabled]) return ( - + {error && ( item._reactKey} + keyExtractor={(item: NotificationFeedListItem) => item._reactKey} renderItem={renderItem} ListHeaderComponent={ListHeaderComponent} ListFooterComponent={FeedFooter} refreshing={isPTRing} - onRefresh={onRefresh} - onEndReached={onEndReached} + onRefresh={() => void onRefresh()} + onEndReached={() => void onEndReached()} onEndReachedThreshold={2} onScrolledDownChange={onScrolledDownChange} - onItemSeen={item => { + onItemSeen={(item: NotificationFeedListItem) => { if ( (item.type === 'reply' || item.type === 'mention' || @@ -199,7 +207,7 @@ export function NotificationFeed({ trackPostView(item.subject) } }} - contentContainerStyle={s.contentContainer} + contentContainerStyle={{paddingBottom: 200}} desktopFixedHeight initialNumToRender={initialNumToRender} windowSize={11} @@ -209,8 +217,3 @@ export function NotificationFeed({ ) } - -const styles = StyleSheet.create({ - feedFooter: {paddingTop: 20}, - emptyState: {paddingVertical: 40}, -}) diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index a89431938d..c8efc82678 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -12,16 +12,17 @@ import { type AppBskyActorDefs, type AppBskyFeedDefs, AppBskyFeedPost, + type AppBskyGraphDefs, AppBskyGraphFollow, + AppBskyGraphStarterpack, + AtUri, moderateProfile, type ModerationDecision, type ModerationOpts, } from '@atproto/api' -import {AtUri} from '@atproto/api' import {TID} from '@atproto/common-web' -import {msg, plural} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Plural, Trans} from '@lingui/react/macro' +import {plural} from '@lingui/core/macro' +import {Plural, Trans, useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' @@ -32,7 +33,6 @@ import {type NavigationProp} from '#/lib/routes/types' import {forceLTR} from '#/lib/strings/bidi' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {niceDate} from '#/lib/strings/time' -import {s} from '#/lib/styles' import {logger} from '#/logger' import {useProfileShadow} from '#/state/cache/profile-shadow' import {type FeedNotification} from '#/state/queries/notifications/feed' @@ -44,7 +44,7 @@ import {Post} from '#/view/com/post/Post' import {formatCount} from '#/view/com/util/numeric/format' import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, platform, useTheme, web} from '#/alf' +import {atoms as a, native, platform, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' @@ -64,7 +64,10 @@ import * as MediaPreview from '#/components/MediaPreview' import {ProfileBadges} from '#/components/ProfileBadges' import * as ProfileCard from '#/components/ProfileCard' import {ProfileHoverCard} from '#/components/ProfileHoverCard' -import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard' +import { + Notification as StarterPackCard, + useStarterPackLink, +} from '#/components/StarterPack/StarterPackCard' import {SubtleHover} from '#/components/SubtleHover' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' @@ -93,9 +96,9 @@ let NotificationFeedItem = ({ }): React.ReactNode => { const queryClient = useQueryClient() const t = useTheme() - const {_, i18n} = useLingui() + const {t: l, i18n} = useLingui() const ax = useAnalytics() - const [isAuthorsExpanded, setIsAuthorsExpanded] = useState(false) + const [isAuthorsExpanded, setIsAuthorsExpanded] = useState(false) const [isHoveringAuthorsList, setIsHoveringAuthorsList] = useState(false) const itemHref = useMemo(() => { switch (item.type) { @@ -247,40 +250,40 @@ let NotificationFeedItem = ({ t.atoms.text, a.font_semi_bold, a.text_md, - a.leading_tight, web({direction: 'ltr', unicodeBidi: 'isolate'}), ]} to={firstAuthor.href} disableMismatchWarning emoji - label={_(msg`Go to ${firstAuthorName}'s profile`)}> + label={l`Go to ${firstAuthorName}'s profile`}> {forceLTR(firstAuthorName)} ) const additionalAuthorsCount = authors.length - 1 const hasMultipleAuthors = additionalAuthorsCount > 0 + const starterPack = item.notification.starterPack + const allFollowedViaSameStarterPack = + item.type === 'follow' && + starterPack !== undefined && + (item.additional ?? []).every( + notification => notification.starterPack?.uri === starterPack.uri, + ) + const starterPackName = + allFollowedViaSameStarterPack && starterPack + ? getStarterPackName(starterPack) + : undefined const formattedAuthorsCount = hasMultipleAuthors ? formatCount(i18n, additionalAuthorsCount) : '' let a11yLabel = '' - let notificationContent: React.ReactElement + let notificationContent: React.ReactElement let icon = ( {firstAuthorLink} and{' '} - + {firstAuthorLink} and{' '} - + {firstAuthorLink} followed you back } else { - a11yLabel = hasMultipleAuthors - ? _( - msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { + a11yLabel = starterPackName + ? hasMultipleAuthors + ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, - })} followed you`, - ) - : _(msg`${firstAuthorName} followed you`) + })} followed you via starter pack ${starterPackName}` + : l`${firstAuthorName} followed you via starter pack ${starterPackName}` + : hasMultipleAuthors + ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} followed you` + : l`${firstAuthorName} followed you` notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} - + } else if (item.type === 'contact-match') { - a11yLabel = _(msg`Your contact ${firstAuthorName} is on Bluesky`) + a11yLabel = l`Your contact ${firstAuthorName} is on Bluesky` notificationContent = ( Your contact {firstAuthorLink} is on Bluesky ) @@ -384,17 +390,15 @@ let NotificationFeedItem = ({ ) } else if (item.type === 'feedgen-like') { a11yLabel = hasMultipleAuthors - ? _( - msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { - one: `${formattedAuthorsCount} other`, - other: `${formattedAuthorsCount} others`, - })} liked your custom feed`, - ) - : _(msg`${firstAuthorName} liked your custom feed`) + ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} liked your custom feed` + : l`${firstAuthorName} liked your custom feed` notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} - + {firstAuthorLink} and{' '} - + {firstAuthorLink} and{' '} - + } else if (item.type === 'unverified') { a11yLabel = hasMultipleAuthors - ? _( - msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { - one: `${formattedAuthorsCount} other`, - other: `${formattedAuthorsCount} others`, - })} removed their verifications from your account`, - ) - : _(msg`${firstAuthorName} removed their verification from your account`) + ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} removed their verifications from your account` + : l`${firstAuthorName} removed their verification from your account` notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} - + } else if (item.type === 'like-via-repost') { a11yLabel = hasMultipleAuthors - ? _( - msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { - one: `${formattedAuthorsCount} other`, - other: `${formattedAuthorsCount} others`, - })} liked your repost`, - ) - : _(msg`${firstAuthorName} liked your repost`) + ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} liked your repost` + : l`${firstAuthorName} liked your repost` notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} - + {firstAuthorLink} and{' '} - + New posts from {firstAuthorLink} and{' '} - + @@ -686,6 +673,9 @@ let NotificationFeedItem = ({ + {allFollowedViaSameStarterPack && starterPack ? ( + + ) : null} {(item.type === 'follow' && !hasMultipleAuthors && !isFollowBack) || (item.type === 'contact-match' && !item.notification.author.viewer?.following) ? ( @@ -737,6 +727,53 @@ let NotificationFeedItem = ({ NotificationFeedItem = memo(NotificationFeedItem) export {NotificationFeedItem} +function FollowedViaStarterPack({ + starterPack, +}: { + starterPack: AppBskyGraphDefs.StarterPackViewBasic +}) { + const t = useTheme() + const link = useStarterPackLink({view: starterPack}) + + const starterPackName = getStarterPackName(starterPack) + + if (!starterPackName) { + return null + } + + return ( + + + via starter pack{' '} + + + {starterPackName} + + + + ) +} + +function getStarterPackName( + starterPack: AppBskyGraphDefs.StarterPackViewBasic, +) { + return bsky.dangerousIsType( + starterPack.record, + AppBskyGraphStarterpack.isRecord, + ) + ? starterPack.record.name + : undefined +} + function ExpandListPressable({ hasMultipleAuthors, children, @@ -767,7 +804,7 @@ function ExpandListPressable({ } function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { - const {_} = useLingui() + const {t: l} = useLingui() const {currentAccount, hasSession} = useSession() const profileShadow = useProfileShadow(profile) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( @@ -787,15 +824,14 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { try { await queueFollow() Toast.show( - _( - msg`Following ${sanitizeDisplayName( - profile.displayName || profile.handle, - )}`, - ), + l`Following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, ) - } catch (err: any) { + } catch (error) { + const err = error as Error if (err?.name !== 'AbortError') { - Toast.show(_(msg`An issue occurred, please try again.`), { + Toast.show(l`An issue occurred, please try again.`, { type: 'error', }) } @@ -809,15 +845,14 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { try { await queueUnfollow() Toast.show( - _( - msg`No longer following ${sanitizeDisplayName( - profile.displayName || profile.handle, - )}`, - ), + l`No longer following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, ) - } catch (err: any) { + } catch (error) { + const err = error as Error if (err?.name !== 'AbortError') { - Toast.show(_(msg`An issue occurred, please try again.`), { + Toast.show(l`An issue occurred, please try again.`, { type: 'error', }) } @@ -838,12 +873,10 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { const isFollowing = profileShadow.viewer.following const isFollowedBy = profileShadow.viewer.followedBy - const followingLabel = _( - msg({ - message: 'Following', - comment: 'User is following this account, click to unfollow', - }), - ) + const followingLabel = l({ + message: 'Following', + comment: 'User is following this account, click to unfollow', + }) return ( @@ -853,7 +886,7 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { color="secondary" size="small" style={[a.self_start]} - onPress={onPressUnfollow}> + onPress={(e: GestureResponderEvent) => void onPressUnfollow(e)}> Following @@ -861,11 +894,11 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { ) : (