Merge remote-tracking branch 'origin/main' into post-page-social-proof
# Conflicts: # src/analytics/features/types.ts
This commit is contained in:
@@ -23,6 +23,31 @@ on:
|
||||
version-code:
|
||||
description: Android version code
|
||||
value: ${{ jobs.build.outputs.version-code }}
|
||||
secrets:
|
||||
EXPO_TOKEN:
|
||||
required: true
|
||||
ENV_TOKEN:
|
||||
required: true
|
||||
SENTRY_DSN:
|
||||
required: true
|
||||
BITDRIFT_API_KEY:
|
||||
required: true
|
||||
EXPO_PUBLIC_GCP_PROJECT_ID:
|
||||
required: true
|
||||
GOOGLE_SERVICES_TOKEN:
|
||||
required: true
|
||||
SENTRY_AUTH_TOKEN:
|
||||
required: true
|
||||
SLACK_CLIENT_ALERT_WEBHOOK:
|
||||
required: true
|
||||
ANDROID_KEYSTORE_BASE64:
|
||||
required: true
|
||||
ANDROID_KEYSTORE_PASSWORD:
|
||||
required: true
|
||||
ANDROID_KEY_ALIAS:
|
||||
required: true
|
||||
ANDROID_KEY_PASSWORD:
|
||||
required: true
|
||||
|
||||
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
|
||||
permissions:
|
||||
@@ -62,7 +87,7 @@ jobs:
|
||||
cache: pnpm
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
|
||||
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
|
||||
|
||||
- name: ⚙️ Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -74,7 +99,7 @@ jobs:
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
@@ -183,7 +208,7 @@ jobs:
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
if: ${{ inputs.profile == 'testflight-android' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
@@ -238,11 +263,14 @@ jobs:
|
||||
- name: 📎 Attach APK to GitHub Release
|
||||
id: attach
|
||||
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: Bluesky-${{ needs.build.outputs.package-version }}.apk
|
||||
fail_on_unmatched_files: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
APK: Bluesky-${{ needs.build.outputs.package-version }}.apk
|
||||
run: |
|
||||
gh release upload "$TAG" "$APK" --clobber
|
||||
url=$(gh release view "$TAG" --json url --jq .url)
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 🔔 Notify Slack of Release Attachment
|
||||
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
||||
|
||||
@@ -35,6 +35,29 @@ on:
|
||||
build-number:
|
||||
description: iOS build number
|
||||
value: ${{ jobs.build.outputs.build-number }}
|
||||
secrets:
|
||||
EXPO_TOKEN:
|
||||
required: true
|
||||
ENV_TOKEN:
|
||||
required: true
|
||||
SENTRY_DSN:
|
||||
required: true
|
||||
BITDRIFT_API_KEY:
|
||||
required: true
|
||||
EXPO_PUBLIC_GCP_PROJECT_ID:
|
||||
required: true
|
||||
GOOGLE_SERVICES_TOKEN:
|
||||
required: true
|
||||
SENTRY_AUTH_TOKEN:
|
||||
required: true
|
||||
ASC_KEY_ID:
|
||||
required: true
|
||||
ASC_ISSUER_ID:
|
||||
required: true
|
||||
ASC_KEY_P8_BASE64:
|
||||
required: true
|
||||
SLACK_CLIENT_ALERT_WEBHOOK:
|
||||
required: true
|
||||
|
||||
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
|
||||
permissions:
|
||||
@@ -73,7 +96,7 @@ jobs:
|
||||
cache: pnpm
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
|
||||
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
|
||||
|
||||
- name: ⚙️ Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -101,7 +124,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: 💾 Cache Pods
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
id: pods-cache
|
||||
with:
|
||||
path: ./ios/Pods
|
||||
@@ -261,7 +284,7 @@ jobs:
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
if: ${{ inputs.profile == 'testflight' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
|
||||
@@ -31,7 +31,9 @@ jobs:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-deploy
|
||||
cancel-in-progress: true
|
||||
outputs:
|
||||
changes-detected: ${{ steps.fingerprint.outputs.includes-changes }}
|
||||
# A version bump forces a native build even if the fingerprint is unchanged
|
||||
changes-detected: ${{ steps.fingerprint.outputs.includes-changes ||
|
||||
steps.version.outputs.version-changed }}
|
||||
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
@@ -60,6 +62,27 @@ jobs:
|
||||
if: ${{ github.ref != 'refs/heads/main' }}
|
||||
run: git fetch origin main:main --depth 100
|
||||
|
||||
# A change to the version in package.json means a new native release, so
|
||||
# an OTA update must not be deployed and full native builds are required
|
||||
# regardless of what the fingerprint says
|
||||
- name: 🔢 Check for version change
|
||||
id: version
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
env:
|
||||
EVENT_BEFORE: ${{ github.event.before }}
|
||||
run: |
|
||||
CURRENT_VERSION=$(jq -r '.version' package.json)
|
||||
if [ -n "$EVENT_BEFORE" ] && [[ ! "$EVENT_BEFORE" =~ ^0+$ ]] && git cat-file -e "$EVENT_BEFORE:package.json" 2>/dev/null; then
|
||||
PREVIOUS_VERSION=$(git show "$EVENT_BEFORE:package.json" | jq -r '.version')
|
||||
else
|
||||
PREVIOUS_VERSION=$(git show HEAD~1:package.json | jq -r '.version')
|
||||
fi
|
||||
echo "Previous version: $PREVIOUS_VERSION, current version: $CURRENT_VERSION"
|
||||
if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then
|
||||
echo "Version changed, full native builds are required"
|
||||
echo "version-changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
@@ -70,7 +93,7 @@ jobs:
|
||||
|
||||
- name: 📷 Check fingerprint and install dependencies
|
||||
id: fingerprint
|
||||
uses: bluesky-social/github-actions/fingerprint-native@b5556913e4aef3964cfd5936d0add3fc0d809bdb # v0.1.0
|
||||
uses: bluesky-social/github-actions/fingerprint-native@b5556913e4aef3964cfd5936d0add3fc0d809bdb # v0.2.0
|
||||
with:
|
||||
profile: ${{ inputs.channel || 'testflight' }}
|
||||
previous-commit-tag: ${{ inputs.runtimeVersion }}
|
||||
@@ -93,15 +116,17 @@ jobs:
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
|
||||
|
||||
# eas.json not used here, set EXPO_PUBLIC_ENV
|
||||
- name: Env
|
||||
@@ -109,7 +134,8 @@ jobs:
|
||||
CHANNEL: ${{ inputs.channel || 'testflight' }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
id: env
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
@@ -125,7 +151,8 @@ jobs:
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ Create Bundle
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
run: >
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
|
||||
@@ -133,7 +160,8 @@ jobs:
|
||||
pnpm export
|
||||
|
||||
- name: 📦 Package Bundle and 🚀 Deploy
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
run: pnpm use-build-number bash scripts/bundleUpdate.sh
|
||||
env:
|
||||
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
|
||||
@@ -142,14 +170,16 @@ jobs:
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
key: most-recent-testflight-commit
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
# GitHub actions are horrible so let's just copy paste this in
|
||||
@@ -212,7 +242,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: 💾 Cache Pods
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
id: pods-cache
|
||||
with:
|
||||
path: ./ios/Pods
|
||||
@@ -295,7 +325,7 @@ jobs:
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
if: ${{ inputs.channel == 'testflight' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
@@ -348,7 +378,7 @@ jobs:
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
@@ -443,7 +473,7 @@ jobs:
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
|
||||
@@ -59,13 +59,13 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Claude
|
||||
uses: anthropics/claude-code-action@30544b674398ee15c84819bd87caf8a87e8c7b55 # v1.0.154
|
||||
uses: anthropics/claude-code-action@4633baf5267540f3f8cb58b684f79901d564c280 # v1.0.160
|
||||
with:
|
||||
use_bedrock: 'true'
|
||||
additional_permissions: |
|
||||
|
||||
@@ -45,13 +45,13 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Claude review
|
||||
uses: anthropics/claude-code-action@30544b674398ee15c84819bd87caf8a87e8c7b55 # v1.0.154
|
||||
uses: anthropics/claude-code-action@4633baf5267540f3f8cb58b684f79901d564c280 # v1.0.160
|
||||
with:
|
||||
use_bedrock: 'true'
|
||||
additional_permissions: |
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: bskyweb/go.mod
|
||||
cache-dependency-path: bskyweb/go.sum
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: bskyweb/go.mod
|
||||
cache-dependency-path: bskyweb/go.sum
|
||||
|
||||
@@ -78,7 +78,20 @@ jobs:
|
||||
with:
|
||||
profile: testflight
|
||||
testFlightGroup: "QA Team"
|
||||
secrets: inherit
|
||||
# Pass only the secrets the reusable workflow declares, rather than `secrets: inherit`,
|
||||
# so the nightly build never hands the reusable workflow the entire repo secret store.
|
||||
secrets:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
ENV_TOKEN: ${{ secrets.ENV_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
BITDRIFT_API_KEY: ${{ secrets.BITDRIFT_API_KEY }}
|
||||
EXPO_PUBLIC_GCP_PROJECT_ID: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
|
||||
GOOGLE_SERVICES_TOKEN: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
|
||||
SLACK_CLIENT_ALERT_WEBHOOK: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
|
||||
android:
|
||||
name: Nightly Android Build
|
||||
@@ -91,7 +104,21 @@ jobs:
|
||||
uses: ./.github/workflows/build-submit-android.yml
|
||||
with:
|
||||
profile: testflight-android
|
||||
secrets: inherit
|
||||
# Pass only the secrets the reusable workflow declares, rather than `secrets: inherit`,
|
||||
# so the nightly build never hands the reusable workflow the entire repo secret store.
|
||||
secrets:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
ENV_TOKEN: ${{ secrets.ENV_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
BITDRIFT_API_KEY: ${{ secrets.BITDRIFT_API_KEY }}
|
||||
EXPO_PUBLIC_GCP_PROJECT_ID: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
|
||||
GOOGLE_SERVICES_TOKEN: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SLACK_CLIENT_ALERT_WEBHOOK: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
|
||||
notify-ios:
|
||||
name: Notify Slack of iOS nightly
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Extract language strings
|
||||
run: pnpm intl:extract
|
||||
- name: Create commit
|
||||
uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0
|
||||
uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0
|
||||
with:
|
||||
commit_message: Nightly source-language update
|
||||
file_pattern: ./src/locale/locales/en/messages.po
|
||||
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
|
||||
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
|
||||
|
||||
- name: Env
|
||||
id: env
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
- name: ⬇️ Get base stats from cache
|
||||
id: get-base-stats
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: stats-base.json
|
||||
key: stats-base-${{ steps.base-commit.outputs.base-commit }}
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
- name: 📷 Check fingerprint and install dependencies
|
||||
id: fingerprint
|
||||
timeout-minutes: 5
|
||||
uses: bluesky-social/github-actions/fingerprint-native@b5556913e4aef3964cfd5936d0add3fc0d809bdb # v0.1.0
|
||||
uses: bluesky-social/github-actions/fingerprint-native@b5556913e4aef3964cfd5936d0add3fc0d809bdb # v0.2.0
|
||||
with:
|
||||
profile: pull-request
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ should go in `/screens` (not `/view/screens`) to encourage better organization
|
||||
and separation from legacy code.
|
||||
|
||||
For complex screens that have specific components or data needs that _are not
|
||||
shared by other screens_, we encourage subdirectoreis within `/screens/<name>`
|
||||
shared by other screens_, we encourage subdirectories within `/screens/<name>`
|
||||
e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and
|
||||
`/screens/ProfileScreen/components/`.
|
||||
|
||||
@@ -126,44 +126,14 @@ eventually.
|
||||
Typically JS style for variables, functions, etc. We use ProudCamelCase for
|
||||
components, and camelCase directories and files.
|
||||
|
||||
When organizing new code, consider if it fits into a single file, or if it
|
||||
should be broken down into multiple files. For "macro" component cases, or
|
||||
things that live in `/features` or `/screens`, we often follow a pattern of
|
||||
having an `index.tsx` for the main component, and then co-locating related
|
||||
components, hooks, and utilities in the same directory. For example:
|
||||
For "macro" cases in `/features`, `/screens`, or `/components`, co-locate related
|
||||
code in a directory with an `index.tsx` main component plus sibling
|
||||
components/hooks/utils (e.g. `screens/ProfileScreen/index.tsx` +
|
||||
`screens/ProfileScreen/components/`). Keep related code together so it lives where
|
||||
someone would look for it. Don't overdo it: a component that fits in one file
|
||||
should just be `Component.tsx`, not `Component/index.tsx`.
|
||||
|
||||
```
|
||||
src
|
||||
├── screens/
|
||||
│ ├── ProfileScreen/
|
||||
│ │ ├── index.tsx # Main screen component
|
||||
│ │ ├── components/ # Sub-components used only by this screen
|
||||
```
|
||||
|
||||
Similar patterns can be found in `/features` and `/components`. The idea here is
|
||||
to keep related code together and make it easier to navigate.
|
||||
|
||||
You should ask yourself: if someone new was looking for the code related to this
|
||||
feature or screen, where would they expect to find it? Organizing code in a way
|
||||
that matches developer expectations can make the codebase much more
|
||||
approachable. Being able to say "Live Now stuff lives in `/features/liveNow`" is
|
||||
easier to understand than having it scattered across multiple directories.
|
||||
|
||||
No need to go overboard with this. If a component or feature fits into a single
|
||||
file, there's no reason to have a `/Component/index.tsx` file when it could just
|
||||
be `/Component.tsx`. Use your judgment based on the complexity and amount of
|
||||
related code.
|
||||
|
||||
#### Platform Specific Files
|
||||
|
||||
We have conflicting patterns in the app for this. The preferred approach is to
|
||||
group platform-specific files into a directory as much as possible. For example,
|
||||
rather than having `Component.tsx`, `Component.web.tsx`, and
|
||||
`Component.native.tsx` in the same directory, we prefer to have a `Component/`
|
||||
directory with `index.tsx`, `index.web.tsx`, and `index.native.tsx`. This keeps
|
||||
related code together and gives us a better visual cue that there are probably
|
||||
other files contained within this "macro" feature, whereas `Component.tsx` on
|
||||
its own looks more like a single component file.
|
||||
Platform-specific files are covered under "Platform-Specific Code" below.
|
||||
|
||||
### Comments
|
||||
|
||||
@@ -204,88 +174,40 @@ const fallbackDate = maximumDate ? toSimpleDateString(maximumDate) : today
|
||||
|
||||
### Documentation and Tests Within Features
|
||||
|
||||
For larger features or components, it's helpful to include a README.md file
|
||||
within the directory that explains the purpose of the feature, how it works, and
|
||||
any important implementation details. The `/Component/index.tsx` pattern lends
|
||||
itself well to this, since the `index.tsx` can be the main component file, and
|
||||
the `README.md` can provide documentation for the whole feature. This is
|
||||
optional, but can be a nice way to keep documentation close to the code it
|
||||
describes.
|
||||
|
||||
Similarly, if there are tests that are specific to a component or feature, it
|
||||
can be helpful to include them in the same directory, either as
|
||||
`Component.test.tsx` or in a `__tests__/` subdirectory. This keeps everything
|
||||
related to the component or feature in one place and makes it easier to find and
|
||||
maintain tests.
|
||||
For larger features or components, co-locate documentation and tests with the
|
||||
code. A `README.md` in the directory (the `/Component/index.tsx` pattern lends
|
||||
itself well to this) can document the whole feature, and feature-specific tests
|
||||
belong alongside it as `Component.test.tsx` or in a `__tests__/` subdirectory.
|
||||
Both are optional.
|
||||
|
||||
## Styling System (ALF)
|
||||
|
||||
ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens.
|
||||
ALF is the custom design system. Tailwind-inspired naming with underscores
|
||||
instead of hyphens. Static atoms (`atoms as a`) are theme-independent; theme
|
||||
atoms/palette come from `useTheme()` (`t.atoms.bg`, `t.palette.primary_500`).
|
||||
Style props take an array of atoms + theme atoms + raw styles.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
Generally, order atoms by:
|
||||
|
||||
- Flexbox configuration, e.g., `a.flex_row`
|
||||
- Spacing, e.g., `a.px_md`
|
||||
- Text styles, e.g., `a.font_bold`
|
||||
- Themes, e.g., `t.atoms.text`,
|
||||
- Raw styles, e.g., `{backgroundColor: t.palette.primary_500}`
|
||||
Order atoms by: flexbox (`a.flex_row`), spacing (`a.px_md`), text (`a.font_bold`),
|
||||
themes (`t.atoms.text`), then raw styles (`{backgroundColor: t.palette.primary_500}`).
|
||||
|
||||
```tsx
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
function MyComponent() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_high]}>Hello</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
const t = useTheme()
|
||||
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]} />
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
|
||||
**Static Atoms** – Theme-independent styles imported from `atoms`:
|
||||
Static atoms live in `a.*` (e.g. `a.flex_row`, `a.p_md`, `a.rounded_md`,
|
||||
`a.text_lg`). Theme atoms/palette come from `useTheme()` (`t.atoms.bg`,
|
||||
`t.atoms.text`, `t.atoms.border_contrast_low`, `t.palette.primary_500`).
|
||||
|
||||
```tsx
|
||||
import {atoms as a} from '#/alf'
|
||||
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
|
||||
```
|
||||
**Platform utilities** (`import {web, native, ios, android, platform} from '#/alf'`)
|
||||
return conditional styles inline in a style array: `web({cursor: 'pointer'})`,
|
||||
`native({paddingBottom: 20})`, `platform({ios: {...}, android: {...}, web: {...}})`.
|
||||
|
||||
**Theme Atoms** – Theme-dependent colors from `useTheme()`:
|
||||
|
||||
```tsx
|
||||
const t = useTheme()
|
||||
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
|
||||
// t.palette.primary_500, t.palette.negative_400, etc.
|
||||
```
|
||||
|
||||
**Platform Utilities** – For platform-specific styles:
|
||||
|
||||
```tsx
|
||||
import {web, native, ios, android, platform} from '#/alf'
|
||||
|
||||
const styles = [
|
||||
a.p_md,
|
||||
web({cursor: 'pointer'}),
|
||||
native({paddingBottom: 20}),
|
||||
platform({ios: {...}, android: {...}, web: {...}}),
|
||||
]
|
||||
```
|
||||
|
||||
**Breakpoints** – Responsive design:
|
||||
|
||||
```tsx
|
||||
import {useBreakpoints} from '#/alf'
|
||||
|
||||
const {gtPhone, gtMobile, gtTablet} = useBreakpoints()
|
||||
if (gtMobile) {
|
||||
// Tablet or desktop layout
|
||||
}
|
||||
```
|
||||
**Breakpoints:** `const {gtPhone, gtMobile, gtTablet} = useBreakpoints()` from `#/alf`.
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
@@ -306,176 +228,67 @@ if (gtMobile) {
|
||||
```tsx
|
||||
import {Fragment} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
function MyComponent({foo = []}: {foo?: string[]}) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
function MyComponent({items = []}: {items?: string[]}) {
|
||||
return (
|
||||
<>
|
||||
<View><Text><Trans>Example</Trans><Text></View>
|
||||
<View>
|
||||
{foo.map((foo, index) => (
|
||||
<Fragment key={foo}>
|
||||
<Text>
|
||||
<Trans>Example</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item}>
|
||||
<Text>{index}</Text>
|
||||
<Text>{foo}</Text>
|
||||
<Text>{item}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Dialog Component
|
||||
|
||||
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
|
||||
|
||||
```tsx
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
|
||||
function MyFeature() {
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button label="Open" onPress={control.open}>
|
||||
<ButtonText>Open Dialog</ButtonText>
|
||||
</Button>
|
||||
|
||||
<Dialog.Outer control={control}>
|
||||
{/* Typically the inner part is in its own component */}
|
||||
<DialogInner />
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogInner() {
|
||||
return (
|
||||
<>
|
||||
<Dialog.Handle /> {/* Native-only drag handle */}
|
||||
<Dialog.ScrollableInner label={l`My Dialog`}>
|
||||
<Dialog.Header>
|
||||
<Dialog.HeaderText>Title</Dialog.HeaderText>
|
||||
</Dialog.Header>
|
||||
<Text>Dialog content here</Text>
|
||||
<Button label="Done" onPress={() => control.close()}>
|
||||
<ButtonText>Done</ButtonText>
|
||||
</Button>
|
||||
<Dialog.Close /> {/* Web-only X button in top left */}
|
||||
</Dialog.ScrollableInner>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
Lives in `#/components/Dialog`. Bottom sheet on native, modal on web. Manage
|
||||
state with `useDialogControl()`. `Dialog.Handle` renders native-only, `Dialog.Close`
|
||||
web-only. CRITICAL: run any post-close action inside the `control.close(() => ...)`
|
||||
callback (see Footguns). Compound-component usage; canonical example in any dialog
|
||||
under `#/components`.
|
||||
|
||||
### Menu Component
|
||||
|
||||
Menus render as a dropdown on web and a bottom sheet dialog on native.
|
||||
|
||||
```tsx
|
||||
import * as Menu from '#/components/Menu'
|
||||
|
||||
function MyMenu() {
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label="Open menu">
|
||||
{({props}) => (
|
||||
<Button {...props} label="Menu">
|
||||
<ButtonIcon icon={DotsHorizontal} />
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
<Menu.Item label="Edit" onPress={handleEdit}>
|
||||
<Menu.ItemIcon icon={Pencil} />
|
||||
<Menu.ItemText>Edit</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
<Menu.Item label="Delete" onPress={handleDelete}>
|
||||
<Menu.ItemIcon icon={Trash} />
|
||||
<Menu.ItemText>Delete</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
)
|
||||
}
|
||||
```
|
||||
Lives in `#/components/Menu`. Dropdown on web, bottom sheet dialog on native.
|
||||
`Menu.Divider` is web-only, `Menu.ContainerItem` native-only. Compound API
|
||||
(`Menu.Root` / `Menu.Trigger` / `Menu.Outer` / `Menu.Group` / `Menu.Item`); grep
|
||||
existing usages across the app for a canonical example.
|
||||
|
||||
### Button Component
|
||||
|
||||
```tsx
|
||||
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||
|
||||
// Solid primary button (most common)
|
||||
<Button label="Save" onPress={handleSave} color="primary" size="large">
|
||||
<ButtonText>Save</ButtonText>
|
||||
</Button>
|
||||
|
||||
// With icon
|
||||
<Button label="Share" onPress={handleShare} color="secondary" size="small">
|
||||
<ButtonIcon icon={Share} />
|
||||
<ButtonText>Share</ButtonText>
|
||||
</Button>
|
||||
|
||||
// Icon-only button
|
||||
<Button label="Close" onPress={handleClose} color="secondary" size="small" shape="round">
|
||||
<ButtonIcon icon={XIcon} />
|
||||
</Button>
|
||||
|
||||
// Ghost variant (deprecated - use color prop)
|
||||
<Button label="Cancel" variant="ghost" color="secondary" size="small">
|
||||
<ButtonText>Cancel</ButtonText>
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Button Props:**
|
||||
`import {Button, ButtonText, ButtonIcon} from '#/components/Button'`. Props:
|
||||
|
||||
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
|
||||
- `size`: `'tiny'` | `'small'` | `'large'`
|
||||
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
|
||||
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`)
|
||||
|
||||
### Typography
|
||||
|
||||
```tsx
|
||||
import {Text, H1, H2, P} from '#/components/Typography'
|
||||
|
||||
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
|
||||
<P>Paragraph text with default styling.</P>
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>Custom text</Text>
|
||||
|
||||
// For text with emoji, add the emoji prop. User-generated text (e.g. display names)
|
||||
// will almost certainly contain emoji, so only omit it when the text is static and
|
||||
// does not contain an emoji
|
||||
<Text emoji>Hello! 👋</Text>
|
||||
```
|
||||
|
||||
The `Text` component's default style is `[a.text_sm, a.leading_snug, t.atoms.text]`.
|
||||
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, prefer `color`)
|
||||
|
||||
### TextField
|
||||
|
||||
```tsx
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
Compound component at `#/components/forms/TextField` (`TextField.LabelText`,
|
||||
`TextField.Root`, `TextField.Icon`, `TextField.Input`). Prefer `defaultValue` over
|
||||
`value` (see Footguns).
|
||||
|
||||
<TextField.LabelText>Email</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={AtSign} />
|
||||
<TextField.Input
|
||||
label="Email address"
|
||||
placeholder="you@example.com"
|
||||
defaultValue={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</TextField.Root>
|
||||
```
|
||||
### Typography
|
||||
|
||||
`import {Text, H1, H2, P} from '#/components/Typography'`. The `Text` default style
|
||||
is `[a.text_sm, a.leading_snug, t.atoms.text]`. Pass the `emoji` prop to any `Text`
|
||||
that may contain emoji - user-generated text (display names etc.) almost always
|
||||
does, so only omit it for static, emoji-free strings: `<Text emoji>Hello!</Text>`.
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
@@ -549,201 +362,49 @@ pnpm intl:compile # Compile translations for runtime
|
||||
|
||||
### TanStack Query (Data Fetching)
|
||||
|
||||
```tsx
|
||||
// src/state/queries/profile.ts
|
||||
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
Follow the established pattern in `src/state/queries/`; `src/state/queries/feed.ts`
|
||||
is a good canonical reference (it uses `createQueryKey`, matching key roots,
|
||||
`useInfiniteQuery`, and `persistedVersion`).
|
||||
|
||||
import {createQueryKey} from '#/state/queries/util'
|
||||
|
||||
/*
|
||||
* Query key name should match the query hook name for consistency
|
||||
*/
|
||||
const profileQueryKeyRoot = 'profile'
|
||||
|
||||
/*
|
||||
* Use object params and createQueryKey helper for better readability and to
|
||||
* avoid bugs with parameter order or types.
|
||||
*/
|
||||
export const createProfileQueryKey = (args: {did: string}) =>
|
||||
createQueryKey(profileQueryKeyRoot, args)
|
||||
|
||||
/*
|
||||
* Query hook should be named use[Name]Query, where [Name] describes the data
|
||||
* being fetched. This is not a strict requirement, but it's a helpful
|
||||
* convention for discoverability
|
||||
*/
|
||||
export function useProfileQuery({did}: {did: string}) {
|
||||
const agent = useAgent()
|
||||
|
||||
return useQuery({
|
||||
queryKey: createProfileQueryKey({did}),
|
||||
queryFn: async () => {
|
||||
const res = await agent.getProfile({actor: did})
|
||||
return res.data
|
||||
},
|
||||
staleTime: STALE.MINUTES.FIVE,
|
||||
enabled: !!did,
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* Mutation hook should match the name of the query hook, but with "Mutation"
|
||||
* suffix. This is not a strict requirement, but it's a helpful convention for
|
||||
* discoverability and consistency.
|
||||
*/
|
||||
export function useProfileMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async data => {
|
||||
// Update logic
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: createProfileQueryKey({did: variables.did}),
|
||||
})
|
||||
},
|
||||
onError: error => {
|
||||
if (isNetworkError(error)) {
|
||||
// don't log, but inform user
|
||||
} else if (error instanceof AppBskyExampleProcedure.ExampleError) {
|
||||
// XRPC APIs often have typed errors, allows nicer handling
|
||||
} else {
|
||||
// Log unexpected errors to Sentry
|
||||
logger.error('Error updating profile', {safeMessage: error})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* If cache mutation is needed, include specific interfaces for the specific
|
||||
* mutations you require adjacent to the source queries. Naming should be
|
||||
* descriptive of the mutation's purpose, e.g. use[Name]CacheMutation. This is
|
||||
* not a strict requirement, but it's a helpful convention for discoverability
|
||||
* and consistency.
|
||||
*/
|
||||
export function useProfileCacheMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return (data: Partial<Profile>) => {
|
||||
queryClient.setQueryData(
|
||||
createProfileQueryKey({did: data.did}),
|
||||
oldData => {
|
||||
if (!oldData) return oldData
|
||||
return {...oldData, ...data}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Stale Time Constants** (from `src/state/queries/index.ts`):
|
||||
|
||||
```tsx
|
||||
STALE.SECONDS.FIFTEEN // 15 seconds
|
||||
STALE.MINUTES.ONE // 1 minute
|
||||
STALE.MINUTES.FIVE // 5 minutes
|
||||
STALE.HOURS.ONE // 1 hour
|
||||
STALE.INFINITY // Never stale
|
||||
```
|
||||
|
||||
**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
|
||||
|
||||
```tsx
|
||||
export function useDraftsQuery() {
|
||||
const agent = useAgent()
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: createQueryKey('drafts'),
|
||||
queryFn: async ({pageParam}) => {
|
||||
const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam})
|
||||
return res.data
|
||||
},
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: page => page.cursor,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []`
|
||||
|
||||
**Persisted Queries**
|
||||
|
||||
To persist query data across app restarts, `createQueryKey` supports a third
|
||||
parameter called `options`, which has a `persistedVersion` property. When this
|
||||
property is set to a number, the query will be persisted.
|
||||
|
||||
When this property is updated (e.g. incremented), the persisted data will be cleared and replaced with the new data from the query function. This is useful for cases where the shape of the data has changed and old persisted data would no longer be valid.
|
||||
|
||||
```tsx
|
||||
export const createProfileQueryKey = (args: {did: string}) =>
|
||||
createQueryKey(profileQueryKeyRoot, args, {persistedVersion: 1})
|
||||
```
|
||||
- Build query keys with `createQueryKey(root, args)` (from `#/state/queries/util`)
|
||||
using an object for `args`. The key root variable should match the hook name.
|
||||
- Naming conventions: `use[Name]Query` for queries, `use[Name]Mutation` for
|
||||
mutations, `use[Name]CacheMutation` for helpers that mutate cached data directly.
|
||||
- Stale times come from `STALE` in `src/state/queries/index.ts`: `STALE.SECONDS.FIFTEEN`,
|
||||
`STALE.MINUTES.ONE`, `STALE.MINUTES.FIVE`, `STALE.HOURS.ONE`, `STALE.INFINITY`.
|
||||
- Paginated atproto APIs (those returning a `cursor`) use `useInfiniteQuery` with
|
||||
`getNextPageParam: page => page.cursor`; flatten results with
|
||||
`data?.pages.flatMap(page => page.items) ?? []`.
|
||||
- Persist a query across restarts by passing options:
|
||||
`createQueryKey(root, args, {persistedVersion: n})`. Bumping `n` clears the old
|
||||
persisted data and refetches - do this whenever the data shape changes.
|
||||
- Error handling in mutations: don't log network errors (just inform the user),
|
||||
handle typed XRPC errors specifically (e.g. `err instanceof SomeNsid.SomeError`),
|
||||
and send unexpected errors to `logger.error('...', {safeMessage: error})`.
|
||||
|
||||
### Preferences (React Context)
|
||||
|
||||
```tsx
|
||||
// Simple boolean preference pattern
|
||||
import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
|
||||
|
||||
function SettingsScreen() {
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
const setAutoplayDisabled = useSetAutoplayDisabled()
|
||||
|
||||
return <Toggle value={autoplayDisabled} onValueChange={setAutoplayDisabled} />
|
||||
}
|
||||
```
|
||||
Boolean/simple UI preferences are exposed as paired hooks from `#/state/preferences`,
|
||||
e.g. `useAutoplayDisabled()` / `useSetAutoplayDisabled()`.
|
||||
|
||||
### Session State
|
||||
|
||||
```tsx
|
||||
import {useSession, useAgent} from '#/state/session'
|
||||
|
||||
function MyComponent() {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
if (!hasSession) {
|
||||
return <LoginPrompt />
|
||||
}
|
||||
|
||||
// Use agent for API calls
|
||||
const response = await agent.getProfile({actor: currentAccount.did})
|
||||
}
|
||||
```
|
||||
`import {useSession, useAgent} from '#/state/session'`. `useSession()` gives
|
||||
`hasSession` and `currentAccount`; `useAgent()` gives the atproto agent for API calls.
|
||||
|
||||
## Navigation
|
||||
|
||||
Navigation uses React Navigation with type-safe route parameters.
|
||||
|
||||
```tsx
|
||||
// Screen component
|
||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
import {type CommonNavigatorParams} from '#/lib/routes/types'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
|
||||
|
||||
export function ProfileScreen({route, navigation}: Props) {
|
||||
const {name} = route.params // Type-safe params
|
||||
|
||||
return <Layout.Screen>{/* Screen content */}</Layout.Screen>
|
||||
}
|
||||
|
||||
// Programmatic navigation
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
const navigation = useNavigation()
|
||||
navigation.navigate('Profile', {name: 'alice.bsky.social'})
|
||||
|
||||
// Or use the navigate helper
|
||||
import {navigate} from '#/Navigation'
|
||||
navigate('Profile', {name: 'alice.bsky.social'})
|
||||
```
|
||||
React Navigation with type-safe route params. Type a screen with
|
||||
`NativeStackScreenProps<CommonNavigatorParams, 'X'>` (`route`/`navigation` come
|
||||
from props; params via `route.params`). Navigate programmatically with
|
||||
`useNavigation()`, or the `navigate` helper from `#/Navigation`. Config lives in
|
||||
`src/Navigation.tsx`, routes in `src/routes.ts`, types in `src/lib/routes/types.ts`.
|
||||
|
||||
## Platform-Specific Code
|
||||
|
||||
Use file extensions for platform-specific implementations:
|
||||
Use file extensions for platform-specific implementations. The bundler resolves
|
||||
them automatically - just import the base path normally, never a conditional
|
||||
`require()`.
|
||||
|
||||
```
|
||||
Component.tsx # Shared/default
|
||||
@@ -753,12 +414,11 @@ Component.ios.tsx # iOS-only
|
||||
Component.android.tsx # Android-only
|
||||
```
|
||||
|
||||
Example from Dialog:
|
||||
|
||||
- `src/components/Dialog/index.tsx` – Native (uses BottomSheet)
|
||||
- `src/components/Dialog/index.web.tsx` – Web (uses modal with Radix primitives)
|
||||
|
||||
**Important:** The bundler automatically resolves platform-specific files. Just import normally:
|
||||
Prefer grouping variants into a `Component/` directory (`index.tsx`,
|
||||
`index.web.tsx`, `index.native.tsx`) rather than sibling `Component.web.tsx` files,
|
||||
so the shared surface reads as one "macro" module (e.g. `src/components/Dialog/index.tsx`
|
||||
native vs `index.web.tsx` web). The app has both patterns; the directory form is
|
||||
preferred for new code.
|
||||
|
||||
```tsx
|
||||
// CORRECT - bundler picks storage.ts or storage.web.ts automatically
|
||||
@@ -770,15 +430,7 @@ const storage = IS_NATIVE
|
||||
: require('#/state/drafts/storage.web')
|
||||
```
|
||||
|
||||
Platform detection (for runtime logic, not imports):
|
||||
|
||||
```tsx
|
||||
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
|
||||
|
||||
if (IS_NATIVE) {
|
||||
// Native-specific logic
|
||||
}
|
||||
```
|
||||
Runtime platform detection (not for imports): `import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'`.
|
||||
|
||||
## Import Aliases
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM node:24.15.0-alpine3.22 AS build
|
||||
FROM node:24.18.0-alpine3.23 AS build
|
||||
|
||||
# Move files into the image and install
|
||||
WORKDIR /app
|
||||
@@ -14,7 +14,7 @@ RUN yarn build
|
||||
RUN yarn install --production --ignore-scripts --prefer-offline
|
||||
|
||||
# Uses assets from build stage to reduce build size
|
||||
FROM node:24.15.0-alpine3.22
|
||||
FROM node:24.18.0-alpine3.23
|
||||
|
||||
RUN apk add --update dumb-init
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.15.0-alpine3.22 AS build
|
||||
FROM node:24.18.0-alpine3.23 AS build
|
||||
|
||||
# Tells pnpm to run non-interactively (needed for install/script steps)
|
||||
ENV CI=true
|
||||
@@ -9,7 +9,7 @@ WORKDIR /app
|
||||
COPY ./bskyogcard/package.json ./
|
||||
COPY ./bskyogcard/pnpm-lock.yaml ./
|
||||
COPY ./bskyogcard/pnpm-workspace.yaml ./
|
||||
RUN npm install --global pnpm@11.5.2
|
||||
RUN npm install --global pnpm@11.10.0
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY ./bskyogcard ./
|
||||
@@ -19,7 +19,7 @@ RUN pnpm install-fonts && pnpm build
|
||||
RUN pnpm install --prod --ignore-scripts --prefer-offline
|
||||
|
||||
# Uses assets from build stage to reduce build size
|
||||
FROM node:24.15.0-alpine3.22
|
||||
FROM node:24.18.0-alpine3.23
|
||||
|
||||
RUN apk add --update dumb-init
|
||||
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ WORKDIR /usr/src/social-app
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Node
|
||||
ENV NODE_VERSION=24.15.0
|
||||
ENV NODE_VERSION=24.18.0
|
||||
ENV NVM_DIR=/usr/share/nvm
|
||||
|
||||
# Go
|
||||
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
|
||||
RUN \. "$NVM_DIR/nvm.sh" && \
|
||||
nvm install $NODE_VERSION && \
|
||||
nvm use $NODE_VERSION && \
|
||||
npm install --global pnpm@11.9.0 && \
|
||||
npm install --global pnpm@11.10.0 && \
|
||||
pnpm install --frozen-lockfile && \
|
||||
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
|
||||
pnpm intl:build && \
|
||||
|
||||
@@ -11,7 +11,8 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn: "Sign in"
|
||||
- tapOn:
|
||||
id: "selectServiceButton"
|
||||
- tapOn: "Custom"
|
||||
- tapOn:
|
||||
id: "manualSelectBtn"
|
||||
- tapOn:
|
||||
id: "customServerTextInput"
|
||||
- inputText: "http://localhost:3000"
|
||||
@@ -20,7 +21,8 @@ appId: xyz.blueskyweb.app
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard
|
||||
- tapOn: "Done"
|
||||
- tapOn:
|
||||
id: "doneBtn"
|
||||
- tapOn:
|
||||
id: "loginUsernameInput"
|
||||
- inputText: "Alice"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
buildStateObject,
|
||||
getCurrentRoute,
|
||||
isStateAtTabRoot,
|
||||
} from '#/lib/routes/helpers'
|
||||
import {type State} from '#/lib/routes/types'
|
||||
|
||||
describe('getCurrentRoute', () => {
|
||||
it('returns Home when there is no state', () => {
|
||||
expect(getCurrentRoute(undefined).name).toBe('Home')
|
||||
})
|
||||
|
||||
it('descends into nested state using the index', () => {
|
||||
const state = {
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
name: 'HomeTab',
|
||||
state: {
|
||||
index: 1,
|
||||
routes: [{name: 'Home'}, {name: 'PostThread'}],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as State
|
||||
|
||||
expect(getCurrentRoute(state).name).toBe('PostThread')
|
||||
})
|
||||
|
||||
/*
|
||||
* Right after a cold start from a deep link, nested navigator states are
|
||||
* still partial and have no `index`. React Navigation focuses the last
|
||||
* route when rehydrating such a state, so getCurrentRoute must do the
|
||||
* same (previously it stopped at the tab route, which re-enabled the
|
||||
* drawer swipe gesture on top of the deep-linked screen).
|
||||
*/
|
||||
it('descends into partial nested state without an index', () => {
|
||||
const state = {
|
||||
routes: [
|
||||
{
|
||||
name: 'HomeTab',
|
||||
state: {
|
||||
routes: [{name: 'Home'}, {name: 'PostThread'}],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as State
|
||||
|
||||
expect(getCurrentRoute(state).name).toBe('PostThread')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isStateAtTabRoot', () => {
|
||||
it('returns true for the initial deep link state of a tab root', () => {
|
||||
const state = buildStateObject('HomeTab', 'Home', {}) as unknown as State
|
||||
expect(isStateAtTabRoot(state)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for the initial deep link state of a nested screen', () => {
|
||||
const state = buildStateObject(
|
||||
'HomeTab',
|
||||
'PostThread',
|
||||
{name: 'alice.test', rkey: '123'},
|
||||
[{name: 'Home', params: {}}],
|
||||
) as unknown as State
|
||||
expect(isStateAtTabRoot(state)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildStateObject', () => {
|
||||
it('focuses the deep-linked route in the nested state', () => {
|
||||
const state = buildStateObject(
|
||||
'HomeTab',
|
||||
'PostThread',
|
||||
{name: 'alice.test', rkey: '123'},
|
||||
[{name: 'Home', params: {}}],
|
||||
)
|
||||
|
||||
expect(state).toEqual({
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
name: 'HomeTab',
|
||||
state: {
|
||||
index: 1,
|
||||
routes: [
|
||||
{name: 'Home', params: {}},
|
||||
{name: 'PostThread', params: {name: 'alice.test', rkey: '123'}},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('builds a single-route state for the Flat navigator', () => {
|
||||
const state = buildStateObject('Flat', 'PostThread', {
|
||||
name: 'alice.test',
|
||||
rkey: '123',
|
||||
})
|
||||
|
||||
expect(state).toEqual({
|
||||
index: 0,
|
||||
routes: [{name: 'PostThread', params: {name: 'alice.test', rkey: '123'}}],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -128,6 +128,7 @@ module.exports = function (_config) {
|
||||
'com.apple.security.application-groups': 'group.app.bsky',
|
||||
'com.apple.developer.usernotifications.communication': true,
|
||||
// 'com.apple.developer.device-information.user-assigned-device-name': true,
|
||||
'com.apple.developer.declared-age-range': true,
|
||||
},
|
||||
privacyManifests: {
|
||||
NSPrivacyCollectedDataTypes: [
|
||||
|
||||
@@ -18,6 +18,7 @@ module.exports = function (api) {
|
||||
plugins: [
|
||||
'@lingui/babel-plugin-lingui-macro',
|
||||
['babel-plugin-react-compiler', {target: '19'}],
|
||||
'module:react-native-dotenv', // used by web build! can remove when we drop webpack
|
||||
[
|
||||
'module-resolver',
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "11.5.2",
|
||||
"version": "11.10.0",
|
||||
"onFail": "warn"
|
||||
}
|
||||
},
|
||||
|
||||
+8
-8
@@ -44,9 +44,9 @@ require (
|
||||
github.com/ipfs/go-log/v2 v2.5.1 // indirect
|
||||
github.com/ipfs/go-metrics-interface v0.0.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.5.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jbenet/goprocess v0.1.4 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
@@ -90,11 +90,11 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.26.0 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
|
||||
+18
-18
@@ -77,12 +77,12 @@ github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fG
|
||||
github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw=
|
||||
github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA=
|
||||
github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o=
|
||||
github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4=
|
||||
@@ -194,8 +194,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
|
||||
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
||||
@@ -247,8 +247,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
@@ -266,16 +266,16 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -291,8 +291,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -304,8 +304,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"build": {
|
||||
"base": {
|
||||
"node": "24.15.0"
|
||||
"node": "24.18.0"
|
||||
},
|
||||
"development": {
|
||||
"extends": "base",
|
||||
|
||||
@@ -671,9 +671,6 @@
|
||||
}
|
||||
},
|
||||
"src/components/forms/TextField.tsx": {
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 3
|
||||
},
|
||||
"react-hooks/refs": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -987,11 +984,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/lib/merge-refs.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/lib/notifications/notifications.ts": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 5
|
||||
@@ -1095,14 +1087,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/Hashtag.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/Home/NoFeedsPinned.tsx": {
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 1
|
||||
@@ -1121,69 +1105,7 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/screens/Login/ChooseAccountForm.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/Login/ForgotPasswordForm.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/Login/LoginForm.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 3
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 4
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 4
|
||||
},
|
||||
"react-hooks/refs": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/Login/SetNewPasswordForm.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/Login/index.tsx": {
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/purity": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/refs": {
|
||||
"count": 1
|
||||
},
|
||||
@@ -1492,11 +1414,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/Signup/StepHandle/index.tsx": {
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/Signup/StepInfo/Policies.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -1564,14 +1481,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/Topic.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/state/a11y.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
|
||||
@@ -38,6 +38,8 @@ export default defineConfig(
|
||||
'*.e2e.ts',
|
||||
'*.e2e.tsx',
|
||||
'eslint.config.mjs',
|
||||
'babel.config.js',
|
||||
'metro.config.js',
|
||||
'.jscodeshift/**',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -121,7 +121,6 @@ class ShareViewController: UIViewController {
|
||||
let firstItem = items.first
|
||||
|
||||
if let dataUrl = try? await firstItem?.loadItem(forTypeIdentifier: "public.movie") as? URL {
|
||||
let ext = String(dataUrl.lastPathComponent.split(separator: ".").last ?? "mp4")
|
||||
if let videoUriInfo = saveVideoWithInfo(dataUrl),
|
||||
let url = URL(string: "\(self.appScheme)://intent/compose?videoUri=\(videoUriInfo)") {
|
||||
_ = self.openURL(url)
|
||||
|
||||
+10
-8
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.127.0",
|
||||
"version": "1.127.1",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=24.18.0"
|
||||
},
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "11.9.0",
|
||||
"version": "11.10.0",
|
||||
"onFail": "warn"
|
||||
},
|
||||
"runtime": {
|
||||
"name": "node",
|
||||
"version": "^24.15.0",
|
||||
"version": "^24.18.0",
|
||||
"onFail": "download"
|
||||
}
|
||||
},
|
||||
@@ -93,8 +93,9 @@
|
||||
"prettier": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.22",
|
||||
"@atproto/syntax": "0.6.3",
|
||||
"@atproto/api": "0.20.25",
|
||||
"@atproto/common-web": "0.5.3",
|
||||
"@atproto/syntax": "0.6.4",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.14",
|
||||
@@ -139,7 +140,6 @@
|
||||
"@tiptap/extension-paragraph": "^2.9.1",
|
||||
"@tiptap/extension-placeholder": "^2.9.1",
|
||||
"@tiptap/extension-text": "^2.9.1",
|
||||
"@tiptap/html": "^2.9.1",
|
||||
"@tiptap/pm": "^2.9.1",
|
||||
"@tiptap/react": "^2.9.1",
|
||||
"@tiptap/suggestion": "^2.9.1",
|
||||
@@ -156,6 +156,7 @@
|
||||
"emoji-regex": "^10.4.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "54.0.34",
|
||||
"expo-age-range": "0.2.18",
|
||||
"expo-application": "~7.0.8",
|
||||
"expo-blur": "~15.0.8",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
@@ -293,8 +294,9 @@
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "~54.0.17",
|
||||
"jest-junit": "^16.0.0",
|
||||
"lint-staged": "^13.2.3",
|
||||
"lint-staged": "^17.0.8",
|
||||
"prettier": "^3.8.3",
|
||||
"react-native-dotenv": "^3.4.11",
|
||||
"react-refresh": "^0.14.0",
|
||||
"svgo": "^3.3.2",
|
||||
"ts-plugin-sort-import-suggestions": "^1.0.4",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
diff --git a/ios/AgeRangeModule.swift b/ios/AgeRangeModule.swift
|
||||
index faf1089f4ddfb290105e959bb1f96a7250586f54..dafd2addb0c8d39a4a453bdeecbe27f7278e20f7 100644
|
||||
--- a/ios/AgeRangeModule.swift
|
||||
+++ b/ios/AgeRangeModule.swift
|
||||
@@ -11,8 +11,9 @@ public class AgeRangeModule: Module {
|
||||
return AgeRangeResponse()
|
||||
}
|
||||
|
||||
- let currentVc: UIViewController? = await MainActor.run { [appContext] in
|
||||
- appContext?.utilities?.currentViewController()
|
||||
+ nonisolated(unsafe) let utilities = appContext?.utilities
|
||||
+ let currentVc: UIViewController? = await MainActor.run {
|
||||
+ utilities?.currentViewController()
|
||||
}
|
||||
|
||||
guard let currentVc else {
|
||||
@@ -1,3 +1,26 @@
|
||||
diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts
|
||||
index 48178d9246ab94d701983ac20473983eb746d1a6..451d38e6e20646c32795db3be28fbea57de1d12f 100644
|
||||
--- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts
|
||||
+++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts
|
||||
@@ -172,6 +172,18 @@ function useChatKeyboard(
|
||||
|
||||
currentHeight.value = e.height;
|
||||
|
||||
+ if (scrollViewRef() == null) {
|
||||
+ // The scroll view can be detached or not yet attached while a
|
||||
+ // keyboard animation is running (e.g. the chat list re-creating its
|
||||
+ // scroll component mid-animation). Every branch below eventually
|
||||
+ // calls scrollTo (directly or via clampScrollIfNeeded); on Paper
|
||||
+ // reanimated's scrollTo would hand the null ref to the native
|
||||
+ // _scrollToPaper HostFunction, which throws "Value is null, expected
|
||||
+ // a number" and crashes in release builds. currentHeight is kept up
|
||||
+ // to date above so the animated style keeps committing.
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
if (inverted) {
|
||||
// Skip post-interactive snap-back (duration === -1)
|
||||
if (e.duration === -1) {
|
||||
diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
|
||||
index 0f6d7c67a307885310ab184fdf9e7a5c7b296825..1a01093e7909973cef5268f999158df389e77634 100644
|
||||
--- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
|
||||
|
||||
Generated
+276
-319
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ allowBuilds:
|
||||
'unrs-resolver': true
|
||||
patchedDependencies:
|
||||
'@sentry/react-native@6.20.0': patches/@sentry__react-native@6.20.0.patch
|
||||
expo-age-range@0.2.18: patches/expo-age-range@0.2.18.patch
|
||||
'expo-glass-effect@55.0.8': patches/expo-glass-effect@55.0.8.patch
|
||||
'expo-haptics@15.0.8': patches/expo-haptics@15.0.8.patch
|
||||
'expo-image-picker@17.0.11': patches/expo-image-picker@17.0.11.patch
|
||||
|
||||
+2
-5
@@ -30,7 +30,7 @@ import {
|
||||
} from '#/lib/hooks/useNotificationHandler'
|
||||
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
|
||||
import {useCallOnce} from '#/lib/once'
|
||||
import {buildStateObject} from '#/lib/routes/helpers'
|
||||
import {buildStateObject, getCurrentRoute} from '#/lib/routes/helpers'
|
||||
import {
|
||||
type AllNavigatorParams,
|
||||
type BottomTabNavigatorParams,
|
||||
@@ -783,10 +783,7 @@ const LINKING = {
|
||||
|
||||
getPathFromState(state: State) {
|
||||
// find the current node in the navigation tree
|
||||
let node = state.routes[state.index || 0]
|
||||
while (node.state?.routes && typeof node.state?.index === 'number') {
|
||||
node = node.state?.routes[node.state?.index]
|
||||
}
|
||||
const node = getCurrentRoute(state)
|
||||
|
||||
// build the path
|
||||
const route = router.matchName(node.name)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_IOS} from '#/env'
|
||||
|
||||
/**
|
||||
* Explains what sharing on-device age signals reveals, shown alongside the
|
||||
* "Share age range" button on the age assurance surfaces (the no-access screen
|
||||
* and the account settings card).
|
||||
*
|
||||
* Platform-split so the copy can name the OS account the age range comes from
|
||||
* (Apple Account vs Google Account) and reassure users that only a range is
|
||||
* shared. Device signals are only supported on iOS and Android, so these are
|
||||
* the only two cases.
|
||||
*/
|
||||
export function DeviceSignalsNotice({onPressKws}: {onPressKws: () => void}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<Text style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
|
||||
{IS_IOS ? (
|
||||
<Trans comment="Shown next to the 'Share age range' button when on-device age verification is available. KWS is the name of a third-party verification partner.">
|
||||
Sharing your age range reveals only the range associated with your
|
||||
Apple Account – for example, that you’re at least 18.{' '}
|
||||
<Text style={[a.text_sm, a.font_bold]}>
|
||||
Your exact age and birthday are never shared,
|
||||
</Text>{' '}
|
||||
and this data never leaves this device. Therefore, it only enables
|
||||
access on this device. Alternatively,{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={l`Verify now using KWS`}
|
||||
{...createStaticClick(() => {
|
||||
onPressKws()
|
||||
})}>
|
||||
you can use our trusted partner, KWS
|
||||
</SimpleInlineLinkText>
|
||||
, to complete your verification and enable access on all platforms.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans comment="Shown next to the 'Share age range' button when on-device age verification is available. KWS is the name of a third-party verification partner.">
|
||||
Sharing your age range reveals only the range associated with your
|
||||
Google Account – for example, that you’re at least 18.{' '}
|
||||
<Text style={[a.text_sm, a.font_bold]}>
|
||||
Your exact age and birthday are never shared,
|
||||
</Text>{' '}
|
||||
and this data never leaves this device. Therefore, it only enables
|
||||
access on this device. Alternatively,{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={l`Verify now using KWS`}
|
||||
{...createStaticClick(() => {
|
||||
onPressKws()
|
||||
})}>
|
||||
you can use our trusted partner, KWS
|
||||
</SimpleInlineLinkText>
|
||||
, to complete your verification and enable access on all platforms.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
import {useCallback, useEffect} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {
|
||||
SupportCode,
|
||||
useCreateSupportLink,
|
||||
} from '#/lib/hooks/useCreateSupportLink'
|
||||
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
|
||||
import {useSessionApi} from '#/state/session'
|
||||
@@ -27,44 +21,60 @@ import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRe
|
||||
import {Full as Logo} from '#/components/icons/Logo'
|
||||
import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
|
||||
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {DeviceSignalsNotice} from '#/ageAssurance/components/DeviceSignalsNotice'
|
||||
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
|
||||
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
|
||||
import {useAgeAssuranceVerificationFlow} from '#/ageAssurance/useVerificationFlow'
|
||||
import {
|
||||
canBirthdateUpdateIncreaseAccess,
|
||||
createGeolocationString,
|
||||
isLegacyBirthdateBug,
|
||||
useAgeAssuranceRegionConfig,
|
||||
} from '#/ageAssurance/util'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {useDeviceGeolocationApi} from '#/geolocation'
|
||||
import {useDeviceGeolocationApi, useGeolocation} from '#/geolocation'
|
||||
|
||||
const textStyles = [a.text_md, a.leading_snug]
|
||||
|
||||
export function NoAccessScreen() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l, i18n} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const insets = useSafeAreaInsets()
|
||||
const birthdateControl = useDialogControl()
|
||||
const deactivateAccountControl = useDialogControl()
|
||||
const deleteAccountControl = useDialogControl()
|
||||
const {metadata} = useAgeAssuranceServerDataContext()
|
||||
const {metadata, deviceSignals} = useAgeAssuranceServerDataContext()
|
||||
const region = useAgeAssuranceRegionConfig()
|
||||
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
||||
const {logoutCurrentAccount} = useSessionApi()
|
||||
const createSupportLink = useCreateSupportLink()
|
||||
const geolocation = useGeolocation()
|
||||
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
||||
const locationControl = Dialog.useDialogControl()
|
||||
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
|
||||
|
||||
const aa = useAgeAssurance()
|
||||
const isBlocked = aa.state.status === aa.Status.Blocked
|
||||
const isAARegion = !!region
|
||||
const hasDeclaredAge = metadata?.declaredAge !== undefined
|
||||
const hasDeclaredAge = aa.flags.hasDeclaredAge
|
||||
const birthdateMightIncreaseAccess = Boolean(
|
||||
region &&
|
||||
canBirthdateUpdateIncreaseAccess({region, metadata, deviceSignals}),
|
||||
)
|
||||
const canUpdateBirthday =
|
||||
isBirthdateUpdateAllowed || isLegacyBirthdateBug(metadata?.birthdate || '')
|
||||
(isBirthdateUpdateAllowed ||
|
||||
isLegacyBirthdateBug(metadata?.birthdate || '')) &&
|
||||
birthdateMightIncreaseAccess
|
||||
const geolocationString = createGeolocationString(geolocation, i18n.locale)
|
||||
const isUsingGPS = !!geolocation.deviceGeolocation?.countryCode && IS_NATIVE
|
||||
|
||||
useEffect(() => {
|
||||
// just counting overall hits here
|
||||
@@ -106,7 +116,7 @@ export function NoAccessScreen() {
|
||||
<Trans>
|
||||
If you believe your birthdate is incorrect, you can update it by{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Click here to update your birthdate`)}
|
||||
label={l`Click here to update your birthdate`}
|
||||
style={[textStyles]}
|
||||
{...createStaticClick(() => {
|
||||
ax.metric('ageAssurance:noAccessScreen:openBirthdateDialog', {})
|
||||
@@ -120,20 +130,7 @@ export function NoAccessScreen() {
|
||||
|
||||
{orgAdmonition}
|
||||
</>
|
||||
) : (
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
If you believe your birthdate is incorrect, please{' '}
|
||||
<SimpleInlineLinkText
|
||||
to={createSupportLink({code: SupportCode.AA_BIRTHDATE})}
|
||||
label={_(msg`Click here to contact our support team`)}
|
||||
style={[textStyles]}>
|
||||
contact our support team
|
||||
</SimpleInlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
)
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -164,80 +161,147 @@ export function NoAccessScreen() {
|
||||
<AgeAssuranceBadge />
|
||||
</View>
|
||||
|
||||
{hasDeclaredAge ? (
|
||||
<>
|
||||
{isAARegion ? (
|
||||
<>
|
||||
<View style={[a.gap_lg]}>
|
||||
{hasDeclaredAge ? (
|
||||
<>
|
||||
{isAARegion ? (
|
||||
<>
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>Hey there!</Trans>
|
||||
</Text>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
You are accessing Bluesky from a region that legally
|
||||
requires us to verify your age before allowing you
|
||||
to access the app.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{region && geolocationString && (
|
||||
<Text style={[textStyles]}>
|
||||
{isUsingGPS ? (
|
||||
<Trans>
|
||||
Based on your device's location, we think you're
|
||||
in{' '}
|
||||
<Text style={[textStyles, a.font_bold]}>
|
||||
{geolocationString}
|
||||
</Text>
|
||||
.
|
||||
</Trans>
|
||||
) : (
|
||||
<>
|
||||
<Trans>
|
||||
Based on your network, we think you're in{' '}
|
||||
<Text style={[textStyles, a.font_bold]}>
|
||||
{geolocationString}
|
||||
</Text>
|
||||
. This estimate may be inaccurate if you're
|
||||
using a VPN.
|
||||
</Trans>
|
||||
</>
|
||||
)}
|
||||
{IS_NATIVE && (
|
||||
<>
|
||||
{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={l`Update your location`}
|
||||
{...createStaticClick(() => {
|
||||
locationControl.open()
|
||||
})}
|
||||
style={[textStyles]}>
|
||||
<Trans>
|
||||
Tap here to update your location with GPS.
|
||||
</Trans>
|
||||
</SimpleInlineLinkText>
|
||||
<DeviceLocationRequestDialog
|
||||
control={locationControl}
|
||||
onLocationAcquired={props => {
|
||||
const access =
|
||||
computeAgeAssuranceRegionAccess(
|
||||
props.geolocation,
|
||||
)
|
||||
if (access !== aa.Access.Full) {
|
||||
props.disableDialogAction()
|
||||
props.setDialogError(
|
||||
l`We're sorry, but based on your device's location, you are currently located in a region that requires age assurance.`,
|
||||
)
|
||||
} else {
|
||||
props.closeDialog(() => {
|
||||
// set this after close!
|
||||
setDeviceGeolocation(props.geolocation)
|
||||
Toast.show(l`Thanks! You're all set.`, {
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!aa.flags.isOverRegionMinAccessAge && (
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Unfortunately, your declared age indicates that
|
||||
you are not old enough to access Bluesky in your
|
||||
region.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!isBlocked && birthdateUpdateText}
|
||||
</View>
|
||||
|
||||
{aa.flags.isOverRegionMinAccessAge && <AccessSection />}
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>Hey there!</Trans>
|
||||
</Text>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
You are accessing Bluesky from a region that legally
|
||||
requires us to verify your age before allowing you to
|
||||
access the app.
|
||||
Unfortunately, the birthdate you have saved to your
|
||||
profile makes you too young to access Bluesky.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{!aa.flags.isOverRegionMinAccessAge && (
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Unfortunately, your declared age indicates that you
|
||||
are not old enough to access Bluesky in your region.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!isBlocked && birthdateUpdateText}
|
||||
{birthdateUpdateText}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>Hi there!</Trans>
|
||||
</Text>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
In order to provide an age-appropriate experience, we need
|
||||
to know your birthdate. This is a one-time thing, and your
|
||||
data will be kept private.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Set your birthdate below and we'll get you back to posting
|
||||
and exploring in no time!
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
color="primary"
|
||||
size="large"
|
||||
label={l`Click here to update your birthdate`}
|
||||
onPress={() => birthdateControl.open()}>
|
||||
<ButtonText>
|
||||
<Trans>Add your birthdate</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
{aa.flags.isOverRegionMinAccessAge && <AccessSection />}
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Unfortunately, the birthdate you have saved to your
|
||||
profile makes you too young to access Bluesky.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{birthdateUpdateText}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>Hi there!</Trans>
|
||||
</Text>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
In order to provide an age-appropriate experience, we need
|
||||
to know your birthdate. This is a one-time thing, and your
|
||||
data will be kept private.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Set your birthdate below and we'll get you back to posting
|
||||
and exploring in no time!
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
color="primary"
|
||||
size="large"
|
||||
label={_(msg`Click here to update your birthdate`)}
|
||||
onPress={() => birthdateControl.open()}>
|
||||
<ButtonText>
|
||||
<Trans>Add your birthdate</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
{orgAdmonition}
|
||||
</View>
|
||||
)}
|
||||
{orgAdmonition}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[a.pt_lg, a.gap_xl, {maxWidth: 280}]}>
|
||||
<Logo width={120} textFill={t.atoms.text.color} />
|
||||
@@ -251,7 +315,7 @@ export function NoAccessScreen() {
|
||||
<Trans>
|
||||
To log out,{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Click here to log out`)}
|
||||
label={l`Click here to log out`}
|
||||
{...createStaticClick(() => {
|
||||
onPressLogout()
|
||||
})}
|
||||
@@ -260,7 +324,7 @@ export function NoAccessScreen() {
|
||||
</SimpleInlineLinkText>
|
||||
. Or if you’d prefer, you can{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Click here to delete your account`)}
|
||||
label={l`Click here to delete your account`}
|
||||
{...createStaticClick(() => {
|
||||
ax.metric(
|
||||
'ageAssurance:noAccessScreen:openDeleteAccountDialog',
|
||||
@@ -299,14 +363,11 @@ export function NoAccessScreen() {
|
||||
|
||||
function AccessSection() {
|
||||
const t = useTheme()
|
||||
const {_, i18n} = useLingui()
|
||||
const {t: l, i18n} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const control = useDialogControl()
|
||||
const appealControl = Dialog.useDialogControl()
|
||||
const locationControl = Dialog.useDialogControl()
|
||||
const getTimeAgo = useGetTimeAgo()
|
||||
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
||||
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
|
||||
|
||||
const aa = useAgeAssurance()
|
||||
const {status, lastInitiatedAt} = aa.state
|
||||
@@ -318,6 +379,15 @@ function AccessSection() {
|
||||
const diff = lastInitiatedAt
|
||||
? dateDiff(lastInitiatedAt, new Date(), 'down')
|
||||
: null
|
||||
const {
|
||||
onPressVerify,
|
||||
openInitDialog,
|
||||
isVerifying,
|
||||
verifyCta,
|
||||
deviceSignalsFailed,
|
||||
} = useAgeAssuranceVerificationFlow({initDialogControl: control})
|
||||
const useDeviceSignals =
|
||||
aa.flags.allowsDeviceVerification && !deviceSignalsFailed
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -331,7 +401,7 @@ function AccessSection() {
|
||||
You are currently unable to access Bluesky's Age Assurance flow.
|
||||
Please{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Contact our moderation team`)}
|
||||
label={l`Contact our moderation team`}
|
||||
{...createStaticClick(() => {
|
||||
appealControl.open()
|
||||
ax.metric('ageAssurance:appealDialogOpen', {})
|
||||
@@ -345,26 +415,22 @@ function AccessSection() {
|
||||
<>
|
||||
<View style={[a.gap_md]}>
|
||||
<Button
|
||||
label={_(msg`Verify now`)}
|
||||
label={verifyCta}
|
||||
size="large"
|
||||
color={hasInitiated ? 'secondary' : 'primary'}
|
||||
onPress={() => {
|
||||
control.open()
|
||||
ax.metric('ageAssurance:initDialogOpen', {
|
||||
hasInitiatedPreviously: hasInitiated,
|
||||
})
|
||||
}}>
|
||||
<ButtonIcon icon={ShieldIcon} />
|
||||
<ButtonText>
|
||||
{hasInitiated ? (
|
||||
<Trans>Verify again</Trans>
|
||||
) : (
|
||||
<Trans>Verify now</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
color={
|
||||
hasInitiated || aa.flags.hasSharedDeviceSignals
|
||||
? 'secondary'
|
||||
: 'primary'
|
||||
}
|
||||
disabled={isVerifying}
|
||||
onPress={() => void onPressVerify()}>
|
||||
<ButtonIcon icon={isVerifying ? Loader : ShieldIcon} />
|
||||
<ButtonText>{verifyCta}</ButtonText>
|
||||
</Button>
|
||||
|
||||
{lastInitiatedAt && timeAgo && diff ? (
|
||||
{useDeviceSignals ? (
|
||||
<DeviceSignalsNotice onPressKws={openInitDialog} />
|
||||
) : lastInitiatedAt && timeAgo && diff ? (
|
||||
<Text
|
||||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
|
||||
title={i18n.date(lastInitiatedAt, {
|
||||
@@ -380,56 +446,12 @@ function AccessSection() {
|
||||
) : (
|
||||
<Text
|
||||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Age assurance only takes a few minutes</Trans>
|
||||
<Trans>Age assurance only takes a few minutes.</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={[a.gap_xs]}>
|
||||
{IS_NATIVE && (
|
||||
<>
|
||||
<Admonition>
|
||||
<Trans>
|
||||
Is your location not accurate?{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Update your location`)}
|
||||
{...createStaticClick(() => {
|
||||
locationControl.open()
|
||||
})}>
|
||||
Tap here to update your location with GPS.
|
||||
</SimpleInlineLinkText>
|
||||
</Trans>
|
||||
</Admonition>
|
||||
|
||||
<DeviceLocationRequestDialog
|
||||
control={locationControl}
|
||||
onLocationAcquired={props => {
|
||||
const access = computeAgeAssuranceRegionAccess(
|
||||
props.geolocation,
|
||||
)
|
||||
if (access !== aa.Access.Full) {
|
||||
props.disableDialogAction()
|
||||
props.setDialogError(
|
||||
_(
|
||||
msg`We're sorry, but based on your device's location, you are currently located in a region that requires age assurance.`,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
props.closeDialog(() => {
|
||||
// set this after close!
|
||||
setDeviceGeolocation(props.geolocation)
|
||||
Toast.show(_(msg`Thanks! You're all set.`), {
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -4,12 +4,29 @@ import {
|
||||
} from '@atproto/api'
|
||||
|
||||
import {AgeAssuranceAccess} from '#/ageAssurance/types'
|
||||
import {ANDROID_API_LEVEL, IOS_MAJOR_VERSION, IS_ANDROID, IS_IOS} from '#/env'
|
||||
|
||||
/**
|
||||
* Minimum age required to access the app at all.
|
||||
*/
|
||||
export const MIN_ACCESS_AGE = 13
|
||||
|
||||
/**
|
||||
* Whether the current device can provide the native on-device age signals we
|
||||
* use for age assurance (via `expo-age-range`). We gate on OS version because
|
||||
* the underlying platform APIs are only available on:
|
||||
*
|
||||
* - iOS 26.0+ (Declared Age Range API)
|
||||
* https://developer.apple.com/documentation/declaredagerange/
|
||||
* - Android 6.0 / API level 23+ (Play Age Signals API)
|
||||
* https://developer.android.com/google/play/age-signals/use-age-signals-api
|
||||
*
|
||||
* On unsupported platforms/versions (including web) this is `false`, so we skip
|
||||
* the device flow and fall back to KWS.
|
||||
*/
|
||||
export const DEVICE_SIGNALS_SUPPORTED: boolean =
|
||||
(IS_IOS && IOS_MAJOR_VERSION >= 26) || (IS_ANDROID && ANDROID_API_LEVEL >= 23)
|
||||
|
||||
export const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = {
|
||||
countryCode: '*',
|
||||
regionCode: undefined,
|
||||
|
||||
+191
-7
@@ -1,11 +1,11 @@
|
||||
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
|
||||
import * as AgeRange from 'expo-age-range'
|
||||
import {
|
||||
type AppBskyAgeassuranceDefs,
|
||||
type AppBskyAgeassuranceGetConfig,
|
||||
type AppBskyAgeassuranceGetState,
|
||||
AtpAgent,
|
||||
type ChatBskyActorDeclaration,
|
||||
getAgeAssuranceRegionConfig,
|
||||
} from '@atproto/api'
|
||||
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
|
||||
@@ -22,14 +22,21 @@ import {
|
||||
} from '#/state/birthdate'
|
||||
import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declaration'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {DEVICE_SIGNALS_SUPPORTED} from '#/ageAssurance/const'
|
||||
import * as debug from '#/ageAssurance/debug'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {type AgeAssuranceMetadata} from '#/ageAssurance/types'
|
||||
import {
|
||||
type AgeAssuranceDeviceSignals,
|
||||
type AgeAssuranceMetadata,
|
||||
} from '#/ageAssurance/types'
|
||||
import {
|
||||
createRegionKey,
|
||||
getAgeAssuranceRegionConfigForGeolocation,
|
||||
getBirthdateStringFromAge,
|
||||
isLegacyBirthdateBug,
|
||||
} from '#/ageAssurance/util'
|
||||
import {IS_DEV} from '#/env'
|
||||
import {useGeolocation} from '#/geolocation'
|
||||
import {device} from '#/storage'
|
||||
|
||||
/**
|
||||
@@ -310,10 +317,7 @@ export function useServerStateQuery() {
|
||||
const isAArequired = Boolean(
|
||||
config &&
|
||||
geolocation &&
|
||||
!!getAgeAssuranceRegionConfig(config, {
|
||||
countryCode: geolocation?.countryCode ?? '',
|
||||
regionCode: geolocation?.regionCode,
|
||||
}),
|
||||
getAgeAssuranceRegionConfigForGeolocation(config, geolocation),
|
||||
)
|
||||
|
||||
// only refetch when needed
|
||||
@@ -485,6 +489,173 @@ export function useOtherRequiredDataQuery() {
|
||||
)
|
||||
}
|
||||
|
||||
export function createDeviceSignalsQueryKey({did}: {did: string}) {
|
||||
return ['device-signals', did]
|
||||
}
|
||||
/**
|
||||
* Prompts the native OS age API. Returns the raw response, or undefined if the
|
||||
* platform can't provide one.
|
||||
*
|
||||
* Native-only: on web `expo-age-range` returns a misleading default (e.g.
|
||||
* `{lowerBound: 18}`), so we never call it there — web users fall back to KWS.
|
||||
*
|
||||
* If this method throws for whatever reason, we catch and log the error and
|
||||
* return undefined. The caller should treat undefined as "no device signals
|
||||
* available" and fall back to KWS.
|
||||
*/
|
||||
export async function getDeviceSignals(): Promise<
|
||||
AgeRange.AgeRangeResponse | undefined
|
||||
> {
|
||||
if (debug.enabled && debug.useMockDeviceSignalsAPIResponse)
|
||||
return debug.resolve(debug.deviceSignals)
|
||||
if (!DEVICE_SIGNALS_SUPPORTED) return undefined
|
||||
try {
|
||||
return await AgeRange.requestAgeRangeAsync({
|
||||
threshold1: 13,
|
||||
threshold2: 16,
|
||||
threshold3: 18,
|
||||
})
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
logger.error(`getDeviceSignals: failed to get device signals`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
/**
|
||||
* The raw region-keyed map of device signals (all regions). Used internally by
|
||||
* the query + writer, which operate on the full map. Most consumers want
|
||||
* {@link getDeviceSignalsFromCacheForRegion}, which resolves to the
|
||||
* current region.
|
||||
*/
|
||||
export function getDeviceSignalsMapFromCache({
|
||||
did,
|
||||
}: {
|
||||
did: string
|
||||
}): AgeAssuranceDeviceSignals | undefined {
|
||||
return qc.getQueryData<AgeAssuranceDeviceSignals>(
|
||||
createDeviceSignalsQueryKey({did}),
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Returns the device signals for the region the user is currently in, or
|
||||
* undefined.
|
||||
*/
|
||||
export function getDeviceSignalsFromCacheForRegion({
|
||||
did,
|
||||
region,
|
||||
}: {
|
||||
did: string
|
||||
region: AppBskyAgeassuranceDefs.ConfigRegion
|
||||
}): AgeRange.AgeRangeResponse | undefined {
|
||||
const regionKey = createRegionKey(region)
|
||||
return getDeviceSignalsMapFromCache({did})?.[regionKey]
|
||||
}
|
||||
/**
|
||||
* Stores freshly granted device signals into the (persisted) cache under the
|
||||
* region they were captured in, merging with any signals already stored for
|
||||
* other regions. Notifies the disabled `useDeviceSignalsQuery` observer so the
|
||||
* AA state recomputes.
|
||||
*
|
||||
* Device assurance is client-side only (it can't be verified server-side) and
|
||||
* region-bound — keying by region is what binds it. See
|
||||
* {@link AgeAssuranceDeviceSignals}.
|
||||
*/
|
||||
export function setDeviceSignalsForRegion({
|
||||
did,
|
||||
region,
|
||||
signals,
|
||||
}: {
|
||||
did: string
|
||||
region: {countryCode: string; regionCode?: string}
|
||||
signals: AgeRange.AgeRangeResponse
|
||||
}) {
|
||||
const regionKey = createRegionKey(region)
|
||||
qc.setQueryData<AgeAssuranceDeviceSignals | undefined>(
|
||||
createDeviceSignalsQueryKey({did}),
|
||||
prev => ({...prev, [regionKey]: signals}),
|
||||
)
|
||||
}
|
||||
export async function prefetchDeviceSignals({agent}: {agent: AtpAgent}) {
|
||||
const did = getDidFromAgentSession(agent)
|
||||
if (!did) return
|
||||
|
||||
/**
|
||||
* Device signals are restored from the persisted cache only — we never call
|
||||
* the native age API during prefetch, since that would prompt the OS for
|
||||
* users who haven't opted in. Awaiting cache hydration ensures any previously
|
||||
* granted signals are available before the AA state is first computed. The
|
||||
* user can (re)grant access later via the NoAccessScreen verify flow.
|
||||
*/
|
||||
await cacheHydrationPromise
|
||||
const cached = getDeviceSignalsMapFromCache({did})
|
||||
logger.debug(
|
||||
`prefetchDeviceSignals: ${cached ? 'restored from cache' : 'no cache'}`,
|
||||
cached,
|
||||
)
|
||||
|
||||
/*
|
||||
* Future silent-refresh path (intentionally left commented out). The OS
|
||||
* returns a previously granted age range without re-prompting, so once a
|
||||
* region is already cached we could refresh it on load instead of waiting for
|
||||
* the user to re-verify. We don't do this yet because the native call can
|
||||
* still surface a prompt for users who never opted in, so it must stay gated
|
||||
* behind an explicit grant for now.
|
||||
*
|
||||
* const geolocation = device.get(['mergedGeolocation'])
|
||||
* if (cached && geolocation?.countryCode) {
|
||||
* const signals = await getDeviceSignals()
|
||||
* if (signals) {
|
||||
* setDeviceSignalsForRegion({did, region: geolocation, signals})
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
}
|
||||
export function useDeviceSignalsQuery() {
|
||||
const agent = useAgent()
|
||||
const did = getDidFromAgentSession(agent)
|
||||
const {data: config} = useConfigQuery()
|
||||
const geolocation = useGeolocation()
|
||||
/*
|
||||
* Resolve the matched config region (no fallback) and key off it, so the read
|
||||
* stays symmetric with the write (see `setDeviceSignalsForRegion`). When
|
||||
* geolocation matches no AA region there's no device grant to surface.
|
||||
*/
|
||||
const regionConfig = config
|
||||
? getAgeAssuranceRegionConfigForGeolocation(config, geolocation)
|
||||
: undefined
|
||||
const regionKey = regionConfig ? createRegionKey(regionConfig) : undefined
|
||||
|
||||
return useQuery(
|
||||
{
|
||||
/**
|
||||
* Disabled so we never auto-call the native age API on load — that would
|
||||
* prompt the OS for every logged-in user. We restore from the persisted
|
||||
* cache (via `initialData`) and otherwise only update reactively when the
|
||||
* user explicitly verifies (see `getDeviceSignals` +
|
||||
* `setDeviceSignalsForRegion` in the NoAccessScreen verify flow).
|
||||
*
|
||||
* A future enhancement could silently refresh here when already cached,
|
||||
* since the OS returns the granted result without re-prompting.
|
||||
*/
|
||||
enabled: false,
|
||||
initialData: getDeviceSignalsMapFromCache({did: did!}),
|
||||
queryKey: createDeviceSignalsQueryKey({did: did!}),
|
||||
queryFn() {
|
||||
// Never auto-fetches (see `enabled: false`); the verify flow writes the
|
||||
// region-keyed signals directly via `setDeviceSignalsForRegion`.
|
||||
return getDeviceSignalsMapFromCache({did: did!})
|
||||
},
|
||||
// The cache holds the full region-keyed map (the writer merges into it);
|
||||
// `select` resolves it to the current region for consumers without
|
||||
// mutating the cached value.
|
||||
select: map => (map && regionKey ? map[regionKey] : undefined),
|
||||
},
|
||||
qc,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to prefetch all age assurance data from the server.
|
||||
*/
|
||||
@@ -494,6 +665,7 @@ export function prefetchAgeAssuranceServerData({agent}: {agent: AtpAgent}) {
|
||||
configPrefetchPromise,
|
||||
prefetchServerState({agent}),
|
||||
prefetchOtherRequiredData({agent}),
|
||||
prefetchDeviceSignals({agent}),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -525,6 +697,14 @@ export type AgeAssuranceServerData = {
|
||||
*/
|
||||
state: AppBskyAgeassuranceDefs.State | undefined
|
||||
metadata: AgeAssuranceMetadata | undefined
|
||||
/**
|
||||
* The native on-device age signals for the region the user is currently in,
|
||||
* if they've granted access there. Already resolved from the region-keyed
|
||||
* cache (see `getDeviceSignalsFromCacheForRegion`), so a grant from
|
||||
* another region won't appear here. Only consumed for regions that permit
|
||||
* device verification.
|
||||
*/
|
||||
deviceSignals: AgeRange.AgeRangeResponse | undefined
|
||||
}
|
||||
const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
|
||||
config: undefined,
|
||||
@@ -534,6 +714,7 @@ const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
|
||||
declaredAge: undefined,
|
||||
birthdate: undefined,
|
||||
},
|
||||
deviceSignals: undefined,
|
||||
})
|
||||
export function useAgeAssuranceServerDataContext() {
|
||||
return useContext(AgeAssuranceServerDataContext)
|
||||
@@ -547,6 +728,8 @@ export function AgeAssuranceServerDataProvider({
|
||||
const serverState = useServerStateQuery()
|
||||
const {state, metadata} = serverState.data || {}
|
||||
const {data} = useOtherRequiredDataQuery()
|
||||
// `select` resolves the cached region-keyed map to the current region.
|
||||
const {data: deviceSignals} = useDeviceSignalsQuery()
|
||||
const ctx = useMemo(
|
||||
() => ({
|
||||
config,
|
||||
@@ -559,8 +742,9 @@ export function AgeAssuranceServerDataProvider({
|
||||
: undefined,
|
||||
birthdate: data?.birthdate,
|
||||
},
|
||||
deviceSignals,
|
||||
}),
|
||||
[config, state, data, metadata],
|
||||
[config, state, data, metadata, deviceSignals],
|
||||
)
|
||||
return (
|
||||
<AgeAssuranceServerDataContext.Provider value={ctx}>
|
||||
|
||||
+49
-12
@@ -1,3 +1,4 @@
|
||||
import type * as AgeRange from 'expo-age-range'
|
||||
import {
|
||||
ageAssuranceRuleIDs as ids,
|
||||
type AppBskyAgeassuranceDefs,
|
||||
@@ -17,17 +18,16 @@ export const geolocation: Geolocation | undefined = enabled
|
||||
}
|
||||
: undefined
|
||||
|
||||
const deviceGeolocationEnabled = false || IS_E2E
|
||||
export const deviceGeolocation: Geolocation | undefined =
|
||||
enabled && deviceGeolocationEnabled
|
||||
? {
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
}
|
||||
: undefined
|
||||
export const deviceGeolocation: Geolocation | undefined = enabled
|
||||
? {
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
...geolocation,
|
||||
}
|
||||
: undefined
|
||||
|
||||
export const otherRequiredData: OtherRequiredData = {
|
||||
birthdate: new Date(2010, 12, 1).toISOString(),
|
||||
birthdate: new Date(2000, 12, 1).toISOString(),
|
||||
}
|
||||
|
||||
const serverStateEnabled = false || IS_E2E
|
||||
@@ -35,9 +35,9 @@ export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
|
||||
serverStateEnabled
|
||||
? {
|
||||
state: {
|
||||
lastInitiatedAt: new Date(2025, 1, 1).toISOString(),
|
||||
status: 'assured',
|
||||
access: 'full',
|
||||
lastInitiatedAt: undefined, // new Date(2025, 1, 1).toISOString(),
|
||||
status: 'unknown',
|
||||
access: 'unknown',
|
||||
},
|
||||
metadata: {
|
||||
accountCreatedAt: new Date(2023, 1, 1).toISOString(),
|
||||
@@ -58,6 +58,26 @@ export const config: AppBskyAgeassuranceDefs.Config = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// On-device verification region. KWS is included as a fallback for
|
||||
// platforms without the native age API (e.g. web) or when the device
|
||||
// result is insufficient.
|
||||
countryCode: 'US',
|
||||
regionCode: 'TX',
|
||||
minAccessAge: 18,
|
||||
additionalVerificationMethods: ['device'],
|
||||
rules: [
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'GB',
|
||||
minAccessAge: 13,
|
||||
@@ -257,6 +277,23 @@ export const config: AppBskyAgeassuranceDefs.Config = {
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* When debug is enabled we mock the `deviceSignals` response by default. Set
|
||||
* this to `false` to hit the real native age API (`expo-age-range`) so the OS
|
||||
* age prompt actually shows — useful for testing the device flow on a physical
|
||||
* device.
|
||||
*/
|
||||
export const useMockDeviceSignalsAPIResponse = true
|
||||
export const deviceSignals: AgeRange.AgeRangeResponse | undefined =
|
||||
useMockDeviceSignalsAPIResponse
|
||||
? {
|
||||
// Simulates the OS reporting the user is at least 18. Lower this below
|
||||
// a region's IfAssuredOverAge threshold to exercise the KWS fallback.
|
||||
lowerBound: 16,
|
||||
upperBound: null,
|
||||
}
|
||||
: undefined
|
||||
|
||||
export async function resolve<T>(data: T) {
|
||||
await new Promise(y => setTimeout(y, 500)) // simulate network
|
||||
return data
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {createContext, useCallback, useContext, useMemo} from 'react'
|
||||
|
||||
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
|
||||
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
|
||||
import {
|
||||
AgeAssuranceServerDataProvider,
|
||||
@@ -51,9 +49,12 @@ const AgeAssuranceStateContext = createContext<{
|
||||
adultContentDisabled: false,
|
||||
chatDisabled: false,
|
||||
groupChatDisabled: false,
|
||||
hasDeclaredAge: false,
|
||||
isDeclaredUnderAdultAge: false,
|
||||
isOverRegionMinAccessAge: false,
|
||||
isOverAppMinAccessAge: false,
|
||||
allowsDeviceVerification: false,
|
||||
hasSharedDeviceSignals: false,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -77,9 +78,8 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
}
|
||||
|
||||
function InnerProvider({children}: {children: React.ReactNode}) {
|
||||
const agent = useAgent()
|
||||
const state = useAgeAssuranceState()
|
||||
const {metadata} = useAgeAssuranceServerDataContext()
|
||||
const {metadata, deviceSignals} = useAgeAssuranceServerDataContext()
|
||||
const regionConfig = useAgeAssuranceRegionConfigWithFallback()
|
||||
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
||||
|
||||
@@ -89,21 +89,15 @@ function InnerProvider({children}: {children: React.ReactNode}) {
|
||||
state: s,
|
||||
regionConfig,
|
||||
metadata,
|
||||
deviceSignals,
|
||||
})
|
||||
if (flags.isAgeRestricted) {
|
||||
void getAndRegisterPushToken({
|
||||
isAgeRestricted: true,
|
||||
})
|
||||
}
|
||||
if (flags.chatDisabled || flags.groupChatDisabled) {
|
||||
void restrictChatSettings({
|
||||
agent,
|
||||
restrictIncoming: flags.chatDisabled,
|
||||
restrictGroupInvites: flags.groupChatDisabled,
|
||||
})
|
||||
}
|
||||
},
|
||||
[agent, getAndRegisterPushToken, regionConfig, metadata],
|
||||
[getAndRegisterPushToken, regionConfig, metadata, deviceSignals],
|
||||
)
|
||||
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
|
||||
|
||||
@@ -118,11 +112,12 @@ function InnerProvider({children}: {children: React.ReactNode}) {
|
||||
state,
|
||||
regionConfig,
|
||||
metadata,
|
||||
deviceSignals,
|
||||
}),
|
||||
}
|
||||
logger.debug(`useAgeAssurance`, res)
|
||||
return res
|
||||
}, [state, metadata, regionConfig])}>
|
||||
}, [state, metadata, regionConfig, deviceSignals])}>
|
||||
{children}
|
||||
</AgeAssuranceStateContext.Provider>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {useEffect, useMemo, useState} from 'react'
|
||||
import type * as AgeRange from 'expo-age-range'
|
||||
import {
|
||||
type AppBskyAgeassuranceDefs,
|
||||
computeAgeAssuranceRegionAccess,
|
||||
@@ -8,6 +9,7 @@ import {getAge} from '#/lib/strings/time'
|
||||
import {useSession} from '#/state/session'
|
||||
import {
|
||||
getConfigFromCache,
|
||||
getDeviceSignalsFromCacheForRegion,
|
||||
getOtherRequiredDataFromCache,
|
||||
getServerStateFromCache,
|
||||
useAgeAssuranceServerDataContext,
|
||||
@@ -23,6 +25,8 @@ import {
|
||||
} from '#/ageAssurance/types'
|
||||
import {
|
||||
computeAgeAssuranceFlags,
|
||||
getAgeAssuranceDataFromDeviceSignals,
|
||||
getAgeAssuranceRegionConfigForGeolocation,
|
||||
getAgeAssuranceRegionConfigWithFallback,
|
||||
} from '#/ageAssurance/util'
|
||||
import {type Geolocation, useGeolocation} from '#/geolocation'
|
||||
@@ -39,12 +43,14 @@ function computeAgeAssuranceState({
|
||||
config,
|
||||
state,
|
||||
metadata,
|
||||
deviceSignals,
|
||||
}: {
|
||||
hasSession: boolean
|
||||
geolocation: Geolocation
|
||||
config?: AppBskyAgeassuranceDefs.Config
|
||||
state?: AppBskyAgeassuranceDefs.State
|
||||
metadata?: AgeAssuranceMetadata
|
||||
deviceSignals?: AgeRange.AgeRangeResponse
|
||||
}) {
|
||||
/**
|
||||
* This is where we control logged-out moderation prefs. It's all
|
||||
@@ -93,10 +99,19 @@ function computeAgeAssuranceState({
|
||||
* Otherwise, we need to compute the access based on the latest data. For
|
||||
* accounts with an accurate birthdate, our default fallback rules should
|
||||
* ensure correct access.
|
||||
*
|
||||
* In regions that permit on-device verification, the OS-provided age range
|
||||
* is treated as an assured age and fed into the rule engine, where it
|
||||
* matches `IfAssuredOverAge`/`IfAssuredUnderAge` rules.
|
||||
*/
|
||||
const {assuredAge} = getAgeAssuranceDataFromDeviceSignals(
|
||||
region,
|
||||
deviceSignals,
|
||||
)
|
||||
const result = computeAgeAssuranceRegionAccess(region, {
|
||||
accountCreatedAt: metadata?.accountCreatedAt,
|
||||
declaredAge: metadata?.declaredAge,
|
||||
assuredAge,
|
||||
})
|
||||
const computed = {
|
||||
lastInitiatedAt: state?.lastInitiatedAt,
|
||||
@@ -138,6 +153,19 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
||||
}
|
||||
|
||||
const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation)
|
||||
/*
|
||||
* Device signals are keyed off the matched config region (no fallback): if
|
||||
* geolocation matches no AA region there's no device grant to read, so we
|
||||
* skip the lookup rather than keying off FALLBACK_REGION_CONFIG. This keeps
|
||||
* the read key symmetric with the write (see `setDeviceSignalsForRegion`).
|
||||
*/
|
||||
const deviceRegion = getAgeAssuranceRegionConfigForGeolocation(
|
||||
config,
|
||||
geolocation,
|
||||
)
|
||||
const deviceSignals = deviceRegion
|
||||
? getDeviceSignalsFromCacheForRegion({did, region: deviceRegion})
|
||||
: undefined
|
||||
const metadata: AgeAssuranceMetadata = {
|
||||
accountCreatedAt: state.metadata?.accountCreatedAt,
|
||||
declaredAge: requiredData?.birthdate
|
||||
@@ -151,6 +179,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
||||
geolocation,
|
||||
state: state.state,
|
||||
metadata,
|
||||
deviceSignals,
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -159,6 +188,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
||||
state: computed,
|
||||
regionConfig: region,
|
||||
metadata,
|
||||
deviceSignals,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -166,7 +196,8 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
||||
export function useAgeAssuranceState(): AgeAssuranceState {
|
||||
const {hasSession} = useSession()
|
||||
const geolocation = useGeolocation()
|
||||
const {config, state, metadata} = useAgeAssuranceServerDataContext()
|
||||
const {config, state, metadata, deviceSignals} =
|
||||
useAgeAssuranceServerDataContext()
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
@@ -176,8 +207,9 @@ export function useAgeAssuranceState(): AgeAssuranceState {
|
||||
geolocation,
|
||||
state,
|
||||
metadata,
|
||||
deviceSignals,
|
||||
}),
|
||||
[hasSession, geolocation, config, state, metadata],
|
||||
[hasSession, geolocation, config, state, metadata, deviceSignals],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import type * as AgeRange from 'expo-age-range'
|
||||
import {type computeAgeAssuranceRegionAccess} from '@atproto/api'
|
||||
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
|
||||
/**
|
||||
* Native on-device age signals, keyed by the region (a `country[-region]`
|
||||
* string, see `createRegionKey`) they were captured in. We keep one entry per
|
||||
* region so multiple regions with differing criteria can each retain their own
|
||||
* grant.
|
||||
*
|
||||
* Device assurance can't be verified server-side (the OS gives us no signed
|
||||
* attestation, only age bounds), so we persist it client-side only and bind it
|
||||
* to its capture region via the key. A grant captured in TX is only ever read
|
||||
* back for TX — it can't silently unlock another region. See
|
||||
* `getAgeAssuranceDataFromDeviceSignals`.
|
||||
*/
|
||||
export type AgeAssuranceDeviceSignals = {
|
||||
[regionKey: string]: AgeRange.AgeRangeResponse
|
||||
}
|
||||
|
||||
export enum AgeAssuranceAccess {
|
||||
Unknown = 'unknown',
|
||||
None = 'none',
|
||||
@@ -34,9 +51,12 @@ export type AgeAssuranceFlags = {
|
||||
adultContentDisabled: boolean
|
||||
chatDisabled: boolean
|
||||
groupChatDisabled: boolean
|
||||
hasDeclaredAge: boolean
|
||||
isDeclaredUnderAdultAge: boolean
|
||||
isOverRegionMinAccessAge: boolean
|
||||
isOverAppMinAccessAge: boolean
|
||||
allowsDeviceVerification: boolean
|
||||
hasSharedDeviceSignals: boolean
|
||||
}
|
||||
|
||||
export function parseStatusFromString(raw: string) {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import {useCallback, useState} from 'react'
|
||||
import type * as AgeRange from 'expo-age-range'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {type DialogControlProps} from '#/components/Dialog'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {getDeviceSignals, setDeviceSignalsForRegion} from '#/ageAssurance/data'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
|
||||
import {
|
||||
getAgeAssuranceDataFromDeviceSignals,
|
||||
useAgeAssuranceRegionConfig,
|
||||
} from '#/ageAssurance/util'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
|
||||
/**
|
||||
* Shared "Verify now" flow for the age assurance surfaces (the no-access screen
|
||||
* and the account settings card). Handles the device-first verification path
|
||||
* with a KWS fallback, keeping the two entry points in sync.
|
||||
*
|
||||
* The caller owns the KWS dialog (`AgeAssuranceInitDialog`) and passes its
|
||||
* `initDialogControl` here so we can open it for the fallback / opt-in cases.
|
||||
*
|
||||
* Returns:
|
||||
* - `onPressVerify`: the button handler. In device-verification regions it
|
||||
* prompts the OS age API, persists a sufficient result (region-bound), and
|
||||
* toasts the outcome; otherwise (or on any missing/failed device response) it
|
||||
* opens the KWS dialog.
|
||||
* - `openInitDialog`: opens the KWS dialog directly (for the inline "use KWS"
|
||||
* link), emitting the same metric as the fallback path.
|
||||
* - `isVerifying`: true while the OS age prompt is up, for a button
|
||||
* loading state.
|
||||
* - `verifyCta`: the localized button label for the current mode.
|
||||
*/
|
||||
export function useAgeAssuranceVerificationFlow({
|
||||
initDialogControl,
|
||||
}: {
|
||||
initDialogControl: DialogControlProps
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const {currentAccount} = useSession()
|
||||
const region = useAgeAssuranceRegionConfig()
|
||||
const aa = useAgeAssurance()
|
||||
const hasInitiated = !!aa.state.lastInitiatedAt
|
||||
const allowsDeviceVerification = region && aa.flags.allowsDeviceVerification
|
||||
|
||||
const [isVerifying, setIsVerifying] = useState(false)
|
||||
/*
|
||||
* In-memory only, and scoped to this hook instance: each surface using this
|
||||
* hook tracks its own failure, and a remount (or another surface) starts
|
||||
* fresh and will offer the device flow again. That's intentional for now -
|
||||
* it's a soft "don't immediately re-offer" signal, not a persisted judgment
|
||||
* that the device can't provide signals.
|
||||
*/
|
||||
const [deviceSignalsFailed, setDeviceSignalsFailed] = useState(false)
|
||||
|
||||
const verifyCta =
|
||||
allowsDeviceVerification && !deviceSignalsFailed
|
||||
? l`Share age range`
|
||||
: hasInitiated
|
||||
? l`Verify again`
|
||||
: l`Verify now`
|
||||
|
||||
const openInitDialog = useCallback(() => {
|
||||
initDialogControl.open()
|
||||
ax.metric('ageAssurance:initDialogOpen', {
|
||||
hasInitiatedPreviously: hasInitiated,
|
||||
})
|
||||
}, [initDialogControl, ax, hasInitiated])
|
||||
|
||||
const onPressVerify = useCallback(async () => {
|
||||
const did = currentAccount?.did
|
||||
|
||||
// Just for typescript, these surfaces aren't shown without a logged in user
|
||||
if (!did) return
|
||||
|
||||
/*
|
||||
* In regions that permit on-device verification, try the native age API
|
||||
* first. We tag the result with the current region (device assurance is
|
||||
* region-bound — a TX grant only counts in TX) and, if it's sufficient,
|
||||
* persist it client-side so the AA state recompute lifts the gate.
|
||||
*
|
||||
* Once the OS returns a response we stay on the device path and report the
|
||||
* outcome via a toast (sufficient, under-age, or no usable data) rather than
|
||||
* silently falling back — users can still opt into KWS via the inline link.
|
||||
* We only fall through to the KWS dialog below when the device can't give us
|
||||
* a response at all: `getDeviceSignals` handles its own errors and returns
|
||||
* undefined (e.g. on web or failure).
|
||||
*/
|
||||
if (allowsDeviceVerification && !deviceSignalsFailed) {
|
||||
// Show a loading state while the OS age prompt is up.
|
||||
setIsVerifying(true)
|
||||
let signals: AgeRange.AgeRangeResponse | undefined
|
||||
try {
|
||||
signals = await getDeviceSignals()
|
||||
} finally {
|
||||
setIsVerifying(false)
|
||||
}
|
||||
if (signals) {
|
||||
const {assuredAge} = getAgeAssuranceDataFromDeviceSignals(
|
||||
region,
|
||||
signals,
|
||||
)
|
||||
if (assuredAge !== undefined) {
|
||||
// Persist (keyed by this region) so the AA state recomputes from the
|
||||
// cache write. Recompute here too so we can react to the outcome: a
|
||||
// sufficient age lifts the gate (nothing more to do), but the device
|
||||
// may report an age below the region's threshold, in which case
|
||||
// access stays `none` and we tell the user.
|
||||
setDeviceSignalsForRegion({did, region, signals})
|
||||
const {state} = unsafeGetAndComputeAgeAssurance({did})
|
||||
if (state.access === aa.Access.None) {
|
||||
Toast.show(
|
||||
l`We're sorry, but based on the data shared by your device, you are not old enough to access Bluesky.`,
|
||||
{type: 'info'},
|
||||
)
|
||||
} else if (state.access === aa.Access.Unknown) {
|
||||
Toast.show(
|
||||
l`Hmm, it seems we weren't able to compute your level of access. Please try again.`,
|
||||
{type: 'warning'},
|
||||
)
|
||||
} else if (state.access === aa.Access.Safe) {
|
||||
Toast.show(
|
||||
l`You're all set! However, certain features may still be restricted until you're able to verify you're an adult.`,
|
||||
{
|
||||
type: 'success',
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Toast.show(l`You're all set!`, {
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
// We got a device response but it carried no usable age information.
|
||||
Toast.show(
|
||||
l`Hmm, it seems your device was unable to share age information with us.`,
|
||||
{type: 'warning'},
|
||||
)
|
||||
setDeviceSignalsFailed(true)
|
||||
return
|
||||
}
|
||||
logger.debug(
|
||||
`onPressVerify: no device signals available (web/error), falling back to KWS`,
|
||||
)
|
||||
}
|
||||
|
||||
openInitDialog()
|
||||
}, [
|
||||
region,
|
||||
currentAccount?.did,
|
||||
openInitDialog,
|
||||
allowsDeviceVerification,
|
||||
aa,
|
||||
l,
|
||||
deviceSignalsFailed,
|
||||
setDeviceSignalsFailed,
|
||||
])
|
||||
|
||||
return {
|
||||
onPressVerify,
|
||||
openInitDialog,
|
||||
isVerifying,
|
||||
verifyCta,
|
||||
deviceSignalsFailed,
|
||||
}
|
||||
}
|
||||
+238
-13
@@ -1,13 +1,20 @@
|
||||
import {useMemo} from 'react'
|
||||
import type * as AgeRange from 'expo-age-range'
|
||||
import {
|
||||
type AppBskyAgeassuranceDefs,
|
||||
AppBskyAgeassuranceDefs,
|
||||
computeAgeAssuranceRegionAccess,
|
||||
getAgeAssuranceRegionConfig,
|
||||
type ModerationPrefs,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {regionName} from '#/locale/helpers'
|
||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const'
|
||||
import {FALLBACK_REGION_CONFIG, MIN_ACCESS_AGE} from '#/ageAssurance/const'
|
||||
import {
|
||||
DEVICE_SIGNALS_SUPPORTED,
|
||||
FALLBACK_REGION_CONFIG,
|
||||
MIN_ACCESS_AGE,
|
||||
} from '#/ageAssurance/const'
|
||||
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
|
||||
import {
|
||||
AgeAssuranceAccess,
|
||||
@@ -16,24 +23,213 @@ import {
|
||||
type AgeAssuranceState,
|
||||
} from '#/ageAssurance/types'
|
||||
import {type Geolocation, useGeolocation} from '#/geolocation'
|
||||
import {USRegionNameToRegionCode} from '#/geolocation/util'
|
||||
|
||||
/**
|
||||
* Resolves a geolocation to its matched age assurance region config, or
|
||||
* undefined when the geolocation matches no AA region.
|
||||
*
|
||||
* This is the single source of truth for geolocation -> region resolution.
|
||||
* Device signals are written and read back under a key derived from the
|
||||
* matched region (see `createRegionKey`), so every site that resolves a region
|
||||
* for that purpose MUST go through this helper - independent re-implementations
|
||||
* risk desyncing the write and read keys and silently losing grants.
|
||||
*/
|
||||
export function getAgeAssuranceRegionConfigForGeolocation(
|
||||
config: AppBskyAgeassuranceDefs.Config,
|
||||
geolocation: Geolocation,
|
||||
): AppBskyAgeassuranceDefs.ConfigRegion | undefined {
|
||||
return getAgeAssuranceRegionConfig(config, {
|
||||
countryCode: geolocation.countryCode ?? '',
|
||||
regionCode: geolocation.regionCode,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get age assurance region config based on geolocation, with fallback to
|
||||
* app defaults if no region config is found.
|
||||
*
|
||||
* See {@link getAgeAssuranceRegionConfig} for the generic option, which can
|
||||
* return undefined if the geolocation does not match any AA region.
|
||||
* See {@link getAgeAssuranceRegionConfigForGeolocation} for the generic option,
|
||||
* which can return undefined if the geolocation does not match any AA region.
|
||||
*/
|
||||
export function getAgeAssuranceRegionConfigWithFallback(
|
||||
config: AppBskyAgeassuranceDefs.Config,
|
||||
geolocation: Geolocation,
|
||||
): AppBskyAgeassuranceDefs.ConfigRegion {
|
||||
const region = getAgeAssuranceRegionConfig(config, {
|
||||
countryCode: geolocation.countryCode ?? '',
|
||||
regionCode: geolocation.regionCode,
|
||||
})
|
||||
return (
|
||||
getAgeAssuranceRegionConfigForGeolocation(config, geolocation) ||
|
||||
FALLBACK_REGION_CONFIG
|
||||
)
|
||||
}
|
||||
|
||||
return region || FALLBACK_REGION_CONFIG
|
||||
/**
|
||||
* Returns the verification methods permitted for a region *in addition to* the
|
||||
* always-supported KWS flow. Empty when the region doesn't specify any (the
|
||||
* historical KWS-only behavior).
|
||||
*/
|
||||
export function getRegionAdditionalVerificationMethods(
|
||||
region: AppBskyAgeassuranceDefs.ConfigRegion,
|
||||
): NonNullable<
|
||||
AppBskyAgeassuranceDefs.ConfigRegion['additionalVerificationMethods']
|
||||
> {
|
||||
return region.additionalVerificationMethods ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a region permits satisfying age assurance via the native on-device
|
||||
* age APIs (Apple Declared Age Range / Google Play Age Signals).
|
||||
*/
|
||||
export function regionAllowsDeviceVerification(
|
||||
region: AppBskyAgeassuranceDefs.ConfigRegion,
|
||||
): boolean {
|
||||
return getRegionAdditionalVerificationMethods(region).includes('device')
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the cache key for a region's device signals — a `country[-region]`
|
||||
* string (e.g. `US-TX` or `GB`). This is the key under which on-device
|
||||
* assurance is stored and read back, which is what binds a grant to its capture
|
||||
* region.
|
||||
*/
|
||||
export function createRegionKey(region: {
|
||||
countryCode: string
|
||||
regionCode?: string
|
||||
}): string {
|
||||
return region.regionCode
|
||||
? `${region.countryCode}-${region.regionCode}`
|
||||
: region.countryCode
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives age assurance data from native device signals for the given region,
|
||||
* but only when the region permits device verification. The signals are
|
||||
* expected to already be resolved to the user's current region (see
|
||||
* `getDeviceSignalsFromCacheForRegion`), so a grant captured in another region
|
||||
* won't reach here.
|
||||
*
|
||||
* The OS-provided `lowerBound` is the minimum age the platform will attest to,
|
||||
* which maps onto the `assuredAge` input of the rule engine (i.e.
|
||||
* `IfAssuredOverAge`/`IfAssuredUnderAge` rules).
|
||||
*
|
||||
* Always returns an object (so callers can spread it unconditionally); fields
|
||||
* are populated only when device verification applies and the OS provided
|
||||
* usable data.
|
||||
*/
|
||||
export function getAgeAssuranceDataFromDeviceSignals(
|
||||
region: AppBskyAgeassuranceDefs.ConfigRegion,
|
||||
deviceSignals: AgeRange.AgeRangeResponse | undefined,
|
||||
): {
|
||||
assuredAge?: number
|
||||
} {
|
||||
if (!regionAllowsDeviceVerification(region)) return {}
|
||||
const lowerBound = deviceSignals?.lowerBound
|
||||
return {
|
||||
assuredAge: typeof lowerBound === 'number' ? lowerBound : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks access levels from most to least restrictive so we can compare two
|
||||
* outcomes. Higher number = more access.
|
||||
*/
|
||||
const ACCESS_RANK: Record<string, number> = {
|
||||
[AgeAssuranceAccess.None]: 0,
|
||||
[AgeAssuranceAccess.Safe]: 1,
|
||||
[AgeAssuranceAccess.Full]: 2,
|
||||
// `unknown` isn't a real granted level; treat it as the floor.
|
||||
[AgeAssuranceAccess.Unknown]: -1,
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether correcting the user's declared age (i.e. updating their birthdate)
|
||||
* could meaningfully improve their standing in the current region.
|
||||
*
|
||||
* There are two ways a birthdate update can help:
|
||||
*
|
||||
* 1. Raising the rule-engine access level. Some regions grant `safe`/`full` off
|
||||
* a sufficient *declared* age, so a user whose birthdate is wrong (too young)
|
||||
* can unlock more by correcting it. Other regions gate higher access purely
|
||||
* on an *assured* age (or account date), where a declared age changes
|
||||
* nothing.
|
||||
* 2. Crossing the region's `minAccessAge`. Below it the user is hard-blocked
|
||||
* with no verify path (see `isOverRegionMinAccessAge` gating in the
|
||||
* NoAccessScreen); crossing it unlocks the verify flow, which is itself a
|
||||
* path to more access even when the rule-engine level would still be `none`.
|
||||
*
|
||||
* We answer by simulating the real rule engine: hold `accountCreatedAt` and
|
||||
* the device-derived `assuredAge` fixed and re-run access for a set of
|
||||
* candidate declared ages drawn from the region's declared-age rule thresholds
|
||||
* and its `minAccessAge`. If any candidate yields strictly more access, or
|
||||
* crosses `minAccessAge` when the current declared age doesn't, a birthdate
|
||||
* update could help. Simulating rather than statically inspecting rules means
|
||||
* first-match precedence (e.g. an assured/account rule pre-empting a declared
|
||||
* rule) is handled correctly for free.
|
||||
*
|
||||
* `deviceSignals` must be passed so the baseline matches the user's *real*
|
||||
* computed access (see `computeAgeAssuranceState`, which derives `assuredAge`
|
||||
* the same way). Without it, a device-assured user's baseline would compute
|
||||
* lower than their actual access and we'd claim a birthdate update helps when
|
||||
* it can't - and account-date rules would flip on when they're really skipped
|
||||
* (the engine bypasses them whenever `assuredAge` is set).
|
||||
*/
|
||||
export function canBirthdateUpdateIncreaseAccess({
|
||||
region,
|
||||
metadata,
|
||||
deviceSignals,
|
||||
}: {
|
||||
region: AppBskyAgeassuranceDefs.ConfigRegion
|
||||
metadata?: AgeAssuranceMetadata
|
||||
deviceSignals?: AgeRange.AgeRangeResponse
|
||||
}): boolean {
|
||||
const {assuredAge} = getAgeAssuranceDataFromDeviceSignals(
|
||||
region,
|
||||
deviceSignals,
|
||||
)
|
||||
const baseline = computeAgeAssuranceRegionAccess(region, {
|
||||
accountCreatedAt: metadata?.accountCreatedAt,
|
||||
declaredAge: metadata?.declaredAge,
|
||||
assuredAge,
|
||||
})
|
||||
const baselineRank =
|
||||
ACCESS_RANK[baseline?.access ?? AgeAssuranceAccess.Unknown]
|
||||
const baselineOverMin =
|
||||
metadata?.declaredAge !== undefined &&
|
||||
metadata.declaredAge >= region.minAccessAge
|
||||
|
||||
/*
|
||||
* Candidate declared ages to probe: each declared-age rule's threshold and
|
||||
* the region's `minAccessAge`, plus one below each (to cover
|
||||
* `IfDeclaredUnderAge` and the min-age boundary). Anything a birthdate edit
|
||||
* could achieve is captured by crossing one of these thresholds, so we don't
|
||||
* need to sweep every integer.
|
||||
*/
|
||||
const thresholds = new Set<number>([region.minAccessAge])
|
||||
for (const rule of region.rules) {
|
||||
if (
|
||||
AppBskyAgeassuranceDefs.isConfigRegionRuleIfDeclaredOverAge(rule) ||
|
||||
AppBskyAgeassuranceDefs.isConfigRegionRuleIfDeclaredUnderAge(rule)
|
||||
) {
|
||||
thresholds.add(rule.age)
|
||||
}
|
||||
}
|
||||
const candidates = new Set<number>()
|
||||
for (const threshold of thresholds) {
|
||||
candidates.add(threshold)
|
||||
candidates.add(Math.max(0, threshold - 1))
|
||||
}
|
||||
|
||||
for (const declaredAge of candidates) {
|
||||
const result = computeAgeAssuranceRegionAccess(region, {
|
||||
accountCreatedAt: metadata?.accountCreatedAt,
|
||||
declaredAge,
|
||||
assuredAge,
|
||||
})
|
||||
const rank = ACCESS_RANK[result?.access ?? AgeAssuranceAccess.Unknown]
|
||||
const overMin = declaredAge >= region.minAccessAge
|
||||
if (rank > baselineRank || (overMin && !baselineOverMin)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,10 +243,7 @@ export function useAgeAssuranceRegionConfig() {
|
||||
return useMemo(() => {
|
||||
if (!config) return
|
||||
// use generic helper, we want to potentially return undefined
|
||||
return getAgeAssuranceRegionConfig(config, {
|
||||
countryCode: geolocation.countryCode ?? '',
|
||||
regionCode: geolocation.regionCode,
|
||||
})
|
||||
return getAgeAssuranceRegionConfigForGeolocation(config, geolocation)
|
||||
}, [config, geolocation])
|
||||
}
|
||||
|
||||
@@ -101,10 +294,12 @@ export function computeAgeAssuranceFlags({
|
||||
state,
|
||||
regionConfig,
|
||||
metadata,
|
||||
deviceSignals,
|
||||
}: {
|
||||
state: AgeAssuranceState
|
||||
regionConfig: AppBskyAgeassuranceDefs.ConfigRegion
|
||||
metadata?: AgeAssuranceMetadata
|
||||
deviceSignals?: AgeRange.AgeRangeResponse
|
||||
}): AgeAssuranceFlags {
|
||||
const isAgeRestricted = state.access !== AgeAssuranceAccess.Full
|
||||
const chatDisabled = isAgeRestricted
|
||||
@@ -120,14 +315,44 @@ export function computeAgeAssuranceFlags({
|
||||
: false
|
||||
const adultContentDisabled =
|
||||
state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge
|
||||
const allowsDeviceVerification =
|
||||
DEVICE_SIGNALS_SUPPORTED && regionAllowsDeviceVerification(regionConfig)
|
||||
const hasSharedDeviceSignals = deviceSignals?.lowerBound !== undefined
|
||||
|
||||
return {
|
||||
isAgeRestricted,
|
||||
adultContentDisabled,
|
||||
chatDisabled,
|
||||
groupChatDisabled,
|
||||
hasDeclaredAge: metadata?.declaredAge !== undefined,
|
||||
isDeclaredUnderAdultAge,
|
||||
isOverRegionMinAccessAge,
|
||||
isOverAppMinAccessAge,
|
||||
allowsDeviceVerification,
|
||||
hasSharedDeviceSignals,
|
||||
}
|
||||
}
|
||||
|
||||
const USRegionCodeToRegionName: {[regionCode: string]: string} =
|
||||
Object.fromEntries(
|
||||
Object.entries(USRegionNameToRegionCode).map(([name, code]) => [
|
||||
code,
|
||||
name,
|
||||
]),
|
||||
)
|
||||
export function createGeolocationString(
|
||||
geolocation: Geolocation,
|
||||
appLang: string,
|
||||
): string | undefined {
|
||||
const {countryCode, regionCode} = geolocation
|
||||
if (!countryCode) return undefined
|
||||
const country = regionName(countryCode, appLang)
|
||||
// If `regionName` couldn't resolve a real name and fell through to the raw
|
||||
// code, we'd rather show nothing than a bare ISO code in the prose.
|
||||
if (country === countryCode) return undefined
|
||||
if (regionCode && countryCode === 'US') {
|
||||
const state = USRegionCodeToRegionName[regionCode]
|
||||
if (state) return `${state}, ${country}`
|
||||
}
|
||||
return country
|
||||
}
|
||||
|
||||
@@ -12,10 +12,7 @@ export enum Features {
|
||||
GroupChatsDisable = 'group_chats:disable',
|
||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
|
||||
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
|
||||
SearchV2Enable = 'search_v2:enable',
|
||||
AdvancedSearchV2Enable = 'advanced_search_v2:enable',
|
||||
|
||||
AATest = 'aa-test',
|
||||
}
|
||||
|
||||
@@ -1404,6 +1404,8 @@ export type Events = {
|
||||
uploadId: string
|
||||
engine: string
|
||||
errorClass: string
|
||||
/** Truncated to 256 chars */
|
||||
errorMessage: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:uploadStarted': {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import {useCallback} from 'react'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -14,26 +13,31 @@ export function LanguageSelect({
|
||||
value: l.code2,
|
||||
})),
|
||||
label,
|
||||
disabledBlueskySupportedLanguageSanitization = false,
|
||||
}: {
|
||||
value?: string
|
||||
onChange: (value: string) => void
|
||||
items?: {label: string; value: string}[]
|
||||
label?: string
|
||||
disabledBlueskySupportedLanguageSanitization?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const selectValue =
|
||||
value && !disabledBlueskySupportedLanguageSanitization
|
||||
? sanitizeAppLanguageSetting(value)
|
||||
: value
|
||||
|
||||
const handleOnChange = useCallback(
|
||||
(value: string) => {
|
||||
if (!value) return
|
||||
onChange(sanitizeAppLanguageSetting(value))
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
const handleOnChange = (value: string) => {
|
||||
if (!value) return
|
||||
onChange(
|
||||
disabledBlueskySupportedLanguageSanitization
|
||||
? value
|
||||
: sanitizeAppLanguageSetting(value),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Select.Root
|
||||
value={value ? sanitizeAppLanguageSetting(value) : undefined}
|
||||
onValueChange={handleOnChange}>
|
||||
<Select.Root value={selectValue} onValueChange={handleOnChange}>
|
||||
<Select.Trigger label={_(msg`Select language`)}>
|
||||
<Select.ValueText placeholder={_(msg`Select language`)} />
|
||||
<Select.Icon />
|
||||
|
||||
@@ -2,7 +2,6 @@ import {View} from 'react-native'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||
import {regionName} from '#/locale/helpers'
|
||||
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
|
||||
@@ -13,49 +12,23 @@ import {
|
||||
useDialogControl,
|
||||
} from '#/components/ageAssurance/AgeAssuranceInitDialog'
|
||||
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
|
||||
import {createStaticClick, InlineLinkText} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {DeviceSignalsNotice} from '#/ageAssurance/components/DeviceSignalsNotice'
|
||||
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
|
||||
import {useAgeAssuranceVerificationFlow} from '#/ageAssurance/useVerificationFlow'
|
||||
import {createGeolocationString} from '#/ageAssurance/util'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
import {
|
||||
type Geolocation,
|
||||
useDeviceGeolocationApi,
|
||||
useGeolocation,
|
||||
} from '#/geolocation'
|
||||
import {USRegionNameToRegionCode} from '#/geolocation/util'
|
||||
import {device, useStorage} from '#/storage'
|
||||
|
||||
const USRegionCodeToRegionName: {[regionCode: string]: string} =
|
||||
Object.fromEntries(
|
||||
Object.entries(USRegionNameToRegionCode).map(([name, code]) => [
|
||||
code,
|
||||
name,
|
||||
]),
|
||||
)
|
||||
|
||||
function formatRegion(
|
||||
geolocation: Geolocation,
|
||||
appLang: string,
|
||||
): string | undefined {
|
||||
const {countryCode, regionCode} = geolocation
|
||||
if (!countryCode) return undefined
|
||||
const country = regionName(countryCode, appLang)
|
||||
// If `regionName` couldn't resolve a real name and fell through to the raw
|
||||
// code, we'd rather show nothing than a bare ISO code in the prose.
|
||||
if (country === countryCode) return undefined
|
||||
if (regionCode && countryCode === 'US') {
|
||||
const state = USRegionCodeToRegionName[regionCode]
|
||||
if (state) return `${state}, ${country}`
|
||||
}
|
||||
return country
|
||||
}
|
||||
import {useDeviceGeolocationApi, useGeolocation} from '#/geolocation'
|
||||
|
||||
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
|
||||
const aa = useAgeAssurance()
|
||||
@@ -91,6 +64,15 @@ function Inner({style}: ViewStyleProp & {}) {
|
||||
const diff = lastInitiatedAt
|
||||
? dateDiff(lastInitiatedAt, new Date(), 'down')
|
||||
: null
|
||||
const {
|
||||
onPressVerify,
|
||||
openInitDialog,
|
||||
isVerifying,
|
||||
verifyCta,
|
||||
deviceSignalsFailed,
|
||||
} = useAgeAssuranceVerificationFlow({initDialogControl: control})
|
||||
const useDeviceSignals =
|
||||
aa.flags.allowsDeviceVerification && !deviceSignalsFailed
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -169,26 +151,22 @@ function Inner({style}: ViewStyleProp & {}) {
|
||||
: [a.gap_md],
|
||||
]}>
|
||||
<Button
|
||||
label={l`Verify now`}
|
||||
label={verifyCta}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color={hasInitiated ? 'secondary' : 'primary'}
|
||||
onPress={() => {
|
||||
control.open()
|
||||
ax.metric('ageAssurance:initDialogOpen', {
|
||||
hasInitiatedPreviously: hasInitiated,
|
||||
})
|
||||
}}>
|
||||
<ButtonText>
|
||||
{hasInitiated ? (
|
||||
<Trans>Verify again</Trans>
|
||||
) : (
|
||||
<Trans>Verify now</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
color={
|
||||
hasInitiated || aa.flags.hasSharedDeviceSignals
|
||||
? 'secondary'
|
||||
: 'primary'
|
||||
}
|
||||
disabled={isVerifying}
|
||||
onPress={() => void onPressVerify()}>
|
||||
<ButtonIcon icon={isVerifying ? Loader : ShieldIcon} />
|
||||
<ButtonText>{verifyCta}</ButtonText>
|
||||
</Button>
|
||||
|
||||
{lastInitiatedAt && timeAgo && diff ? (
|
||||
{useDeviceSignals ? (
|
||||
<DeviceSignalsNotice onPressKws={openInitDialog} />
|
||||
) : lastInitiatedAt && timeAgo && diff ? (
|
||||
<Text
|
||||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
|
||||
title={i18n.date(lastInitiatedAt, {
|
||||
@@ -220,13 +198,12 @@ function RegionNotice() {
|
||||
const {t: l, i18n} = useLingui()
|
||||
const aa = useAgeAssurance()
|
||||
const geolocation = useGeolocation()
|
||||
const [deviceGeolocation] = useStorage(device, ['deviceGeolocation'])
|
||||
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
||||
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
|
||||
const locationControl = Dialog.useDialogControl()
|
||||
|
||||
const region = formatRegion(geolocation, i18n.locale)
|
||||
const isGPS = !!deviceGeolocation?.countryCode && IS_NATIVE
|
||||
const region = createGeolocationString(geolocation, i18n.locale)
|
||||
const isGPS = !!geolocation.deviceGeolocation?.countryCode && IS_NATIVE
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -258,8 +235,7 @@ function RegionNotice() {
|
||||
{isGPS ? (
|
||||
<Trans>
|
||||
Based on your device's location, we think you're in{' '}
|
||||
<Text style={[a.text_sm, a.font_bold]}>{region}</Text>. This
|
||||
estimate may be inaccurate if you're using a VPN.
|
||||
<Text style={[a.text_sm, a.font_bold]}>{region}</Text>.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
@@ -268,20 +244,20 @@ function RegionNotice() {
|
||||
estimate may be inaccurate if you're using a VPN.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{IS_NATIVE && (
|
||||
<Text style={[a.text_sm, a.leading_snug]}>
|
||||
<Trans>
|
||||
<InlineLinkText
|
||||
label={l`Update your location`}
|
||||
{...createStaticClick(() => {
|
||||
locationControl.open()
|
||||
})}>
|
||||
Tap here to update your location with GPS.
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
{IS_NATIVE && (
|
||||
<Text style={[a.text_sm, a.leading_snug]}>
|
||||
{' '}
|
||||
<Trans>
|
||||
<InlineLinkText
|
||||
label={l`Update your location`}
|
||||
{...createStaticClick(() => {
|
||||
locationControl.open()
|
||||
})}>
|
||||
Tap here to update your location with GPS.
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -296,6 +296,7 @@ function Inner() {
|
||||
setLanguageError(false)
|
||||
}}
|
||||
items={KWS_SUPPORTED_LANGS}
|
||||
disabledBlueskySupportedLanguageSanitization
|
||||
/>
|
||||
|
||||
{languageError && (
|
||||
@@ -342,11 +343,12 @@ function convertToKWSSupportedLanguage(
|
||||
// pt-PT is pt (pt-BR is supported independently)
|
||||
case 'pt-PT':
|
||||
return 'pt'
|
||||
// only chinese (simplified) is supported, map all chinese variants
|
||||
// map Chinese variants to simplified or traditional
|
||||
case 'zh-Hans-CN':
|
||||
return 'zh-Hans'
|
||||
case 'zh-Hant-HK':
|
||||
case 'zh-Hant-TW':
|
||||
return 'zh-Hans'
|
||||
return 'zh-Hant'
|
||||
default:
|
||||
// try and map directly - if undefined, they will have to pick from the dropdown
|
||||
return KWS_SUPPORTED_LANGS.find(v => v.value === appLanguage)?.value
|
||||
|
||||
@@ -7,21 +7,36 @@ export const urls = {
|
||||
export const KWS_SUPPORTED_LANGS = [
|
||||
{value: 'en', label: 'English'},
|
||||
{value: 'ar', label: 'العربية'},
|
||||
{value: 'zh-Hans', label: '简体中文'},
|
||||
{value: 'cs', label: 'Čeština'},
|
||||
{value: 'nl', label: 'Nederlands'},
|
||||
{value: 'tl', label: 'Filipino'},
|
||||
{value: 'fr', label: 'Français'},
|
||||
{value: 'bg', label: 'български'},
|
||||
{value: 'cs', label: 'čeština'},
|
||||
{value: 'da', label: 'dansk'},
|
||||
{value: 'de', label: 'Deutsch'},
|
||||
{value: 'el', label: 'Ελληνικά'},
|
||||
{value: 'es', label: 'español'},
|
||||
{value: 'es-MX', label: 'español de México'},
|
||||
{value: 'fi', label: 'suomi'},
|
||||
{value: 'fil', label: 'Filipino'},
|
||||
{value: 'fr', label: 'français'},
|
||||
{value: 'hi', label: 'हिंदी'},
|
||||
{value: 'hu', label: 'magyar'},
|
||||
{value: 'id', label: 'Bahasa Indonesia'},
|
||||
{value: 'it', label: 'Italiano'},
|
||||
{value: 'it', label: 'italiano'},
|
||||
{value: 'ja', label: '日本語'},
|
||||
{value: 'ko', label: '한국어'},
|
||||
{value: 'pt', label: 'Português'},
|
||||
{value: 'pt-BR', label: 'Português (Brasil)'},
|
||||
{value: 'ru', label: 'Русский'},
|
||||
{value: 'es', label: 'Español'},
|
||||
{value: 'tr', label: 'Türkçe'},
|
||||
{value: 'ms', label: 'Bahasa Melayu'},
|
||||
{value: 'nl', label: 'Nederlands'},
|
||||
{value: 'no', label: 'norsk'},
|
||||
{value: 'pl', label: 'polski'},
|
||||
{value: 'pt', label: 'português'},
|
||||
{value: 'pt-BR', label: 'português do Brasil'},
|
||||
{value: 'ro', label: 'română'},
|
||||
{value: 'ru', label: 'русский'},
|
||||
{value: 'sv', label: 'svenska'},
|
||||
{value: 'th', label: 'ภาษาไทย'},
|
||||
{value: 'tl', label: 'Tagalog'},
|
||||
{value: 'tr', label: 'Türkçe'},
|
||||
{value: 'uk', label: 'українська'},
|
||||
{value: 'vi', label: 'Tiếng Việt'},
|
||||
{value: 'zh-Hans', label: '简体中文'},
|
||||
{value: 'zh-Hant', label: '繁體中文'},
|
||||
]
|
||||
|
||||
@@ -131,11 +131,11 @@ function DialogInner({
|
||||
style={web({maxWidth: 500})}>
|
||||
<View style={[a.relative, a.gap_md, a.w_full]}>
|
||||
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Choose your account provider</Trans>
|
||||
<Trans>Choose your hosting provider</Trans>
|
||||
</Text>
|
||||
<SegmentedControl.Root
|
||||
type="tabs"
|
||||
label={_(msg`Account provider`)}
|
||||
label={_(msg`Hosting provider`)}
|
||||
value={fixedOption}
|
||||
onChange={setFixedOption}>
|
||||
<SegmentedControl.Item
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function FormError({error}: {error?: string}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (!error) return null
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{backgroundColor: t.palette.negative_400},
|
||||
a.flex_row,
|
||||
a.rounded_sm,
|
||||
a.p_md,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Warning fill={t.palette.white} size="md" />
|
||||
<View style={[a.flex_1]}>
|
||||
<Text
|
||||
style={[{color: t.palette.white}, a.font_semi_bold, a.leading_snug]}>
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -47,6 +47,10 @@ const Context = createContext<{
|
||||
})
|
||||
Context.displayName = 'TextFieldContext'
|
||||
|
||||
export function useTextFieldContext() {
|
||||
return useContext(Context)
|
||||
}
|
||||
|
||||
export type RootProps = React.PropsWithChildren<
|
||||
{isInvalid?: boolean} & TextStyleProp
|
||||
>
|
||||
@@ -203,7 +207,7 @@ export function createInput(Component: typeof TextInput) {
|
||||
|
||||
const refs = mergeRefs([ctx.inputRef, inputRef!].filter(Boolean))
|
||||
|
||||
const flattened = StyleSheet.flatten([
|
||||
const flattened = StyleSheet.flatten<TextStyle>([
|
||||
a.relative,
|
||||
a.z_20,
|
||||
a.flex_1,
|
||||
|
||||
@@ -274,9 +274,12 @@ export function Gallery({
|
||||
aria-label={l`Image gallery, ${images.length} images`}
|
||||
horizontal
|
||||
pagingEnabled={false}
|
||||
// Disable Android's stretch overscroll, which can leave the carousel
|
||||
// settled just off the left edge instead of aligned to x = 0
|
||||
overScrollMode={IS_ANDROID ? 'never' : 'auto'}
|
||||
// We use snapToOffsets on Android to ensure that if the stretch overscroll
|
||||
// leaves the carousel slightly off the left edge, it snaps back to 0.
|
||||
// This fixes the offset bug without disabling the native stretch behavior.
|
||||
overScrollMode="auto"
|
||||
snapToOffsets={IS_ANDROID ? [0] : undefined}
|
||||
snapToEnd={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
directionalLockEnabled
|
||||
nestedScrollEnabled
|
||||
|
||||
Vendored
+16
-6
@@ -5,12 +5,23 @@ import {BUNDLE_IDENTIFIER, IS_TESTFLIGHT, RELEASE_VERSION} from '#/env/common'
|
||||
|
||||
export * from '#/env/common'
|
||||
|
||||
// for some reason Platform.OS === 'ios' AND Platform.Version is undefined in our CI unit tests -sfn
|
||||
const iOSMajorVersion =
|
||||
/**
|
||||
* The major version number of the current iOS device (e.g. `26` for iOS 26.x),
|
||||
* or `0` on non-iOS platforms.
|
||||
*
|
||||
* Note: for some reason Platform.OS === 'ios' AND Platform.Version is undefined
|
||||
* in our CI unit tests -sfn
|
||||
*/
|
||||
export const IOS_MAJOR_VERSION: number =
|
||||
Platform.OS === 'ios' && typeof Platform.Version === 'string'
|
||||
? parseInt(Platform.Version.split('.')[0], 10)
|
||||
: 0
|
||||
const androidPlatformVersion =
|
||||
/**
|
||||
* The Android API level of the current device (e.g. `23` for Android 6.0), or
|
||||
* `0` on non-Android platforms. Note this is the API level, not the marketing
|
||||
* version.
|
||||
*/
|
||||
export const ANDROID_API_LEVEL: number =
|
||||
Platform.OS === 'android' && typeof Platform.Version === 'number'
|
||||
? Platform.Version
|
||||
: 0
|
||||
@@ -52,8 +63,7 @@ export const IS_WEB_FIREFOX: boolean = false
|
||||
*/
|
||||
export const IS_HIGH_DPI: boolean = true
|
||||
// ideally we'd use isLiquidGlassAvailable() from expo-glass-effect but checking iOS version is good enough for now
|
||||
export const IS_LIQUID_GLASS: boolean = iOSMajorVersion >= 26
|
||||
export const IS_LIQUID_GLASS: boolean = IOS_MAJOR_VERSION >= 26
|
||||
// So we can avoid attempting on-device translation when we know it's unsupported.
|
||||
export const IS_TRANSLATION_SUPPORTED: boolean =
|
||||
(IS_IOS && iOSMajorVersion >= 18) ||
|
||||
(IS_ANDROID && androidPlatformVersion > 22)
|
||||
(IS_IOS && IOS_MAJOR_VERSION >= 18) || (IS_ANDROID && ANDROID_API_LEVEL > 22)
|
||||
|
||||
Vendored
+11
@@ -14,6 +14,17 @@ export const APP_VERSION = RELEASE_VERSION
|
||||
*/
|
||||
export const APP_METADATA = `${BUNDLE_IDENTIFIER.slice(0, 7)} (${__DEV__ ? 'dev' : 'prod'})`
|
||||
|
||||
/**
|
||||
* The major version number of the current iOS device, or `0` on non-iOS
|
||||
* platforms (always `0` on web).
|
||||
*/
|
||||
export const IOS_MAJOR_VERSION: number = 0
|
||||
/**
|
||||
* The Android API level of the current device, or `0` on non-Android platforms
|
||||
* (always `0` on web).
|
||||
*/
|
||||
export const ANDROID_API_LEVEL: number = 0
|
||||
|
||||
/**
|
||||
* Platform detection
|
||||
*/
|
||||
|
||||
@@ -5,8 +5,8 @@ import {type Geolocation} from '#/geolocation/types'
|
||||
const localEnabled = false
|
||||
export const enabled = IS_DEV && (localEnabled || aaDebug.geolocation)
|
||||
export const geolocation: Geolocation = aaDebug.geolocation ?? {
|
||||
countryCode: 'US',
|
||||
regionCode: 'TX',
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
}
|
||||
|
||||
const deviceLocalEnabled = false
|
||||
@@ -14,8 +14,8 @@ export const deviceGeolocation: Geolocation | undefined =
|
||||
aaDebug.deviceGeolocation ||
|
||||
(deviceLocalEnabled
|
||||
? {
|
||||
countryCode: 'US',
|
||||
regionCode: 'TX',
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
}
|
||||
: undefined)
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export * from '#/geolocation/types'
|
||||
const GeolocationContext = createContext<Geolocation>({
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
serviceGeolocation: undefined,
|
||||
deviceGeolocation: undefined,
|
||||
})
|
||||
|
||||
const DeviceGeolocationAPIContext = createContext<{
|
||||
@@ -44,7 +46,17 @@ export function Provider({children}: {children: ReactNode}) {
|
||||
'deviceGeolocation',
|
||||
])
|
||||
const geolocation = useMemo(() => {
|
||||
return mergeGeolocations(deviceGeolocation, geolocationService)
|
||||
const geo = mergeGeolocations(deviceGeolocation, geolocationService)
|
||||
// create new objects to avoid cyclical references
|
||||
geo.serviceGeolocation = {
|
||||
countryCode: geolocationService?.countryCode,
|
||||
regionCode: geolocationService?.regionCode,
|
||||
}
|
||||
geo.deviceGeolocation = {
|
||||
countryCode: deviceGeolocation?.countryCode,
|
||||
regionCode: deviceGeolocation?.regionCode,
|
||||
}
|
||||
return geo
|
||||
}, [deviceGeolocation, geolocationService])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export type Geolocation = {
|
||||
countryCode: string | undefined
|
||||
regionCode: string | undefined
|
||||
serviceGeolocation?: Geolocation
|
||||
deviceGeolocation?: Geolocation
|
||||
}
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
import {
|
||||
ALL_FORMATS,
|
||||
type AudioCodec,
|
||||
BlobSource,
|
||||
BufferTarget,
|
||||
canEncodeAudio,
|
||||
canEncodeVideo,
|
||||
Conversion,
|
||||
Input,
|
||||
Mp4OutputFormat,
|
||||
Output,
|
||||
type Input as MediaBunnyInput,
|
||||
type VideoCodec,
|
||||
WebMOutputFormat,
|
||||
} from 'mediabunny'
|
||||
|
||||
import {VIDEO_MAX_SIZE} from '#/lib/constants'
|
||||
@@ -23,6 +14,7 @@ import {
|
||||
COMPRESSION_MIN_SIZE_BYTES,
|
||||
COMPRESSION_TARGET_BITRATE,
|
||||
} from './constants'
|
||||
import {loadMediaBunny} from './mediabunny'
|
||||
import {type CompressedVideo, type ProbedMetadata} from './types'
|
||||
|
||||
// Codecs to try in order of preference
|
||||
@@ -44,8 +36,12 @@ export async function compressVideo(
|
||||
hasWebCodecs: hasWebCodecs(),
|
||||
})
|
||||
|
||||
const response = await fetch(asset.uri)
|
||||
const blob = await response.blob()
|
||||
/*
|
||||
* Prefer the original File over re-fetching the blob URL. A fetch round
|
||||
* trip copies the bytes and fails with a bare TypeError if the URL was
|
||||
* revoked or the read fails - the top web compressFailed error class.
|
||||
*/
|
||||
const blob = asset.file ?? (await (await fetch(asset.uri)).blob())
|
||||
|
||||
const isGif = blob.type === 'image/gif'
|
||||
const hasCodecs = hasWebCodecs()
|
||||
@@ -112,6 +108,7 @@ export async function compressVideo(
|
||||
}
|
||||
|
||||
async function probeWithMediaBunny(blob: Blob): Promise<ProbedMetadata> {
|
||||
const {ALL_FORMATS, BlobSource, Input} = await loadMediaBunny()
|
||||
const input = new Input({source: new BlobSource(blob), formats: ALL_FORMATS})
|
||||
try {
|
||||
const videoTrack = await input.getPrimaryVideoTrack()
|
||||
@@ -153,6 +150,7 @@ async function findEncodableVideoCodec(
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<{codec: VideoCodec; useWebM: boolean} | null> {
|
||||
const {canEncodeVideo} = await loadMediaBunny()
|
||||
for (const codec of VIDEO_CODECS) {
|
||||
const canEncode = await canEncodeVideo(codec, {
|
||||
width,
|
||||
@@ -179,9 +177,10 @@ const AUDIO_CODECS_MP4: AudioCodec[] = ['aac']
|
||||
const AUDIO_CODECS_WEBM: AudioCodec[] = ['opus', 'vorbis']
|
||||
|
||||
async function findEncodableAudioCodec(
|
||||
audioTrack: Awaited<ReturnType<Input['getPrimaryAudioTrack']>>,
|
||||
audioTrack: Awaited<ReturnType<MediaBunnyInput['getPrimaryAudioTrack']>>,
|
||||
useWebM: boolean,
|
||||
): Promise<{codec: AudioCodec} | null> {
|
||||
const {canEncodeAudio} = await loadMediaBunny()
|
||||
if (!audioTrack) {
|
||||
return null
|
||||
}
|
||||
@@ -228,6 +227,17 @@ async function doCompression(
|
||||
): Promise<CompressedVideo> {
|
||||
const {onProgress, signal} = opts
|
||||
|
||||
const {
|
||||
ALL_FORMATS,
|
||||
BlobSource,
|
||||
BufferTarget,
|
||||
Conversion,
|
||||
Input,
|
||||
Mp4OutputFormat,
|
||||
Output,
|
||||
WebMOutputFormat,
|
||||
} = await loadMediaBunny()
|
||||
|
||||
const input = new Input({
|
||||
source: new BlobSource(blob),
|
||||
formats: ALL_FORMATS,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
type MediaBunnyModule = typeof import('mediabunny')
|
||||
|
||||
let promise: Promise<MediaBunnyModule> | undefined
|
||||
|
||||
/**
|
||||
* Lazily loads mediabunny into its own webpack chunk and caches the resulting
|
||||
* promise so the module is fetched at most once. If the import fails (e.g. a
|
||||
* transient chunk-load error), the cached promise is reset so a later call can
|
||||
* retry the load.
|
||||
*/
|
||||
export function loadMediaBunny(): Promise<MediaBunnyModule> {
|
||||
promise ??= import(/* webpackChunkName: "mediabunny" */ 'mediabunny').catch(
|
||||
e => {
|
||||
// reset so a later attempt can retry after a transient chunk-load failure
|
||||
promise = undefined
|
||||
throw e
|
||||
},
|
||||
)
|
||||
return promise
|
||||
}
|
||||
@@ -26,6 +26,11 @@ function errorClass(e: unknown): string {
|
||||
return 'Unknown'
|
||||
}
|
||||
|
||||
function errorMessage(e: unknown): string {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
return message.slice(0, 256)
|
||||
}
|
||||
|
||||
export type VideoTelemetry = {
|
||||
readonly uploadId: string
|
||||
readonly engine: string
|
||||
@@ -208,6 +213,7 @@ export function createVideoTelemetry({
|
||||
uploadId,
|
||||
engine,
|
||||
errorClass: errorClass(e),
|
||||
errorMessage: errorMessage(e),
|
||||
elapsedMs: Date.now() - phaseStartedAt,
|
||||
})
|
||||
endTxn('error')
|
||||
|
||||
+32
-11
@@ -1,3 +1,22 @@
|
||||
import {type Ref, type RefCallback} from 'react'
|
||||
|
||||
/**
|
||||
* Assigns a value to a ref.
|
||||
* @param ref The ref to assign the value to.
|
||||
* @param value The value to assign to the ref.
|
||||
* @returns The ref cleanup callback, if any.
|
||||
*/
|
||||
function assignRef<T>(
|
||||
ref: Ref<T> | undefined | null,
|
||||
value: T | null,
|
||||
): ReturnType<RefCallback<T>> {
|
||||
if (typeof ref === 'function') {
|
||||
return ref(value)
|
||||
} else if (ref) {
|
||||
ref.current = value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This TypeScript function merges multiple React refs into a single ref callback.
|
||||
* When developing low level UI components, it is common to have to use a local ref
|
||||
@@ -12,16 +31,18 @@
|
||||
* @returns The function `mergeRefs` is being returned. It takes an array of mutable or legacy refs and
|
||||
* returns a ref callback function that can be used to merge multiple refs into a single ref.
|
||||
*/
|
||||
export function mergeRefs<T = any>(
|
||||
refs: Array<React.MutableRefObject<T> | React.Ref<T> | undefined>,
|
||||
): React.RefCallback<T> {
|
||||
return value => {
|
||||
refs.forEach(ref => {
|
||||
if (typeof ref === 'function') {
|
||||
ref(value)
|
||||
} else if (ref != null) {
|
||||
;(ref as React.MutableRefObject<T | null>).current = value
|
||||
}
|
||||
})
|
||||
export function mergeRefs<T>(refs: (Ref<T> | undefined)[]): Ref<T> {
|
||||
return (value: T | null) => {
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
for (const ref of refs) {
|
||||
const cleanup = assignRef(ref, value)
|
||||
const isCleanup = typeof cleanup === 'function'
|
||||
cleanups.push(isCleanup ? cleanup : () => assignRef(ref, null))
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const cleanup of cleanups) cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,20 @@ export function getRootNavigation<T extends {}>(
|
||||
|
||||
export function getCurrentRoute(state?: State) {
|
||||
if (!state) {
|
||||
return {name: 'Home'}
|
||||
return {name: 'Home', params: undefined}
|
||||
}
|
||||
|
||||
/*
|
||||
* Nested navigator states may still be partial ("stale") right after a cold
|
||||
* start from a deep link, in which case `index` is not set yet. React
|
||||
* Navigation focuses the last route when it rehydrates such a state, so
|
||||
* mirror that here instead of stopping the descent early and misreporting
|
||||
* the tab root as the current route (which e.g. re-enabled the drawer
|
||||
* swipe gesture on top of a deep-linked screen).
|
||||
*/
|
||||
let node = state.routes[state.index || 0]
|
||||
while (node.state?.routes && typeof node.state?.index === 'number') {
|
||||
node = node.state?.routes[node.state?.index]
|
||||
while (node.state?.routes) {
|
||||
node = node.state.routes[node.state.index ?? node.state.routes.length - 1]
|
||||
}
|
||||
return node
|
||||
}
|
||||
@@ -83,15 +91,24 @@ export function buildStateObject(
|
||||
) {
|
||||
if (stack === 'Flat') {
|
||||
return {
|
||||
index: 0,
|
||||
routes: [{name: route, params}],
|
||||
}
|
||||
}
|
||||
/*
|
||||
* Set `index` explicitly so consumers of this state (e.g. getCurrentRoute)
|
||||
* can tell which route is focused before React Navigation has rehydrated
|
||||
* the nested navigator state.
|
||||
*/
|
||||
const routes = [...state, {name: route, params}]
|
||||
return {
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
name: stack,
|
||||
state: {
|
||||
routes: [...state, {name: route, params}],
|
||||
index: routes.length - 1,
|
||||
routes,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Regex from the go implementation
|
||||
// https://github.com/bluesky-social/indigo/blob/main/atproto/syntax/handle.go#L10
|
||||
import {i18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
|
||||
import {forceLTR} from '#/lib/strings/bidi'
|
||||
|
||||
const VALIDATE_REGEX =
|
||||
@@ -32,7 +35,7 @@ export function sanitizeHandle(
|
||||
): string {
|
||||
const lowercasedWithPrefix = `${prefix}${handle.toLocaleLowerCase()}`
|
||||
return isInvalidHandle(handle)
|
||||
? '⚠Invalid Handle'
|
||||
? i18n._(msg({message: `⚠Invalid Handle`}))
|
||||
: forceLeftToRight
|
||||
? forceLTR(lowercasedWithPrefix)
|
||||
: lowercasedWithPrefix
|
||||
|
||||
@@ -47,30 +47,12 @@ export function countLines(str: string | undefined): number {
|
||||
return str.match(/\n/g)?.length ?? 0
|
||||
}
|
||||
|
||||
// Augments search query with additional syntax like `from:me`
|
||||
export function augmentSearchQuery(query: string, {did}: {did?: string}) {
|
||||
// Don't do anything if there's no DID
|
||||
if (!did) {
|
||||
return query
|
||||
}
|
||||
|
||||
// replace “smart quotes” with normal ones
|
||||
// iOS keyboard will add fancy unicode quotes, but only normal ones work
|
||||
query = query.replaceAll(/[“”]/g, '"')
|
||||
|
||||
// We don't want to replace substrings that are being "quoted" because those
|
||||
// are exact string matches, so what we'll do here is to split them apart
|
||||
|
||||
// Even-indexed strings are unquoted, odd-indexed strings are quoted
|
||||
const splits = query.split(/("(?:[^"\\]|\\.)*")/g)
|
||||
|
||||
return splits
|
||||
.map((str, idx) => {
|
||||
if (idx % 2 === 0) {
|
||||
return str.replaceAll(/(^|\s)from:me(\s|$)/g, `$1${did}$2`)
|
||||
}
|
||||
|
||||
return str
|
||||
})
|
||||
.join('')
|
||||
/**
|
||||
* Normalizes a raw search query for the backend. The iOS keyboard inserts smart
|
||||
* quotes, but only straight quotes work for exact-phrase matching. Operators
|
||||
* like `from:me` are passed through untouched - the backend resolves `me` to
|
||||
* the viewer.
|
||||
*/
|
||||
export function augmentSearchQuery(query: string) {
|
||||
return query.replaceAll(/[“”]/g, '"')
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {startUriToStarterPackUri} from '#/lib/strings/starter-pack'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export const BSKY_APP_HOST = 'https://bsky.app'
|
||||
export const BSKY_HOSTING_ENDSWITH = '.host.bsky.network'
|
||||
const BSKY_TRUSTED_HOSTS = [
|
||||
'bsky\\.app',
|
||||
'bsky\\.social',
|
||||
@@ -91,6 +92,35 @@ export function toBskyAppUrl(url: string): string {
|
||||
return new URL(url, BSKY_APP_HOST).toString()
|
||||
}
|
||||
|
||||
export function toNiceHostingUrl(url: string): string {
|
||||
try {
|
||||
const urlp = new URL(url)
|
||||
if (urlp.host.endsWith(BSKY_HOSTING_ENDSWITH)) {
|
||||
return 'Bluesky'
|
||||
}
|
||||
return urlp.host
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given service URL points at a Bluesky-operated PDS. True when the
|
||||
* host is `bsky.social` (the {@link BSKY_SERVICE} host) or ends with
|
||||
* `.host.bsky.network`. Returns false if the URL can't be parsed.
|
||||
*/
|
||||
export function isBlueskyHostedUrl(url: string): boolean {
|
||||
try {
|
||||
const {host} = new URL(url)
|
||||
return (
|
||||
host === new URL(BSKY_SERVICE).host ||
|
||||
host.endsWith(BSKY_HOSTING_ENDSWITH)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isBskyAppUrl(url: string): boolean {
|
||||
return url.startsWith('https://bsky.app/')
|
||||
}
|
||||
|
||||
+744
-630
File diff suppressed because it is too large
Load Diff
+733
-623
File diff suppressed because it is too large
Load Diff
+742
-628
File diff suppressed because it is too large
Load Diff
+733
-623
File diff suppressed because it is too large
Load Diff
+733
-623
File diff suppressed because it is too large
Load Diff
+733
-623
File diff suppressed because it is too large
Load Diff
+802
-688
File diff suppressed because it is too large
Load Diff
+746
-632
File diff suppressed because it is too large
Load Diff
+746
-632
File diff suppressed because it is too large
Load Diff
+746
-632
File diff suppressed because it is too large
Load Diff
+748
-634
File diff suppressed because it is too large
Load Diff
+744
-630
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+619
-478
File diff suppressed because it is too large
Load Diff
+774
-660
File diff suppressed because it is too large
Load Diff
+746
-632
File diff suppressed because it is too large
Load Diff
+747
-633
File diff suppressed because it is too large
Load Diff
+743
-629
File diff suppressed because it is too large
Load Diff
+747
-633
File diff suppressed because it is too large
Load Diff
+744
-630
File diff suppressed because it is too large
Load Diff
+743
-629
File diff suppressed because it is too large
Load Diff
+745
-631
File diff suppressed because it is too large
Load Diff
+743
-629
File diff suppressed because it is too large
Load Diff
+733
-623
File diff suppressed because it is too large
Load Diff
+743
-629
File diff suppressed because it is too large
Load Diff
+771
-657
File diff suppressed because it is too large
Load Diff
+744
-630
File diff suppressed because it is too large
Load Diff
+744
-630
File diff suppressed because it is too large
Load Diff
+749
-635
File diff suppressed because it is too large
Load Diff
+746
-632
File diff suppressed because it is too large
Load Diff
+733
-623
File diff suppressed because it is too large
Load Diff
+743
-629
File diff suppressed because it is too large
Load Diff
+752
-638
File diff suppressed because it is too large
Load Diff
+733
-623
File diff suppressed because it is too large
Load Diff
+733
-623
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user