Merge remote-tracking branch 'origin/main' into app-2100

This commit is contained in:
vineyardbovines
2026-07-20 09:38:24 -04:00
279 changed files with 42632 additions and 34435 deletions
@@ -25,10 +25,10 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx - name: Setup Docker buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log into registry ${{ env.REGISTRY }} - name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}} username: ${{ env.USERNAME}}
@@ -36,7 +36,7 @@ jobs:
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -51,7 +51,7 @@ jobs:
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
@@ -26,10 +26,10 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx - name: Setup Docker buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log into registry ${{ env.REGISTRY }} - name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME }} username: ${{ env.USERNAME }}
@@ -37,7 +37,7 @@ jobs:
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -46,7 +46,7 @@ jobs:
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
@@ -25,10 +25,10 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx - name: Setup Docker buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log into registry ${{ env.REGISTRY }} - name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}} username: ${{ env.USERNAME}}
@@ -36,7 +36,7 @@ jobs:
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -45,7 +45,7 @@ jobs:
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
@@ -25,10 +25,10 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx - name: Setup Docker buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log into registry ${{ env.REGISTRY }} - name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}} username: ${{ env.USERNAME}}
@@ -36,7 +36,7 @@ jobs:
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -45,7 +45,7 @@ jobs:
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with: with:
context: . context: .
push: true push: true
@@ -25,10 +25,10 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx - name: Setup Docker buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log into registry ${{ env.REGISTRY }} - name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}} username: ${{ env.USERNAME}}
@@ -36,7 +36,7 @@ jobs:
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -45,7 +45,7 @@ jobs:
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with: with:
context: . context: .
push: true push: true
+1 -1
View File
@@ -114,7 +114,7 @@ jobs:
- name: ☕️ Assert Cocoapods version - name: ☕️ Assert Cocoapods version
run: | run: |
EXPECTED=1.16.2 EXPECTED=1.17.0
ACTUAL=$(pod --version) ACTUAL=$(pod --version)
if [ "$ACTUAL" != "$EXPECTED" ]; then if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL." echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
@@ -232,7 +232,7 @@ jobs:
- name: ☕️ Assert Cocoapods version - name: ☕️ Assert Cocoapods version
run: | run: |
EXPECTED=1.16.2 EXPECTED=1.17.0
ACTUAL=$(pod --version) ACTUAL=$(pod --version)
if [ "$ACTUAL" != "$EXPECTED" ]; then if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL." echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
+1 -1
View File
@@ -65,7 +65,7 @@ jobs:
aws-region: us-east-2 aws-region: us-east-2
- name: Claude - name: Claude
uses: anthropics/claude-code-action@4633baf5267540f3f8cb58b684f79901d564c280 # v1.0.160 uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1.0.166
with: with:
use_bedrock: 'true' use_bedrock: 'true'
additional_permissions: | additional_permissions: |
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
aws-region: us-east-2 aws-region: us-east-2
- name: Claude review - name: Claude review
uses: anthropics/claude-code-action@4633baf5267540f3f8cb58b684f79901d564c280 # v1.0.160 uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1.0.166
with: with:
use_bedrock: 'true' use_bedrock: 'true'
additional_permissions: | additional_permissions: |
+2 -1
View File
@@ -21,7 +21,8 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
job: [lint, prettier, typecheck] job:
[lint, prettier, 'typecheck:ios', 'typecheck:android', 'typecheck:web']
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+3 -3
View File
@@ -106,7 +106,7 @@ jobs:
core.setOutput('head-ref', pr.data.head.ref); core.setOutput('head-ref', pr.data.head.ref);
- name: 💬 Drop a comment - name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
with: with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }} header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }} number: ${{ github.event.issue.number }}
@@ -193,7 +193,7 @@ jobs:
RUNTIME_VERSION: RUNTIME_VERSION:
- name: 💬 Drop a comment - name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
env: env:
ISSUE_NUMBER: ${{ github.event.issue.number }} ISSUE_NUMBER: ${{ github.event.issue.number }}
with: with:
@@ -210,7 +210,7 @@ jobs:
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖* *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Drop a comment - name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
if: failure() if: failure()
with: with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }} header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
+51 -10
View File
@@ -19,8 +19,47 @@ concurrency:
permissions: {} permissions: {}
jobs: jobs:
# Populate this from main so every PR can restore the same trusted baseline.
webpack-analyzer-base:
runs-on: ubuntu-24.04
if: ${{ github.event_name == 'push' }}
permissions:
contents: read
steps:
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
- name: ⬇️ Get base stats from cache
id: get-base-stats
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: stats.json
key: stats-base-main-${{ github.sha }}
- name: 🔦 Generate stats file for base commit
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
run: |
pnpm install
pnpm intl:build
pnpm generate-webpack-stats-file
- name: ⬆️ Save base stats to cache
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: stats.json
key: stats-base-main-${{ github.sha }}
webpack-analyzer: webpack-analyzer:
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}} if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
permissions: permissions:
contents: read contents: read
@@ -71,15 +110,18 @@ jobs:
- name: ⬇️ Get base stats from cache - name: ⬇️ Get base stats from cache
id: get-base-stats id: get-base-stats
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 # Restore-only prevents PR-scoped fallback builds from creating caches.
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with: with:
path: stats-base.json path: stats.json
key: stats-base-${{ steps.base-commit.outputs.base-commit }} key: stats-base-main-${{ steps.base-commit.outputs.base-commit }}
- name: Restore to base commit - name: Restore to base commit
if: ${{ !steps.get-base-stats.outputs.cache-hit }} if: ${{ !steps.get-base-stats.outputs.cache-hit }}
env:
BASE_COMMIT: ${{ steps.base-commit.outputs.base-commit }}
run: | run: |
git reset HEAD~ git reset "$BASE_COMMIT"
git restore . git restore .
- name: 🔦 Generate stats file from base commit - name: 🔦 Generate stats file from base commit
@@ -88,18 +130,17 @@ jobs:
pnpm install pnpm install
pnpm intl:build pnpm intl:build
pnpm generate-webpack-stats-file pnpm generate-webpack-stats-file
mv stats.json stats-base.json
- name: Get diff - name: Get diff
id: get-diff id: get-diff
uses: NejcZdovc/bundle-size-diff@5321de41d2d62a7b0f4d6e60f59d1280a0034160 # v1.1.0 uses: NejcZdovc/bundle-size-diff@5321de41d2d62a7b0f4d6e60f59d1280a0034160 # v1.1.0
with: with:
base_path: "stats-base.json" base_path: "stats.json"
pr_path: "../stats-new.json" pr_path: "../stats-new.json"
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico" excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
- name: 💬 Drop a comment - name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
with: with:
header: bundle-diff header: bundle-diff
message: | message: |
@@ -140,7 +181,7 @@ jobs:
profile: pull-request profile: pull-request
- name: 💬 Drop a comment - name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
if: ${{ steps.fingerprint.outputs.includes-changes }} if: ${{ steps.fingerprint.outputs.includes-changes }}
with: with:
header: fingerprint-diff header: fingerprint-diff
@@ -158,7 +199,7 @@ jobs:
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖* *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Delete comment - name: 💬 Delete comment
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
if: ${{ !steps.fingerprint.outputs.includes-changes }} if: ${{ !steps.fingerprint.outputs.includes-changes }}
with: with:
header: fingerprint-diff header: fingerprint-diff
+2 -2
View File
@@ -108,8 +108,8 @@ google-services.json
# Performance results (Flashlight) # Performance results (Flashlight)
.perf/ .perf/
# ESLint # Oxlint
.eslintcache .oxlintcache
# i18n # i18n
src/locale/locales/_build/ src/locale/locales/_build/
+407
View File
@@ -0,0 +1,407 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"typescript",
"react",
"import"
],
"categories": {
"correctness": "off"
},
"options": {
"typeAware": true
},
"env": {
"builtin": true
},
"settings": {
"import-x/extensions": [
".ts",
".tsx",
".cts",
".mts",
".js",
".jsx",
".cjs",
".mjs"
],
"import-x/external-module-folders": [
"node_modules",
"node_modules/@types"
],
"import-x/parsers": {
"@typescript-eslint/parser": [
".ts",
".tsx",
".cts",
".mts"
]
},
"import-x/resolver": {
"node": {
"extensions": [
".js",
".web.js",
".ios.js",
".android.js"
]
}
}
},
"ignorePatterns": [
"**/__mocks__/*.ts",
"ios/**",
"android/**",
"coverage/**",
"*.lock",
".husky/**",
"patches/**",
"*.html",
"bskyweb/**",
"bskyembed/**",
"bskyogcard/**",
"src/locale/locales/_build/**",
"src/locale/locales/**/*.js",
"*.e2e.ts",
"*.e2e.tsx",
"eslint.config.mjs",
".jscodeshift/**"
],
"rules": {
"constructor-super": "error",
"for-direction": "error",
"getter-return": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unexpected-multiline": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": "error",
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error",
"no-array-constructor": "error",
"no-unused-expressions": "error",
"import/namespace": "off",
"import/default": "error",
"import/no-named-as-default": "warn",
"import/no-named-as-default-member": "warn",
"import/no-duplicates": "warn",
"react/rules-of-hooks": "error",
"react/exhaustive-deps": "warn",
"typescript/await-thenable": "error",
"typescript/ban-ts-comment": "error",
"typescript/no-array-delete": "error",
"typescript/no-base-to-string": "error",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-duplicate-type-constituents": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "error",
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-floating-promises": "error",
"typescript/no-for-in-array": "error",
"typescript/no-implied-eval": "error",
"typescript/no-misused-new": "error",
"typescript/no-misused-promises": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-redundant-type-constituents": "error",
"typescript/no-require-imports": "error",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-assertion": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-argument": "error",
"typescript/no-unsafe-assignment": "error",
"typescript/no-unsafe-call": "error",
"typescript/no-unsafe-declaration-merging": "error",
"typescript/no-unsafe-enum-comparison": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-unsafe-member-access": "error",
"typescript/no-unsafe-return": "error",
"typescript/no-unsafe-unary-minus": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/only-throw-error": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/prefer-promise-reject-errors": "error",
"typescript/require-await": "error",
"typescript/restrict-plus-operands": "error",
"typescript/restrict-template-expressions": "error",
"typescript/triple-slash-reference": "error",
"typescript/unbound-method": "error"
},
"overrides": [
{
"files": [
"**/*.ts",
"**/*.tsx",
"**/*.mts",
"**/*.cts"
],
"rules": {
"constructor-super": "off",
"getter-return": "off",
"no-class-assign": "off",
"no-const-assign": "off",
"no-dupe-class-members": "off",
"no-dupe-keys": "off",
"no-func-assign": "off",
"no-import-assign": "off",
"no-new-native-nonconstructor": "off",
"no-obj-calls": "off",
"no-redeclare": "off",
"no-setter-return": "off",
"no-this-before-super": "off",
"no-unreachable": "off",
"no-unsafe-negation": "off",
"no-var": "error",
"no-with": "off",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error"
}
},
{
"files": [
"**/*.{js,jsx,ts,tsx}"
],
"rules": {
"bsky-internal/avoid-unwrapped-text": [
"error",
{
"impliedTextComponents": [
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"P",
"Admonition",
"Admonition.Admonition",
"Toast.Action",
"AgeAssuranceAdmonition",
"Span",
"StackedButton"
],
"impliedTextProps": [],
"suggestedTextWrappers": {
"Button": "ButtonText",
"ToggleButton.Button": "ToggleButton.ButtonText",
"SegmentedControl.Item": "SegmentedControl.ItemText"
}
}
],
"bsky-internal/use-exact-imports": "error",
"bsky-internal/use-prefixed-imports": "error",
"bsky-internal/lingui-msg-rule": "error",
"react/display-name": "error",
"react/jsx-key": "error",
"react/jsx-no-comment-textnodes": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-target-blank": "error",
"react/jsx-no-undef": "error",
"react/no-children-prop": "error",
"react/no-danger-with-children": "error",
"react/no-direct-mutation-state": "error",
"react/no-find-dom-node": "error",
"react/no-is-mounted": "error",
"react/no-render-return-value": "error",
"react/no-string-refs": "error",
"react/no-unescaped-entities": "off",
"react/no-unknown-property": "error",
"react/no-unsafe": "off",
"react/react-in-jsx-scope": "off",
"react/hook-use-state": "warn",
"react-native/no-inline-styles": "off",
"react-native-a11y/has-accessibility-hint": "error",
"react-native-a11y/has-accessibility-props": "error",
"react-native-a11y/has-valid-accessibility-actions": "error",
"react-native-a11y/has-valid-accessibility-component-type": "error",
"react-native-a11y/has-valid-accessibility-descriptors": "error",
"react-native-a11y/has-valid-accessibility-role": "error",
"react-native-a11y/has-valid-accessibility-state": "error",
"react-native-a11y/has-valid-accessibility-states": "error",
"react-native-a11y/has-valid-accessibility-traits": "error",
"react-native-a11y/has-valid-accessibility-value": "error",
"react-native-a11y/no-nested-touchables": "error",
"react-native-a11y/has-valid-accessibility-ignores-invert-colors": "error",
"react-native-a11y/has-valid-accessibility-live-region": "error",
"react-native-a11y/has-valid-important-for-accessibility": "error",
"react-compiler/react-compiler": "warn",
"simple-import-sort/imports": [
"error",
{
"groups": [
[
"^\\u0000"
],
[
"^node:"
],
[
"^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)",
"^(expo(.*)$)|^(expo$)",
"^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w"
],
[
"^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)",
"^(?:#\\/)?view(?:$|\\/)",
"^(?:#\\/)?screens(?:$|\\/)",
"^(?:#\\/)?alf(?:$|\\/)",
"^(?:#\\/)?components(?:$|\\/)",
"^#\\/",
"^\\."
],
[
"^"
]
]
}
],
"simple-import-sort/exports": "error",
"no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_.+",
"caughtErrors": "none",
"ignoreRestSiblings": true
}
],
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "react",
"importNames": [
"React",
"default"
],
"message": "React is already in the global type namespace. Use named imports for runtime modules."
}
]
}
],
"no-empty-pattern": "off",
"no-async-promise-executor": "off",
"no-constant-binary-expression": "warn",
"prefer-const": "off",
"no-empty": "off",
"no-unsafe-optional-chaining": "off",
"no-prototype-builtins": "off",
"no-var": "off",
"prefer-rest-params": "off",
"no-case-declarations": "off",
"no-irregular-whitespace": "off",
"no-useless-escape": "off",
"no-sparse-arrays": "off",
"no-fallthrough": "off",
"no-control-regex": "off",
"no-unused-expressions": [
"error",
{
"allowTernary": true
}
],
"import/consistent-type-specifier-style": [
"warn",
"prefer-inline"
],
"import/no-nodejs-modules": "error",
"typescript/consistent-type-imports": [
"warn",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"typescript/no-require-imports": "off",
"typescript/ban-ts-comment": "off",
"typescript/no-empty-object-type": "off",
"typescript/no-unsafe-function-type": "off",
"typescript/no-unsafe-assignment": "off",
"typescript/unbound-method": "off",
"typescript/no-unsafe-argument": "off",
"typescript/no-unsafe-return": "off"
},
"jsPlugins": [
"eslint-plugin-bsky-internal",
"eslint-plugin-react-native",
"eslint-plugin-react-native-a11y",
"eslint-plugin-react-compiler",
"eslint-plugin-simple-import-sort"
],
"env": {
"es2026": true,
"browser": true,
"node": true
}
},
{
"files": [
"bskyogcard/**/*.{js,jsx,ts,tsx}",
"dev-env/**/*.{js,jsx,ts,tsx}"
],
"rules": {
"import/no-nodejs-modules": "off"
}
},
{
"files": [
"**/__tests__/**/*.{js,jsx,ts,tsx}",
"**/*.test.{js,jsx,ts,tsx}"
],
"env": {
"jest": true
}
}
]
}
+96 -444
View File
@@ -30,7 +30,7 @@ pnpm ios # Run on iOS
# Testing & Quality # Testing & Quality
# IMPORTANT: Always use these pnpm scripts, never call the underlying tools directly # IMPORTANT: Always use these pnpm scripts, never call the underlying tools directly
pnpm test # Run Jest tests pnpm test # Run Jest tests
pnpm lint # Run ESLint pnpm lint # Run Oxlint
pnpm typecheck # Run TypeScript type checking pnpm typecheck # Run TypeScript type checking
pnpm prettier # Run Prettier for code formatting pnpm prettier # Run Prettier for code formatting
@@ -82,7 +82,7 @@ should go in `/screens` (not `/view/screens`) to encourage better organization
and separation from legacy code. and separation from legacy code.
For complex screens that have specific components or data needs that _are not 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 e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and
`/screens/ProfileScreen/components/`. `/screens/ProfileScreen/components/`.
@@ -126,44 +126,14 @@ eventually.
Typically JS style for variables, functions, etc. We use ProudCamelCase for Typically JS style for variables, functions, etc. We use ProudCamelCase for
components, and camelCase directories and files. components, and camelCase directories and files.
When organizing new code, consider if it fits into a single file, or if it For "macro" cases in `/features`, `/screens`, or `/components`, co-locate related
should be broken down into multiple files. For "macro" component cases, or code in a directory with an `index.tsx` main component plus sibling
things that live in `/features` or `/screens`, we often follow a pattern of components/hooks/utils (e.g. `screens/ProfileScreen/index.tsx` +
having an `index.tsx` for the main component, and then co-locating related `screens/ProfileScreen/components/`). Keep related code together so it lives where
components, hooks, and utilities in the same directory. For example: 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`.
``` Platform-specific files are covered under "Platform-Specific Code" below.
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.
### Comments ### Comments
@@ -204,88 +174,40 @@ const fallbackDate = maximumDate ? toSimpleDateString(maximumDate) : today
### Documentation and Tests Within Features ### Documentation and Tests Within Features
For larger features or components, it's helpful to include a README.md file For larger features or components, co-locate documentation and tests with the
within the directory that explains the purpose of the feature, how it works, and code. A `README.md` in the directory (the `/Component/index.tsx` pattern lends
any important implementation details. The `/Component/index.tsx` pattern lends itself well to this) can document the whole feature, and feature-specific tests
itself well to this, since the `index.tsx` can be the main component file, and belong alongside it as `Component.test.tsx` or in a `__tests__/` subdirectory.
the `README.md` can provide documentation for the whole feature. This is Both are optional.
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.
## Styling System (ALF) ## 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 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}`).
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}`
```tsx ```tsx
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
function MyComponent() { const t = useTheme()
const t = useTheme() <View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]} />
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>
)
}
``` ```
### Key Concepts ### 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 **Platform utilities** (`import {web, native, ios, android, platform} from '#/alf'`)
import {atoms as a} from '#/alf' return conditional styles inline in a style array: `web({cursor: 'pointer'})`,
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc. `native({paddingBottom: 20})`, `platform({ios: {...}, android: {...}, web: {...}})`.
```
**Theme Atoms**  Theme-dependent colors from `useTheme()`: **Breakpoints:** `const {gtPhone, gtMobile, gtTablet} = useBreakpoints()` from `#/alf`.
```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
}
```
### Naming Conventions ### Naming Conventions
@@ -306,176 +228,67 @@ if (gtMobile) {
```tsx ```tsx
import {Fragment} from 'react' import {Fragment} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
function MyComponent({foo = []}: {foo?: string[]}) { function MyComponent({items = []}: {items?: string[]}) {
const {t: l} = useLingui()
return ( return (
<> <>
<View><Text><Trans>Example</Trans><Text></View>
<View> <View>
{foo.map((foo, index) => ( <Text>
<Fragment key={foo}> <Trans>Example</Trans>
</Text>
</View>
<View>
{items.map((item, index) => (
<Fragment key={item}>
<Text>{index}</Text> <Text>{index}</Text>
<Text>{foo}</Text> <Text>{item}</Text>
</Fragment> </Fragment>
))} ))}
</View> </View>
</> </>
); )
} }
``` ```
### Dialog Component ### Dialog Component
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state. Lives in `#/components/Dialog`. Bottom sheet on native, modal on web. Manage
state with `useDialogControl()`. `Dialog.Handle` renders native-only, `Dialog.Close`
```tsx web-only. CRITICAL: run any post-close action inside the `control.close(() => ...)`
import * as Dialog from '#/components/Dialog' callback (see Footguns). Compound-component usage; canonical example in any dialog
under `#/components`.
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>
</>
)
}
```
### Menu Component ### Menu Component
Menus render as a dropdown on web and a bottom sheet dialog on native. Lives in `#/components/Menu`. Dropdown on web, bottom sheet dialog on native.
`Menu.Divider` is web-only, `Menu.ContainerItem` native-only. Compound API
```tsx (`Menu.Root` / `Menu.Trigger` / `Menu.Outer` / `Menu.Group` / `Menu.Item`); grep
import * as Menu from '#/components/Menu' existing usages across the app for a canonical example.
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>
)
}
```
### Button Component ### Button Component
```tsx `import {Button, ButtonText, ButtonIcon} from '#/components/Button'`. Props:
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:**
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'` - `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
- `size`: `'tiny'` | `'small'` | `'large'` - `size`: `'tiny'` | `'small'` | `'large'`
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'` - `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`) - `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, prefer `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]`.
### TextField ### TextField
```tsx Compound component at `#/components/forms/TextField` (`TextField.LabelText`,
import * as TextField from '#/components/forms/TextField' `TextField.Root`, `TextField.Icon`, `TextField.Input`). Prefer `defaultValue` over
`value` (see Footguns).
<TextField.LabelText>Email</TextField.LabelText> ### Typography
<TextField.Root>
<TextField.Icon icon={AtSign} /> `import {Text, H1, H2, P} from '#/components/Typography'`. The `Text` default style
<TextField.Input is `[a.text_sm, a.leading_snug, t.atoms.text]`. Pass the `emoji` prop to any `Text`
label="Email address" that may contain emoji - user-generated text (display names etc.) almost always
placeholder="you@example.com" does, so only omit it for static, emoji-free strings: `<Text emoji>Hello!</Text>`.
defaultValue={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
</TextField.Root>
```
## Internationalization (i18n) ## Internationalization (i18n)
@@ -549,201 +362,49 @@ pnpm intl:compile # Compile translations for runtime
### TanStack Query (Data Fetching) ### TanStack Query (Data Fetching)
```tsx Follow the established pattern in `src/state/queries/`; `src/state/queries/feed.ts`
// src/state/queries/profile.ts is a good canonical reference (it uses `createQueryKey`, matching key roots,
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' `useInfiniteQuery`, and `persistedVersion`).
import {createQueryKey} from '#/state/queries/util' - 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
* Query key name should match the query hook name for consistency 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`,
const profileQueryKeyRoot = 'profile' `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
* Use object params and createQueryKey helper for better readability and to `data?.pages.flatMap(page => page.items) ?? []`.
* avoid bugs with parameter order or types. - Persist a query across restarts by passing options:
*/ `createQueryKey(root, args, {persistedVersion: n})`. Bumping `n` clears the old
export const createProfileQueryKey = (args: {did: string}) => persisted data and refetches - do this whenever the data shape changes.
createQueryKey(profileQueryKeyRoot, args) - 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})`.
* 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})
```
### Preferences (React Context) ### Preferences (React Context)
```tsx Boolean/simple UI preferences are exposed as paired hooks from `#/state/preferences`,
// Simple boolean preference pattern e.g. `useAutoplayDisabled()` / `useSetAutoplayDisabled()`.
import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
function SettingsScreen() {
const autoplayDisabled = useAutoplayDisabled()
const setAutoplayDisabled = useSetAutoplayDisabled()
return <Toggle value={autoplayDisabled} onValueChange={setAutoplayDisabled} />
}
```
### Session State ### Session State
```tsx `import {useSession, useAgent} from '#/state/session'`. `useSession()` gives
import {useSession, useAgent} from '#/state/session' `hasSession` and `currentAccount`; `useAgent()` gives the atproto agent for API calls.
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})
}
```
## Navigation ## Navigation
Navigation uses React Navigation with type-safe route parameters. React Navigation with type-safe route params. Type a screen with
`NativeStackScreenProps<CommonNavigatorParams, 'X'>` (`route`/`navigation` come
```tsx from props; params via `route.params`). Navigate programmatically with
// Screen component `useNavigation()`, or the `navigate` helper from `#/Navigation`. Config lives in
import {type NativeStackScreenProps} from '@react-navigation/native-stack' `src/Navigation.tsx`, routes in `src/routes.ts`, types in `src/lib/routes/types.ts`.
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'})
```
## Platform-Specific Code ## 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 Component.tsx # Shared/default
@@ -753,12 +414,11 @@ Component.ios.tsx # iOS-only
Component.android.tsx # Android-only Component.android.tsx # Android-only
``` ```
Example from Dialog: Prefer grouping variants into a `Component/` directory (`index.tsx`,
`index.web.tsx`, `index.native.tsx`) rather than sibling `Component.web.tsx` files,
- `src/components/Dialog/index.tsx`  Native (uses BottomSheet) so the shared surface reads as one "macro" module (e.g. `src/components/Dialog/index.tsx`
- `src/components/Dialog/index.web.tsx Web (uses modal with Radix primitives) native vs `index.web.tsx` web). The app has both patterns; the directory form is
preferred for new code.
**Important:** The bundler automatically resolves platform-specific files. Just import normally:
```tsx ```tsx
// CORRECT - bundler picks storage.ts or storage.web.ts automatically // CORRECT - bundler picks storage.ts or storage.web.ts automatically
@@ -770,15 +430,7 @@ const storage = IS_NATIVE
: require('#/state/drafts/storage.web') : require('#/state/drafts/storage.web')
``` ```
Platform detection (for runtime logic, not imports): Runtime platform detection (not for imports): `import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'`.
```tsx
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
if (IS_NATIVE) {
// Native-specific logic
}
```
## Import Aliases ## Import Aliases
+1 -1
View File
@@ -9,7 +9,7 @@ WORKDIR /app
COPY ./bskyogcard/package.json ./ COPY ./bskyogcard/package.json ./
COPY ./bskyogcard/pnpm-lock.yaml ./ COPY ./bskyogcard/pnpm-lock.yaml ./
COPY ./bskyogcard/pnpm-workspace.yaml ./ COPY ./bskyogcard/pnpm-workspace.yaml ./
RUN npm install --global pnpm@11.10.0 RUN npm install --global pnpm@11.13.1
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
COPY ./bskyogcard ./ COPY ./bskyogcard ./
+1 -1
View File
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
RUN \. "$NVM_DIR/nvm.sh" && \ RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \ nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \ nvm use $NODE_VERSION && \
npm install --global pnpm@11.10.0 && \ npm install --global pnpm@11.13.1 && \
pnpm install --frozen-lockfile && \ pnpm install --frozen-lockfile && \
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \ cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
pnpm intl:build && \ pnpm intl:build && \
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#006AFF" d="M16 2.5a4 4 0 0 1 4 4v12.995c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2 2 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#006aff" d="M16 2.5a4 4 0 0 1 4 4v12.995c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2 2 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8Z"/></svg>

