Merge remote-tracking branch 'origin/main' into post-page-social-proof

# Conflicts:
#	src/analytics/features/types.ts
This commit is contained in:
Alex Benzer
2026-07-09 21:59:34 -07:00
165 changed files with 41625 additions and 34283 deletions
+36 -8
View File
@@ -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' }}
+26 -3
View File
@@ -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
+45 -15
View File
@@ -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
+2 -2
View File
@@ -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: |
+2 -2
View File
@@ -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: |
+2 -2
View File
@@ -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
+29 -2
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
24.15.0
24.18.0
+95 -443
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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 && \
+4 -2
View File
@@ -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"
+107
View File
@@ -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'}}],
})
})
})
+1
View File
@@ -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: [
+1
View File
@@ -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',
{
+1 -1
View File
@@ -6,7 +6,7 @@
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.5.2",
"version": "11.10.0",
"onFail": "warn"
}
},
+8 -8
View File
@@ -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
View File
@@ -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=
+1 -1
View File
@@ -6,7 +6,7 @@
},
"build": {
"base": {
"node": "24.15.0"
"node": "24.18.0"
},
"development": {
"extends": "base",
-91
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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",
+16
View File
@@ -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
+276 -319
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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
View File
@@ -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 youre 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 youre 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>
)
}
+185 -163
View File
@@ -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 youd 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>
</>
)
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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
+8 -13
View File
@@ -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>
)
+34 -2
View File
@@ -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],
)
}
+20
View File
@@ -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) {
+171
View File
@@ -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
View File
@@ -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
}
-3
View File
@@ -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',
}
+2
View File
@@ -1404,6 +1404,8 @@ export type Events = {
uploadId: string
engine: string
errorClass: string
/** Truncated to 256 chars */
errorMessage: string
elapsedMs: number
}
'video:upload:uploadStarted': {
+15 -11
View File
@@ -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
+26 -11
View File
@@ -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: '繁體中文'},
]
+2 -2
View File
@@ -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
-30
View File
@@ -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>
)
}
+5 -1
View File
@@ -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,
+6 -3
View File
@@ -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
+16 -6
View File
@@ -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)
+11
View File
@@ -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
*/
+4 -4
View File
@@ -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)
+13 -1
View File
@@ -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(() => {
+2
View File
@@ -1,4 +1,6 @@
export type Geolocation = {
countryCode: string | undefined
regionCode: string | undefined
serviceGeolocation?: Geolocation
deviceGeolocation?: Geolocation
}
+23 -13
View File
@@ -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,
+20
View File
@@ -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
}
+6
View File
@@ -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
View File
@@ -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()
}
}
}
+21 -4
View File
@@ -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,
},
},
],
+4 -1
View File
@@ -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
+8 -26
View File
@@ -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, '"')
}
+30
View File
@@ -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/')
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
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