Before

Width:  |  Height:  |  Size: 250 B

After

Width:  |  Height:  |  Size: 250 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" viewBox="0 0 1000 500"><defs><clipPath id="cp1" clipPathUnits="userSpaceOnUse"><path d="M626.36 100.49c-5.11 3.07-10.03 6.4-14.6 9.83-16.71 12.55-33.83 29.35-50.01 47.72-20.77 23.59-39.97 49.76-54.68 72.84-8.59 13.48-15.65 25.91-20.6 36.14-17.68-36.56-62.26-101-107.36-141.8-5.99-5.42-11.99-10.44-17.94-14.9-26.96-20.24-66.21-37.06-85.02-17.49-6.73 6.99-10.87 18.59-10.87 36.4 0 13.51 7.75 113.48 12.29 129.72 12.82 45.8 53.13 63.9 95.24 64.44l1.72 4.1c-66.07 20.14-79.15 64.89-35.52 109.66 40.27 41.32 70.75 42.51 93.25 28.05 29.66-19.06 45.43-65.32 51.38-82.78q.71-2.11 1.24-3.62c1.07-3.02 1.58-4.08 1.59-2.4.01-2.24.91.39 2.83 6.02 9.24 27.14 42.23 123.92 114.26 79.79 9.44-5.78 19.55-13.95 30.36-25.06 15.25-15.63 23.56-31.27 25.13-45.86 1.64-15.29-4.17-29.4-17.18-41.17l-12.59-30.19c29.25-6.89 54.25-24.56 64.85-56.99.43-1.32.85-2.63 1.24-3.99 4.54-16.24 12.28-116.21 12.28-129.72 0-59.2-45.42-50.3-81.29-28.74"/></clipPath></defs><style>.s0{fill:#7bb244}.s1{fill:#008442}.s3{fill:#008cd2}.s4{fill:#e7451e}.s5{fill:#f6a700}.s6{fill:#fff}.s7{mix-blend-mode:multiply;fill:#e7451e}.s8{fill:#4b9754}</style><g id="デザイン"><g id="&lt;Group&gt;"><path id="&lt;Path&gt;" d="M362.1 110.32c-26.96-20.24-66.21-37.06-85.03-17.49l96.66 230.56c9.77.13 19.64-.69 29.28-2.33-10.04 1.71-19.2 3.88-27.56 6.43l57.74 137.71c29.66-19.06 45.43-65.32 51.38-82.78q.71-2.11 1.24-3.62L380.04 125.22c-5.99-5.42-11.99-10.44-17.94-14.9" class="s0"/><path d="M266.21 129.23c0 13.51 7.75 113.48 12.29 129.72 12.81 45.8 53.13 63.9 95.23 64.44L277.07 92.83c-6.72 6.99-10.86 18.59-10.86 36.4m73.73 307.93c40.26 41.31 70.75 42.5 93.25 28.04l-57.74-137.71c-66.06 20.14-79.14 64.89-35.51 109.67" class="s1"/><path d="M696.29 258.95c4.55-16.24 12.29-116.21 12.29-129.72 0-59.2-45.42-50.31-81.29-28.74l67.76 162.45q.66-1.96 1.24-3.99" class="s0"/><path d="m642.8 350.12 17.17 41.17c1.64-15.28-4.16-29.4-17.17-41.17m52.25-87.18-67.76-162.45c-5.11 3.07-10.03 6.4-14.6 9.83-16.71 12.55-33.84 29.35-50.01 47.72l67.53 161.89c29.25-6.89 54.25-24.56 64.84-56.99m-207.65 4.08c-17.69-36.57-62.27-101-107.36-141.8L485.81 378.8c1.07-3.02 1.58-4.09 1.59-2.4.01-2.24.91.39 2.82 6.02 9.25 27.14 42.24 123.92 114.27 79.79L508 230.88c-8.59 13.48-15.65 25.91-20.6 36.14" class="s1"/><path d="M571.78 321.06c19.44 3.31 39.79 3.27 58.43-1.13l-67.53-161.89c-20.78 23.59-39.97 49.76-54.68 72.84l96.49 231.33c9.44-5.78 19.54-13.95 30.36-25.05 15.24-15.64 23.56-31.28 25.12-45.87l-17.17-41.17c-14.63-13.24-38.36-23.5-71.02-29.06" class="s0"/><path d="m537.4 4-17.72 143.35 88.33-113.64z" style="fill:#f4ae1a"/><path d="m895.69 371.73-230.18-31.9 148.57 101.66z" class="s3"/><path d="m90.72 95.32 159.34 126.77L0 162.43z" class="s4"/><path d="m148.73 361.28 250.64-7.29-290.74 141.27z" class="s5"/><g id="&lt;Clip Group&gt;" clip-path="url(#cp1)"><path id="&lt;Compound Path&gt;" fill-rule="evenodd" d="m569.43 371.9 51.37 123.15-4.45 1.85-51.37-123.16c-10.11 3.92-21.06 6.16-32.54 6.16-49.82 0-90.36-40.54-90.36-90.36 0-36.66 21.97-68.22 53.41-82.38l-33.88-81.21 4.45-1.86 33.88 81.23c10.11-3.92 21.04-6.15 32.5-6.15 49.83 0 90.37 40.54 90.37 90.37 0 36.64-21.96 68.19-53.38 82.36M501.8 209.77l65.77 157.68c29.68-13.44 50.42-43.27 50.42-77.91 0-47.17-38.38-85.55-85.55-85.55-10.81 0-21.11 2.1-30.64 5.78m61.32 159.52-65.77-157.68c-29.7 13.43-50.45 43.27-50.45 77.93 0 47.16 38.37 85.54 85.54 85.54 10.82 0 21.14-2.1 30.68-5.79" class="s6"/></g><path d="m763.79 80.37-201.27 208 304.16 14z" class="s4"/><path d="M970.03 253.88c-48.46 57.07-138.53 68.27-201.19 25.02-62.65-43.24-74.16-124.56-25.7-181.63 48.46-57.06 138.53-68.26 201.18-25.02 62.66 43.25 74.17 124.57 25.71 181.63" class="s6"/><path d="M914.45 112.85s-47.72-9.42-60.98-3.44c0 0-20.85 41.71-19.79 49.19 0 0 42.36 33.2 51.62 34.93 0 0 41.25-18.78 47.5-27.04 0 0-6.8-44.96-18.35-53.64" class="s5"/><path d="M745.71 149.33s8.94 18.59 30.66 28.61c0 0-8.04 41.65-6.08 52.61 0 0-29.32 1.2-36.92-.32 0 0-13.68-21.28-15.2-48.63 0 0 11.16-22.62 27.54-32.27" class="s7"/><path d="M882.41 248.11s19.49 18.58 36.64 21.56c0 0-10.87 18.86-27.62 28.95 0 0-46.24 8.07-56.83.06 0 0-12.6-21.27-11.02-30.65 0 0 47.78-9.62 58.83-19.92" class="s8"/><path d="M996.29 160.32s-11.03 24.46-22.17 27.94c0 0 1.59 34.85-6.18 48.82l10.12.75s24.82-41.68 18.23-77.51" class="s7"/><path d="m908.15 55.46-6.72 1.39s22.77 2.58 34.07 29c0 0 29.75 6.27 39.52 19.76 0 0-11.63-20.9-27.55-31.65-15.91-10.75-39.32-18.5-39.32-18.5" class="s8"/><path d="m829.78 51.19 9.62 1.95s-22.02 16.72-20.32 25.78c0 0-32.01 5-52.33 20.56l-7.5-13.26s16.01-15.28 34.08-24.48c18.08-9.21 36.45-10.55 36.45-10.55" class="s3"/></g></g></svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" fill-rule="evenodd" d="M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6.386c1.064-.002 2 .86 2 2.001V19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6.613c0-1.142.936-2.003 2-2.001V4Zm2 6.946 6 2 6-2V4H6v6.946ZM9 8a1 1 0 0 1 1-1h4a1 1 0 1 1 0 2h-4a1 1 0 0 1-1-1Zm2.367 6.843L4 12.387V19h16v-6.613l-7.367 2.456a2 2 0 0 1-1.265 0Z" clip-rule="evenodd"/></svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080b12" fill-rule="evenodd" d="M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6.386c1.064-.002 2 .86 2 2.001V19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6.613c0-1.142.936-2.003 2-2.001V4Zm2 6.946 6 2 6-2V4H6v6.946ZM9 8a1 1 0 0 1 1-1h4a1 1 0 1 1 0 2h-4a1 1 0 0 1-1-1Zm2.367 6.843L4 12.387V19h16v-6.613l-7.367 2.456a2 2 0 0 1-1.265 0Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 426 B

After

Width:  |  Height:  |  Size: 426 B

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 46 46"><path stroke="#080B12" stroke-linecap="round" stroke-width="1.5" d="m1.417 28.645 7.586-5.676a5.33 5.33 0 0 1 6.867.809c3.98 4.286 8.594 8.182 14.88 8.182 5.794 0 9.633-2.147 13.333-5.847m-38 18.637h33.334a5.333 5.333 0 0 0 5.333-5.333V6.083A5.333 5.333 0 0 0 39.417.75H6.083A5.333 5.333 0 0 0 .75 6.083v33.334a5.333 5.333 0 0 0 5.333 5.333ZM36.75 14.083a5.333 5.333 0 1 1-10.667 0 5.333 5.333 0 0 1 10.667 0Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 46 46"><path stroke="#080b12" stroke-linecap="round" stroke-width="1.5" d="m1.417 28.645 7.586-5.676a5.33 5.33 0 0 1 6.867.809c3.98 4.286 8.594 8.182 14.88 8.182 5.794 0 9.633-2.147 13.333-5.847m-38 18.637h33.334a5.333 5.333 0 0 0 5.333-5.333V6.083A5.333 5.333 0 0 0 39.417.75H6.083A5.333 5.333 0 0 0 .75 6.083v33.334a5.333 5.333 0 0 0 5.333 5.333ZM36.75 14.083a5.333 5.333 0 1 1-10.667 0 5.333 5.333 0 0 1 10.667 0Z"/></svg>

Before

Width:  |  Height:  |  Size: 491 B

After

Width:  |  Height:  |  Size: 491 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1185FE" d="M6.335 4.212c2.293 1.76 4.76 5.327 5.665 7.241.906-1.914 3.372-5.482 5.665-7.241C19.319 2.942 22 1.96 22 5.086c0 .624-.35 5.244-.556 5.994-.713 2.608-3.315 3.273-5.629 2.87 4.045.704 5.074 3.035 2.852 5.366-4.22 4.426-6.066-1.111-6.54-2.53-.086-.26-.126-.382-.127-.278 0-.104-.041.018-.128.278-.473 1.419-2.318 6.956-6.539 2.53-2.222-2.331-1.193-4.662 2.852-5.366-2.314.403-4.916-.262-5.63-2.87C2.35 10.33 2 5.71 2 5.086c0-3.126 2.68-2.144 4.335-.874Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1185fe" d="M6.335 4.212c2.293 1.76 4.76 5.327 5.665 7.241.906-1.914 3.372-5.482 5.665-7.241C19.319 2.942 22 1.96 22 5.086c0 .624-.35 5.244-.556 5.994-.713 2.608-3.315 3.273-5.629 2.87 4.045.704 5.074 3.035 2.852 5.366-4.22 4.426-6.066-1.111-6.54-2.53-.086-.26-.126-.382-.127-.278 0-.104-.041.018-.128.278-.473 1.419-2.318 6.956-6.539 2.53-2.222-2.331-1.193-4.662 2.852-5.366-2.314.403-4.916-.262-5.63-2.87C2.35 10.33 2 5.71 2 5.086c0-3.126 2.68-2.144 4.335-.874Z"/></svg>

Before

Width:  |  Height:  |  Size: 557 B

After

Width:  |  Height:  |  Size: 557 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#FFC404" fill-rule="evenodd" d="M11.183 8.561c0 .544.348.984.892.984.545 0 .893-.44.893-.985V6.985c0-.544-.348-.985-.893-.985-.543 0-.892.44-.892.985v1.576Zm5.94 7.481c0 .539-.438.942-.976.942H8.004c-.538 0-.975-.411-.975-.95 0-2.782 2.264-5.021 5.046-5.021s5.047 2.247 5.047 5.03Zm-.43-4.584a.983.983 0 0 1 0-1.393l1.114-1.114a.985.985 0 0 1 1.393 1.393l-1.114 1.114a.985.985 0 0 1-1.393 0Zm2.897 3.741h1.575c.544 0 .985.349.985.892 0 .544-.44.892-.985.892h-1.67a.87.87 0 0 1-.89-.887c0-.543.44-.897.985-.897Zm-14.045.893c0-.544-.44-.892-.985-.892H2.985c-.544 0-.985.349-.985.892 0 .544.44.892.985.892H4.56c.545 0 .985-.349.985-.892Zm1.913-6.027a.985.985 0 0 1-1.393 1.393L4.95 10.344A.985.985 0 0 1 6.344 8.95l1.114 1.114Z" clip-rule="evenodd"/></svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#ffc404" fill-rule="evenodd" d="M11.183 8.561c0 .544.348.984.892.984.545 0 .893-.44.893-.985V6.985c0-.544-.348-.985-.893-.985-.543 0-.892.44-.892.985v1.576Zm5.94 7.481c0 .539-.438.942-.976.942H8.004c-.538 0-.975-.411-.975-.95 0-2.782 2.264-5.021 5.046-5.021s5.047 2.247 5.047 5.03Zm-.43-4.584a.983.983 0 0 1 0-1.393l1.114-1.114a.985.985 0 0 1 1.393 1.393l-1.114 1.114a.985.985 0 0 1-1.393 0Zm2.897 3.741h1.575c.544 0 .985.349.985.892 0 .544-.44.892-.985.892h-1.67a.87.87 0 0 1-.89-.887c0-.543.44-.897.985-.897Zm-14.045.893c0-.544-.44-.892-.985-.892H2.985c-.544 0-.985.349-.985.892 0 .544.44.892.985.892H4.56c.545 0 .985-.349.985-.892Zm1.913-6.027a.985.985 0 0 1-1.393 1.393L4.95 10.344A.985.985 0 0 1 6.344 8.95l1.114 1.114Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 838 B

After

Width:  |  Height:  |  Size: 838 B

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm6 0H5v4h4V5ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm6 0H5v4h4v-4Zm4-10a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V5Zm6 0h-4v4h4V5Zm-5 8a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Zm3 1a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm0 4a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-2Z" clip-rule="evenodd"/></svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080b12" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm6 0H5v4h4V5ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm6 0H5v4h4v-4Zm4-10a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V5Zm6 0h-4v4h4V5Zm-5 8a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Zm3 1a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm0 4a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-2Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 579 B

After

Width:  |  Height:  |  Size: 579 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" d="M20.002 7a2 2 0 0 0-2-2h-12a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2a1 1 0 0 1 1 1v1.918l3.375-2.7a1 1 0 0 1 .625-.218h5a2 2 0 0 0 2-2V7Zm2 8a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080b12" d="M20.002 7a2 2 0 0 0-2-2h-12a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2a1 1 0 0 1 1 1v1.918l3.375-2.7a1 1 0 0 1 .625-.218h5a2 2 0 0 0 2-2V7Zm2 8a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z"/></svg>

Before

Width:  |  Height:  |  Size: 355 B

After

Width:  |  Height:  |  Size: 355 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" d="M22.002 15a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080b12" d="M22.002 15a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z"/></svg>

Before

Width:  |  Height:  |  Size: 233 B

After

Width:  |  Height:  |  Size: 233 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 153 133"><path fill="url(#a)" fill-rule="evenodd" d="m60.196 105.445-18.1 4.85c-11.73 3.143-23.788-3.819-26.931-15.55L1.19 42.597c-3.143-11.731 3.819-23.79 15.55-26.932L68.889 1.69C80.62-1.452 92.68 5.51 95.821 17.241l4.667 17.416a50 50 0 0 1 3.522-.125c27.053 0 48.984 21.931 48.984 48.984S131.063 132.5 104.01 132.5c-19.17 0-35.769-11.012-43.814-27.055ZM19.457 25.804 71.606 11.83c6.131-1.643 12.434 1.996 14.076 8.127l4.44 16.571c-20.289 5.987-35.096 24.758-35.096 46.988 0 4.157.517 8.193 1.492 12.047l-17.138 4.593c-6.131 1.642-12.434-1.996-14.077-8.128L11.33 39.88c-1.643-6.131 1.996-12.434 8.127-14.077Zm83.812 19.232q.369-.007.741-.007c21.256 0 38.487 17.231 38.487 38.487s-17.231 38.488-38.487 38.488c-14.29 0-26.76-7.788-33.4-19.35l23.635-6.333c11.731-3.143 18.693-15.2 15.55-26.932l-6.526-24.353Zm-10.428 1.638 6.815 25.432c1.642 6.131-1.996 12.434-8.128 14.076l-24.867 6.664a38.6 38.6 0 0 1-1.139-9.33c0-17.372 11.51-32.056 27.32-36.842Z" clip-rule="evenodd"/><defs><linearGradient id="a" x1="76.715" x2="76.715" y1=".937" y2="132.5" gradientUnits="userSpaceOnUse"><stop stop-color="#0A7AFF"/><stop offset="1" stop-color="#59B9FF"/></linearGradient></defs></svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 153 133"><path fill="url(#a)" fill-rule="evenodd" d="m60.196 105.445-18.1 4.85c-11.73 3.143-23.788-3.819-26.931-15.55L1.19 42.597c-3.143-11.731 3.819-23.79 15.55-26.932L68.889 1.69C80.62-1.452 92.68 5.51 95.821 17.241l4.667 17.416a50 50 0 0 1 3.522-.125c27.053 0 48.984 21.931 48.984 48.984S131.063 132.5 104.01 132.5c-19.17 0-35.769-11.012-43.814-27.055ZM19.457 25.804 71.606 11.83c6.131-1.643 12.434 1.996 14.076 8.127l4.44 16.571c-20.289 5.987-35.096 24.758-35.096 46.988 0 4.157.517 8.193 1.492 12.047l-17.138 4.593c-6.131 1.642-12.434-1.996-14.077-8.128L11.33 39.88c-1.643-6.131 1.996-12.434 8.127-14.077Zm83.812 19.232q.369-.007.741-.007c21.256 0 38.487 17.231 38.487 38.487s-17.231 38.488-38.487 38.488c-14.29 0-26.76-7.788-33.4-19.35l23.635-6.333c11.731-3.143 18.693-15.2 15.55-26.932l-6.526-24.353Zm-10.428 1.638 6.815 25.432c1.642 6.131-1.996 12.434-8.128 14.076l-24.867 6.664a38.6 38.6 0 0 1-1.139-9.33c0-17.372 11.51-32.056 27.32-36.842Z" clip-rule="evenodd"/><defs><linearGradient id="a" x1="76.715" x2="76.715" y1=".937" y2="132.5" gradientUnits="userSpaceOnUse"><stop stop-color="#0a7aff"/><stop offset="1" stop-color="#59b9ff"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="11.5" fill="#1183FE"/><path fill="#fff" fill-rule="evenodd" d="M17.659 8.175a1.36 1.36 0 0 1 0 1.925l-6.224 6.223a1.36 1.36 0 0 1-1.925 0L6.4 13.212a1.361 1.361 0 0 1 1.925-1.925l2.149 2.148 5.26-5.26a1.36 1.36 0 0 1 1.925 0Z" clip-rule="evenodd"/></svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="11.5" fill="#1183fe"/><path fill="#fff" fill-rule="evenodd" d="M17.659 8.175a1.36 1.36 0 0 1 0 1.925l-6.224 6.223a1.36 1.36 0 0 1-1.925 0L6.4 13.212a1.361 1.361 0 0 1 1.925-1.925l2.149 2.148 5.26-5.26a1.36 1.36 0 0 1 1.925 0Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 354 B

After

Width:  |  Height:  |  Size: 354 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1185FE" d="M8.792 1.615a4.154 4.154 0 0 1 6.416 0 4.15 4.15 0 0 0 3.146 1.515 4.154 4.154 0 0 1 4 5.017 4.15 4.15 0 0 0 .777 3.404 4.154 4.154 0 0 1-1.427 6.255 4.15 4.15 0 0 0-2.177 2.73 4.154 4.154 0 0 1-5.781 2.784 4.15 4.15 0 0 0-3.492 0 4.154 4.154 0 0 1-5.78-2.784 4.15 4.15 0 0 0-2.178-2.73A4.154 4.154 0 0 1 .87 11.551a4.15 4.15 0 0 0 .776-3.404 4.154 4.154 0 0 1 4-5.017 4.15 4.15 0 0 0 3.146-1.515Z"/><path fill="#fff" fill-rule="evenodd" d="M17.861 8.26a1.44 1.44 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L5.97 13.58a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z" clip-rule="evenodd"/></svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1185fe" d="M8.792 1.615a4.154 4.154 0 0 1 6.416 0 4.15 4.15 0 0 0 3.146 1.515 4.154 4.154 0 0 1 4 5.017 4.15 4.15 0 0 0 .777 3.404 4.154 4.154 0 0 1-1.427 6.255 4.15 4.15 0 0 0-2.177 2.73 4.154 4.154 0 0 1-5.781 2.784 4.15 4.15 0 0 0-3.492 0 4.154 4.154 0 0 1-5.78-2.784 4.15 4.15 0 0 0-2.178-2.73A4.154 4.154 0 0 1 .87 11.551a4.15 4.15 0 0 0 .776-3.404 4.154 4.154 0 0 1 4-5.017 4.15 4.15 0 0 0 3.146-1.515Z"/><path fill="#fff" fill-rule="evenodd" d="M17.861 8.26a1.44 1.44 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L5.97 13.58a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 733 B

After

Width:  |  Height:  |  Size: 733 B

+1 -1
View File
@@ -6,7 +6,7 @@
"devEngines": { "devEngines": {
"packageManager": { "packageManager": {
"name": "pnpm", "name": "pnpm",
"version": "11.10.0", "version": "11.13.1",
"onFail": "warn" "onFail": "warn"
} }
}, },
+38 -38
View File
@@ -7,52 +7,52 @@ importers:
configDependencies: {} configDependencies: {}
packageManagerDependencies: packageManagerDependencies:
'@pnpm/exe': '@pnpm/exe':
specifier: 11.5.2 specifier: 11.13.1
version: 11.5.2 version: 11.13.1
pnpm: pnpm:
specifier: 11.5.2 specifier: 11.13.1
version: 11.5.2 version: 11.13.1
packages: packages:
'@pnpm/exe@11.5.2': '@pnpm/exe@11.13.1':
resolution: {integrity: sha512-4UFnP2rhNu1xjAQ+I1GdIUUEtCJuTYJlbpiWSFA4POAID3Lpt+2vrjImWO7eOJ7iCY3vpc4TFe2IW3sAolW4Kg==} resolution: {integrity: sha512-P4euEK6lOFnd5oTHEc5M/HhvyF4XUhTnVsklEcM6rmY0QJxPD6xbT+u1+gskEIBp4nSRorz20IJQtAU1Nerggg==}
hasBin: true hasBin: true
'@pnpm/linux-arm64@11.5.2': '@pnpm/linux-arm64@11.13.1':
resolution: {integrity: sha512-MbJySnu2y9cCBqlODLjUlZ87JnRC3Inq40rvGHWJSrSQ0PnuHeSw2NDMnLI8Hf9hCY+ooussRc5iiR4IAkjUvg==} resolution: {integrity: sha512-wB8zloqrYrudPyuA5qbuTCnJGe4eETPwqOjoPjoyyyvA4zFI5XfLpxgqOOcaY5UJBoqzckcGpRVDwhSRfsQ/6A==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@pnpm/linux-x64@11.5.2': '@pnpm/linux-x64@11.13.1':
resolution: {integrity: sha512-g6g2BGpQA47wUACy6B1MdeSHPtnl6x4AeCg0IOWQ7xXorEtC+VRiSHhLpA5kByFGeSwyYh/nLc7mLul5DAaELw==} resolution: {integrity: sha512-A+wnEvzfWEvanXiwww3tnOPmtjPSrrf5tOP6vk8+K0BRFEe/Df0oPytm2nWgGcn5iwPnqtr1Btkof913McnSPA==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@pnpm/linuxstatic-arm64@11.5.2': '@pnpm/linuxstatic-arm64@11.13.1':
resolution: {integrity: sha512-xTxs9BLxYW39BPNGnmvYCUBnMPWm4mzmzujmdYbpRxDnBXrx55qPR5K/3LSohX7VrmsdDrYxuH6AmG1AaOlIfA==} resolution: {integrity: sha512-k4t65VeqRX4COMFe45TF58CVmCpmAsKZShaR1HobmUeleo98mWTctggKolrA2MHcVUeSS+12yB5Urb3uDazhmw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@pnpm/linuxstatic-x64@11.5.2': '@pnpm/linuxstatic-x64@11.13.1':
resolution: {integrity: sha512-RGmmc/SoGLD90gmOHcU85UEKNoNRstLvizli4wzDASmETz/VeqJOqU5nD1YBgjzcP72sUMS352dh4bmzTfKyvQ==} resolution: {integrity: sha512-A65GqPzwCl0bAMk3kRWfbjSRBm5RRaqR2oMxV/9AYZrwO0X9yEfngbLBISCPHjt6/Qe4nH7DFemyhy6yODYwEw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@pnpm/macos-arm64@11.5.2': '@pnpm/macos-arm64@11.13.1':
resolution: {integrity: sha512-gW3A2jRlC3SJRw8qX2SAzjMIu9o98daTSqCKzeeYcjF/uEbtbz3dn4HqYrYffBnenKbc4hsgZQmNOHAvUKIlSg==} resolution: {integrity: sha512-MJvOtyGOWSfBoqdVEfAH8ljmHs13mt82k/UxN4f+q7koDxJRR2n4Nie6Og6RwbnbaubCz0Fh2bTeL1+MxDSFpA==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@pnpm/win-arm64@11.5.2': '@pnpm/win-arm64@11.13.1':
resolution: {integrity: sha512-+VJCDoH/pRzLXBikwjvxgAnGfQufT8EALBX8cfSmrwD40JABUZvgPtjBjde7OwEoK/XwtlH8w+ZceFV0K3/YHQ==} resolution: {integrity: sha512-kl/g1cCKOJPe4HntspyrAJW0LRco0UHnVfxHSspezo4Zj4AanJAZ8WzLqfa6/w3lBSKHTEN4x0pb3m4J7B7Vpw==}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@pnpm/win-x64@11.5.2': '@pnpm/win-x64@11.13.1':
resolution: {integrity: sha512-zgglREh75RbFgV/E0tNRS03ElX+hJOV43KRSSeaboxtj3ei1rrguxOgOCXUs/GsizoHVsuD+qXGABE4Kc4GMCg==} resolution: {integrity: sha512-Bcb14NeBlbHS2Gq1qr8VnCiAz5eC1lYzXOls7zH0bnV0Taaj4/xyfm0HVO4dn9R2TQtVtz1qnBZHHL9PDFumqQ==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -116,45 +116,45 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
pnpm@11.5.2: pnpm@11.13.1:
resolution: {integrity: sha512-ccYx44IGbvwlYl1c8CkHXeB7YbN/bic1D72Esb2lhkyMGWetwoB3a0XDCnFcA1mjvgj+9C1bsJ4rmQKZeWkpFg==} resolution: {integrity: sha512-svx2g7imUlQU59E+G6KMqt3elr9m7FQL+ut+cCuB8+C+TR8pXt9/n+A5Z0Co3ORQnFgt33mJH0VD/qMtN2RfJQ==}
engines: {node: '>=22.13'} engines: {node: '>=22.13'}
hasBin: true hasBin: true
snapshots: snapshots:
'@pnpm/exe@11.5.2': '@pnpm/exe@11.13.1':
dependencies: dependencies:
'@reflink/reflink': 0.1.19 '@reflink/reflink': 0.1.19
detect-libc: 2.1.2 detect-libc: 2.1.2
optionalDependencies: optionalDependencies:
'@pnpm/linux-arm64': 11.5.2 '@pnpm/linux-arm64': 11.13.1
'@pnpm/linux-x64': 11.5.2 '@pnpm/linux-x64': 11.13.1
'@pnpm/linuxstatic-arm64': 11.5.2 '@pnpm/linuxstatic-arm64': 11.13.1
'@pnpm/linuxstatic-x64': 11.5.2 '@pnpm/linuxstatic-x64': 11.13.1
'@pnpm/macos-arm64': 11.5.2 '@pnpm/macos-arm64': 11.13.1
'@pnpm/win-arm64': 11.5.2 '@pnpm/win-arm64': 11.13.1
'@pnpm/win-x64': 11.5.2 '@pnpm/win-x64': 11.13.1
'@pnpm/linux-arm64@11.5.2': '@pnpm/linux-arm64@11.13.1':
optional: true optional: true
'@pnpm/linux-x64@11.5.2': '@pnpm/linux-x64@11.13.1':
optional: true optional: true
'@pnpm/linuxstatic-arm64@11.5.2': '@pnpm/linuxstatic-arm64@11.13.1':
optional: true optional: true
'@pnpm/linuxstatic-x64@11.5.2': '@pnpm/linuxstatic-x64@11.13.1':
optional: true optional: true
'@pnpm/macos-arm64@11.5.2': '@pnpm/macos-arm64@11.13.1':
optional: true optional: true
'@pnpm/win-arm64@11.5.2': '@pnpm/win-arm64@11.13.1':
optional: true optional: true
'@pnpm/win-x64@11.5.2': '@pnpm/win-x64@11.13.1':
optional: true optional: true
'@reflink/reflink-darwin-arm64@0.1.19': '@reflink/reflink-darwin-arm64@0.1.19':
@@ -194,7 +194,7 @@ snapshots:
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
pnpm@11.5.2: {} pnpm@11.13.1: {}
--- ---
lockfileVersion: '9.0' lockfileVersion: '9.0'
File diff suppressed because it is too large Load Diff
-319
View File
@@ -1,319 +0,0 @@
// @ts-check
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import {defineConfig} from 'eslint/config'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
// @ts-expect-error no types
import reactNative from 'eslint-plugin-react-native'
// @ts-expect-error no types
import reactNativeA11y from 'eslint-plugin-react-native-a11y'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import importX from 'eslint-plugin-import-x'
import lingui from 'eslint-plugin-lingui'
import reactCompiler from 'eslint-plugin-react-compiler'
import bskyInternal from 'eslint-plugin-bsky-internal'
import globals from 'globals'
import tsParser from '@typescript-eslint/parser'
export default defineConfig(
/**
* Global ignores
*/
{
ignores: [
'**/__mocks__/*.ts',
'ios/**',
'android/**',
'coverage/**',
'*.lock',
'.husky/**',
'patches/**',
'*.html',
'bskyweb/**',
'bskyembed/**',
'bskyogcard/**',
'src/locale/locales/_build/**',
'src/locale/locales/**/*.js',
'*.e2e.ts',
'*.e2e.tsx',
'eslint.config.mjs',
'babel.config.js',
'metro.config.js',
'.jscodeshift/**',
],
},
/**
* Base configurations
*/
js.configs.recommended,
tseslint.configs.recommendedTypeChecked,
reactHooks.configs.flat.recommended,
importX.flatConfigs.recommended,
importX.flatConfigs.typescript,
importX.flatConfigs['react-native'],
/**
* Main configuration for all JS/TS/JSX/TSX files
*/
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
react,
'react-native': reactNative,
'react-native-a11y': reactNativeA11y,
'simple-import-sort': simpleImportSort,
// @ts-expect-error - not sure why
lingui,
'react-compiler': reactCompiler,
'bsky-internal': bskyInternal,
},
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
parser: tsParser,
projectService: true,
tsconfigRootDir: import.meta.dirname,
ecmaFeatures: {
jsx: true,
},
},
},
settings: {
react: {
version: 'detect',
},
componentWrapperFunctions: ['observer'],
},
rules: {
/**
* Custom rules
*/
'bsky-internal/avoid-unwrapped-text': [
'error',
{
impliedTextComponents: [
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'P',
'Admonition',
'Admonition.Admonition',
'Toast.Action',
'AgeAssuranceAdmonition',
'Span',
'StackedButton',
],
impliedTextProps: [],
suggestedTextWrappers: {
Button: 'ButtonText',
'ToggleButton.Button': 'ToggleButton.ButtonText',
'SegmentedControl.Item': 'SegmentedControl.ItemText',
},
},
],
'bsky-internal/use-exact-imports': 'error',
'bsky-internal/use-prefixed-imports': 'error',
'bsky-internal/lingui-msg-rule': 'error',
/**
* React & React Native
*/
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
'react/hook-use-state': 'warn',
'react/no-unescaped-entities': 'off',
'react/prop-types': 'off',
'react-native/no-inline-styles': 'off',
...reactNativeA11y.configs.all.rules,
'react-compiler/react-compiler': 'warn',
'react-hooks/set-state-in-effect': 'error',
'react-hooks/purity': 'error',
'react-hooks/refs': 'error',
'react-hooks/immutability': 'error',
/**
* Import sorting
*/
'simple-import-sort/imports': [
'error',
{
groups: [
// Side effect imports.
['^\\u0000'],
// Node.js builtins prefixed with `node:`.
['^node:'],
// Packages.
// Things that start with a letter (or digit or underscore), or `@` followed by a letter.
// React/React Native prioritized, followed by expo
// Followed by all packages excluding unprefixed relative ones
[
'^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)',
'^(expo(.*)$)|^(expo$)',
'^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w',
],
// Relative imports.
// Ideally, anything that starts with a dot or #
// due to unprefixed relative imports being used, we whitelist the relative paths we use
// (?:$|\\/) matches end of string or /
[
'^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)',
'^(?:#\\/)?view(?:$|\\/)',
'^(?:#\\/)?screens(?:$|\\/)',
'^(?:#\\/)?alf(?:$|\\/)',
'^(?:#\\/)?components(?:$|\\/)',
'^#\\/',
'^\\.',
],
// anything else - hopefully we don't have any of these
['^'],
],
},
],
'simple-import-sort/exports': 'error',
/**
* Import linting
*/
'import-x/consistent-type-specifier-style': ['warn', 'prefer-inline'],
'import-x/no-unresolved': [
'error',
{
/*
* The `postinstall` hook runs `compile-if-needed` locally, but not in
* CI. For CI-sake, ignore this.
*/
ignore: ['^#\/locale\/locales\/.+\/messages'],
},
],
'import-x/no-extraneous-dependencies': [
'error',
{
whitelist: [
// test files only
'@jest/globals',
// we only use a really simple util from this, and we know it will be present
'expo-modules-core',
// this is a dep for @atproto/api, but we absolutely need them in sync, so just
// rely on the transient version
'@atproto/common-web',
],
},
],
'import-x/no-nodejs-modules': 'error',
/**
* TypeScript-specific rules
*/
'no-unused-vars': 'off', // off, we use TS-specific rule below
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_.+',
caughtErrors: 'none',
ignoreRestSiblings: true,
},
],
'@typescript-eslint/consistent-type-imports': [
'warn',
{prefer: 'type-imports', fixStyle: 'inline-type-imports'},
],
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-expressions': [
'error',
{
allowTernary: true,
},
],
/**
* Maintain previous behavior via eslint-suppressions.json - these are
* stricter in typescript-eslint v8. `off` ones are a bit too nit-picky.
*/
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/require-await': 'error',
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/no-redundant-type-constituents': 'error',
'@typescript-eslint/no-duplicate-type-constituents': 'error',
'@typescript-eslint/no-base-to-string': 'error',
'@typescript-eslint/prefer-promise-reject-errors': 'error',
'@typescript-eslint/await-thenable': 'error',
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'react',
importNames: ['React', 'default'],
message:
'React is already in the global type namespace. Use named imports for runtime modules.',
},
],
},
],
/**
* Turn off rules that we haven't enforced thus far
*/
'no-empty-pattern': 'off',
'no-async-promise-executor': 'off',
'no-constant-binary-expression': 'warn',
'prefer-const': 'off',
'no-empty': 'off',
'no-unsafe-optional-chaining': 'off',
'no-prototype-builtins': 'off',
'no-var': 'off',
'prefer-rest-params': 'off',
'no-case-declarations': 'off',
'no-irregular-whitespace': 'off',
'no-useless-escape': 'off',
'no-sparse-arrays': 'off',
'no-fallthrough': 'off',
'no-control-regex': 'off',
},
},
/**
* bskyogcard, dev-env - server-side, Node.js imports are fine
*/
{
files: ['bskyogcard/**/*.{js,jsx,ts,tsx}', 'dev-env/**/*.{js,jsx,ts,tsx}'],
rules: {
'import-x/no-nodejs-modules': 'off',
},
},
/**
* Test files configuration
*/
{
files: ['**/__tests__/**/*.{js,jsx,ts,tsx}', '**/*.test.{js,jsx,ts,tsx}'],
languageOptions: {
globals: {
...globals.jest,
},
},
},
)
@@ -1,16 +1,11 @@
const {RuleTester} = require('eslint') const {RuleTester} = require('oxlint/plugins-dev')
const tseslint = require('typescript-eslint')
const avoidUnwrappedText = require('../avoid-unwrapped-text') const avoidUnwrappedText = require('../avoid-unwrapped-text')
const ruleTester = new RuleTester({ const ruleTester = new RuleTester({
eslintCompat: true,
languageOptions: { languageOptions: {
parser: tseslint.parser,
parserOptions: { parserOptions: {
ecmaFeatures: { lang: 'tsx',
jsx: true,
},
ecmaVersion: 'latest',
sourceType: 'module',
}, },
}, },
}) })
@@ -1,16 +1,11 @@
const {RuleTester} = require('eslint') const {RuleTester} = require('oxlint/plugins-dev')
const tseslint = require('typescript-eslint')
const linguiMsgRule = require('../lingui-msg-rule') const linguiMsgRule = require('../lingui-msg-rule')
const ruleTester = new RuleTester({ const ruleTester = new RuleTester({
eslintCompat: true,
languageOptions: { languageOptions: {
parser: tseslint.parser,
parserOptions: { parserOptions: {
ecmaFeatures: { lang: 'tsx',
jsx: true,
},
ecmaVersion: 'latest',
sourceType: 'module',
}, },
}, },
}) })
+3
View File
@@ -0,0 +1,3 @@
## Eslint plugin tests
These are disabled as Oxlints RuleTester doesnt work well with Hermes.
-40
View File
@@ -1,40 +0,0 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const {getSentryExpoConfig} = require('@sentry/react-native/metro')
const cfg = getSentryExpoConfig(__dirname)
// inject `.e2e.ts` and `.e2e.tsx` into the sourceExts when running tests
cfg.resolver.sourceExts = process.env.RN_SRC_EXT
? process.env.RN_SRC_EXT.split(',').concat(cfg.resolver.sourceExts)
: cfg.resolver.sourceExts
if (cfg.resolver.resolveRequest) {
throw Error('Update this override because it is conflicting now.')
}
if (process.env.BSKY_PROFILE) {
cfg.cacheVersion += ':PROFILE'
}
cfg.resolver.assetExts = [...cfg.resolver.assetExts, 'woff2']
cfg.resolver.resolveRequest = (context, moduleName, platform) => {
if (process.env.BSKY_PROFILE) {
if (moduleName.endsWith('ReactNativeRenderer-prod')) {
return context.resolveRequest(
context,
moduleName.replace('-prod', '-profiling'),
platform,
)
}
}
return context.resolveRequest(context, moduleName, platform)
}
cfg.transformer.getTransformOptions = async () => ({
transform: {
experimentalImportSupport: true,
inlineRequires: true,
},
})
module.exports = cfg
+57
View File
@@ -0,0 +1,57 @@
// Learn more https://docs.expo.io/guides/customizing-metro
import {type CustomResolver} from '@expo/metro/metro-resolver'
import {getDefaultConfig} from '@expo/metro-config'
import {getSentryExpoConfig} from '@sentry/react-native/metro.js'
const config = getSentryExpoConfig(import.meta.dirname, {
// TODO: confirm this doesn't break anything when we switch to metro web
includeWebReplay: false,
annotateReactComponents: {
textComponentNames: ['Text', 'ButtonText'],
},
getDefaultConfig: (projectRoot, options) => {
const config = getDefaultConfig(projectRoot, options)
if (typeof process.env.RN_SRC_EXT === 'string') {
// inject `.e2e.ts` and `.e2e.tsx` into the sourceExts when running tests)
config.resolver.sourceExts.unshift(...process.env.RN_SRC_EXT.split(','))
}
config.resolver.assetExts = [...config.resolver.assetExts, 'woff2']
if (config.resolver.resolveRequest) {
throw Error('Update this override because it is conflicting now.')
}
if (process.env.BSKY_PROFILE) {
// @ts-expect-error readonly property
config.cacheVersion += ':PROFILE'
const resolver: CustomResolver = (context, moduleName, platform) => {
if (moduleName.endsWith('ReactNativeRenderer-prod')) {
return context.resolveRequest(
context,
moduleName.replace('-prod', '-profiling'),
platform,
)
}
return context.resolveRequest(context, moduleName, platform)
}
// @ts-expect-error readonly property
config.resolver.resolveRequest = resolver
}
config.transformer.getTransformOptions = () =>
Promise.resolve({
transform: {
experimentalImportSupport: true,
inlineRequires: true as false, // ??? typescript why?
},
})
return config as unknown as Record<string, unknown>
},
})
export default config
@@ -3,6 +3,7 @@ import AVKit
let IMAGE_EXTENSIONS: [String] = ["png", "jpg", "jpeg", "gif", "heic"] let IMAGE_EXTENSIONS: [String] = ["png", "jpg", "jpeg", "gif", "heic"]
let MOVIE_EXTENSIONS: [String] = ["mov", "mp4", "m4v"] let MOVIE_EXTENSIONS: [String] = ["mov", "mp4", "m4v"]
let MAX_IMAGES = 10
enum URLType: String, CaseIterable { enum URLType: String, CaseIterable {
case image case image
@@ -71,17 +72,12 @@ class ShareViewController: UIViewController {
} }
private func handleImages(items: [NSItemProvider]) async { private func handleImages(items: [NSItemProvider]) async {
let firstFourItems: [NSItemProvider] let itemsToProcess = Array(items.prefix(MAX_IMAGES))
if items.count < 4 {
firstFourItems = items
} else {
firstFourItems = Array(items[0...3])
}
var valid = true var valid = true
var imageUris = "" var imageUris = ""
for (index, item) in firstFourItems.enumerated() { for (index, item) in itemsToProcess.enumerated() {
var imageUriInfo: String? var imageUriInfo: String?
do { do {
@@ -100,7 +96,7 @@ class ShareViewController: UIViewController {
if let imageUriInfo = imageUriInfo { if let imageUriInfo = imageUriInfo {
imageUris.append(imageUriInfo) imageUris.append(imageUriInfo)
if index < items.count - 1 { if index < itemsToProcess.count - 1 {
imageUris.append(",") imageUris.append(",")
} }
} else { } else {
@@ -121,7 +117,6 @@ class ShareViewController: UIViewController {
let firstItem = items.first let firstItem = items.first
if let dataUrl = try? await firstItem?.loadItem(forTypeIdentifier: "public.movie") as? URL { 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), if let videoUriInfo = saveVideoWithInfo(dataUrl),
let url = URL(string: "\(self.appScheme)://intent/compose?videoUri=\(videoUriInfo)") { let url = URL(string: "\(self.appScheme)://intent/compose?videoUri=\(videoUriInfo)") {
_ = self.openURL(url) _ = self.openURL(url)
+6 -3
View File
@@ -32,9 +32,12 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
var cornerRadius: CGFloat? var cornerRadius: CGFloat?
var sourceViewTag: Int? var sourceViewTag: Int?
var minHeight = 0.0 var minHeight = 0.0
var maxHeight: CGFloat! { // getScreenHeight() is nil when no window scene is connected yet (e.g. during
// prewarming or a background launch). A nil here previously trapped in
// clampHeight, so fall back to the full screen bounds instead.
var maxHeight: CGFloat = UIScreen.main.bounds.height {
didSet { didSet {
let screenHeight = Util.getScreenHeight() ?? 0 let screenHeight = Util.getScreenHeight() ?? UIScreen.main.bounds.height
if maxHeight > screenHeight { if maxHeight > screenHeight {
maxHeight = screenHeight maxHeight = screenHeight
} }
@@ -77,7 +80,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
required init (appContext: AppContext? = nil) { required init (appContext: AppContext? = nil) {
super.init(appContext: appContext) super.init(appContext: appContext)
self.maxHeight = Util.getScreenHeight() self.maxHeight = Util.getScreenHeight() ?? UIScreen.main.bounds.height
self.touchHandler = RCTTouchHandler(bridge: appContext?.reactBridge) self.touchHandler = RCTTouchHandler(bridge: appContext?.reactBridge)
SheetManager.shared.add(self) SheetManager.shared.add(self)
} }
@@ -1,5 +1,14 @@
import {Component} from 'react'
import {type BottomSheetViewProps} from './BottomSheet.types' import {type BottomSheetViewProps} from './BottomSheet.types'
export function BottomSheetNativeComponent(_: BottomSheetViewProps) { export class BottomSheetNativeComponent extends Component<BottomSheetViewProps> {
throw new Error('BottomSheetNativeComponent is not available on web') /*
* Native sheets do not exist on web; there is nothing to dismiss.
*/
static dismissAll = async () => {}
render(): never {
throw new Error('BottomSheetNativeComponent is not available on web')
}
} }
@@ -6,7 +6,15 @@ export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo
throw new NotImplementedError() throw new NotImplementedError()
} }
export function getReferrerInfo(): ReferrerInfo | null { /*
* Promise-returning for parity with Android, whose native referrer API only
* exposes a promise.
*/
export function getReferrerInfo(): Promise<ReferrerInfo | null> {
return Promise.resolve(getReferrerInfoSync())
}
function getReferrerInfoSync(): ReferrerInfo | null {
const referrer = SharedPrefs.getString('referrer') const referrer = SharedPrefs.getString('referrer')
if (referrer) { if (referrer) {
SharedPrefs.removeValue('referrer') SharedPrefs.removeValue('referrer')
@@ -5,6 +5,6 @@ export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo
throw new NotImplementedError() throw new NotImplementedError()
} }
export function getReferrerInfo(): ReferrerInfo | null { export function getReferrerInfo(): Promise<ReferrerInfo | null> {
throw new NotImplementedError() throw new NotImplementedError()
} }
@@ -7,7 +7,15 @@ export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo
throw new NotImplementedError() throw new NotImplementedError()
} }
export function getReferrerInfo(): ReferrerInfo | null { /*
* Promise-returning for parity with Android, whose native referrer API only
* exposes a promise.
*/
export function getReferrerInfo(): Promise<ReferrerInfo | null> {
return Promise.resolve(getReferrerInfoSync())
}
function getReferrerInfoSync(): ReferrerInfo | null {
if ( if (
Platform.OS === 'web' && Platform.OS === 'web' &&
// for ssr // for ssr
@@ -15,6 +15,7 @@ import java.io.FileOutputStream
import java.net.URLEncoder import java.net.URLEncoder
private const val TAG = "ExpoReceiveAndroidIntents" private const val TAG = "ExpoReceiveAndroidIntents"
private const val MAX_IMAGES = 10
enum class AttachmentType { enum class AttachmentType {
IMAGE, IMAGE,
@@ -100,12 +101,12 @@ class ExpoReceiveAndroidIntentsModule : Module() {
intent intent
.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) .getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
?.filterIsInstance<Uri>() ?.filterIsInstance<Uri>()
?.take(4) ?.take(MAX_IMAGES)
} else { } else {
intent intent
.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM) .getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
?.filterIsInstance<Uri>() ?.filterIsInstance<Uri>()
?.take(4) ?.take(MAX_IMAGES)
} }
val text = intent.getStringExtra(Intent.EXTRA_TEXT) val text = intent.getStringExtra(Intent.EXTRA_TEXT)
File diff suppressed because it is too large Load Diff
+16 -20
View File
@@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.127.1", "version": "1.129.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=24.18.0" "node": ">=24.18.0"
@@ -8,7 +8,7 @@
"devEngines": { "devEngines": {
"packageManager": { "packageManager": {
"name": "pnpm", "name": "pnpm",
"version": "11.10.0", "version": "11.13.1",
"onFail": "warn" "onFail": "warn"
}, },
"runtime": { "runtime": {
@@ -59,10 +59,13 @@
"test-watch": "NODE_ENV=test jest --watchAll", "test-watch": "NODE_ENV=test jest --watchAll",
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit", "test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "NODE_ENV=test jest --coverage", "test-coverage": "NODE_ENV=test jest --coverage",
"lint": "eslint --cache --quiet src modules", "lint": "oxlint --quiet src modules",
"lint-native": "swiftlint ./modules && ktlint ./modules", "lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules", "lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsgo --project ./tsconfig.check.json", "typecheck": "pnpm run typecheck:ios && pnpm run typecheck:android && pnpm run typecheck:web",
"typecheck:ios": "tsgo --project ./tsconfig.check.ios.json",
"typecheck:android": "tsgo --project ./tsconfig.check.android.json",
"typecheck:web": "tsgo --project ./tsconfig.check.web.json",
"e2e:mock-server": "cd dev-env && pnpm start", "e2e:mock-server": "cd dev-env && pnpm start",
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios", "e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android", "e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
@@ -93,9 +96,9 @@
"prettier": "prettier --check ." "prettier": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.20.25", "@atproto/api": "0.20.28",
"@atproto/common-web": "0.5.3", "@atproto/common-web": "0.5.6",
"@atproto/syntax": "0.6.4", "@atproto/syntax": "0.7.2",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.14", "@bsky.app/alf": "^0.1.14",
@@ -128,7 +131,7 @@
"@react-navigation/bottom-tabs": "^7.15.5", "@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/native": "^7.1.33", "@react-navigation/native": "^7.1.33",
"@react-navigation/native-stack": "^7.14.4", "@react-navigation/native-stack": "^7.14.4",
"@sentry/react-native": "~6.20.0", "@sentry/react-native": "~8.18.0",
"@tanstack/query-async-storage-persister": "^5.96.2", "@tanstack/query-async-storage-persister": "^5.96.2",
"@tanstack/react-query": "^5.96.2", "@tanstack/react-query": "^5.96.2",
"@tanstack/react-query-persist-client": "^5.96.2", "@tanstack/react-query-persist-client": "^5.96.2",
@@ -140,7 +143,6 @@
"@tiptap/extension-paragraph": "^2.9.1", "@tiptap/extension-paragraph": "^2.9.1",
"@tiptap/extension-placeholder": "^2.9.1", "@tiptap/extension-placeholder": "^2.9.1",
"@tiptap/extension-text": "^2.9.1", "@tiptap/extension-text": "^2.9.1",
"@tiptap/html": "^2.9.1",
"@tiptap/pm": "^2.9.1", "@tiptap/pm": "^2.9.1",
"@tiptap/react": "^2.9.1", "@tiptap/react": "^2.9.1",
"@tiptap/suggestion": "^2.9.1", "@tiptap/suggestion": "^2.9.1",
@@ -257,7 +259,6 @@
"@babel/preset-env": "^7.26.0", "@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0", "@babel/runtime": "^7.26.0",
"@crowdin/cli": "^4.14.1", "@crowdin/cli": "^4.14.1",
"@eslint/js": "^9.39.2",
"@lingui/babel-plugin-lingui-macro": "^5.9.2", "@lingui/babel-plugin-lingui-macro": "^5.9.2",
"@lingui/cli": "^5.9.2", "@lingui/cli": "^5.9.2",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
@@ -277,14 +278,8 @@
"babel-plugin-module-resolver": "^5.0.2", "babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "19.1.0-rc.3", "babel-plugin-react-compiler": "19.1.0-rc.3",
"babel-preset-expo": "~54.0.10", "babel-preset-expo": "~54.0.10",
"eslint": "^9.39.2",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-bsky-internal": "link:eslint", "eslint-plugin-bsky-internal": "link:eslint",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-lingui": "^0.12.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "19.1.0-rc.2", "eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-native": "^5.0.0", "eslint-plugin-react-native": "^5.0.0",
"eslint-plugin-react-native-a11y": "^3.5.1", "eslint-plugin-react-native-a11y": "^3.5.1",
"eslint-plugin-simple-import-sort": "^13.0.0", "eslint-plugin-simple-import-sort": "^13.0.0",
@@ -295,14 +290,15 @@
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-expo": "~54.0.17", "jest-expo": "~54.0.17",
"jest-junit": "^16.0.0", "jest-junit": "^16.0.0",
"lint-staged": "^13.2.3", "lint-staged": "^17.0.8",
"oxlint": "^1.73.0",
"oxlint-tsgolint": "^0.24.0",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"react-native-dotenv": "^3.4.11", "react-native-dotenv": "^3.4.11",
"react-refresh": "^0.14.0", "react-refresh": "^0.14.0",
"svgo": "^3.3.2", "svgo": "^4.0.2",
"ts-plugin-sort-import-suggestions": "^1.0.4", "ts-plugin-sort-import-suggestions": "^1.0.4",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"typescript-eslint": "^8.58.0",
"webpack-bundle-analyzer": "^4.10.1" "webpack-bundle-analyzer": "^4.10.1"
}, },
"jest": { "jest": {
@@ -367,7 +363,7 @@
}, },
"lint-staged": { "lint-staged": {
"*{.js,.jsx,.ts,.tsx}": [ "*{.js,.jsx,.ts,.tsx}": [
"eslint --cache --fix" "oxlint --fix"
], ],
"*{.js,.jsx,.ts,.tsx,.css}": [ "*{.js,.jsx,.ts,.tsx,.css}": [
"prettier --cache --write --ignore-unknown" "prettier --cache --write --ignore-unknown"
@@ -0,0 +1,14 @@
diff --git a/cli.js b/cli.js
index 2a2e6afbd46a6a5182918373d71853193945ff31..44e5888e869003438fc7ef4931b9992f2896a23f 100755
--- a/cli.js
+++ b/cli.js
@@ -304,6 +304,9 @@ for (const [assetGroupName, assets] of Object.entries(groupedAssets)) {
if (isHermes) {
args.push('--debug-id-reference');
}
+ if (process.env.SENTRY_DIST) {
+ args.push('--dist', process.env.SENTRY_DIST);
+ }
args.push(...assets);
const result = spawnSync(sentryCliBin, args, {
@@ -0,0 +1,7 @@
# @sentry/expo-upload-sourcemaps patch
Adds `--dist $SENTRY_DIST` to the `sentry-cli sourcemaps upload` call, when the
env var is set. Symbolication matches on debug IDs so this isn't strictly
required, but it keeps OTA sourcemap artifacts associated with the commit hash
(`dist`) in the Sentry UI, matching the runtime `dist` set in
`src/logger/sentry/setup/index.ts`.
@@ -1,33 +0,0 @@
diff --git a/dist/js/tools/sentryMetroSerializer.js b/dist/js/tools/sentryMetroSerializer.js
index d7f2350196fc008f9f1ce530e224fe3d052347e9..4ce7b6614e38c8af747ebcf166dba556affea3de 100644
--- a/dist/js/tools/sentryMetroSerializer.js
+++ b/dist/js/tools/sentryMetroSerializer.js
@@ -12,12 +12,9 @@ exports.createSentryMetroSerializer = exports.unstable_beforeAssetSerializationP
const crypto = require("crypto");
const utils_1 = require("./utils");
const utils_2 = require("./vendor/metro/utils");
-let countLines;
-try {
- countLines = require('metro/private/lib/countLines');
-}
-catch (e) {
- countLines = require('metro/src/lib/countLines');
+const newline = /\r\n?|\n|\u2028|\u2029/g;
+function countLines(string) {
+ return (string.match(newline) || []).length + 1;
}
const DEBUG_ID_PLACE_HOLDER = '__debug_id_place_holder__';
const DEBUG_ID_MODULE_PATH = '__debugid__';
diff --git a/scripts/expo-upload-sourcemaps.js b/scripts/expo-upload-sourcemaps.js
index b3783b572171482778d31a96b8d6ebadbcc8783b..d5e3e45477c07b5419285237372d99ddd83c56a6 100755
--- a/scripts/expo-upload-sourcemaps.js
+++ b/scripts/expo-upload-sourcemaps.js
@@ -218,7 +218,7 @@ for (const [assetGroupName, assets] of Object.entries(groupedAssets)) {
const isHermes = assets.find(asset => asset.endsWith('.hbc'));
const windowsCallback = process.platform === "win32" ? 'node ' : '';
- execSync(`${windowsCallback}${sentryCliBin} sourcemaps upload ${isHermes ? '--debug-id-reference' : ''} ${assets.join(' ')}`, {
+ execSync(`${windowsCallback}${sentryCliBin} sourcemaps upload ${isHermes ? '--debug-id-reference' : ''} ${assets.join(' ')} --dist ${process.env.SENTRY_DIST}`, {
env: {
...process.env,
[SENTRY_PROJECT]: sentryProject,
@@ -1,9 +0,0 @@
# @sentry/react-native/scripts/expo-upload-sourcemaps.js patch
Lets us specify the output directory for the sourcemaps via an environment variable.
# @sentry/react-native/dist/js/tools/sentryMetroSerializer.js patch
Patch of this: https://github.com/getsentry/sentry-react-native/issues/5180#issuecomment-3311772038
Will be fixed in an upcoming release of @sentry/react-native - remove when available.
+12
View File
@@ -55,3 +55,15 @@ index 06829bd00dbded262e1871aaf6db3ae0cfa9d1b1..1b158185a6d7e907aa18ddc806902fcc
invalidate(); invalidate();
} }
diff --git a/lib/typescript/ReactNativeSVG.web.d.ts b/lib/typescript/ReactNativeSVG.web.d.ts
deleted file mode 100644
index e2001975fa97398fb159d2f85f45d067200f575b..0000000000000000000000000000000000000000
diff --git a/lib/typescript/ReactNativeSVG.web.d.ts.map b/lib/typescript/ReactNativeSVG.web.d.ts.map
deleted file mode 100644
index c81af4416af5ee3eaca77b51bfc2952b30d49c8a..0000000000000000000000000000000000000000
diff --git a/lib/typescript/elements.web.d.ts b/lib/typescript/elements.web.d.ts
deleted file mode 100644
index cf4083fbf749b2b002fb649d80b33ac74763847b..0000000000000000000000000000000000000000
diff --git a/lib/typescript/elements.web.d.ts.map b/lib/typescript/elements.web.d.ts.map
deleted file mode 100644
index 29602aa47ad903bc7aaa111462a61dd862c4476d..0000000000000000000000000000000000000000
+687 -1573
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -19,7 +19,7 @@ allowBuilds:
'esbuild': true 'esbuild': true
'unrs-resolver': true 'unrs-resolver': true
patchedDependencies: patchedDependencies:
'@sentry/react-native@6.20.0': patches/@sentry__react-native@6.20.0.patch '@sentry/expo-upload-sourcemaps@8.18.0': patches/@sentry__expo-upload-sourcemaps@8.18.0.patch
expo-age-range@0.2.18: patches/expo-age-range@0.2.18.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-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-haptics@15.0.8': patches/expo-haptics@15.0.8.patch
+2
View File
@@ -33,6 +33,7 @@ import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {MessagesProvider} from '#/state/messages' import {MessagesProvider} from '#/state/messages'
import {init as initPersistedState} from '#/state/persisted' import {init as initPersistedState} from '#/state/persisted'
import {Provider as PrefsStateProvider} from '#/state/preferences' import {Provider as PrefsStateProvider} from '#/state/preferences'
import {BetaUserStorageSync} from '#/state/preferences/beta-user-sync'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread' import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
@@ -152,6 +153,7 @@ function InnerApp() {
key={currentAccount?.did}> key={currentAccount?.did}>
<AnalyticsFeaturesContext> <AnalyticsFeaturesContext>
<QueryProvider currentDid={currentAccount?.did}> <QueryProvider currentDid={currentAccount?.did}>
<BetaUserStorageSync />
<PolicyUpdateOverlayProvider> <PolicyUpdateOverlayProvider>
<LiveEventsProvider> <LiveEventsProvider>
<AgeAssuranceV2Provider> <AgeAssuranceV2Provider>
+2
View File
@@ -26,6 +26,7 @@ import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {MessagesProvider} from '#/state/messages' import {MessagesProvider} from '#/state/messages'
import {init as initPersistedState} from '#/state/persisted' import {init as initPersistedState} from '#/state/persisted'
import {Provider as PrefsStateProvider} from '#/state/preferences' import {Provider as PrefsStateProvider} from '#/state/preferences'
import {BetaUserStorageSync} from '#/state/preferences/beta-user-sync'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread' import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
@@ -132,6 +133,7 @@ function InnerApp() {
key={currentAccount?.did}> key={currentAccount?.did}>
<AnalyticsFeaturesContext> <AnalyticsFeaturesContext>
<QueryProvider currentDid={currentAccount?.did}> <QueryProvider currentDid={currentAccount?.did}>
<BetaUserStorageSync />
<PolicyUpdateOverlayProvider> <PolicyUpdateOverlayProvider>
<LiveEventsProvider> <LiveEventsProvider>
<AgeAssuranceV2Provider> <AgeAssuranceV2Provider>
+18 -8
View File
@@ -108,6 +108,7 @@ import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings' import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords' import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
import {AutomationLabelSettingsScreen} from '#/screens/Settings/AutomationLabelSettings' import {AutomationLabelSettingsScreen} from '#/screens/Settings/AutomationLabelSettings'
import {BetaFeaturesSettingsScreen} from '#/screens/Settings/BetaFeaturesSettings'
import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings' import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings'
import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences' import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences'
import {FindContactsSettingsScreen} from '#/screens/Settings/FindContactsSettings' import {FindContactsSettingsScreen} from '#/screens/Settings/FindContactsSettings'
@@ -405,6 +406,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true, requireAuth: true,
}} }}
/> />
<Stack.Screen
name="BetaFeaturesSettings"
getComponent={() => BetaFeaturesSettingsScreen}
options={{
title: title(msg`Beta features`),
requireAuth: true,
}}
/>
<Stack.Screen <Stack.Screen
name="AutomationLabelSettings" name="AutomationLabelSettings"
getComponent={() => AutomationLabelSettingsScreen} getComponent={() => AutomationLabelSettingsScreen}
@@ -994,14 +1003,15 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
}) })
if (IS_WEB) { if (IS_WEB) {
const referrerInfo = Referrer.getReferrerInfo() void Referrer.getReferrerInfo().then(referrerInfo => {
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') { if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
ax.metric('deepLink:referrerReceived', { ax.metric('deepLink:referrerReceived', {
to: window.location.href, to: window.location.href,
referrer: referrerInfo?.referrer, referrer: referrerInfo?.referrer,
hostname: referrerInfo?.hostname, hostname: referrerInfo?.hostname,
}) })
} }
})
} }
// temp, just testing // temp, just testing
+92 -5
View File
@@ -1,8 +1,11 @@
import {MMKV} from '@bsky.app/react-native-mmkv' import {MMKV} from '@bsky.app/react-native-mmkv'
import {setPolyfills} from '@growthbook/growthbook' import {setPolyfills} from '@growthbook/growthbook'
import {GrowthBook} from '@growthbook/growthbook-react' import {GrowthBook} from '@growthbook/growthbook-react'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {Logger} from '#/logger' import {Logger} from '#/logger'
import {Features} from '#/analytics/features/types'
import {getNavigationMetadata, type Metadata} from '#/analytics/metadata' import {getNavigationMetadata, type Metadata} from '#/analytics/metadata'
import * as env from '#/env' import * as env from '#/env'
@@ -11,12 +14,14 @@ export {Features} from '#/analytics/features/types'
const logger = Logger.create(Logger.Context.Growthbook) const logger = Logger.create(Logger.Context.Growthbook)
const CACHE = new MMKV({id: 'bsky_features_cache'}) const CACHE = new MMKV({id: 'bsky_features_cache'})
const BETA_USER_ATTRIBUTE = 'isBetaUser'
setPolyfills({ setPolyfills({
localStorage: { localStorage: {
getItem: key => { getItem: key => {
return CACHE.getString(key) ?? null return CACHE.getString(key) ?? null
}, },
setItem: async (key, value) => { setItem: (key, value) => {
CACHE.set(key, value) CACHE.set(key, value)
}, },
}, },
@@ -44,15 +49,13 @@ export const features = new GrowthBook({
* that case, we may see a flash of uncustomized content until the * that case, we may see a flash of uncustomized content until the
* initialization completes. * initialization completes.
*/ */
export const init = new Promise<void>(async y => { export const init = features.init({timeout: TIMEOUT_INIT}).then(res => {
const res = await features.init({timeout: TIMEOUT_INIT})
if (!res.success) { if (!res.success) {
logger.warn('GrowthBook initialization failed or timed out', { logger.warn('GrowthBook initialization failed or timed out', {
source: res.source, source: res.source,
safeMessage: res.error?.toString(), safeMessage: res.error?.toString(),
}) })
} }
y()
}) })
/** /**
@@ -68,6 +71,89 @@ export async function refresh({strategy}: {strategy: FeatureFetchStrategy}) {
}) })
} }
export function getFeatures() {
return features.getFeatures()
}
export function getFeatureDescription(feature: Features, i18n: I18n) {
switch (feature) {
case Features.PostThreadKnownLikersEnable:
return {
key: feature,
name: i18n._(
msg({
message: 'Social proofing on posts',
comment: 'Name for a feature flag',
}),
),
description: i18n._(
msg({
message: 'Spot posts your friends and follows have liked.',
comment: 'Description of a feature flag (Social proofing on posts)',
}),
),
}
default:
return null
}
}
/**
* Walks a GrowthBook condition tree to determine whether it targets the given
* attribute. Conditions can nest via the logical operators `$and`, `$or`,
* `$nor` (arrays of sub-conditions) and `$not` (a single sub-condition), so a
* flat scan of the top-level keys would miss e.g.
* `{$and: [{isBetaUser: true}, ...]}`. Dot-notation access (e.g.
* `isBetaUser.foo`) counts as targeting the attribute as well.
*/
function conditionTargetsAttribute(
condition: unknown,
attribute: string,
): boolean {
if (!condition || typeof condition !== 'object') return false
for (const [key, value] of Object.entries(condition)) {
if (key === attribute || key.startsWith(`${attribute}.`)) return true
if (key === '$and' || key === '$or' || key === '$nor') {
if (
Array.isArray(value) &&
value.some(sub => conditionTargetsAttribute(sub, attribute))
) {
return true
}
} else if (key === '$not') {
if (conditionTargetsAttribute(value, attribute)) return true
}
}
return false
}
export function getTargetedFeatures(i18n: I18n) {
const allFeatures = features.getFeatures()
const targetedFeatures: {key: Features; name: string; description: string}[] =
[]
for (const [featureKey, feature] of Object.entries(allFeatures)) {
// Check if the feature contains any rules
if (!feature.rules) continue
// Determine if any rule targets the beta user attribute
const hasTargeting = feature.rules.some(rule =>
conditionTargetsAttribute(rule.condition, BETA_USER_ATTRIBUTE),
)
if (hasTargeting) {
const featureName = getFeatureDescription(featureKey as Features, i18n)
if (featureName) {
targetedFeatures.push(featureName)
}
}
}
return targetedFeatures
}
/** /**
* Converts our metadata into GrowthBook attributes and sets them. GrowthBook * Converts our metadata into GrowthBook attributes and sets them. GrowthBook
* attributes are manually configured in the GrowthBook dashboard. So these * attributes are manually configured in the GrowthBook dashboard. So these
@@ -80,7 +166,7 @@ export function setAttributes({
session, session,
preferences, preferences,
}: Metadata) { }: Metadata) {
features.setAttributes({ void features.setAttributes({
deviceId: base.deviceId, deviceId: base.deviceId,
sessionId: base.sessionId, sessionId: base.sessionId,
platform: base.platform, platform: base.platform,
@@ -92,5 +178,6 @@ export function setAttributes({
appLanguage: preferences?.appLanguage, appLanguage: preferences?.appLanguage,
contentLanguages: preferences?.contentLanguages, contentLanguages: preferences?.contentLanguages,
currentScreen: getNavigationMetadata()?.currentScreen, currentScreen: getNavigationMetadata()?.currentScreen,
isBetaUser: base.isBetaUser,
}) })
} }
+7 -2
View File
@@ -1,3 +1,7 @@
/**
* If a feature is in the beta program, be sure to add a localized description
* for it via getFeatureDescription().
*/
export enum Features { export enum Features {
// core flags // core flags
IsBskyTeam = 'is_bsky_team', IsBskyTeam = 'is_bsky_team',
@@ -12,8 +16,9 @@ export enum Features {
GroupChatsDisable = 'group_chats:disable', GroupChatsDisable = 'group_chats:disable',
ComposerLanguageDetectionEnable = 'composer:language_detection:enable', ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable', PostGalleryEmbedEnable = 'post_gallery_embed:enable',
SearchV2Enable = 'search_v2:enable', PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
AdvancedSearchV2Enable = 'advanced_search_v2:enable', PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable',
CustomLogoJapanEnable = 'custom_logo:japan:enable',
AATest = 'aa-test', AATest = 'aa-test',
} }
+52 -3
View File
@@ -1,4 +1,10 @@
import {createContext, useContext, useMemo} from 'react' import {
createContext,
useCallback,
useContext,
useMemo,
useSyncExternalStore,
} from 'react'
import {Platform} from 'react-native' import {Platform} from 'react-native'
import {type Result} from '@growthbook/growthbook-react' import {type Result} from '@growthbook/growthbook-react'
@@ -26,7 +32,7 @@ import {type Metrics, metrics} from '#/analytics/metrics'
import * as refParams from '#/analytics/misc/refParams' import * as refParams from '#/analytics/misc/refParams'
import * as env from '#/env' import * as env from '#/env'
import {useGeolocationServiceResponse} from '#/geolocation/service' import {useGeolocationServiceResponse} from '#/geolocation/service'
import {device} from '#/storage' import {account, device} from '#/storage'
export * as utils from '#/analytics/utils' export * as utils from '#/analytics/utils'
export const features = {init, refresh} export const features = {init, refresh}
@@ -120,6 +126,38 @@ Context.displayName = 'AnalyticsContext'
*/ */
export const setupDeviceId = getAndMigrateDeviceId() export const setupDeviceId = getAndMigrateDeviceId()
/**
* Reads the per-account cached `isBetaUser` flag for `did`, kept in sync with
* writes from `BetaUserStorageSync` and the beta settings toggle.
*
* This deliberately does not use `useStorage`, whose `useState` seeds once and
* only updates via the change listener. The consuming `AnalyticsContext` lives
* above the `<Fragment key={did}>` remount breaker, so on an account switch it
* re-renders (with a new did) rather than remounting. `useStorage` would keep
* serving the previous account's seeded value until a write happened to fire
* its listener, leaking a beta account's flag into a non-beta account. Reading
* via `useSyncExternalStore` re-evaluates `getSnapshot` every render, so the
* value is always correct for the current did.
*/
function useAccountIsBetaUser(did: string | undefined): boolean | undefined {
const subscribe = useCallback(
(onChange: () => void) => {
if (!did) return () => {}
const sub = account.addOnValueChangedListener(
[did, 'isBetaUser'],
onChange,
)
return () => sub.remove()
},
[did],
)
const getSnapshot = useCallback(() => {
if (!did) return undefined
return account.get([did, 'isBetaUser'])
}, [did])
return useSyncExternalStore(subscribe, getSnapshot)
}
/** /**
* Analytics context provider. Decorates the parent analytics context with * Analytics context provider. Decorates the parent analytics context with
* additional metadata. Nesting should be done carefully and sparingly. * additional metadata. Nesting should be done carefully and sparingly.
@@ -141,6 +179,16 @@ export function AnalyticsContext({
const sessionId = useSessionId() const sessionId = useSessionId()
const geolocation = useGeolocationServiceResponse() const geolocation = useGeolocationServiceResponse()
const parentContext = useContext(Context) const parentContext = useContext(Context)
/*
* `isBetaUser` is account-specific, so it's cached per account. Read it
* scoped to the did for this render's session (from the `metadata` prop when
* set, otherwise inherited from the parent context). Without a did (e.g.
* logged out, or the top-level context above the session provider) there's
* no value, so beta-gated features are never evaluated for an ineligible or
* absent account.
*/
const did = metadata?.session?.did ?? parentContext.metadata.session?.did
const isBetaUser = useAccountIsBetaUser(did)
const childContext = useMemo(() => { const childContext = useMemo(() => {
const combinedMetadata = { const combinedMetadata = {
...parentContext.metadata, ...parentContext.metadata,
@@ -148,6 +196,7 @@ export function AnalyticsContext({
base: { base: {
...parentContext.metadata.base, ...parentContext.metadata.base,
sessionId, sessionId,
isBetaUser,
}, },
geolocation, geolocation,
} }
@@ -166,7 +215,7 @@ export function AnalyticsContext({
}, },
} }
return context return context
}, [sessionId, geolocation, parentContext, metadata]) }, [parentContext, metadata, sessionId, isBetaUser, geolocation])
return <Context.Provider value={childContext}>{children}</Context.Provider> return <Context.Provider value={childContext}>{children}</Context.Provider>
} }
+2 -1
View File
@@ -9,6 +9,7 @@ export type BaseMetadata = {
bundleDate: number bundleDate: number
referrerSrc: string referrerSrc: string
referrerUrl: string referrerUrl: string
isBetaUser?: boolean
} }
export type GeolocationMetadata = Geolocation export type GeolocationMetadata = Geolocation
@@ -66,7 +67,7 @@ export function getMetadataForLogger({
base, base,
geolocation, geolocation,
session, session,
}: Metadata): Record<string, any> { }: Metadata): Record<string, unknown> {
return { return {
deviceId: base.deviceId, deviceId: base.deviceId,
sessionId: base.sessionId, sessionId: base.sessionId,
+2
View File
@@ -1461,4 +1461,6 @@ export type Events = {
jobId?: string jobId?: string
elapsedInPhaseMs: number elapsedInPhaseMs: number
} }
'post:likedBy:click': {}
} }
+25 -2
View File
@@ -2,8 +2,9 @@ import {type StyleProp, type ViewStyle} from 'react-native'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import * as Menu from '#/components/Menu' import * as Menu from '#/components/Menu'
import {type TriggerProps as MenuTriggerProps} from '#/components/Menu/types'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {type AuxiliaryViewProps} from './types' import {type AuxiliaryViewProps, type TriggerProps} from './types'
export { export {
ContainerItem, ContainerItem,
@@ -16,7 +17,6 @@ export {
ItemText, ItemText,
LabelText, LabelText,
Root, Root,
Trigger,
useMenuContext as useContextMenuContext, useMenuContext as useContextMenuContext,
useMenuControl as useContextMenuControl, useMenuControl as useContextMenuControl,
} from '#/components/Menu' } from '#/components/Menu'
@@ -30,14 +30,37 @@ export function AuxiliaryView({}: AuxiliaryViewProps) {
return null return null
} }
/*
* On web the context menu is just a Menu; contentLabel, onTap, style, and
* swipeGesture only apply to the native press-and-hold presentation.
*/
export function Trigger({children, label, hint, role}: TriggerProps) {
return (
<Menu.Trigger label={label} hint={hint} role={role}>
{/*
* Menu supplies the same web-arm child props shape as ContextMenu's
* TriggerChildProps; only the native arms of the two unions differ,
* and those never occur here.
*/}
{children as unknown as MenuTriggerProps['children']}
</Menu.Trigger>
)
}
export function Outer({ export function Outer({
children, children,
label, label,
align: _align,
style, style,
onCloseAutoFocus, onCloseAutoFocus,
}: { }: {
children: React.ReactNode children: React.ReactNode
label?: string label?: string
/**
* Native positions the menu against the message bubble explicitly; the web
* dropdown is anchored by radix, so this is accepted only for parity.
*/
align?: 'left' | 'right'
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
onCloseAutoFocus?: (event: Event) => void onCloseAutoFocus?: (event: Event) => void
}) { }) {
+32 -6
View File
@@ -12,6 +12,7 @@ import {
type GestureResponderEvent, type GestureResponderEvent,
type LayoutChangeEvent, type LayoutChangeEvent,
Pressable, Pressable,
type ScrollView,
type StyleProp, type StyleProp,
View, View,
type ViewStyle, type ViewStyle,
@@ -24,6 +25,7 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useA11y} from '#/state/a11y' import {useA11y} from '#/state/a11y'
import {useDialogStateControlContext} from '#/state/dialogs' import {useDialogStateControlContext} from '#/state/dialogs'
import {type ListMethods} from '#/view/com/util/List'
import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf' import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import {Context} from '#/components/Dialog/context' import {Context} from '#/components/Dialog/context'
@@ -224,11 +226,21 @@ export function Inner({
) )
} }
export const ScrollableInner = Inner /*
* There is no inner ScrollView on web - the ref is accepted for parity with
* the native variant and never attached, so native-only scrolling code in
* shared callers stays a no-op here.
*/
export function ScrollableInner({
ref: _ref,
...props
}: DialogInnerProps & {ref?: React.Ref<ScrollView>}) {
return <Inner {...props} />
}
export const InnerFlatList = forwardRef< export const InnerFlatList = forwardRef<
FlatList, ListMethods,
FlatListProps<any> & {label: string} & { FlatListProps<any> & {label?: string} & {
webInnerStyle?: StyleProp<ViewStyle> webInnerStyle?: StyleProp<ViewStyle>
webInnerContentContainerStyle?: StyleProp<ViewStyle> webInnerContentContainerStyle?: StyleProp<ViewStyle>
footer?: React.ReactNode footer?: React.ReactNode
@@ -247,7 +259,11 @@ export const InnerFlatList = forwardRef<
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
return ( return (
<Inner <Inner
label={label} /*
* Most shared callers cannot pass a label since the native variant has
* no such prop; aria-label is simply absent for them, as before.
*/
label={label as string}
style={[ style={[
a.overflow_hidden, a.overflow_hidden,
a.px_0, a.px_0,
@@ -256,7 +272,13 @@ export const InnerFlatList = forwardRef<
]} ]}
contentContainerStyle={[a.h_full, a.px_0, webInnerContentContainerStyle]}> contentContainerStyle={[a.h_full, a.px_0, webInnerContentContainerStyle]}>
<FlatList <FlatList
ref={ref} /*
* The FlatList instance satisfies the (web) ListMethods interface
* shared callers hold their refs as, except scrollToTop, which no
* platform-agnostic caller can use since the native ListMethods
* (FlatList) lacks it too.
*/
ref={ref as React.Ref<FlatList>}
style={[a.h_full, gtMobile ? a.px_2xl : a.px_xl, style]} style={[a.h_full, gtMobile ? a.px_2xl : a.px_xl, style]}
{...props} {...props}
/> />
@@ -321,7 +343,11 @@ export function Close() {
) )
} }
export function Handle() { /*
* The drag handle only exists on the native bottom sheet; props are accepted
* for parity with the native variant.
*/
export function Handle(_props: {difference?: boolean; fill?: string}) {
return null return null
} }
@@ -1,5 +1,7 @@
import {useState} from 'react' import {useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import type Animated from 'react-native-reanimated'
import {type AnimatedRef, type SharedValue} from 'react-native-reanimated'
import {useTheme} from '#/alf' import {useTheme} from '#/alf'
import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid' import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid'
@@ -18,6 +20,10 @@ interface SortableListProps<T> {
onDragEnd?: () => void onDragEnd?: () => void
/** Fixed row height used for position math. */ /** Fixed row height used for position math. */
itemHeight: number itemHeight: number
/** Ref to the parent Animated.ScrollView for auto-scroll. Ignored on web. */
scrollRef?: AnimatedRef<Animated.ScrollView>
/** Scroll offset shared value from useScrollViewOffset. Ignored on web. */
scrollOffset?: SharedValue<number>
} }
export function SortableList<T>({ export function SortableList<T>({
+15 -11
View File
@@ -1,4 +1,3 @@
import {useCallback} from 'react'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -14,26 +13,31 @@ export function LanguageSelect({
value: l.code2, value: l.code2,
})), })),
label, label,
disabledBlueskySupportedLanguageSanitization = false,
}: { }: {
value?: string value?: string
onChange: (value: string) => void onChange: (value: string) => void
items?: {label: string; value: string}[] items?: {label: string; value: string}[]
label?: string label?: string
disabledBlueskySupportedLanguageSanitization?: boolean
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const selectValue =
value && !disabledBlueskySupportedLanguageSanitization
? sanitizeAppLanguageSetting(value)
: value
const handleOnChange = useCallback( const handleOnChange = (value: string) => {
(value: string) => { if (!value) return
if (!value) return onChange(
onChange(sanitizeAppLanguageSetting(value)) disabledBlueskySupportedLanguageSanitization
}, ? value
[onChange], : sanitizeAppLanguageSetting(value),
) )
}
return ( return (
<Select.Root <Select.Root value={selectValue} onValueChange={handleOnChange}>
value={value ? sanitizeAppLanguageSetting(value) : undefined}
onValueChange={handleOnChange}>
<Select.Trigger label={_(msg`Select language`)}> <Select.Trigger label={_(msg`Select language`)}>
<Select.ValueText placeholder={_(msg`Select language`)} /> <Select.ValueText placeholder={_(msg`Select language`)} />
<Select.Icon /> <Select.Icon />
+4 -3
View File
@@ -4,7 +4,6 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {HITSLOP_30} from '#/lib/constants'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {useSetDrawerOpen} from '#/state/shell' import {useSetDrawerOpen} from '#/state/shell'
import {useIsWithinSplitView} from '#/screens/Messages/components/splitView/context' import {useIsWithinSplitView} from '#/screens/Messages/components/splitView/context'
@@ -33,6 +32,8 @@ import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env' import {IS_IOS} from '#/env'
const HEADER_BUTTON_HITSLOP = {top: 30, bottom: 10, left: 30, right: 30}
export function Outer({ export function Outer({
children, children,
noBottomBorder, noBottomBorder,
@@ -136,7 +137,7 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
color="secondary" color="secondary"
shape="round" shape="round"
onPress={onPressBack} onPress={onPressBack}
hitSlop={HITSLOP_30} hitSlop={HEADER_BUTTON_HITSLOP}
style={[ style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, {marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent, a.bg_transparent,
@@ -170,7 +171,7 @@ export function MenuButton() {
color="secondary" color="secondary"
shape="square" shape="square"
onPress={onPress} onPress={onPress}
hitSlop={HITSLOP_30} hitSlop={HEADER_BUTTON_HITSLOP}
style={[ style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, {marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent, a.bg_transparent,
+2
View File
@@ -32,6 +32,8 @@ import {
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export {type DialogControlProps as MenuControlProps} from '#/components/Dialog'
export {useMenuContext} export {useMenuContext}
export function useMenuControl(): Dialog.DialogControlProps { export function useMenuControl(): Dialog.DialogControlProps {
+83 -46
View File
@@ -66,8 +66,60 @@ export function Label({
const isLabeler = Boolean(desc.sourceType && desc.sourceDid) const isLabeler = Boolean(desc.sourceType && desc.sourceDid)
const isBlueskyLabel = const isBlueskyLabel =
desc.sourceType === 'labeler' && desc.sourceDid === BSKY_LABELER_DID desc.sourceType === 'labeler' && desc.sourceDid === BSKY_LABELER_DID
const avi = size === 'lg' ? 16 : 12
const {outer, avi, text} = useMemo(() => { return (
<>
<LabelBase
label={desc.name}
size={size}
noBg={noBg}
disabled={disableDetailsDialog}
onPress={() => control.open()}
icon={
isBlueskyLabel || !isLabeler ? (
<desc.icon width={avi} fill={t.atoms.text_contrast_medium.color} />
) : (
<UserAvatar avatar={desc.sourceAvi} type="user" size={avi} />
)
}
/>
{!disableDetailsDialog && (
<ModerationDetailsDialog control={control} modcause={cause} />
)}
</>
)
}
export type LabelBaseProps = {
/**
* The accessibility label for the pill.
*/
label: string
/**
* The visible pill text. Defaults to `label`. Use this when the visible
* text is too terse to serve as the accessibility label, e.g. "+2".
*/
cta?: string
onPress: () => void
disabled?: boolean
noBg?: boolean
icon?: React.ReactNode
} & CommonProps
export function LabelBase({
label,
cta = label,
onPress,
disabled,
size = 'sm',
noBg,
icon,
}: LabelBaseProps) {
const t = useTheme()
const {outer, text} = useMemo(() => {
switch (size) { switch (size) {
case 'lg': { case 'lg': {
return { return {
@@ -79,7 +131,6 @@ export function Label({
paddingVertical: 5, paddingVertical: 5,
}, },
], ],
avi: 16,
text: [a.text_sm], text: [a.text_sm],
} }
} }
@@ -94,7 +145,6 @@ export function Label({
paddingVertical: 3, paddingVertical: 3,
}, },
], ],
avi: 12,
text: [a.text_xs], text: [a.text_xs],
} }
} }
@@ -102,52 +152,39 @@ export function Label({
}, [t, size, noBg]) }, [t, size, noBg])
return ( return (
<> <Button
<Button disabled={disabled}
disabled={disableDetailsDialog} label={label}
label={desc.name} onPress={e => {
onPress={e => { e.preventDefault()
e.preventDefault() e.stopPropagation()
e.stopPropagation() onPress()
control.open() }}>
}}> {({hovered, pressed}) => (
{({hovered, pressed}) => ( <View
<View style={[
a.flex_row,
a.align_center,
a.rounded_full,
outer,
(hovered || pressed) && t.atoms.bg_contrast_50,
]}>
{icon}
<Text
emoji
style={[ style={[
a.flex_row, text,
a.align_center, a.font_semi_bold,
a.rounded_full, a.leading_tight,
outer, t.atoms.text_contrast_medium,
(hovered || pressed) && t.atoms.bg_contrast_50, {paddingRight: 3},
]}> ]}>
{isBlueskyLabel || !isLabeler ? ( {cta}
<desc.icon </Text>
width={avi} </View>
fill={t.atoms.text_contrast_medium.color}
/>
) : (
<UserAvatar avatar={desc.sourceAvi} type="user" size={avi} />
)}
<Text
emoji
style={[
text,
a.font_semi_bold,
a.leading_tight,
t.atoms.text_contrast_medium,
{paddingRight: 3},
]}>
{desc.name}
</Text>
</View>
)}
</Button>
{!disableDetailsDialog && (
<ModerationDetailsDialog control={control} modcause={cause} />
)} )}
</> </Button>
) )
} }
+1 -1
View File
@@ -22,7 +22,7 @@ export function Badge() {
backgroundColor: t.palette.primary_25, backgroundColor: t.palette.primary_25,
}, },
]}> ]}>
<Logo fill={t.palette.primary_600} width={14} /> <Logo allowVariants={false} fill={t.palette.primary_600} width={14} />
<Text <Text
style={[ style={[
a.font_semi_bold, a.font_semi_bold,
@@ -26,12 +26,18 @@ export function VideoEmbedInnerNative({
setStatus, setStatus,
setIsLoading, setIsLoading,
setIsActive, setIsActive,
onError,
}: { }: {
ref: React.Ref<{togglePlayback: () => void}> ref: React.Ref<{togglePlayback: () => void}>
embed: AppBskyEmbedVideo.View embed: AppBskyEmbedVideo.View
setStatus: (status: 'playing' | 'paused') => void setStatus: (status: 'playing' | 'paused') => void
setIsLoading: (isLoading: boolean) => void setIsLoading: (isLoading: boolean) => void
setIsActive: (isActive: boolean) => void setIsActive: (isActive: boolean) => void
/**
* Called with the native error message before the component throws to the
* surrounding error boundary.
*/
onError?: (error: string) => void
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const videoRef = useRef<BlueskyVideoView>(null) const videoRef = useRef<BlueskyVideoView>(null)
@@ -81,6 +87,7 @@ export function VideoEmbedInnerNative({
setTimeRemaining(e.nativeEvent.timeRemaining) setTimeRemaining(e.nativeEvent.timeRemaining)
}} }}
onError={e => { onError={e => {
onError?.(e.nativeEvent.error)
setError(e.nativeEvent.error) setError(e.nativeEvent.error)
}} }}
ref={videoRef} ref={videoRef}
@@ -1,3 +1,10 @@
export function VideoEmbedInnerWeb() { import {type VideoEmbedInnerWebProps} from './VideoEmbedInnerWeb.shared'
export {
HLSUnsupportedError,
VideoNotFoundError,
} from './VideoEmbedInnerWeb.shared'
export function VideoEmbedInnerWeb(_props: VideoEmbedInnerWebProps): never {
throw new Error('VideoEmbedInnerWeb may not be used on native.') throw new Error('VideoEmbedInnerWeb may not be used on native.')
} }
@@ -0,0 +1,21 @@
import {type AppBskyEmbedVideo} from '@atproto/api'
export type VideoEmbedInnerWebProps = {
embed: AppBskyEmbedVideo.View
active: boolean
setActive: () => void
onScreen: boolean
lastKnownTime: React.RefObject<number | undefined>
}
export class HLSUnsupportedError extends Error {
constructor() {
super('HLS is not supported')
}
}
export class VideoNotFoundError extends Error {
constructor() {
super('Video not found')
}
}
@@ -1,6 +1,5 @@
import {useCallback, useEffect, useId, useRef, useState} from 'react' import {useCallback, useEffect, useId, useRef, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {type AppBskyEmbedVideo} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import type * as HlsTypes from 'hls.js' import type * as HlsTypes from 'hls.js'
@@ -10,21 +9,25 @@ import {atoms as a} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog' import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {useFullscreen} from '#/components/hooks/useFullscreen' import {useFullscreen} from '#/components/hooks/useFullscreen'
import * as BandwidthEstimate from './bandwidth-estimate' import * as BandwidthEstimate from './bandwidth-estimate'
import {
HLSUnsupportedError,
type VideoEmbedInnerWebProps,
VideoNotFoundError,
} from './VideoEmbedInnerWeb.shared'
import {Controls} from './web-controls/VideoControls' import {Controls} from './web-controls/VideoControls'
export {
HLSUnsupportedError,
VideoNotFoundError,
} from './VideoEmbedInnerWeb.shared'
export function VideoEmbedInnerWeb({ export function VideoEmbedInnerWeb({
embed, embed,
active, active,
setActive, setActive,
onScreen, onScreen,
lastKnownTime, lastKnownTime,
}: { }: VideoEmbedInnerWebProps) {
embed: AppBskyEmbedVideo.View
active: boolean
setActive: () => void
onScreen: boolean
lastKnownTime: React.RefObject<number | undefined>
}) {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const [focused, setFocused] = useState(false) const [focused, setFocused] = useState(false)
@@ -104,12 +107,6 @@ export function VideoEmbedInnerWeb({
) )
} }
export class HLSUnsupportedError extends Error {
constructor() {
super('HLS is not supported')
}
}
// Bluesky serves HLS as MPEG-TS with H.264 + AAC. `Hls.isSupported()` is loose // Bluesky serves HLS as MPEG-TS with H.264 + AAC. `Hls.isSupported()` is loose
// (true if MSE supports *any* of {H.264, AV1, VP9} OR *any* of {AAC, FLAC}), // (true if MSE supports *any* of {H.264, AV1, VP9} OR *any* of {AAC, FLAC}),
// so on Linux boxes missing H.264 (e.g. no ubuntu-restricted-extras, sandboxed // so on Linux boxes missing H.264 (e.g. no ubuntu-restricted-extras, sandboxed
@@ -138,12 +135,6 @@ function canPlayBskyVideoCodecs(): boolean {
) )
} }
export class VideoNotFoundError extends Error {
constructor() {
super('Video not found')
}
}
type CachedPromise<T> = Promise<T> & {value: undefined | T} type CachedPromise<T> = Promise<T> & {value: undefined | T}
const promiseForHls = import( const promiseForHls = import(
// @ts-ignore // @ts-ignore
@@ -1,3 +1,5 @@
export function Controls() { import {type ControlsProps} from './VideoControls.shared'
export function Controls(_props: ControlsProps): never {
throw new Error('VideoWebControls may not be used on native.') throw new Error('VideoWebControls may not be used on native.')
} }
@@ -0,0 +1,17 @@
import type Hls from 'hls.js'
export type ControlsProps = {
videoRef: React.RefObject<HTMLVideoElement | null>
hlsRef: React.RefObject<Hls | undefined | null>
active: boolean
setActive: () => void
focused: boolean
setFocused: (focused: boolean) => void
onScreen: boolean
fullscreenRef: React.RefObject<HTMLDivElement | null>
hlsLoading: boolean
hasSubtitleTrack: boolean
isGif: boolean
altText?: string
updateCuePositions: (controlsVisible?: boolean) => void
}
@@ -3,7 +3,6 @@ import {Pressable, View} from 'react-native'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import type Hls from 'hls.js'
import {clamp} from '#/lib/numbers' import {clamp} from '#/lib/numbers'
import { import {
@@ -33,6 +32,7 @@ import {TimeIndicator} from '../TimeIndicator'
import {ControlButton} from './ControlButton' import {ControlButton} from './ControlButton'
import {Scrubber} from './Scrubber' import {Scrubber} from './Scrubber'
import {formatTime, useVideoElement} from './utils' import {formatTime, useVideoElement} from './utils'
import {type ControlsProps} from './VideoControls.shared'
import {VolumeControl} from './VolumeControl' import {VolumeControl} from './VolumeControl'
export function Controls({ export function Controls({
@@ -49,21 +49,7 @@ export function Controls({
isGif, isGif,
altText, altText,
updateCuePositions, updateCuePositions,
}: { }: ControlsProps) {
videoRef: React.RefObject<HTMLVideoElement | null>
hlsRef: React.RefObject<Hls | undefined | null>
active: boolean
setActive: () => void
focused: boolean
setFocused: (focused: boolean) => void
onScreen: boolean
fullscreenRef: React.RefObject<HTMLDivElement | null>
hlsLoading: boolean
hasSubtitleTrack: boolean
isGif: boolean
altText?: string
updateCuePositions: (controlsVisible?: boolean) => void
}) {
const { const {
play, play,
pause, pause,
+43 -4
View File
@@ -1,4 +1,4 @@
import {useCallback, useRef, useState} from 'react' import {useCallback, useEffect, useRef, useState} from 'react'
import {ActivityIndicator, View} from 'react-native' import {ActivityIndicator, View} from 'react-native'
import {ImageBackground} from 'expo-image' import {ImageBackground} from 'expo-image'
import {type AppBskyEmbedVideo} from '@atproto/api' import {type AppBskyEmbedVideo} from '@atproto/api'
@@ -6,6 +6,10 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {
createPlaybackTelemetry,
type PlaybackTelemetry,
} from '#/lib/media/video/playbackTelemetry'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, platform} from '#/alf' import {atoms as a, platform} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
@@ -75,6 +79,17 @@ function InnerWrapper({embed}: Props) {
const [isActive, setIsActive] = useState(false) const [isActive, setIsActive] = useState(false)
const showSpinner = useThrottledValue(isActive && isLoading, 100) const showSpinner = useThrottledValue(isActive && isLoading, 100)
/*
* Created lazily on first activation so videos that are never scrolled into
* the active position cost nothing.
*/
const telemetryRef = useRef<PlaybackTelemetry | null>(null)
useEffect(() => {
return () => {
telemetryRef.current?.deactivated()
}
}, [])
const showOverlay = const showOverlay =
!isActive || !isActive ||
isLoading || isLoading ||
@@ -89,9 +104,33 @@ function InnerWrapper({embed}: Props) {
<> <>
<VideoEmbedInnerNative <VideoEmbedInnerNative
embed={embed} embed={embed}
setStatus={setStatus} setStatus={s => {
setIsLoading={setIsLoading} setStatus(s)
setIsActive={setIsActive} if (s === 'playing') {
telemetryRef.current?.playing()
}
}}
setIsLoading={loading => {
setIsLoading(loading)
if (!loading) {
telemetryRef.current?.ready()
}
}}
setIsActive={active => {
setIsActive(active)
if (active) {
telemetryRef.current ??= createPlaybackTelemetry({
surface: 'feed',
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
})
telemetryRef.current.activated()
} else {
telemetryRef.current?.deactivated()
}
}}
onError={error => {
telemetryRef.current?.error(error)
}}
ref={ref} ref={ref}
/> />
<ImageBackground <ImageBackground
+5 -1
View File
@@ -321,7 +321,11 @@ export function QuoteEmbed({
linkDisabled linkDisabled
/> />
{moderation ? ( {moderation ? (
<PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} /> <PostAlerts
post={quote}
modui={moderation.ui('contentView')}
style={[a.py_xs]}
/>
) : null} ) : null}
{richText ? ( {richText ? (
<RichText <RichText
+5 -28
View File
@@ -46,7 +46,7 @@ export const BookmarkButton = memo(function BookmarkButton({
}), }),
) )
const save = async ({disableUndo}: {disableUndo?: boolean} = {}) => { const save = async () => {
try { try {
await bookmark({ await bookmark({
action: 'create', action: 'create',
@@ -59,25 +59,6 @@ export const BookmarkButton = memo(function BookmarkButton({
logContext, logContext,
feedDescriptor, feedDescriptor,
}) })
toast.show(
<toast.Outer>
<toast.Icon />
<toast.Text>
<Trans>Post saved</Trans>
</toast.Text>
{!disableUndo && (
<toast.Action
label={undoLabel}
onPress={() => remove({disableUndo: true})}>
{undoLabel}
</toast.Action>
)}
</toast.Outer>,
{
type: 'success',
},
)
} catch (e: any) { } catch (e: any) {
const {raw, clean} = cleanError(e) const {raw, clean} = cleanError(e)
toast.show(clean || raw || e, { toast.show(clean || raw || e, {
@@ -86,7 +67,7 @@ export const BookmarkButton = memo(function BookmarkButton({
} }
} }
const remove = async ({disableUndo}: {disableUndo?: boolean} = {}) => { const remove = async () => {
try { try {
await bookmark({ await bookmark({
action: 'delete', action: 'delete',
@@ -106,13 +87,9 @@ export const BookmarkButton = memo(function BookmarkButton({
<toast.Text> <toast.Text>
<Trans>Removed from saved posts</Trans> <Trans>Removed from saved posts</Trans>
</toast.Text> </toast.Text>
{!disableUndo && ( <toast.Action label={undoLabel} onPress={() => save()}>
<toast.Action {undoLabel}
label={undoLabel} </toast.Action>
onPress={() => save({disableUndo: true})}>
{undoLabel}
</toast.Action>
)}
</toast.Outer>, </toast.Outer>,
) )
} catch (e: any) { } catch (e: any) {
+2
View File
@@ -29,6 +29,7 @@ import {
type TextStyleProp, type TextStyleProp,
useTheme, useTheme,
type ViewStyleProp, type ViewStyleProp,
web,
} from '#/alf' } from '#/alf'
import { import {
Button, Button,
@@ -257,6 +258,7 @@ function InlineNameAndHandle({
a.leading_tight, a.leading_tight,
a.flex_shrink_0, a.flex_shrink_0,
{maxWidth: '70%'}, {maxWidth: '70%'},
web({direction: 'ltr', unicodeBidi: 'isolate'}),
]} ]}
numberOfLines={1}> numberOfLines={1}>
{forceLTR(name)} {forceLTR(name)}
@@ -23,7 +23,7 @@ import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {Default as ProfileCard} from '#/components/ProfileCard' import {Default as ProfileCard} from '#/components/ProfileCard'
import {IS_NATIVE, IS_WEB} from '#/env' import {IS_NATIVE, IS_WEB} from '#/env'
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) { function keyExtractor(item: AppBskyActorDefs.ProfileView, index: number) {
return `${item.did}-${index}` return `${item.did}-${index}`
} }
@@ -87,7 +87,7 @@ export const ProfilesList = forwardRef<SectionRef, ProfilesListProps>(
const renderItem = ({ const renderItem = ({
item, item,
index, index,
}: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => { }: ListRenderItemInfo<AppBskyActorDefs.ProfileView>) => {
return ( return (
<View <View
style={[ style={[
@@ -1,6 +1,5 @@
import {useCallback, useEffect, useImperativeHandle, useState} from 'react' import {useCallback, useEffect, useImperativeHandle, useState} from 'react'
import { import {
findNodeHandle,
type ListRenderItemInfo, type ListRenderItemInfo,
type StyleProp, type StyleProp,
useWindowDimensions, useWindowDimensions,
@@ -26,6 +25,7 @@ import {
type EmptyStateButtonProps, type EmptyStateButtonProps,
} from '#/view/com/util/EmptyState' } from '#/view/com/util/EmptyState'
import {List, type ListRef} from '#/view/com/util/List' import {List, type ListRef} from '#/view/com/util/List'
import {findListNativeTag} from '#/view/com/util/listNativeTag'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {atoms as a, ios, useTheme} from '#/alf' import {atoms as a, ios, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -57,7 +57,7 @@ interface ProfileFeedgensProps {
emptyStateIcon?: React.ComponentType<any> | React.ReactElement emptyStateIcon?: React.ComponentType<any> | React.ReactElement
} }
function keyExtractor(item: AppBskyGraphDefs.StarterPackView) { function keyExtractor(item: AppBskyGraphDefs.StarterPackViewBasic) {
return item.uri return item.uri
} }
@@ -138,13 +138,16 @@ export function ProfileStarterPacks({
useEffect(() => { useEffect(() => {
if (IS_IOS && enabled && scrollElRef.current) { if (IS_IOS && enabled && scrollElRef.current) {
const nativeTag = findNodeHandle(scrollElRef.current) const nativeTag = findListNativeTag(scrollElRef.current)
setScrollViewTag(nativeTag) setScrollViewTag(nativeTag)
} }
}, [enabled, scrollElRef, setScrollViewTag]) }, [enabled, scrollElRef, setScrollViewTag])
const renderItem = useCallback( const renderItem = useCallback(
({item, index}: ListRenderItemInfo<AppBskyGraphDefs.StarterPackView>) => { ({
item,
index,
}: ListRenderItemInfo<AppBskyGraphDefs.StarterPackViewBasic>) => {
return ( return (
<View <View
style={[ style={[
+6 -2
View File
@@ -86,7 +86,7 @@ export function QrCode({
<Trans> <Trans>
on on
<View style={[a.flex_row, a.align_center, {gap: 6}]}> <View style={[a.flex_row, a.align_center, {gap: 6}]}>
<Logo width={25} fill="white" /> <Logo allowVariants={false} width={25} fill="white" />
<View style={[{marginTop: 3.5}]}> <View style={[{marginTop: 3.5}]}>
<Logotype width={72} fill="white" /> <Logotype width={72} fill="white" />
</View> </View>
@@ -129,7 +129,11 @@ export function QrCodeInner({link}: {link: string}) {
zIndex: 1, zIndex: 1,
padding: 4, padding: 4,
}}> }}>
<Logo width={logoArea.width - 14} height={logoArea.height - 14} /> <Logo
allowVariants={false}
width={logoArea.width - 14}
height={logoArea.height - 14}
/>
</View> </View>
)} )}
<QRCode <QRCode
+36 -1
View File
@@ -1,6 +1,41 @@
import {atoms as a} from '#/alf' import {atoms as a, select, type Theme} from '#/alf'
/**
* Visual variant for the tooltip surface. `default` is a neutral floating card;
* `primary` is a subtle blue surface matching the `primary_subtle` button.
*/
export type TooltipColor = 'default' | 'primary'
export const BUBBLE_MAX_WIDTH = 240 export const BUBBLE_MAX_WIDTH = 240
export const ARROW_SIZE = 12 export const ARROW_SIZE = 12
export const ARROW_HALF_SIZE = ARROW_SIZE / 2 export const ARROW_HALF_SIZE = ARROW_SIZE / 2
export const MIN_EDGE_SPACE = a.px_lg.paddingLeft export const MIN_EDGE_SPACE = a.px_lg.paddingLeft
/**
* Resolves the surface (background/arrow fill) and text colors for a tooltip
* variant. Kept here so the native and web implementations stay in sync.
*/
export function getTooltipStyle(t: Theme, color: TooltipColor) {
if (color === 'primary') {
return {
surface: t.palette.primary_50,
text: t.palette.primary_600,
border: {
color: t.atoms.border_contrast_low.borderColor,
width: 1,
},
}
}
return {
surface: select(t.name, {
light: t.atoms.bg.backgroundColor,
dark: t.atoms.bg_contrast_100.backgroundColor,
dim: t.atoms.bg_contrast_100.backgroundColor,
}),
text: t.atoms.text.color,
border: {
color: undefined,
width: 0,
},
}
}
+1 -1
View File
@@ -14,6 +14,6 @@ export function Content() {
return null return null
} }
export function TextBubble() { export function BubbleText() {
return null return null
} }
+45 -41
View File
@@ -1,5 +1,4 @@
import { import {
Children,
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
@@ -14,14 +13,16 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events' import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
import {atoms as a, select, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {useOnGesture} from '#/components/hooks/useOnGesture' import {useOnGesture} from '#/components/hooks/useOnGesture'
import {createPortalGroup, Portal as RootPortal} from '#/components/Portal' import {createPortalGroup, Portal as RootPortal} from '#/components/Portal'
import { import {
ARROW_HALF_SIZE, ARROW_HALF_SIZE,
ARROW_SIZE, ARROW_SIZE,
BUBBLE_MAX_WIDTH, BUBBLE_MAX_WIDTH,
getTooltipStyle,
MIN_EDGE_SPACE, MIN_EDGE_SPACE,
type TooltipColor,
} from '#/components/Tooltip/const' } from '#/components/Tooltip/const'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -56,10 +57,10 @@ SheetCompatProvider.displayName = 'TooltipSheetCompatProvider'
* These are native specific values, not shared with web * These are native specific values, not shared with web
*/ */
const ARROW_VISUAL_OFFSET = ARROW_SIZE / 1.25 // vibes-based, slightly off the target const ARROW_VISUAL_OFFSET = ARROW_SIZE / 1.25 // vibes-based, slightly off the target
const BUBBLE_SHADOW_OFFSET = ARROW_SIZE / 3 // vibes-based, provide more shadow beneath tip
type TooltipContextType = { type TooltipContextType = {
position: 'top' | 'bottom' position: 'top' | 'bottom'
color: TooltipColor
visible: boolean visible: boolean
onVisibleChange: (visible: boolean) => void onVisibleChange: (visible: boolean) => void
} }
@@ -79,6 +80,7 @@ type TargetContextType = {
const TooltipContext = createContext<TooltipContextType>({ const TooltipContext = createContext<TooltipContextType>({
position: 'bottom', position: 'bottom',
color: 'default',
visible: false, visible: false,
onVisibleChange: () => {}, onVisibleChange: () => {},
}) })
@@ -94,11 +96,13 @@ TargetContext.displayName = 'TargetContext'
export function Outer({ export function Outer({
children, children,
position = 'bottom', position = 'bottom',
color = 'default',
visible: requestVisible, visible: requestVisible,
onVisibleChange, onVisibleChange,
}: { }: {
children: React.ReactNode children: React.ReactNode
position?: 'top' | 'bottom' position?: 'top' | 'bottom'
color?: TooltipColor
visible: boolean visible: boolean
onVisibleChange: (visible: boolean) => void onVisibleChange: (visible: boolean) => void
}) { }) {
@@ -126,8 +130,8 @@ export function Outer({
} }
const ctx = useMemo( const ctx = useMemo(
() => ({position, visible, onVisibleChange}), () => ({position, color, visible, onVisibleChange}),
[position, visible, onVisibleChange], [position, color, visible, onVisibleChange],
) )
const targetCtx = useMemo( const targetCtx = useMemo(
() => ({ () => ({
@@ -149,13 +153,13 @@ export function Outer({
export function Target({children}: {children: React.ReactNode}) { export function Target({children}: {children: React.ReactNode}) {
const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext) const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
const [hasLayedOut, setHasLayedOut] = useState(false) const [hasLaidOut, setHasLaidOut] = useState(false)
const targetRef = useRef<View>(null) const targetRef = useRef<View>(null)
const containerRef = useContext(TooltipProviderContext) const containerRef = useContext(TooltipProviderContext)
const keyboardIsOpen = useIsKeyboardVisible() const keyboardIsOpen = useIsKeyboardVisible()
useEffect(() => { useEffect(() => {
if (!shouldMeasure || !hasLayedOut) return if (!shouldMeasure || !hasLaidOut) return
/* /*
* Once opened, measure the dimensions and position of the target * Once opened, measure the dimensions and position of the target
*/ */
@@ -179,7 +183,7 @@ export function Target({children}: {children: React.ReactNode}) {
}, [ }, [
shouldMeasure, shouldMeasure,
setTargetMeasurements, setTargetMeasurements,
hasLayedOut, hasLaidOut,
containerRef, containerRef,
keyboardIsOpen, keyboardIsOpen,
]) ])
@@ -188,7 +192,7 @@ export function Target({children}: {children: React.ReactNode}) {
<View <View
collapsable={false} collapsable={false}
ref={targetRef} ref={targetRef}
onLayout={() => setHasLayedOut(true)}> onLayout={() => setHasLaidOut(true)}>
{children} {children}
</View> </View>
) )
@@ -201,7 +205,7 @@ export function Content({
children: React.ReactNode children: React.ReactNode
label: string label: string
}) { }) {
const {position, visible, onVisibleChange} = useContext(TooltipContext) const {position, color, visible, onVisibleChange} = useContext(TooltipContext)
const {targetMeasurements} = useContext(TargetContext) const {targetMeasurements} = useContext(TargetContext)
const isWithinProvider = !!useContext(TooltipProviderContext) const isWithinProvider = !!useContext(TooltipProviderContext)
const requestClose = useCallback(() => { const requestClose = useCallback(() => {
@@ -217,8 +221,9 @@ export function Content({
<Bubble <Bubble
label={label} label={label}
position={position} position={position}
color={color}
/* /*
* Gotta pass these in here. Inside the Bubble, we're Potal-ed outside * Gotta pass these in here. Inside the Bubble, we're Portal-ed outside
* the context providers. * the context providers.
*/ */
targetMeasurements={targetMeasurements} targetMeasurements={targetMeasurements}
@@ -233,12 +238,14 @@ function Bubble({
children, children,
label, label,
position, position,
color,
requestClose, requestClose,
targetMeasurements, targetMeasurements,
}: { }: {
children: React.ReactNode children: React.ReactNode
label: string label: string
position: TooltipContextType['position'] position: TooltipContextType['position']
color: TooltipColor
requestClose: () => void requestClose: () => void
targetMeasurements: Exclude< targetMeasurements: Exclude<
TargetContextType['targetMeasurements'], TargetContextType['targetMeasurements'],
@@ -246,6 +253,7 @@ function Bubble({
> >
}) { }) {
const t = useTheme() const t = useTheme()
const style = getTooltipStyle(t, color)
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const dimensions = useWindowDimensions() const dimensions = useWindowDimensions()
const [bubbleMeasurements, setBubbleMeasurements] = useState< const [bubbleMeasurements, setBubbleMeasurements] = useState<
@@ -383,21 +391,19 @@ function Bubble({
]}> ]}>
<Animated.View <Animated.View
entering={ZoomIn.easing(Easing.out(Easing.exp))} entering={ZoomIn.easing(Easing.out(Easing.exp))}
style={{transformOrigin: oppposite(position)}}> style={{transformOrigin: opposite(position)}}>
<View <View
style={[ style={[
a.absolute, a.absolute,
a.top_0, a.top_0,
a.z_10, a.z_10,
t.atoms.bg,
select(t.name, {
light: t.atoms.bg,
dark: t.atoms.bg_contrast_100,
dim: t.atoms.bg_contrast_100,
}),
{ {
backgroundColor: style.surface,
borderTopLeftRadius: a.rounded_2xs.borderRadius, borderTopLeftRadius: a.rounded_2xs.borderRadius,
borderBottomRightRadius: a.rounded_2xs.borderRadius, borderBottomRightRadius: a.rounded_2xs.borderRadius,
borderColor: style.border.color,
borderTopWidth: style.border.width,
borderLeftWidth: style.border.width,
width: ARROW_SIZE, width: ARROW_SIZE,
height: ARROW_SIZE, height: ARROW_SIZE,
transform: [{rotate: '45deg'}], transform: [{rotate: '45deg'}],
@@ -410,21 +416,12 @@ function Bubble({
style={[ style={[
a.px_md, a.px_md,
a.py_sm, a.py_sm,
a.rounded_sm, a.rounded_md,
select(t.name, { t.atoms.shadow_xs,
light: t.atoms.bg,
dark: t.atoms.bg_contrast_100,
dim: t.atoms.bg_contrast_100,
}),
t.atoms.shadow_md,
{ {
shadowOpacity: 0.2, backgroundColor: style.surface,
shadowOffset: { borderColor: style.border.color,
width: 0, borderWidth: style.border.width,
height:
BUBBLE_SHADOW_OFFSET *
(coords.computedPosition === 'bottom' ? -1 : 1),
},
}, },
]} ]}
onLayout={e => { onLayout={e => {
@@ -440,7 +437,7 @@ function Bubble({
) )
} }
function oppposite(position: 'top' | 'bottom') { function opposite(position: 'top' | 'bottom') {
switch (position) { switch (position) {
case 'top': case 'top':
return 'center bottom' return 'center bottom'
@@ -451,16 +448,23 @@ function oppposite(position: 'top' | 'bottom') {
} }
} }
export function TextBubble({children}: {children: React.ReactNode}) { export function BubbleText({
const c = Children.toArray(children) children,
label,
}: {
children: React.ReactNode
label: string
}) {
const t = useTheme()
const {color} = useContext(TooltipContext)
const style = getTooltipStyle(t, color)
// eslint-disable-next-line bsky-internal/avoid-unwrapped-text
return ( return (
<Content label={c.join(' ')}> <Content label={label}>
<View style={[a.gap_xs]}> <View style={[a.gap_xs]}>
{c.map((child, i) => ( <Text style={[a.text_sm, a.leading_snug, {color: style.text}]}>
<Text key={i} style={[a.text_sm, a.leading_snug]}> {children}
{child} </Text>
</Text>
))}
</View> </View>
</Content> </Content>
) )
+35 -28
View File
@@ -1,13 +1,15 @@
import {Children, createContext, useContext, useMemo} from 'react' import {createContext, useContext, useMemo} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {utils} from '@bsky.app/alf' import {utils} from '@bsky.app/alf'
import {Popover} from 'radix-ui' import {Popover} from 'radix-ui'
import {atoms as a, flatten, select, useTheme} from '#/alf' import {atoms as a, flatten, useTheme} from '#/alf'
import { import {
ARROW_SIZE, ARROW_SIZE,
BUBBLE_MAX_WIDTH, BUBBLE_MAX_WIDTH,
getTooltipStyle,
MIN_EDGE_SPACE, MIN_EDGE_SPACE,
type TooltipColor,
} from '#/components/Tooltip/const' } from '#/components/Tooltip/const'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -19,26 +21,32 @@ Provider.displayName = 'TooltipProvider'
type TooltipContextType = { type TooltipContextType = {
position: 'top' | 'bottom' position: 'top' | 'bottom'
color: TooltipColor
onVisibleChange: (open: boolean) => void onVisibleChange: (open: boolean) => void
} }
const TooltipContext = createContext<Pick<TooltipContextType, 'position'>>({ const TooltipContext = createContext<
Pick<TooltipContextType, 'position' | 'color'>
>({
position: 'bottom', position: 'bottom',
color: 'default',
}) })
TooltipContext.displayName = 'TooltipContext' TooltipContext.displayName = 'TooltipContext'
export function Outer({ export function Outer({
children, children,
position = 'bottom', position = 'bottom',
color = 'default',
visible, visible,
onVisibleChange, onVisibleChange,
}: { }: {
children: React.ReactNode children: React.ReactNode
position?: 'top' | 'bottom' position?: 'top' | 'bottom'
color?: TooltipColor
visible: boolean visible: boolean
onVisibleChange: (visible: boolean) => void onVisibleChange: (visible: boolean) => void
}) { }) {
const ctx = useMemo(() => ({position}), [position]) const ctx = useMemo(() => ({position, color}), [position, color])
return ( return (
<Popover.Root open={visible} onOpenChange={onVisibleChange}> <Popover.Root open={visible} onOpenChange={onVisibleChange}>
<TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider> <TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider>
@@ -62,7 +70,8 @@ export function Content({
label: string label: string
}) { }) {
const t = useTheme() const t = useTheme()
const {position} = useContext(TooltipContext) const {position, color} = useContext(TooltipContext)
const style = getTooltipStyle(t, color)
return ( return (
<Popover.Portal> <Popover.Portal>
<Popover.Content <Popover.Content
@@ -78,28 +87,19 @@ export function Content({
}} }}
style={flatten([ style={flatten([
a.rounded_sm, a.rounded_sm,
select(t.name, {
light: t.atoms.bg,
dark: t.atoms.bg_contrast_100,
dim: t.atoms.bg_contrast_100,
}),
{ {
backgroundColor: style.surface,
borderColor: style.border.color,
borderWidth: style.border.width,
borderStyle: 'solid',
minWidth: 'max-content', minWidth: 'max-content',
boxShadow: select(t.name, { boxShadow: `0 0 24px ${utils.alpha(t.palette.black, 0.2)}`,
light: `0 0 24px ${utils.alpha(t.palette.black, 0.2)}`,
dark: `0 0 24px ${utils.alpha(t.palette.black, 0.2)}`,
dim: `0 0 24px ${utils.alpha(t.palette.black, 0.2)}`,
}),
}, },
])}> ])}>
<Popover.Arrow <Popover.Arrow
width={ARROW_SIZE} width={ARROW_SIZE}
height={ARROW_SIZE / 2} height={ARROW_SIZE / 2}
fill={select(t.name, { fill={style.surface}
light: t.atoms.bg.backgroundColor,
dark: t.atoms.bg_contrast_100.backgroundColor,
dim: t.atoms.bg_contrast_100.backgroundColor,
})}
/> />
<View style={[a.px_md, a.py_sm, {maxWidth: BUBBLE_MAX_WIDTH}]}> <View style={[a.px_md, a.py_sm, {maxWidth: BUBBLE_MAX_WIDTH}]}>
{children} {children}
@@ -109,16 +109,23 @@ export function Content({
) )
} }
export function TextBubble({children}: {children: React.ReactNode}) { export function BubbleText({
const c = Children.toArray(children) children,
label,
}: {
children: React.ReactNode
label: string
}) {
const t = useTheme()
const {color} = useContext(TooltipContext)
const style = getTooltipStyle(t, color)
// eslint-disable-next-line bsky-internal/avoid-unwrapped-text
return ( return (
<Content label={c.join(' ')}> <Content label={label}>
<View style={[a.gap_xs]}> <View style={[a.gap_xs]}>
{c.map((child, i) => ( <Text style={[a.text_sm, a.leading_snug, {color: style.text}]}>
<Text key={i} style={[a.text_sm, a.leading_snug]}> {children}
{child} </Text>
</Text>
))}
</View> </View>
</Content> </Content>
) )
+3 -6
View File
@@ -10,6 +10,7 @@ import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {Logo} from '#/view/icons/Logo' import {Logo} from '#/view/icons/Logo'
import {atoms as a, flatten, useBreakpoints, web} from '#/alf' import {atoms as a, flatten, useBreakpoints, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import {type WelcomeModalControl} from '#/components/hooks/useWelcomeModal.shared'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
@@ -17,11 +18,7 @@ import {useAnalytics} from '#/analytics'
const welcomeModalBg = require('../../assets/images/welcome-modal-bg.jpg') const welcomeModalBg = require('../../assets/images/welcome-modal-bg.jpg')
interface WelcomeModalProps { interface WelcomeModalProps {
control: { control: WelcomeModalControl
isOpen: boolean
open: () => void
close: () => void
}
} }
export function WelcomeModal({control}: WelcomeModalProps) { export function WelcomeModal({control}: WelcomeModalProps) {
@@ -107,7 +104,7 @@ export function WelcomeModal({control}: WelcomeModalProps) {
a.p_0, a.p_0,
]}> ]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}> <View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Logo width={26} /> <Logo allowVariants={false} width={26} />
<Text <Text
style={[ style={[
a.text_2xl, a.text_2xl,
@@ -1,8 +1,6 @@
import {useCallback, useEffect, useState} from 'react' import {useCallback, useEffect, useState} from 'react'
import {type ModerationOpts} from '@atproto/api' import {type ModerationOpts} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
@@ -11,7 +9,6 @@ import {useDialogControl} from '#/components/Dialog'
import {BellPlus_Stroke2_Corner0_Rounded as BellPlusIcon} from '#/components/icons/BellPlus' import {BellPlus_Stroke2_Corner0_Rounded as BellPlusIcon} from '#/components/icons/BellPlus'
import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
import * as Tooltip from '#/components/Tooltip' import * as Tooltip from '#/components/Tooltip'
import {Text} from '#/components/Typography'
import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged' import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {SubscribeProfileDialog} from './SubscribeProfileDialog' import {SubscribeProfileDialog} from './SubscribeProfileDialog'
@@ -25,7 +22,7 @@ export function SubscribeProfileButton({
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
disableHint?: boolean disableHint?: boolean
}) { }) {
const {_} = useLingui() const {t: l} = useLingui()
const requireEmailVerification = useRequireEmailVerification() const requireEmailVerification = useRequireEmailVerification()
const subscribeDialogControl = useDialogControl() const subscribeDialogControl = useDialogControl()
const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] = const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] =
@@ -84,18 +81,15 @@ export function SubscribeProfileButton({
size="small" size="small"
color={tooltipVisible ? 'primary_subtle' : 'secondary'} color={tooltipVisible ? 'primary_subtle' : 'secondary'}
shape="round" shape="round"
label={_(msg`Get notified when ${name} posts`)} label={l`Get notified when ${name} posts`}
onPress={wrappedOnPress}> onPress={wrappedOnPress}>
<ButtonIcon icon={Icon} size="md" /> <ButtonIcon icon={Icon} size="md" />
</Button> </Button>
</Tooltip.Target> </Tooltip.Target>
<Tooltip.TextBubble> <Tooltip.BubbleText label={l`Get notified about new posts`}>
<Text> <Trans>Get notified about new posts</Trans>
<Trans>Get notified about new posts</Trans> </Tooltip.BubbleText>
</Text>
</Tooltip.TextBubble>
</Tooltip.Outer> </Tooltip.Outer>
<SubscribeProfileDialog <SubscribeProfileDialog
control={subscribeDialogControl} control={subscribeDialogControl}
profile={profile} profile={profile}
@@ -296,6 +296,7 @@ function Inner() {
setLanguageError(false) setLanguageError(false)
}} }}
items={KWS_SUPPORTED_LANGS} items={KWS_SUPPORTED_LANGS}
disabledBlueskySupportedLanguageSanitization
/> />
{languageError && ( {languageError && (
@@ -342,11 +343,12 @@ function convertToKWSSupportedLanguage(
// pt-PT is pt (pt-BR is supported independently) // pt-PT is pt (pt-BR is supported independently)
case 'pt-PT': case 'pt-PT':
return 'pt' return 'pt'
// only chinese (simplified) is supported, map all chinese variants // map Chinese variants to simplified or traditional
case 'zh-Hans-CN': case 'zh-Hans-CN':
return 'zh-Hans'
case 'zh-Hant-HK': case 'zh-Hant-HK':
case 'zh-Hant-TW': case 'zh-Hant-TW':
return 'zh-Hans' return 'zh-Hant'
default: default:
// try and map directly - if undefined, they will have to pick from the dropdown // try and map directly - if undefined, they will have to pick from the dropdown
return KWS_SUPPORTED_LANGS.find(v => v.value === appLanguage)?.value 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 = [ export const KWS_SUPPORTED_LANGS = [
{value: 'en', label: 'English'}, {value: 'en', label: 'English'},
{value: 'ar', label: 'العربية'}, {value: 'ar', label: 'العربية'},
{value: 'zh-Hans', label: '简体中文'}, {value: 'bg', label: 'български'},
{value: 'cs', label: 'Čeština'}, {value: 'cs', label: 'čeština'},
{value: 'nl', label: 'Nederlands'}, {value: 'da', label: 'dansk'},
{value: 'tl', label: 'Filipino'},
{value: 'fr', label: 'Français'},
{value: 'de', label: 'Deutsch'}, {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: 'id', label: 'Bahasa Indonesia'},
{value: 'it', label: 'Italiano'}, {value: 'it', label: 'italiano'},
{value: 'ja', label: '日本語'}, {value: 'ja', label: '日本語'},
{value: 'ko', label: '한국어'}, {value: 'ko', label: '한국어'},
{value: 'pt', label: 'Português'}, {value: 'ms', label: 'Bahasa Melayu'},
{value: 'pt-BR', label: 'Português (Brasil)'}, {value: 'nl', label: 'Nederlands'},
{value: 'ru', label: 'Русский'}, {value: 'no', label: 'norsk'},
{value: 'es', label: 'Español'}, {value: 'pl', label: 'polski'},
{value: 'tr', label: 'Türkçe'}, {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: 'th', label: 'ภาษาไทย'},
{value: 'tl', label: 'Tagalog'},
{value: 'tr', label: 'Türkçe'},
{value: 'uk', label: 'українська'},
{value: 'vi', label: 'Tiếng Việt'}, {value: 'vi', label: 'Tiếng Việt'},
{value: 'zh-Hans', label: '简体中文'},
{value: 'zh-Hant', label: '繁體中文'},
] ]
@@ -1,3 +1,11 @@
export function FindContactsFlow() { import {type Action, type State} from './state'
export function FindContactsFlow(_props: {
state: State
dispatch: React.ActionDispatch<[Action]>
onBack?: () => void
onCancel: () => void
context: 'Onboarding' | 'Standalone'
}): never {
throw new Error('FindContactsFlow is not available on web') throw new Error('FindContactsFlow is not available on web')
} }
+1 -1
View File
@@ -55,7 +55,7 @@ function SigninDialogInner({}: {control: Dialog.DialogOuterProps['control']}) {
a.gap_sm, a.gap_sm,
a.pb_lg, a.pb_lg,
]}> ]}>
<Logo width={36} /> <Logo allowVariants={false} width={36} />
<View style={{paddingTop: 6}}> <View style={{paddingTop: 6}}>
<Logotype width={120} fill={t.atoms.text.color} /> <Logotype width={120} fill={t.atoms.text.color} />
</View> </View>
+27 -8
View File
@@ -246,6 +246,8 @@ let MessageItem = ({
const rt = new RichTextAPI({text: message.text, facets: message.facets}) const rt = new RichTextAPI({text: message.text, facets: message.facets})
const isEmojiOnly = isOnlyEmoji(message.text)
const hasEmbed = const hasEmbed =
AppBskyEmbedRecord.isView(message.embed) || AppBskyEmbedRecord.isView(message.embed) ||
ChatBskyEmbedJoinLink.isView(message.embed) ChatBskyEmbedJoinLink.isView(message.embed)
@@ -261,16 +263,33 @@ let MessageItem = ({
const topRadiusSV = useSharedValue(targetTopRadius) const topRadiusSV = useSharedValue(targetTopRadius)
const showDisplayName = const showDisplayName =
isGroupChat && !isFromSelf && isFirstInCluster && !isOnlyEmoji(message.text) isGroupChat && !isFromSelf && isFirstInCluster && !isEmojiOnly
const showAvatar = isGroupChat && !isFromSelf && isLastInCluster const showAvatar = isGroupChat && !isFromSelf && isLastInCluster
/*
* Emoji-only messages have no bubble background (see the `!isOnlyEmoji` gate
* on the bubble styling below), so the corner-radius animation is invisible
* overhead for them. Worse, on Android the resulting re-layout of the parent
* Animated.View re-measures the enlarged emoji `<Text>` and some Android
* device's text stack drops the trailing glyph on that second pass (while
* keeping its reserved width). Set the radii directly for emoji-only messages
* so nothing re-lays-out the glyph after its initial paint.
*/
useEffect(() => { useEffect(() => {
bottomRadiusSV.set(withTiming(targetBottomRadius, {duration: 300})) bottomRadiusSV.set(
}, [targetBottomRadius, bottomRadiusSV]) isEmojiOnly
? targetBottomRadius
: withTiming(targetBottomRadius, {duration: 300}),
)
}, [targetBottomRadius, bottomRadiusSV, isEmojiOnly])
useEffect(() => { useEffect(() => {
topRadiusSV.set(withTiming(targetTopRadius, {duration: 300})) topRadiusSV.set(
}, [targetTopRadius, topRadiusSV]) isEmojiOnly
? targetTopRadius
: withTiming(targetTopRadius, {duration: 300}),
)
}, [targetTopRadius, topRadiusSV, isEmojiOnly])
// Flash the message background when it's been scrolled to (e.g. by tapping a // Flash the message background when it's been scrolled to (e.g. by tapping a
// reply that quotes it), so it's easy to spot. Keyed on the highlight `key` // reply that quotes it), so it's easy to spot. Keyed on the highlight `key`
@@ -521,7 +540,7 @@ let MessageItem = ({
accessibilityHint={l`Double tap or long press the message to add a reaction`} accessibilityHint={l`Double tap or long press the message to add a reaction`}
style={[ style={[
!isFromSelf && isGroupChat && a.ml_sm, !isFromSelf && isGroupChat && a.ml_sm,
!isOnlyEmoji(message.text) && [ !isEmojiOnly && [
a.rounded_xl, a.rounded_xl,
a.py_sm, a.py_sm,
a.px_md, a.px_md,
@@ -536,7 +555,7 @@ let MessageItem = ({
highlightStyle, highlightStyle,
], ],
]}> ]}>
{replyTo && !isOnlyEmoji(message.text) ? ( {replyTo && !isEmojiOnly ? (
<ReplyQuote <ReplyQuote
replyTo={replyTo} replyTo={replyTo}
isFromSelf={isFromSelf} isFromSelf={isFromSelf}
@@ -553,7 +572,7 @@ let MessageItem = ({
// glyph, then pull the bottom up by the same amount so // glyph, then pull the bottom up by the same amount so
// the glyph bottom-aligns with the avatar instead of // the glyph bottom-aligns with the avatar instead of
// sitting above its line-box baseline. // sitting above its line-box baseline.
isOnlyEmoji(message.text) && [ isEmojiOnly && [
a.leading_tight, a.leading_tight,
// Visually align bottom of the emoji with the avatar // Visually align bottom of the emoji with the avatar
!isFromSelf && !isFromSelf &&
@@ -1,3 +1,5 @@
export function useWelcomeModal() { import {type WelcomeModalControl} from './useWelcomeModal.shared'
export function useWelcomeModal(): WelcomeModalControl {
throw new Error('useWelcomeModal is web only') throw new Error('useWelcomeModal is web only')
} }
@@ -0,0 +1,5 @@
export type WelcomeModalControl = {
isOpen: boolean
open: () => void
close: () => void
}
+2 -1
View File
@@ -2,8 +2,9 @@ import {useEffect, useState} from 'react'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {IS_WEB} from '#/env' import {IS_WEB} from '#/env'
import {type WelcomeModalControl} from './useWelcomeModal.shared'
export function useWelcomeModal() { export function useWelcomeModal(): WelcomeModalControl {
const {hasSession} = useSession() const {hasSession} = useSession()
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
+11 -3
View File
@@ -485,9 +485,17 @@ function GalleryImage({
loading={index === 0 ? 'eager' : 'lazy'} loading={index === 0 ? 'eager' : 'lazy'}
style={[dims]} style={[dims]}
onLoad={e => { onLoad={e => {
const ar = getAspectRatio(e.source) /*
if (ar && ar !== aspectRatio) { * Only sync from the loaded thumb if we don't already have a
setAspectRatio(ar) * ratio from the record. The CDN scales thumbs to integer pixel
* dims, so its reported ratio drifts a fraction from the
* record's - re-syncing would jiggle the FlatList item widths.
*/
if (aspectRatio === undefined) {
const ar = getAspectRatio(e.source)
if (ar !== undefined) {
setAspectRatio(ar)
}
} }
onThumbDims(index, { onThumbDims(index, {
width: e.source.width, width: e.source.width,
+162
View File
@@ -0,0 +1,162 @@
import {useState} from 'react'
import {View} from 'react-native'
import {type ComAtprotoLabelDefs, ToolsOzoneReportDefs} from '@atproto/api'
import {XRPCError} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useMutation} from '@tanstack/react-query'
import {useLabelSubject} from '#/lib/moderation'
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {atoms as a, useBreakpoints} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {IS_ANDROID} from '#/env'
export function AppealForm({
label,
control,
onPressBack,
}: {
label: ComAtprotoLabelDefs.Label
control: Dialog.DialogOuterProps['control']
onPressBack: () => void
}) {
const {_} = useLingui()
const {labeler, strings} = useLabelInfo(label)
const {gtMobile} = useBreakpoints()
const [details, setDetails] = useState('')
const {subject} = useLabelSubject({label})
const isAccountReport = 'did' in subject
const agent = useAgent()
const sourceName = labeler
? sanitizeHandle(labeler.creator.handle, '@')
: label.src
const [error, setError] = useState<string | null>(null)
const {mutate, isPending} = useMutation({
mutationFn: async () => {
const $type = !isAccountReport
? 'com.atproto.repo.strongRef'
: 'com.atproto.admin.defs#repoRef'
await agent.createModerationReport(
{
reasonType: ToolsOzoneReportDefs.REASONAPPEAL,
subject: {
$type,
...subject,
},
reason: details,
},
{
encoding: 'application/json',
headers: {
'atproto-proxy': `${label.src}#atproto_labeler`,
},
},
)
},
onError: err => {
if (err instanceof XRPCError && err.error === 'AlreadyAppealed') {
setError(
_(
msg`You've already appealed this label and it's being reviewed by our moderation team.`,
),
)
} else {
setError(_(msg`Failed to submit appeal, please try again.`))
}
logger.error('Failed to submit label appeal', {message: err})
},
onSuccess: () => {
control.close()
Toast.show(_(msg({message: 'Appeal submitted', context: 'toast'})))
},
})
const onSubmit = () => mutate()
return (
<>
<View>
<Text style={[a.text_2xl, a.font_semi_bold, a.pb_xs, a.leading_tight]}>
<Trans>Appeal "{strings.name}" label</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
This appeal will be sent to{' '}
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}
style={[a.text_md, a.leading_snug]}>
{sourceName}
</InlineLinkText>
.
</Trans>
</Text>
</View>
{error && (
<Admonition type="error" style={[a.mt_sm]}>
{error}
</Admonition>
)}
<View style={[a.my_md]}>
<Dialog.Input
label={_(msg`Text input field`)}
placeholder={_(
msg`Please explain why you think this label was incorrectly applied by ${
labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src
}`,
)}
value={details}
onChangeText={setDetails}
autoFocus={true}
numberOfLines={3}
multiline
maxLength={300}
/>
</View>
<View
style={
gtMobile
? [a.flex_row, a.justify_between]
: [{flexDirection: 'column-reverse'}, a.gap_sm]
}>
<Button
testID="backBtn"
variant="solid"
color="secondary"
size="large"
onPress={onPressBack}
label={_(msg`Back`)}>
<ButtonText>{_(msg`Back`)}</ButtonText>
</Button>
<Button
testID="submitBtn"
variant="solid"
color="primary"
size="large"
onPress={onSubmit}
label={_(msg`Submit`)}>
<ButtonText>{_(msg`Submit`)}</ButtonText>
{isPending && <ButtonIcon icon={Loader} />}
</Button>
</View>
{IS_ANDROID && <View style={{height: 300}} />}
</>
)
}

Some files were not shown because too many files have changed in this diff Show More