Merge remote-tracking branch 'origin/main' into app-2100
@@ -25,10 +25,10 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- 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 }}
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -26,10 +26,10 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- 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 }}
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME }}
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -25,10 +25,10 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- 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 }}
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -25,10 +25,10 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- 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 }}
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -25,10 +25,10 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- 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 }}
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
|
||||
- name: ☕️ Assert Cocoapods version
|
||||
run: |
|
||||
EXPECTED=1.16.2
|
||||
EXPECTED=1.17.0
|
||||
ACTUAL=$(pod --version)
|
||||
if [ "$ACTUAL" != "$EXPECTED" ]; then
|
||||
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
|
||||
|
||||
@@ -232,7 +232,7 @@ jobs:
|
||||
|
||||
- name: ☕️ Assert Cocoapods version
|
||||
run: |
|
||||
EXPECTED=1.16.2
|
||||
EXPECTED=1.17.0
|
||||
ACTUAL=$(pod --version)
|
||||
if [ "$ACTUAL" != "$EXPECTED" ]; then
|
||||
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Claude
|
||||
uses: anthropics/claude-code-action@4633baf5267540f3f8cb58b684f79901d564c280 # v1.0.160
|
||||
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1.0.166
|
||||
with:
|
||||
use_bedrock: 'true'
|
||||
additional_permissions: |
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Claude review
|
||||
uses: anthropics/claude-code-action@4633baf5267540f3f8cb58b684f79901d564c280 # v1.0.160
|
||||
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1.0.166
|
||||
with:
|
||||
use_bedrock: 'true'
|
||||
additional_permissions: |
|
||||
|
||||
@@ -21,7 +21,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job: [lint, prettier, typecheck]
|
||||
job:
|
||||
[lint, prettier, 'typecheck:ios', 'typecheck:android', 'typecheck:web']
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
core.setOutput('head-ref', pr.data.head.ref);
|
||||
|
||||
- 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:
|
||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||
number: ${{ github.event.issue.number }}
|
||||
@@ -193,7 +193,7 @@ jobs:
|
||||
RUNTIME_VERSION:
|
||||
|
||||
- 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:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
with:
|
||||
@@ -210,7 +210,7 @@ jobs:
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
|
||||
- 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()
|
||||
with:
|
||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||
|
||||
@@ -19,8 +19,47 @@ concurrency:
|
||||
permissions: {}
|
||||
|
||||
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:
|
||||
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'}}
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -71,15 +110,18 @@ jobs:
|
||||
|
||||
- name: ⬇️ Get base stats from cache
|
||||
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:
|
||||
path: stats-base.json
|
||||
key: stats-base-${{ steps.base-commit.outputs.base-commit }}
|
||||
path: stats.json
|
||||
key: stats-base-main-${{ steps.base-commit.outputs.base-commit }}
|
||||
|
||||
- name: Restore to base commit
|
||||
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||||
env:
|
||||
BASE_COMMIT: ${{ steps.base-commit.outputs.base-commit }}
|
||||
run: |
|
||||
git reset HEAD~
|
||||
git reset "$BASE_COMMIT"
|
||||
git restore .
|
||||
|
||||
- name: 🔦 Generate stats file from base commit
|
||||
@@ -88,18 +130,17 @@ jobs:
|
||||
pnpm install
|
||||
pnpm intl:build
|
||||
pnpm generate-webpack-stats-file
|
||||
mv stats.json stats-base.json
|
||||
|
||||
- name: % Get diff
|
||||
id: get-diff
|
||||
uses: NejcZdovc/bundle-size-diff@5321de41d2d62a7b0f4d6e60f59d1280a0034160 # v1.1.0
|
||||
with:
|
||||
base_path: "stats-base.json"
|
||||
base_path: "stats.json"
|
||||
pr_path: "../stats-new.json"
|
||||
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
|
||||
|
||||
- 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:
|
||||
header: bundle-diff
|
||||
message: |
|
||||
@@ -140,7 +181,7 @@ jobs:
|
||||
profile: pull-request
|
||||
|
||||
- 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 }}
|
||||
with:
|
||||
header: fingerprint-diff
|
||||
@@ -158,7 +199,7 @@ jobs:
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
|
||||
- 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 }}
|
||||
with:
|
||||
header: fingerprint-diff
|
||||
|
||||
@@ -108,8 +108,8 @@ google-services.json
|
||||
# Performance results (Flashlight)
|
||||
.perf/
|
||||
|
||||
# ESLint
|
||||
.eslintcache
|
||||
# Oxlint
|
||||
.oxlintcache
|
||||
|
||||
# i18n
|
||||
src/locale/locales/_build/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -30,7 +30,7 @@ pnpm ios # Run on iOS
|
||||
# Testing & Quality
|
||||
# IMPORTANT: Always use these pnpm scripts, never call the underlying tools directly
|
||||
pnpm test # Run Jest tests
|
||||
pnpm lint # Run ESLint
|
||||
pnpm lint # Run Oxlint
|
||||
pnpm typecheck # Run TypeScript type checking
|
||||
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.
|
||||
|
||||
For complex screens that have specific components or data needs that _are not
|
||||
shared by other screens_, we encourage subdirectoreis within `/screens/<name>`
|
||||
shared by other screens_, we encourage subdirectories within `/screens/<name>`
|
||||
e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and
|
||||
`/screens/ProfileScreen/components/`.
|
||||
|
||||
@@ -126,44 +126,14 @@ eventually.
|
||||
Typically JS style for variables, functions, etc. We use ProudCamelCase for
|
||||
components, and camelCase directories and files.
|
||||
|
||||
When organizing new code, consider if it fits into a single file, or if it
|
||||
should be broken down into multiple files. For "macro" component cases, or
|
||||
things that live in `/features` or `/screens`, we often follow a pattern of
|
||||
having an `index.tsx` for the main component, and then co-locating related
|
||||
components, hooks, and utilities in the same directory. For example:
|
||||
For "macro" cases in `/features`, `/screens`, or `/components`, co-locate related
|
||||
code in a directory with an `index.tsx` main component plus sibling
|
||||
components/hooks/utils (e.g. `screens/ProfileScreen/index.tsx` +
|
||||
`screens/ProfileScreen/components/`). Keep related code together so it lives where
|
||||
someone would look for it. Don't overdo it: a component that fits in one file
|
||||
should just be `Component.tsx`, not `Component/index.tsx`.
|
||||
|
||||
```
|
||||
src
|
||||
├── screens/
|
||||
│ ├── ProfileScreen/
|
||||
│ │ ├── index.tsx # Main screen component
|
||||
│ │ ├── components/ # Sub-components used only by this screen
|
||||
```
|
||||
|
||||
Similar patterns can be found in `/features` and `/components`. The idea here is
|
||||
to keep related code together and make it easier to navigate.
|
||||
|
||||
You should ask yourself: if someone new was looking for the code related to this
|
||||
feature or screen, where would they expect to find it? Organizing code in a way
|
||||
that matches developer expectations can make the codebase much more
|
||||
approachable. Being able to say "Live Now stuff lives in `/features/liveNow`" is
|
||||
easier to understand than having it scattered across multiple directories.
|
||||
|
||||
No need to go overboard with this. If a component or feature fits into a single
|
||||
file, there's no reason to have a `/Component/index.tsx` file when it could just
|
||||
be `/Component.tsx`. Use your judgment based on the complexity and amount of
|
||||
related code.
|
||||
|
||||
#### Platform Specific Files
|
||||
|
||||
We have conflicting patterns in the app for this. The preferred approach is to
|
||||
group platform-specific files into a directory as much as possible. For example,
|
||||
rather than having `Component.tsx`, `Component.web.tsx`, and
|
||||
`Component.native.tsx` in the same directory, we prefer to have a `Component/`
|
||||
directory with `index.tsx`, `index.web.tsx`, and `index.native.tsx`. This keeps
|
||||
related code together and gives us a better visual cue that there are probably
|
||||
other files contained within this "macro" feature, whereas `Component.tsx` on
|
||||
its own looks more like a single component file.
|
||||
Platform-specific files are covered under "Platform-Specific Code" below.
|
||||
|
||||
### Comments
|
||||
|
||||
@@ -204,88 +174,40 @@ const fallbackDate = maximumDate ? toSimpleDateString(maximumDate) : today
|
||||
|
||||
### Documentation and Tests Within Features
|
||||
|
||||
For larger features or components, it's helpful to include a README.md file
|
||||
within the directory that explains the purpose of the feature, how it works, and
|
||||
any important implementation details. The `/Component/index.tsx` pattern lends
|
||||
itself well to this, since the `index.tsx` can be the main component file, and
|
||||
the `README.md` can provide documentation for the whole feature. This is
|
||||
optional, but can be a nice way to keep documentation close to the code it
|
||||
describes.
|
||||
|
||||
Similarly, if there are tests that are specific to a component or feature, it
|
||||
can be helpful to include them in the same directory, either as
|
||||
`Component.test.tsx` or in a `__tests__/` subdirectory. This keeps everything
|
||||
related to the component or feature in one place and makes it easier to find and
|
||||
maintain tests.
|
||||
For larger features or components, co-locate documentation and tests with the
|
||||
code. A `README.md` in the directory (the `/Component/index.tsx` pattern lends
|
||||
itself well to this) can document the whole feature, and feature-specific tests
|
||||
belong alongside it as `Component.test.tsx` or in a `__tests__/` subdirectory.
|
||||
Both are optional.
|
||||
|
||||
## Styling System (ALF)
|
||||
|
||||
ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens.
|
||||
ALF is the custom design system. Tailwind-inspired naming with underscores
|
||||
instead of hyphens. Static atoms (`atoms as a`) are theme-independent; theme
|
||||
atoms/palette come from `useTheme()` (`t.atoms.bg`, `t.palette.primary_500`).
|
||||
Style props take an array of atoms + theme atoms + raw styles.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
Generally, order atoms by:
|
||||
|
||||
- Flexbox configuration, e.g., `a.flex_row`
|
||||
- Spacing, e.g., `a.px_md`
|
||||
- Text styles, e.g., `a.font_bold`
|
||||
- Themes, e.g., `t.atoms.text`,
|
||||
- Raw styles, e.g., `{backgroundColor: t.palette.primary_500}`
|
||||
Order atoms by: flexbox (`a.flex_row`), spacing (`a.px_md`), text (`a.font_bold`),
|
||||
themes (`t.atoms.text`), then raw styles (`{backgroundColor: t.palette.primary_500}`).
|
||||
|
||||
```tsx
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
function MyComponent() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_high]}>Hello</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
const t = useTheme()
|
||||
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]} />
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
|
||||
**Static Atoms** – Theme-independent styles imported from `atoms`:
|
||||
Static atoms live in `a.*` (e.g. `a.flex_row`, `a.p_md`, `a.rounded_md`,
|
||||
`a.text_lg`). Theme atoms/palette come from `useTheme()` (`t.atoms.bg`,
|
||||
`t.atoms.text`, `t.atoms.border_contrast_low`, `t.palette.primary_500`).
|
||||
|
||||
```tsx
|
||||
import {atoms as a} from '#/alf'
|
||||
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
|
||||
```
|
||||
**Platform utilities** (`import {web, native, ios, android, platform} from '#/alf'`)
|
||||
return conditional styles inline in a style array: `web({cursor: 'pointer'})`,
|
||||
`native({paddingBottom: 20})`, `platform({ios: {...}, android: {...}, web: {...}})`.
|
||||
|
||||
**Theme Atoms** – Theme-dependent colors from `useTheme()`:
|
||||
|
||||
```tsx
|
||||
const t = useTheme()
|
||||
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
|
||||
// t.palette.primary_500, t.palette.negative_400, etc.
|
||||
```
|
||||
|
||||
**Platform Utilities** – For platform-specific styles:
|
||||
|
||||
```tsx
|
||||
import {web, native, ios, android, platform} from '#/alf'
|
||||
|
||||
const styles = [
|
||||
a.p_md,
|
||||
web({cursor: 'pointer'}),
|
||||
native({paddingBottom: 20}),
|
||||
platform({ios: {...}, android: {...}, web: {...}}),
|
||||
]
|
||||
```
|
||||
|
||||
**Breakpoints** – Responsive design:
|
||||
|
||||
```tsx
|
||||
import {useBreakpoints} from '#/alf'
|
||||
|
||||
const {gtPhone, gtMobile, gtTablet} = useBreakpoints()
|
||||
if (gtMobile) {
|
||||
// Tablet or desktop layout
|
||||
}
|
||||
```
|
||||
**Breakpoints:** `const {gtPhone, gtMobile, gtTablet} = useBreakpoints()` from `#/alf`.
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
@@ -306,176 +228,67 @@ if (gtMobile) {
|
||||
```tsx
|
||||
import {Fragment} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
function MyComponent({foo = []}: {foo?: string[]}) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
function MyComponent({items = []}: {items?: string[]}) {
|
||||
return (
|
||||
<>
|
||||
<View><Text><Trans>Example</Trans><Text></View>
|
||||
<View>
|
||||
{foo.map((foo, index) => (
|
||||
<Fragment key={foo}>
|
||||
<Text>
|
||||
<Trans>Example</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item}>
|
||||
<Text>{index}</Text>
|
||||
<Text>{foo}</Text>
|
||||
<Text>{item}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Dialog Component
|
||||
|
||||
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
|
||||
|
||||
```tsx
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
|
||||
function MyFeature() {
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button label="Open" onPress={control.open}>
|
||||
<ButtonText>Open Dialog</ButtonText>
|
||||
</Button>
|
||||
|
||||
<Dialog.Outer control={control}>
|
||||
{/* Typically the inner part is in its own component */}
|
||||
<DialogInner />
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogInner() {
|
||||
return (
|
||||
<>
|
||||
<Dialog.Handle /> {/* Native-only drag handle */}
|
||||
<Dialog.ScrollableInner label={l`My Dialog`}>
|
||||
<Dialog.Header>
|
||||
<Dialog.HeaderText>Title</Dialog.HeaderText>
|
||||
</Dialog.Header>
|
||||
<Text>Dialog content here</Text>
|
||||
<Button label="Done" onPress={() => control.close()}>
|
||||
<ButtonText>Done</ButtonText>
|
||||
</Button>
|
||||
<Dialog.Close /> {/* Web-only X button in top left */}
|
||||
</Dialog.ScrollableInner>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
Lives in `#/components/Dialog`. Bottom sheet on native, modal on web. Manage
|
||||
state with `useDialogControl()`. `Dialog.Handle` renders native-only, `Dialog.Close`
|
||||
web-only. CRITICAL: run any post-close action inside the `control.close(() => ...)`
|
||||
callback (see Footguns). Compound-component usage; canonical example in any dialog
|
||||
under `#/components`.
|
||||
|
||||
### Menu Component
|
||||
|
||||
Menus render as a dropdown on web and a bottom sheet dialog on native.
|
||||
|
||||
```tsx
|
||||
import * as Menu from '#/components/Menu'
|
||||
|
||||
function MyMenu() {
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label="Open menu">
|
||||
{({props}) => (
|
||||
<Button {...props} label="Menu">
|
||||
<ButtonIcon icon={DotsHorizontal} />
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
<Menu.Item label="Edit" onPress={handleEdit}>
|
||||
<Menu.ItemIcon icon={Pencil} />
|
||||
<Menu.ItemText>Edit</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
<Menu.Item label="Delete" onPress={handleDelete}>
|
||||
<Menu.ItemIcon icon={Trash} />
|
||||
<Menu.ItemText>Delete</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
)
|
||||
}
|
||||
```
|
||||
Lives in `#/components/Menu`. Dropdown on web, bottom sheet dialog on native.
|
||||
`Menu.Divider` is web-only, `Menu.ContainerItem` native-only. Compound API
|
||||
(`Menu.Root` / `Menu.Trigger` / `Menu.Outer` / `Menu.Group` / `Menu.Item`); grep
|
||||
existing usages across the app for a canonical example.
|
||||
|
||||
### Button Component
|
||||
|
||||
```tsx
|
||||
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||
|
||||
// Solid primary button (most common)
|
||||
<Button label="Save" onPress={handleSave} color="primary" size="large">
|
||||
<ButtonText>Save</ButtonText>
|
||||
</Button>
|
||||
|
||||
// With icon
|
||||
<Button label="Share" onPress={handleShare} color="secondary" size="small">
|
||||
<ButtonIcon icon={Share} />
|
||||
<ButtonText>Share</ButtonText>
|
||||
</Button>
|
||||
|
||||
// Icon-only button
|
||||
<Button label="Close" onPress={handleClose} color="secondary" size="small" shape="round">
|
||||
<ButtonIcon icon={XIcon} />
|
||||
</Button>
|
||||
|
||||
// Ghost variant (deprecated - use color prop)
|
||||
<Button label="Cancel" variant="ghost" color="secondary" size="small">
|
||||
<ButtonText>Cancel</ButtonText>
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Button Props:**
|
||||
`import {Button, ButtonText, ButtonIcon} from '#/components/Button'`. Props:
|
||||
|
||||
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
|
||||
- `size`: `'tiny'` | `'small'` | `'large'`
|
||||
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
|
||||
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`)
|
||||
|
||||
### Typography
|
||||
|
||||
```tsx
|
||||
import {Text, H1, H2, P} from '#/components/Typography'
|
||||
|
||||
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
|
||||
<P>Paragraph text with default styling.</P>
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>Custom text</Text>
|
||||
|
||||
// For text with emoji, add the emoji prop. User-generated text (e.g. display names)
|
||||
// will almost certainly contain emoji, so only omit it when the text is static and
|
||||
// does not contain an emoji
|
||||
<Text emoji>Hello! 👋</Text>
|
||||
```
|
||||
|
||||
The `Text` component's default style is `[a.text_sm, a.leading_snug, t.atoms.text]`.
|
||||
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, prefer `color`)
|
||||
|
||||
### TextField
|
||||
|
||||
```tsx
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
Compound component at `#/components/forms/TextField` (`TextField.LabelText`,
|
||||
`TextField.Root`, `TextField.Icon`, `TextField.Input`). Prefer `defaultValue` over
|
||||
`value` (see Footguns).
|
||||
|
||||
<TextField.LabelText>Email</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={AtSign} />
|
||||
<TextField.Input
|
||||
label="Email address"
|
||||
placeholder="you@example.com"
|
||||
defaultValue={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</TextField.Root>
|
||||
```
|
||||
### Typography
|
||||
|
||||
`import {Text, H1, H2, P} from '#/components/Typography'`. The `Text` default style
|
||||
is `[a.text_sm, a.leading_snug, t.atoms.text]`. Pass the `emoji` prop to any `Text`
|
||||
that may contain emoji - user-generated text (display names etc.) almost always
|
||||
does, so only omit it for static, emoji-free strings: `<Text emoji>Hello!</Text>`.
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
@@ -549,201 +362,49 @@ pnpm intl:compile # Compile translations for runtime
|
||||
|
||||
### TanStack Query (Data Fetching)
|
||||
|
||||
```tsx
|
||||
// src/state/queries/profile.ts
|
||||
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
Follow the established pattern in `src/state/queries/`; `src/state/queries/feed.ts`
|
||||
is a good canonical reference (it uses `createQueryKey`, matching key roots,
|
||||
`useInfiniteQuery`, and `persistedVersion`).
|
||||
|
||||
import {createQueryKey} from '#/state/queries/util'
|
||||
|
||||
/*
|
||||
* Query key name should match the query hook name for consistency
|
||||
*/
|
||||
const profileQueryKeyRoot = 'profile'
|
||||
|
||||
/*
|
||||
* Use object params and createQueryKey helper for better readability and to
|
||||
* avoid bugs with parameter order or types.
|
||||
*/
|
||||
export const createProfileQueryKey = (args: {did: string}) =>
|
||||
createQueryKey(profileQueryKeyRoot, args)
|
||||
|
||||
/*
|
||||
* Query hook should be named use[Name]Query, where [Name] describes the data
|
||||
* being fetched. This is not a strict requirement, but it's a helpful
|
||||
* convention for discoverability
|
||||
*/
|
||||
export function useProfileQuery({did}: {did: string}) {
|
||||
const agent = useAgent()
|
||||
|
||||
return useQuery({
|
||||
queryKey: createProfileQueryKey({did}),
|
||||
queryFn: async () => {
|
||||
const res = await agent.getProfile({actor: did})
|
||||
return res.data
|
||||
},
|
||||
staleTime: STALE.MINUTES.FIVE,
|
||||
enabled: !!did,
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* Mutation hook should match the name of the query hook, but with "Mutation"
|
||||
* suffix. This is not a strict requirement, but it's a helpful convention for
|
||||
* discoverability and consistency.
|
||||
*/
|
||||
export function useProfileMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async data => {
|
||||
// Update logic
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: createProfileQueryKey({did: variables.did}),
|
||||
})
|
||||
},
|
||||
onError: error => {
|
||||
if (isNetworkError(error)) {
|
||||
// don't log, but inform user
|
||||
} else if (error instanceof AppBskyExampleProcedure.ExampleError) {
|
||||
// XRPC APIs often have typed errors, allows nicer handling
|
||||
} else {
|
||||
// Log unexpected errors to Sentry
|
||||
logger.error('Error updating profile', {safeMessage: error})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* If cache mutation is needed, include specific interfaces for the specific
|
||||
* mutations you require adjacent to the source queries. Naming should be
|
||||
* descriptive of the mutation's purpose, e.g. use[Name]CacheMutation. This is
|
||||
* not a strict requirement, but it's a helpful convention for discoverability
|
||||
* and consistency.
|
||||
*/
|
||||
export function useProfileCacheMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return (data: Partial<Profile>) => {
|
||||
queryClient.setQueryData(
|
||||
createProfileQueryKey({did: data.did}),
|
||||
oldData => {
|
||||
if (!oldData) return oldData
|
||||
return {...oldData, ...data}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Stale Time Constants** (from `src/state/queries/index.ts`):
|
||||
|
||||
```tsx
|
||||
STALE.SECONDS.FIFTEEN // 15 seconds
|
||||
STALE.MINUTES.ONE // 1 minute
|
||||
STALE.MINUTES.FIVE // 5 minutes
|
||||
STALE.HOURS.ONE // 1 hour
|
||||
STALE.INFINITY // Never stale
|
||||
```
|
||||
|
||||
**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
|
||||
|
||||
```tsx
|
||||
export function useDraftsQuery() {
|
||||
const agent = useAgent()
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: createQueryKey('drafts'),
|
||||
queryFn: async ({pageParam}) => {
|
||||
const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam})
|
||||
return res.data
|
||||
},
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: page => page.cursor,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []`
|
||||
|
||||
**Persisted Queries**
|
||||
|
||||
To persist query data across app restarts, `createQueryKey` supports a third
|
||||
parameter called `options`, which has a `persistedVersion` property. When this
|
||||
property is set to a number, the query will be persisted.
|
||||
|
||||
When this property is updated (e.g. incremented), the persisted data will be cleared and replaced with the new data from the query function. This is useful for cases where the shape of the data has changed and old persisted data would no longer be valid.
|
||||
|
||||
```tsx
|
||||
export const createProfileQueryKey = (args: {did: string}) =>
|
||||
createQueryKey(profileQueryKeyRoot, args, {persistedVersion: 1})
|
||||
```
|
||||
- Build query keys with `createQueryKey(root, args)` (from `#/state/queries/util`)
|
||||
using an object for `args`. The key root variable should match the hook name.
|
||||
- Naming conventions: `use[Name]Query` for queries, `use[Name]Mutation` for
|
||||
mutations, `use[Name]CacheMutation` for helpers that mutate cached data directly.
|
||||
- Stale times come from `STALE` in `src/state/queries/index.ts`: `STALE.SECONDS.FIFTEEN`,
|
||||
`STALE.MINUTES.ONE`, `STALE.MINUTES.FIVE`, `STALE.HOURS.ONE`, `STALE.INFINITY`.
|
||||
- Paginated atproto APIs (those returning a `cursor`) use `useInfiniteQuery` with
|
||||
`getNextPageParam: page => page.cursor`; flatten results with
|
||||
`data?.pages.flatMap(page => page.items) ?? []`.
|
||||
- Persist a query across restarts by passing options:
|
||||
`createQueryKey(root, args, {persistedVersion: n})`. Bumping `n` clears the old
|
||||
persisted data and refetches - do this whenever the data shape changes.
|
||||
- Error handling in mutations: don't log network errors (just inform the user),
|
||||
handle typed XRPC errors specifically (e.g. `err instanceof SomeNsid.SomeError`),
|
||||
and send unexpected errors to `logger.error('...', {safeMessage: error})`.
|
||||
|
||||
### Preferences (React Context)
|
||||
|
||||
```tsx
|
||||
// Simple boolean preference pattern
|
||||
import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
|
||||
|
||||
function SettingsScreen() {
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
const setAutoplayDisabled = useSetAutoplayDisabled()
|
||||
|
||||
return <Toggle value={autoplayDisabled} onValueChange={setAutoplayDisabled} />
|
||||
}
|
||||
```
|
||||
Boolean/simple UI preferences are exposed as paired hooks from `#/state/preferences`,
|
||||
e.g. `useAutoplayDisabled()` / `useSetAutoplayDisabled()`.
|
||||
|
||||
### Session State
|
||||
|
||||
```tsx
|
||||
import {useSession, useAgent} from '#/state/session'
|
||||
|
||||
function MyComponent() {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
if (!hasSession) {
|
||||
return <LoginPrompt />
|
||||
}
|
||||
|
||||
// Use agent for API calls
|
||||
const response = await agent.getProfile({actor: currentAccount.did})
|
||||
}
|
||||
```
|
||||
`import {useSession, useAgent} from '#/state/session'`. `useSession()` gives
|
||||
`hasSession` and `currentAccount`; `useAgent()` gives the atproto agent for API calls.
|
||||
|
||||
## Navigation
|
||||
|
||||
Navigation uses React Navigation with type-safe route parameters.
|
||||
|
||||
```tsx
|
||||
// Screen component
|
||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
import {type CommonNavigatorParams} from '#/lib/routes/types'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
|
||||
|
||||
export function ProfileScreen({route, navigation}: Props) {
|
||||
const {name} = route.params // Type-safe params
|
||||
|
||||
return <Layout.Screen>{/* Screen content */}</Layout.Screen>
|
||||
}
|
||||
|
||||
// Programmatic navigation
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
const navigation = useNavigation()
|
||||
navigation.navigate('Profile', {name: 'alice.bsky.social'})
|
||||
|
||||
// Or use the navigate helper
|
||||
import {navigate} from '#/Navigation'
|
||||
navigate('Profile', {name: 'alice.bsky.social'})
|
||||
```
|
||||
React Navigation with type-safe route params. Type a screen with
|
||||
`NativeStackScreenProps<CommonNavigatorParams, 'X'>` (`route`/`navigation` come
|
||||
from props; params via `route.params`). Navigate programmatically with
|
||||
`useNavigation()`, or the `navigate` helper from `#/Navigation`. Config lives in
|
||||
`src/Navigation.tsx`, routes in `src/routes.ts`, types in `src/lib/routes/types.ts`.
|
||||
|
||||
## Platform-Specific Code
|
||||
|
||||
Use file extensions for platform-specific implementations:
|
||||
Use file extensions for platform-specific implementations. The bundler resolves
|
||||
them automatically - just import the base path normally, never a conditional
|
||||
`require()`.
|
||||
|
||||
```
|
||||
Component.tsx # Shared/default
|
||||
@@ -753,12 +414,11 @@ Component.ios.tsx # iOS-only
|
||||
Component.android.tsx # Android-only
|
||||
```
|
||||
|
||||
Example from Dialog:
|
||||
|
||||
- `src/components/Dialog/index.tsx` – Native (uses BottomSheet)
|
||||
- `src/components/Dialog/index.web.tsx` – Web (uses modal with Radix primitives)
|
||||
|
||||
**Important:** The bundler automatically resolves platform-specific files. Just import normally:
|
||||
Prefer grouping variants into a `Component/` directory (`index.tsx`,
|
||||
`index.web.tsx`, `index.native.tsx`) rather than sibling `Component.web.tsx` files,
|
||||
so the shared surface reads as one "macro" module (e.g. `src/components/Dialog/index.tsx`
|
||||
native vs `index.web.tsx` web). The app has both patterns; the directory form is
|
||||
preferred for new code.
|
||||
|
||||
```tsx
|
||||
// CORRECT - bundler picks storage.ts or storage.web.ts automatically
|
||||
@@ -770,15 +430,7 @@ const storage = IS_NATIVE
|
||||
: require('#/state/drafts/storage.web')
|
||||
```
|
||||
|
||||
Platform detection (for runtime logic, not imports):
|
||||
|
||||
```tsx
|
||||
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
|
||||
|
||||
if (IS_NATIVE) {
|
||||
// Native-specific logic
|
||||
}
|
||||
```
|
||||
Runtime platform detection (not for imports): `import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'`.
|
||||
|
||||
## Import Aliases
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ WORKDIR /app
|
||||
COPY ./bskyogcard/package.json ./
|
||||
COPY ./bskyogcard/pnpm-lock.yaml ./
|
||||
COPY ./bskyogcard/pnpm-workspace.yaml ./
|
||||
RUN npm install --global pnpm@11.10.0
|
||||
RUN npm install --global pnpm@11.13.1
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY ./bskyogcard ./
|
||||
|
||||
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
|
||||
RUN \. "$NVM_DIR/nvm.sh" && \
|
||||
nvm install $NODE_VERSION && \
|
||||
nvm use $NODE_VERSION && \
|
||||
npm install --global pnpm@11.10.0 && \
|
||||
npm install --global pnpm@11.13.1 && \
|
||||
pnpm install --frozen-lockfile && \
|
||||
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
|
||||
pnpm intl:build && \
|
||||
|
||||
@@ -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 |
@@ -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="<Group>"><path id="<Path>" 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="<Clip Group>" clip-path="url(#cp1)"><path id="<Compound Path>" 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 @@
|
||||
<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 @@
|
||||
<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 @@
|
||||
<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 @@
|
||||
<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 @@
|
||||
<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 @@
|
||||
<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 @@
|
||||
<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 |
@@ -6,7 +6,7 @@
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "11.10.0",
|
||||
"version": "11.13.1",
|
||||
"onFail": "warn"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,52 +7,52 @@ importers:
|
||||
configDependencies: {}
|
||||
packageManagerDependencies:
|
||||
'@pnpm/exe':
|
||||
specifier: 11.5.2
|
||||
version: 11.5.2
|
||||
specifier: 11.13.1
|
||||
version: 11.13.1
|
||||
pnpm:
|
||||
specifier: 11.5.2
|
||||
version: 11.5.2
|
||||
specifier: 11.13.1
|
||||
version: 11.13.1
|
||||
|
||||
packages:
|
||||
|
||||
'@pnpm/exe@11.5.2':
|
||||
resolution: {integrity: sha512-4UFnP2rhNu1xjAQ+I1GdIUUEtCJuTYJlbpiWSFA4POAID3Lpt+2vrjImWO7eOJ7iCY3vpc4TFe2IW3sAolW4Kg==}
|
||||
'@pnpm/exe@11.13.1':
|
||||
resolution: {integrity: sha512-P4euEK6lOFnd5oTHEc5M/HhvyF4XUhTnVsklEcM6rmY0QJxPD6xbT+u1+gskEIBp4nSRorz20IJQtAU1Nerggg==}
|
||||
hasBin: true
|
||||
|
||||
'@pnpm/linux-arm64@11.5.2':
|
||||
resolution: {integrity: sha512-MbJySnu2y9cCBqlODLjUlZ87JnRC3Inq40rvGHWJSrSQ0PnuHeSw2NDMnLI8Hf9hCY+ooussRc5iiR4IAkjUvg==}
|
||||
'@pnpm/linux-arm64@11.13.1':
|
||||
resolution: {integrity: sha512-wB8zloqrYrudPyuA5qbuTCnJGe4eETPwqOjoPjoyyyvA4zFI5XfLpxgqOOcaY5UJBoqzckcGpRVDwhSRfsQ/6A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linux-x64@11.5.2':
|
||||
resolution: {integrity: sha512-g6g2BGpQA47wUACy6B1MdeSHPtnl6x4AeCg0IOWQ7xXorEtC+VRiSHhLpA5kByFGeSwyYh/nLc7mLul5DAaELw==}
|
||||
'@pnpm/linux-x64@11.13.1':
|
||||
resolution: {integrity: sha512-A+wnEvzfWEvanXiwww3tnOPmtjPSrrf5tOP6vk8+K0BRFEe/Df0oPytm2nWgGcn5iwPnqtr1Btkof913McnSPA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.5.2':
|
||||
resolution: {integrity: sha512-xTxs9BLxYW39BPNGnmvYCUBnMPWm4mzmzujmdYbpRxDnBXrx55qPR5K/3LSohX7VrmsdDrYxuH6AmG1AaOlIfA==}
|
||||
'@pnpm/linuxstatic-arm64@11.13.1':
|
||||
resolution: {integrity: sha512-k4t65VeqRX4COMFe45TF58CVmCpmAsKZShaR1HobmUeleo98mWTctggKolrA2MHcVUeSS+12yB5Urb3uDazhmw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.5.2':
|
||||
resolution: {integrity: sha512-RGmmc/SoGLD90gmOHcU85UEKNoNRstLvizli4wzDASmETz/VeqJOqU5nD1YBgjzcP72sUMS352dh4bmzTfKyvQ==}
|
||||
'@pnpm/linuxstatic-x64@11.13.1':
|
||||
resolution: {integrity: sha512-A65GqPzwCl0bAMk3kRWfbjSRBm5RRaqR2oMxV/9AYZrwO0X9yEfngbLBISCPHjt6/Qe4nH7DFemyhy6yODYwEw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/macos-arm64@11.5.2':
|
||||
resolution: {integrity: sha512-gW3A2jRlC3SJRw8qX2SAzjMIu9o98daTSqCKzeeYcjF/uEbtbz3dn4HqYrYffBnenKbc4hsgZQmNOHAvUKIlSg==}
|
||||
'@pnpm/macos-arm64@11.13.1':
|
||||
resolution: {integrity: sha512-MJvOtyGOWSfBoqdVEfAH8ljmHs13mt82k/UxN4f+q7koDxJRR2n4Nie6Og6RwbnbaubCz0Fh2bTeL1+MxDSFpA==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/win-arm64@11.5.2':
|
||||
resolution: {integrity: sha512-+VJCDoH/pRzLXBikwjvxgAnGfQufT8EALBX8cfSmrwD40JABUZvgPtjBjde7OwEoK/XwtlH8w+ZceFV0K3/YHQ==}
|
||||
'@pnpm/win-arm64@11.13.1':
|
||||
resolution: {integrity: sha512-kl/g1cCKOJPe4HntspyrAJW0LRco0UHnVfxHSspezo4Zj4AanJAZ8WzLqfa6/w3lBSKHTEN4x0pb3m4J7B7Vpw==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@pnpm/win-x64@11.5.2':
|
||||
resolution: {integrity: sha512-zgglREh75RbFgV/E0tNRS03ElX+hJOV43KRSSeaboxtj3ei1rrguxOgOCXUs/GsizoHVsuD+qXGABE4Kc4GMCg==}
|
||||
'@pnpm/win-x64@11.13.1':
|
||||
resolution: {integrity: sha512-Bcb14NeBlbHS2Gq1qr8VnCiAz5eC1lYzXOls7zH0bnV0Taaj4/xyfm0HVO4dn9R2TQtVtz1qnBZHHL9PDFumqQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -116,45 +116,45 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
pnpm@11.5.2:
|
||||
resolution: {integrity: sha512-ccYx44IGbvwlYl1c8CkHXeB7YbN/bic1D72Esb2lhkyMGWetwoB3a0XDCnFcA1mjvgj+9C1bsJ4rmQKZeWkpFg==}
|
||||
pnpm@11.13.1:
|
||||
resolution: {integrity: sha512-svx2g7imUlQU59E+G6KMqt3elr9m7FQL+ut+cCuB8+C+TR8pXt9/n+A5Z0Co3ORQnFgt33mJH0VD/qMtN2RfJQ==}
|
||||
engines: {node: '>=22.13'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@pnpm/exe@11.5.2':
|
||||
'@pnpm/exe@11.13.1':
|
||||
dependencies:
|
||||
'@reflink/reflink': 0.1.19
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
'@pnpm/linux-arm64': 11.5.2
|
||||
'@pnpm/linux-x64': 11.5.2
|
||||
'@pnpm/linuxstatic-arm64': 11.5.2
|
||||
'@pnpm/linuxstatic-x64': 11.5.2
|
||||
'@pnpm/macos-arm64': 11.5.2
|
||||
'@pnpm/win-arm64': 11.5.2
|
||||
'@pnpm/win-x64': 11.5.2
|
||||
'@pnpm/linux-arm64': 11.13.1
|
||||
'@pnpm/linux-x64': 11.13.1
|
||||
'@pnpm/linuxstatic-arm64': 11.13.1
|
||||
'@pnpm/linuxstatic-x64': 11.13.1
|
||||
'@pnpm/macos-arm64': 11.13.1
|
||||
'@pnpm/win-arm64': 11.13.1
|
||||
'@pnpm/win-x64': 11.13.1
|
||||
|
||||
'@pnpm/linux-arm64@11.5.2':
|
||||
'@pnpm/linux-arm64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linux-x64@11.5.2':
|
||||
'@pnpm/linux-x64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.5.2':
|
||||
'@pnpm/linuxstatic-arm64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.5.2':
|
||||
'@pnpm/linuxstatic-x64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@pnpm/macos-arm64@11.5.2':
|
||||
'@pnpm/macos-arm64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-arm64@11.5.2':
|
||||
'@pnpm/win-arm64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-x64@11.5.2':
|
||||
'@pnpm/win-x64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||
@@ -194,7 +194,7 @@ snapshots:
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
pnpm@11.5.2: {}
|
||||
pnpm@11.13.1: {}
|
||||
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
@@ -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 tseslint = require('typescript-eslint')
|
||||
const {RuleTester} = require('oxlint/plugins-dev')
|
||||
const avoidUnwrappedText = require('../avoid-unwrapped-text')
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
eslintCompat: true,
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
lang: 'tsx',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,16 +1,11 @@
|
||||
const {RuleTester} = require('eslint')
|
||||
const tseslint = require('typescript-eslint')
|
||||
const {RuleTester} = require('oxlint/plugins-dev')
|
||||
const linguiMsgRule = require('../lingui-msg-rule')
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
eslintCompat: true,
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
lang: 'tsx',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
## Eslint plugin tests
|
||||
|
||||
These are disabled as Oxlint’s RuleTester doesn’t work well with Hermes.
|
||||
@@ -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
|
||||
@@ -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 MOVIE_EXTENSIONS: [String] = ["mov", "mp4", "m4v"]
|
||||
let MAX_IMAGES = 10
|
||||
|
||||
enum URLType: String, CaseIterable {
|
||||
case image
|
||||
@@ -71,17 +72,12 @@ class ShareViewController: UIViewController {
|
||||
}
|
||||
|
||||
private func handleImages(items: [NSItemProvider]) async {
|
||||
let firstFourItems: [NSItemProvider]
|
||||
if items.count < 4 {
|
||||
firstFourItems = items
|
||||
} else {
|
||||
firstFourItems = Array(items[0...3])
|
||||
}
|
||||
let itemsToProcess = Array(items.prefix(MAX_IMAGES))
|
||||
|
||||
var valid = true
|
||||
var imageUris = ""
|
||||
|
||||
for (index, item) in firstFourItems.enumerated() {
|
||||
for (index, item) in itemsToProcess.enumerated() {
|
||||
var imageUriInfo: String?
|
||||
|
||||
do {
|
||||
@@ -100,7 +96,7 @@ class ShareViewController: UIViewController {
|
||||
|
||||
if let imageUriInfo = imageUriInfo {
|
||||
imageUris.append(imageUriInfo)
|
||||
if index < items.count - 1 {
|
||||
if index < itemsToProcess.count - 1 {
|
||||
imageUris.append(",")
|
||||
}
|
||||
} else {
|
||||
@@ -121,7 +117,6 @@ class ShareViewController: UIViewController {
|
||||
let firstItem = items.first
|
||||
|
||||
if let dataUrl = try? await firstItem?.loadItem(forTypeIdentifier: "public.movie") as? URL {
|
||||
let ext = String(dataUrl.lastPathComponent.split(separator: ".").last ?? "mp4")
|
||||
if let videoUriInfo = saveVideoWithInfo(dataUrl),
|
||||
let url = URL(string: "\(self.appScheme)://intent/compose?videoUri=\(videoUriInfo)") {
|
||||
_ = self.openURL(url)
|
||||
|
||||
@@ -32,9 +32,12 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
var cornerRadius: CGFloat?
|
||||
var sourceViewTag: Int?
|
||||
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 {
|
||||
let screenHeight = Util.getScreenHeight() ?? 0
|
||||
let screenHeight = Util.getScreenHeight() ?? UIScreen.main.bounds.height
|
||||
if maxHeight > screenHeight {
|
||||
maxHeight = screenHeight
|
||||
}
|
||||
@@ -77,7 +80,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
|
||||
required init (appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
self.maxHeight = Util.getScreenHeight()
|
||||
self.maxHeight = Util.getScreenHeight() ?? UIScreen.main.bounds.height
|
||||
self.touchHandler = RCTTouchHandler(bridge: appContext?.reactBridge)
|
||||
SheetManager.shared.add(self)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import {Component} from 'react'
|
||||
|
||||
import {type BottomSheetViewProps} from './BottomSheet.types'
|
||||
|
||||
export function BottomSheetNativeComponent(_: BottomSheetViewProps) {
|
||||
export class BottomSheetNativeComponent extends Component<BottomSheetViewProps> {
|
||||
/*
|
||||
* 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()
|
||||
}
|
||||
|
||||
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')
|
||||
if (referrer) {
|
||||
SharedPrefs.removeValue('referrer')
|
||||
|
||||
@@ -5,6 +5,6 @@ export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo
|
||||
throw new NotImplementedError()
|
||||
}
|
||||
|
||||
export function getReferrerInfo(): ReferrerInfo | null {
|
||||
export function getReferrerInfo(): Promise<ReferrerInfo | null> {
|
||||
throw new NotImplementedError()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,15 @@ export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo
|
||||
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 (
|
||||
Platform.OS === 'web' &&
|
||||
// for ssr
|
||||
|
||||
@@ -15,6 +15,7 @@ import java.io.FileOutputStream
|
||||
import java.net.URLEncoder
|
||||
|
||||
private const val TAG = "ExpoReceiveAndroidIntents"
|
||||
private const val MAX_IMAGES = 10
|
||||
|
||||
enum class AttachmentType {
|
||||
IMAGE,
|
||||
@@ -100,12 +101,12 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
intent
|
||||
.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
?.filterIsInstance<Uri>()
|
||||
?.take(4)
|
||||
?.take(MAX_IMAGES)
|
||||
} else {
|
||||
intent
|
||||
.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
?.filterIsInstance<Uri>()
|
||||
?.take(4)
|
||||
?.take(MAX_IMAGES)
|
||||
}
|
||||
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.127.1",
|
||||
"version": "1.129.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24.18.0"
|
||||
@@ -8,7 +8,7 @@
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "11.10.0",
|
||||
"version": "11.13.1",
|
||||
"onFail": "warn"
|
||||
},
|
||||
"runtime": {
|
||||
@@ -59,10 +59,13 @@
|
||||
"test-watch": "NODE_ENV=test jest --watchAll",
|
||||
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
||||
"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: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: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",
|
||||
@@ -93,9 +96,9 @@
|
||||
"prettier": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.25",
|
||||
"@atproto/common-web": "0.5.3",
|
||||
"@atproto/syntax": "0.6.4",
|
||||
"@atproto/api": "0.20.28",
|
||||
"@atproto/common-web": "0.5.6",
|
||||
"@atproto/syntax": "0.7.2",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.14",
|
||||
@@ -128,7 +131,7 @@
|
||||
"@react-navigation/bottom-tabs": "^7.15.5",
|
||||
"@react-navigation/native": "^7.1.33",
|
||||
"@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/react-query": "^5.96.2",
|
||||
"@tanstack/react-query-persist-client": "^5.96.2",
|
||||
@@ -140,7 +143,6 @@
|
||||
"@tiptap/extension-paragraph": "^2.9.1",
|
||||
"@tiptap/extension-placeholder": "^2.9.1",
|
||||
"@tiptap/extension-text": "^2.9.1",
|
||||
"@tiptap/html": "^2.9.1",
|
||||
"@tiptap/pm": "^2.9.1",
|
||||
"@tiptap/react": "^2.9.1",
|
||||
"@tiptap/suggestion": "^2.9.1",
|
||||
@@ -257,7 +259,6 @@
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/runtime": "^7.26.0",
|
||||
"@crowdin/cli": "^4.14.1",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@lingui/babel-plugin-lingui-macro": "^5.9.2",
|
||||
"@lingui/cli": "^5.9.2",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
@@ -277,14 +278,8 @@
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"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-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-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-native": "^5.0.0",
|
||||
"eslint-plugin-react-native-a11y": "^3.5.1",
|
||||
"eslint-plugin-simple-import-sort": "^13.0.0",
|
||||
@@ -295,14 +290,15 @@
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "~54.0.17",
|
||||
"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",
|
||||
"react-native-dotenv": "^3.4.11",
|
||||
"react-refresh": "^0.14.0",
|
||||
"svgo": "^3.3.2",
|
||||
"svgo": "^4.0.2",
|
||||
"ts-plugin-sort-import-suggestions": "^1.0.4",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"webpack-bundle-analyzer": "^4.10.1"
|
||||
},
|
||||
"jest": {
|
||||
@@ -367,7 +363,7 @@
|
||||
},
|
||||
"lint-staged": {
|
||||
"*{.js,.jsx,.ts,.tsx}": [
|
||||
"eslint --cache --fix"
|
||||
"oxlint --fix"
|
||||
],
|
||||
"*{.js,.jsx,.ts,.tsx,.css}": [
|
||||
"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.
|
||||
@@ -55,3 +55,15 @@ index 06829bd00dbded262e1871aaf6db3ae0cfa9d1b1..1b158185a6d7e907aa18ddc806902fcc
|
||||
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
|
||||
|
||||
@@ -19,7 +19,7 @@ allowBuilds:
|
||||
'esbuild': true
|
||||
'unrs-resolver': true
|
||||
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-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
|
||||
|
||||
@@ -33,6 +33,7 @@ import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
||||
import {MessagesProvider} from '#/state/messages'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
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 ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
||||
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
|
||||
@@ -152,6 +153,7 @@ function InnerApp() {
|
||||
key={currentAccount?.did}>
|
||||
<AnalyticsFeaturesContext>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<BetaUserStorageSync />
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<LiveEventsProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
||||
import {MessagesProvider} from '#/state/messages'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
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 ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
||||
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
|
||||
@@ -132,6 +133,7 @@ function InnerApp() {
|
||||
key={currentAccount?.did}>
|
||||
<AnalyticsFeaturesContext>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<BetaUserStorageSync />
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<LiveEventsProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
|
||||
@@ -108,6 +108,7 @@ import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
|
||||
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
|
||||
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
|
||||
import {AutomationLabelSettingsScreen} from '#/screens/Settings/AutomationLabelSettings'
|
||||
import {BetaFeaturesSettingsScreen} from '#/screens/Settings/BetaFeaturesSettings'
|
||||
import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings'
|
||||
import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences'
|
||||
import {FindContactsSettingsScreen} from '#/screens/Settings/FindContactsSettings'
|
||||
@@ -405,6 +406,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="BetaFeaturesSettings"
|
||||
getComponent={() => BetaFeaturesSettingsScreen}
|
||||
options={{
|
||||
title: title(msg`Beta features`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="AutomationLabelSettings"
|
||||
getComponent={() => AutomationLabelSettingsScreen}
|
||||
@@ -994,7 +1003,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
})
|
||||
|
||||
if (IS_WEB) {
|
||||
const referrerInfo = Referrer.getReferrerInfo()
|
||||
void Referrer.getReferrerInfo().then(referrerInfo => {
|
||||
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
|
||||
ax.metric('deepLink:referrerReceived', {
|
||||
to: window.location.href,
|
||||
@@ -1002,6 +1011,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
hostname: referrerInfo?.hostname,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// temp, just testing
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {MMKV} from '@bsky.app/react-native-mmkv'
|
||||
import {setPolyfills} from '@growthbook/growthbook'
|
||||
import {GrowthBook} from '@growthbook/growthbook-react'
|
||||
import {type I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
|
||||
import {Logger} from '#/logger'
|
||||
import {Features} from '#/analytics/features/types'
|
||||
import {getNavigationMetadata, type Metadata} from '#/analytics/metadata'
|
||||
import * as env from '#/env'
|
||||
|
||||
@@ -11,12 +14,14 @@ export {Features} from '#/analytics/features/types'
|
||||
const logger = Logger.create(Logger.Context.Growthbook)
|
||||
const CACHE = new MMKV({id: 'bsky_features_cache'})
|
||||
|
||||
const BETA_USER_ATTRIBUTE = 'isBetaUser'
|
||||
|
||||
setPolyfills({
|
||||
localStorage: {
|
||||
getItem: key => {
|
||||
return CACHE.getString(key) ?? null
|
||||
},
|
||||
setItem: async (key, value) => {
|
||||
setItem: (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
|
||||
* initialization completes.
|
||||
*/
|
||||
export const init = new Promise<void>(async y => {
|
||||
const res = await features.init({timeout: TIMEOUT_INIT})
|
||||
export const init = features.init({timeout: TIMEOUT_INIT}).then(res => {
|
||||
if (!res.success) {
|
||||
logger.warn('GrowthBook initialization failed or timed out', {
|
||||
source: res.source,
|
||||
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
|
||||
* attributes are manually configured in the GrowthBook dashboard. So these
|
||||
@@ -80,7 +166,7 @@ export function setAttributes({
|
||||
session,
|
||||
preferences,
|
||||
}: Metadata) {
|
||||
features.setAttributes({
|
||||
void features.setAttributes({
|
||||
deviceId: base.deviceId,
|
||||
sessionId: base.sessionId,
|
||||
platform: base.platform,
|
||||
@@ -92,5 +178,6 @@ export function setAttributes({
|
||||
appLanguage: preferences?.appLanguage,
|
||||
contentLanguages: preferences?.contentLanguages,
|
||||
currentScreen: getNavigationMetadata()?.currentScreen,
|
||||
isBetaUser: base.isBetaUser,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
// core flags
|
||||
IsBskyTeam = 'is_bsky_team',
|
||||
@@ -12,8 +16,9 @@ export enum Features {
|
||||
GroupChatsDisable = 'group_chats:disable',
|
||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||
SearchV2Enable = 'search_v2:enable',
|
||||
AdvancedSearchV2Enable = 'advanced_search_v2:enable',
|
||||
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
|
||||
PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable',
|
||||
CustomLogoJapanEnable = 'custom_logo:japan:enable',
|
||||
|
||||
AATest = 'aa-test',
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import {createContext, useContext, useMemo} from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import {Platform} from 'react-native'
|
||||
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 env from '#/env'
|
||||
import {useGeolocationServiceResponse} from '#/geolocation/service'
|
||||
import {device} from '#/storage'
|
||||
import {account, device} from '#/storage'
|
||||
|
||||
export * as utils from '#/analytics/utils'
|
||||
export const features = {init, refresh}
|
||||
@@ -120,6 +126,38 @@ Context.displayName = 'AnalyticsContext'
|
||||
*/
|
||||
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
|
||||
* additional metadata. Nesting should be done carefully and sparingly.
|
||||
@@ -141,6 +179,16 @@ export function AnalyticsContext({
|
||||
const sessionId = useSessionId()
|
||||
const geolocation = useGeolocationServiceResponse()
|
||||
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 combinedMetadata = {
|
||||
...parentContext.metadata,
|
||||
@@ -148,6 +196,7 @@ export function AnalyticsContext({
|
||||
base: {
|
||||
...parentContext.metadata.base,
|
||||
sessionId,
|
||||
isBetaUser,
|
||||
},
|
||||
geolocation,
|
||||
}
|
||||
@@ -166,7 +215,7 @@ export function AnalyticsContext({
|
||||
},
|
||||
}
|
||||
return context
|
||||
}, [sessionId, geolocation, parentContext, metadata])
|
||||
}, [parentContext, metadata, sessionId, isBetaUser, geolocation])
|
||||
return <Context.Provider value={childContext}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export type BaseMetadata = {
|
||||
bundleDate: number
|
||||
referrerSrc: string
|
||||
referrerUrl: string
|
||||
isBetaUser?: boolean
|
||||
}
|
||||
|
||||
export type GeolocationMetadata = Geolocation
|
||||
@@ -66,7 +67,7 @@ export function getMetadataForLogger({
|
||||
base,
|
||||
geolocation,
|
||||
session,
|
||||
}: Metadata): Record<string, any> {
|
||||
}: Metadata): Record<string, unknown> {
|
||||
return {
|
||||
deviceId: base.deviceId,
|
||||
sessionId: base.sessionId,
|
||||
|
||||
@@ -1461,4 +1461,6 @@ export type Events = {
|
||||
jobId?: string
|
||||
elapsedInPhaseMs: number
|
||||
}
|
||||
|
||||
'post:likedBy:click': {}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import {type StyleProp, type ViewStyle} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {type TriggerProps as MenuTriggerProps} from '#/components/Menu/types'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {type AuxiliaryViewProps} from './types'
|
||||
import {type AuxiliaryViewProps, type TriggerProps} from './types'
|
||||
|
||||
export {
|
||||
ContainerItem,
|
||||
@@ -16,7 +17,6 @@ export {
|
||||
ItemText,
|
||||
LabelText,
|
||||
Root,
|
||||
Trigger,
|
||||
useMenuContext as useContextMenuContext,
|
||||
useMenuControl as useContextMenuControl,
|
||||
} from '#/components/Menu'
|
||||
@@ -30,14 +30,37 @@ export function AuxiliaryView({}: AuxiliaryViewProps) {
|
||||
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({
|
||||
children,
|
||||
label,
|
||||
align: _align,
|
||||
style,
|
||||
onCloseAutoFocus,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
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>
|
||||
onCloseAutoFocus?: (event: Event) => void
|
||||
}) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type GestureResponderEvent,
|
||||
type LayoutChangeEvent,
|
||||
Pressable,
|
||||
type ScrollView,
|
||||
type StyleProp,
|
||||
View,
|
||||
type ViewStyle,
|
||||
@@ -24,6 +25,7 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
||||
import {logger} from '#/logger'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {type ListMethods} from '#/view/com/util/List'
|
||||
import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
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<
|
||||
FlatList,
|
||||
FlatListProps<any> & {label: string} & {
|
||||
ListMethods,
|
||||
FlatListProps<any> & {label?: string} & {
|
||||
webInnerStyle?: StyleProp<ViewStyle>
|
||||
webInnerContentContainerStyle?: StyleProp<ViewStyle>
|
||||
footer?: React.ReactNode
|
||||
@@ -247,7 +259,11 @@ export const InnerFlatList = forwardRef<
|
||||
const {gtMobile} = useBreakpoints()
|
||||
return (
|
||||
<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={[
|
||||
a.overflow_hidden,
|
||||
a.px_0,
|
||||
@@ -256,7 +272,13 @@ export const InnerFlatList = forwardRef<
|
||||
]}
|
||||
contentContainerStyle={[a.h_full, a.px_0, webInnerContentContainerStyle]}>
|
||||
<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]}
|
||||
{...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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {useState} from 'react'
|
||||
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 {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid'
|
||||
@@ -18,6 +20,10 @@ interface SortableListProps<T> {
|
||||
onDragEnd?: () => void
|
||||
/** Fixed row height used for position math. */
|
||||
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>({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import {useCallback} from 'react'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -14,26 +13,31 @@ export function LanguageSelect({
|
||||
value: l.code2,
|
||||
})),
|
||||
label,
|
||||
disabledBlueskySupportedLanguageSanitization = false,
|
||||
}: {
|
||||
value?: string
|
||||
onChange: (value: string) => void
|
||||
items?: {label: string; value: string}[]
|
||||
label?: string
|
||||
disabledBlueskySupportedLanguageSanitization?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const selectValue =
|
||||
value && !disabledBlueskySupportedLanguageSanitization
|
||||
? sanitizeAppLanguageSetting(value)
|
||||
: value
|
||||
|
||||
const handleOnChange = useCallback(
|
||||
(value: string) => {
|
||||
const handleOnChange = (value: string) => {
|
||||
if (!value) return
|
||||
onChange(sanitizeAppLanguageSetting(value))
|
||||
},
|
||||
[onChange],
|
||||
onChange(
|
||||
disabledBlueskySupportedLanguageSanitization
|
||||
? value
|
||||
: sanitizeAppLanguageSetting(value),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Select.Root
|
||||
value={value ? sanitizeAppLanguageSetting(value) : undefined}
|
||||
onValueChange={handleOnChange}>
|
||||
<Select.Root value={selectValue} onValueChange={handleOnChange}>
|
||||
<Select.Trigger label={_(msg`Select language`)}>
|
||||
<Select.ValueText placeholder={_(msg`Select language`)} />
|
||||
<Select.Icon />
|
||||
|
||||
@@ -4,7 +4,6 @@ import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {HITSLOP_30} from '#/lib/constants'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {useSetDrawerOpen} from '#/state/shell'
|
||||
import {useIsWithinSplitView} from '#/screens/Messages/components/splitView/context'
|
||||
@@ -33,6 +32,8 @@ import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_IOS} from '#/env'
|
||||
|
||||
const HEADER_BUTTON_HITSLOP = {top: 30, bottom: 10, left: 30, right: 30}
|
||||
|
||||
export function Outer({
|
||||
children,
|
||||
noBottomBorder,
|
||||
@@ -136,7 +137,7 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
|
||||
color="secondary"
|
||||
shape="round"
|
||||
onPress={onPressBack}
|
||||
hitSlop={HITSLOP_30}
|
||||
hitSlop={HEADER_BUTTON_HITSLOP}
|
||||
style={[
|
||||
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
|
||||
a.bg_transparent,
|
||||
@@ -170,7 +171,7 @@ export function MenuButton() {
|
||||
color="secondary"
|
||||
shape="square"
|
||||
onPress={onPress}
|
||||
hitSlop={HITSLOP_30}
|
||||
hitSlop={HEADER_BUTTON_HITSLOP}
|
||||
style={[
|
||||
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
|
||||
a.bg_transparent,
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export {type DialogControlProps as MenuControlProps} from '#/components/Dialog'
|
||||
|
||||
export {useMenuContext}
|
||||
|
||||
export function useMenuControl(): Dialog.DialogControlProps {
|
||||
|
||||
@@ -66,8 +66,60 @@ export function Label({
|
||||
const isLabeler = Boolean(desc.sourceType && desc.sourceDid)
|
||||
const isBlueskyLabel =
|
||||
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) {
|
||||
case 'lg': {
|
||||
return {
|
||||
@@ -79,7 +131,6 @@ export function Label({
|
||||
paddingVertical: 5,
|
||||
},
|
||||
],
|
||||
avi: 16,
|
||||
text: [a.text_sm],
|
||||
}
|
||||
}
|
||||
@@ -94,7 +145,6 @@ export function Label({
|
||||
paddingVertical: 3,
|
||||
},
|
||||
],
|
||||
avi: 12,
|
||||
text: [a.text_xs],
|
||||
}
|
||||
}
|
||||
@@ -102,14 +152,13 @@ export function Label({
|
||||
}, [t, size, noBg])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disabled={disableDetailsDialog}
|
||||
label={desc.name}
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
control.open()
|
||||
onPress()
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
@@ -120,14 +169,7 @@ export function Label({
|
||||
outer,
|
||||
(hovered || pressed) && t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
{isBlueskyLabel || !isLabeler ? (
|
||||
<desc.icon
|
||||
width={avi}
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
/>
|
||||
) : (
|
||||
<UserAvatar avatar={desc.sourceAvi} type="user" size={avi} />
|
||||
)}
|
||||
{icon}
|
||||
|
||||
<Text
|
||||
emoji
|
||||
@@ -138,16 +180,11 @@ export function Label({
|
||||
t.atoms.text_contrast_medium,
|
||||
{paddingRight: 3},
|
||||
]}>
|
||||
{desc.name}
|
||||
{cta}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{!disableDetailsDialog && (
|
||||
<ModerationDetailsDialog control={control} modcause={cause} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export function Badge() {
|
||||
backgroundColor: t.palette.primary_25,
|
||||
},
|
||||
]}>
|
||||
<Logo fill={t.palette.primary_600} width={14} />
|
||||
<Logo allowVariants={false} fill={t.palette.primary_600} width={14} />
|
||||
<Text
|
||||
style={[
|
||||
a.font_semi_bold,
|
||||
|
||||
@@ -26,12 +26,18 @@ export function VideoEmbedInnerNative({
|
||||
setStatus,
|
||||
setIsLoading,
|
||||
setIsActive,
|
||||
onError,
|
||||
}: {
|
||||
ref: React.Ref<{togglePlayback: () => void}>
|
||||
embed: AppBskyEmbedVideo.View
|
||||
setStatus: (status: 'playing' | 'paused') => void
|
||||
setIsLoading: (isLoading: 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 videoRef = useRef<BlueskyVideoView>(null)
|
||||
@@ -81,6 +87,7 @@ export function VideoEmbedInnerNative({
|
||||
setTimeRemaining(e.nativeEvent.timeRemaining)
|
||||
}}
|
||||
onError={e => {
|
||||
onError?.(e.nativeEvent.error)
|
||||
setError(e.nativeEvent.error)
|
||||
}}
|
||||
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.')
|
||||
}
|
||||
|
||||
@@ -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 {View} from 'react-native'
|
||||
import {type AppBskyEmbedVideo} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import type * as HlsTypes from 'hls.js'
|
||||
@@ -10,21 +9,25 @@ import {atoms as a} from '#/alf'
|
||||
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
|
||||
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
||||
import * as BandwidthEstimate from './bandwidth-estimate'
|
||||
import {
|
||||
HLSUnsupportedError,
|
||||
type VideoEmbedInnerWebProps,
|
||||
VideoNotFoundError,
|
||||
} from './VideoEmbedInnerWeb.shared'
|
||||
import {Controls} from './web-controls/VideoControls'
|
||||
|
||||
export {
|
||||
HLSUnsupportedError,
|
||||
VideoNotFoundError,
|
||||
} from './VideoEmbedInnerWeb.shared'
|
||||
|
||||
export function VideoEmbedInnerWeb({
|
||||
embed,
|
||||
active,
|
||||
setActive,
|
||||
onScreen,
|
||||
lastKnownTime,
|
||||
}: {
|
||||
embed: AppBskyEmbedVideo.View
|
||||
active: boolean
|
||||
setActive: () => void
|
||||
onScreen: boolean
|
||||
lastKnownTime: React.RefObject<number | undefined>
|
||||
}) {
|
||||
}: VideoEmbedInnerWebProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
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
|
||||
// (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
|
||||
@@ -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}
|
||||
const promiseForHls = import(
|
||||
// @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.')
|
||||
}
|
||||
|
||||
@@ -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 {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import type Hls from 'hls.js'
|
||||
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {
|
||||
@@ -33,6 +32,7 @@ import {TimeIndicator} from '../TimeIndicator'
|
||||
import {ControlButton} from './ControlButton'
|
||||
import {Scrubber} from './Scrubber'
|
||||
import {formatTime, useVideoElement} from './utils'
|
||||
import {type ControlsProps} from './VideoControls.shared'
|
||||
import {VolumeControl} from './VolumeControl'
|
||||
|
||||
export function Controls({
|
||||
@@ -49,21 +49,7 @@ export function Controls({
|
||||
isGif,
|
||||
altText,
|
||||
updateCuePositions,
|
||||
}: {
|
||||
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
|
||||
}) {
|
||||
}: ControlsProps) {
|
||||
const {
|
||||
play,
|
||||
pause,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback, useRef, useState} from 'react'
|
||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {ActivityIndicator, View} from 'react-native'
|
||||
import {ImageBackground} from 'expo-image'
|
||||
import {type AppBskyEmbedVideo} from '@atproto/api'
|
||||
@@ -6,6 +6,10 @@ import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {
|
||||
createPlaybackTelemetry,
|
||||
type PlaybackTelemetry,
|
||||
} from '#/lib/media/video/playbackTelemetry'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {atoms as a, platform} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
@@ -75,6 +79,17 @@ function InnerWrapper({embed}: Props) {
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
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 =
|
||||
!isActive ||
|
||||
isLoading ||
|
||||
@@ -89,9 +104,33 @@ function InnerWrapper({embed}: Props) {
|
||||
<>
|
||||
<VideoEmbedInnerNative
|
||||
embed={embed}
|
||||
setStatus={setStatus}
|
||||
setIsLoading={setIsLoading}
|
||||
setIsActive={setIsActive}
|
||||
setStatus={s => {
|
||||
setStatus(s)
|
||||
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}
|
||||
/>
|
||||
<ImageBackground
|
||||
|
||||
@@ -321,7 +321,11 @@ export function QuoteEmbed({
|
||||
linkDisabled
|
||||
/>
|
||||
{moderation ? (
|
||||
<PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} />
|
||||
<PostAlerts
|
||||
post={quote}
|
||||
modui={moderation.ui('contentView')}
|
||||
style={[a.py_xs]}
|
||||
/>
|
||||
) : null}
|
||||
{richText ? (
|
||||
<RichText
|
||||
|
||||
@@ -46,7 +46,7 @@ export const BookmarkButton = memo(function BookmarkButton({
|
||||
}),
|
||||
)
|
||||
|
||||
const save = async ({disableUndo}: {disableUndo?: boolean} = {}) => {
|
||||
const save = async () => {
|
||||
try {
|
||||
await bookmark({
|
||||
action: 'create',
|
||||
@@ -59,25 +59,6 @@ export const BookmarkButton = memo(function BookmarkButton({
|
||||
logContext,
|
||||
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) {
|
||||
const {raw, clean} = cleanError(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 {
|
||||
await bookmark({
|
||||
action: 'delete',
|
||||
@@ -106,13 +87,9 @@ export const BookmarkButton = memo(function BookmarkButton({
|
||||
<toast.Text>
|
||||
<Trans>Removed from saved posts</Trans>
|
||||
</toast.Text>
|
||||
{!disableUndo && (
|
||||
<toast.Action
|
||||
label={undoLabel}
|
||||
onPress={() => save({disableUndo: true})}>
|
||||
<toast.Action label={undoLabel} onPress={() => save()}>
|
||||
{undoLabel}
|
||||
</toast.Action>
|
||||
)}
|
||||
</toast.Outer>,
|
||||
)
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type TextStyleProp,
|
||||
useTheme,
|
||||
type ViewStyleProp,
|
||||
web,
|
||||
} from '#/alf'
|
||||
import {
|
||||
Button,
|
||||
@@ -257,6 +258,7 @@ function InlineNameAndHandle({
|
||||
a.leading_tight,
|
||||
a.flex_shrink_0,
|
||||
{maxWidth: '70%'},
|
||||
web({direction: 'ltr', unicodeBidi: 'isolate'}),
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{forceLTR(name)}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||
import {Default as ProfileCard} from '#/components/ProfileCard'
|
||||
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}`
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export const ProfilesList = forwardRef<SectionRef, ProfilesListProps>(
|
||||
const renderItem = ({
|
||||
item,
|
||||
index,
|
||||
}: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => {
|
||||
}: ListRenderItemInfo<AppBskyActorDefs.ProfileView>) => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {useCallback, useEffect, useImperativeHandle, useState} from 'react'
|
||||
import {
|
||||
findNodeHandle,
|
||||
type ListRenderItemInfo,
|
||||
type StyleProp,
|
||||
useWindowDimensions,
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
type EmptyStateButtonProps,
|
||||
} from '#/view/com/util/EmptyState'
|
||||
import {List, type ListRef} from '#/view/com/util/List'
|
||||
import {findListNativeTag} from '#/view/com/util/listNativeTag'
|
||||
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {atoms as a, ios, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
@@ -57,7 +57,7 @@ interface ProfileFeedgensProps {
|
||||
emptyStateIcon?: React.ComponentType<any> | React.ReactElement
|
||||
}
|
||||
|
||||
function keyExtractor(item: AppBskyGraphDefs.StarterPackView) {
|
||||
function keyExtractor(item: AppBskyGraphDefs.StarterPackViewBasic) {
|
||||
return item.uri
|
||||
}
|
||||
|
||||
@@ -138,13 +138,16 @@ export function ProfileStarterPacks({
|
||||
|
||||
useEffect(() => {
|
||||
if (IS_IOS && enabled && scrollElRef.current) {
|
||||
const nativeTag = findNodeHandle(scrollElRef.current)
|
||||
const nativeTag = findListNativeTag(scrollElRef.current)
|
||||
setScrollViewTag(nativeTag)
|
||||
}
|
||||
}, [enabled, scrollElRef, setScrollViewTag])
|
||||
|
||||
const renderItem = useCallback(
|
||||
({item, index}: ListRenderItemInfo<AppBskyGraphDefs.StarterPackView>) => {
|
||||
({
|
||||
item,
|
||||
index,
|
||||
}: ListRenderItemInfo<AppBskyGraphDefs.StarterPackViewBasic>) => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -86,7 +86,7 @@ export function QrCode({
|
||||
<Trans>
|
||||
on
|
||||
<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}]}>
|
||||
<Logotype width={72} fill="white" />
|
||||
</View>
|
||||
@@ -129,7 +129,11 @@ export function QrCodeInner({link}: {link: string}) {
|
||||
zIndex: 1,
|
||||
padding: 4,
|
||||
}}>
|
||||
<Logo width={logoArea.width - 14} height={logoArea.height - 14} />
|
||||
<Logo
|
||||
allowVariants={false}
|
||||
width={logoArea.width - 14}
|
||||
height={logoArea.height - 14}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<QRCode
|
||||
|
||||
@@ -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 ARROW_SIZE = 12
|
||||
export const ARROW_HALF_SIZE = ARROW_SIZE / 2
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,6 @@ export function Content() {
|
||||
return null
|
||||
}
|
||||
|
||||
export function TextBubble() {
|
||||
export function BubbleText() {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Children,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
@@ -14,14 +13,16 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
|
||||
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
||||
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 {createPortalGroup, Portal as RootPortal} from '#/components/Portal'
|
||||
import {
|
||||
ARROW_HALF_SIZE,
|
||||
ARROW_SIZE,
|
||||
BUBBLE_MAX_WIDTH,
|
||||
getTooltipStyle,
|
||||
MIN_EDGE_SPACE,
|
||||
type TooltipColor,
|
||||
} from '#/components/Tooltip/const'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -56,10 +57,10 @@ SheetCompatProvider.displayName = 'TooltipSheetCompatProvider'
|
||||
* These are native specific values, not shared with web
|
||||
*/
|
||||
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 = {
|
||||
position: 'top' | 'bottom'
|
||||
color: TooltipColor
|
||||
visible: boolean
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
}
|
||||
@@ -79,6 +80,7 @@ type TargetContextType = {
|
||||
|
||||
const TooltipContext = createContext<TooltipContextType>({
|
||||
position: 'bottom',
|
||||
color: 'default',
|
||||
visible: false,
|
||||
onVisibleChange: () => {},
|
||||
})
|
||||
@@ -94,11 +96,13 @@ TargetContext.displayName = 'TargetContext'
|
||||
export function Outer({
|
||||
children,
|
||||
position = 'bottom',
|
||||
color = 'default',
|
||||
visible: requestVisible,
|
||||
onVisibleChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
position?: 'top' | 'bottom'
|
||||
color?: TooltipColor
|
||||
visible: boolean
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
}) {
|
||||
@@ -126,8 +130,8 @@ export function Outer({
|
||||
}
|
||||
|
||||
const ctx = useMemo(
|
||||
() => ({position, visible, onVisibleChange}),
|
||||
[position, visible, onVisibleChange],
|
||||
() => ({position, color, visible, onVisibleChange}),
|
||||
[position, color, visible, onVisibleChange],
|
||||
)
|
||||
const targetCtx = useMemo(
|
||||
() => ({
|
||||
@@ -149,13 +153,13 @@ export function Outer({
|
||||
|
||||
export function Target({children}: {children: React.ReactNode}) {
|
||||
const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
|
||||
const [hasLayedOut, setHasLayedOut] = useState(false)
|
||||
const [hasLaidOut, setHasLaidOut] = useState(false)
|
||||
const targetRef = useRef<View>(null)
|
||||
const containerRef = useContext(TooltipProviderContext)
|
||||
const keyboardIsOpen = useIsKeyboardVisible()
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldMeasure || !hasLayedOut) return
|
||||
if (!shouldMeasure || !hasLaidOut) return
|
||||
/*
|
||||
* Once opened, measure the dimensions and position of the target
|
||||
*/
|
||||
@@ -179,7 +183,7 @@ export function Target({children}: {children: React.ReactNode}) {
|
||||
}, [
|
||||
shouldMeasure,
|
||||
setTargetMeasurements,
|
||||
hasLayedOut,
|
||||
hasLaidOut,
|
||||
containerRef,
|
||||
keyboardIsOpen,
|
||||
])
|
||||
@@ -188,7 +192,7 @@ export function Target({children}: {children: React.ReactNode}) {
|
||||
<View
|
||||
collapsable={false}
|
||||
ref={targetRef}
|
||||
onLayout={() => setHasLayedOut(true)}>
|
||||
onLayout={() => setHasLaidOut(true)}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
@@ -201,7 +205,7 @@ export function Content({
|
||||
children: React.ReactNode
|
||||
label: string
|
||||
}) {
|
||||
const {position, visible, onVisibleChange} = useContext(TooltipContext)
|
||||
const {position, color, visible, onVisibleChange} = useContext(TooltipContext)
|
||||
const {targetMeasurements} = useContext(TargetContext)
|
||||
const isWithinProvider = !!useContext(TooltipProviderContext)
|
||||
const requestClose = useCallback(() => {
|
||||
@@ -217,8 +221,9 @@ export function Content({
|
||||
<Bubble
|
||||
label={label}
|
||||
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.
|
||||
*/
|
||||
targetMeasurements={targetMeasurements}
|
||||
@@ -233,12 +238,14 @@ function Bubble({
|
||||
children,
|
||||
label,
|
||||
position,
|
||||
color,
|
||||
requestClose,
|
||||
targetMeasurements,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
label: string
|
||||
position: TooltipContextType['position']
|
||||
color: TooltipColor
|
||||
requestClose: () => void
|
||||
targetMeasurements: Exclude<
|
||||
TargetContextType['targetMeasurements'],
|
||||
@@ -246,6 +253,7 @@ function Bubble({
|
||||
>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const style = getTooltipStyle(t, color)
|
||||
const insets = useSafeAreaInsets()
|
||||
const dimensions = useWindowDimensions()
|
||||
const [bubbleMeasurements, setBubbleMeasurements] = useState<
|
||||
@@ -383,21 +391,19 @@ function Bubble({
|
||||
]}>
|
||||
<Animated.View
|
||||
entering={ZoomIn.easing(Easing.out(Easing.exp))}
|
||||
style={{transformOrigin: oppposite(position)}}>
|
||||
style={{transformOrigin: opposite(position)}}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.top_0,
|
||||
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,
|
||||
borderBottomRightRadius: a.rounded_2xs.borderRadius,
|
||||
borderColor: style.border.color,
|
||||
borderTopWidth: style.border.width,
|
||||
borderLeftWidth: style.border.width,
|
||||
width: ARROW_SIZE,
|
||||
height: ARROW_SIZE,
|
||||
transform: [{rotate: '45deg'}],
|
||||
@@ -410,21 +416,12 @@ function Bubble({
|
||||
style={[
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
a.rounded_sm,
|
||||
select(t.name, {
|
||||
light: t.atoms.bg,
|
||||
dark: t.atoms.bg_contrast_100,
|
||||
dim: t.atoms.bg_contrast_100,
|
||||
}),
|
||||
t.atoms.shadow_md,
|
||||
a.rounded_md,
|
||||
t.atoms.shadow_xs,
|
||||
{
|
||||
shadowOpacity: 0.2,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height:
|
||||
BUBBLE_SHADOW_OFFSET *
|
||||
(coords.computedPosition === 'bottom' ? -1 : 1),
|
||||
},
|
||||
backgroundColor: style.surface,
|
||||
borderColor: style.border.color,
|
||||
borderWidth: style.border.width,
|
||||
},
|
||||
]}
|
||||
onLayout={e => {
|
||||
@@ -440,7 +437,7 @@ function Bubble({
|
||||
)
|
||||
}
|
||||
|
||||
function oppposite(position: 'top' | 'bottom') {
|
||||
function opposite(position: 'top' | 'bottom') {
|
||||
switch (position) {
|
||||
case 'top':
|
||||
return 'center bottom'
|
||||
@@ -451,16 +448,23 @@ function oppposite(position: 'top' | 'bottom') {
|
||||
}
|
||||
}
|
||||
|
||||
export function TextBubble({children}: {children: React.ReactNode}) {
|
||||
const c = Children.toArray(children)
|
||||
export function BubbleText({
|
||||
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 (
|
||||
<Content label={c.join(' ')}>
|
||||
<Content label={label}>
|
||||
<View style={[a.gap_xs]}>
|
||||
{c.map((child, i) => (
|
||||
<Text key={i} style={[a.text_sm, a.leading_snug]}>
|
||||
{child}
|
||||
<Text style={[a.text_sm, a.leading_snug, {color: style.text}]}>
|
||||
{children}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</Content>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import {Children, createContext, useContext, useMemo} from 'react'
|
||||
import {createContext, useContext, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {utils} from '@bsky.app/alf'
|
||||
import {Popover} from 'radix-ui'
|
||||
|
||||
import {atoms as a, flatten, select, useTheme} from '#/alf'
|
||||
import {atoms as a, flatten, useTheme} from '#/alf'
|
||||
import {
|
||||
ARROW_SIZE,
|
||||
BUBBLE_MAX_WIDTH,
|
||||
getTooltipStyle,
|
||||
MIN_EDGE_SPACE,
|
||||
type TooltipColor,
|
||||
} from '#/components/Tooltip/const'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -19,26 +21,32 @@ Provider.displayName = 'TooltipProvider'
|
||||
|
||||
type TooltipContextType = {
|
||||
position: 'top' | 'bottom'
|
||||
color: TooltipColor
|
||||
onVisibleChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const TooltipContext = createContext<Pick<TooltipContextType, 'position'>>({
|
||||
const TooltipContext = createContext<
|
||||
Pick<TooltipContextType, 'position' | 'color'>
|
||||
>({
|
||||
position: 'bottom',
|
||||
color: 'default',
|
||||
})
|
||||
TooltipContext.displayName = 'TooltipContext'
|
||||
|
||||
export function Outer({
|
||||
children,
|
||||
position = 'bottom',
|
||||
color = 'default',
|
||||
visible,
|
||||
onVisibleChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
position?: 'top' | 'bottom'
|
||||
color?: TooltipColor
|
||||
visible: boolean
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
}) {
|
||||
const ctx = useMemo(() => ({position}), [position])
|
||||
const ctx = useMemo(() => ({position, color}), [position, color])
|
||||
return (
|
||||
<Popover.Root open={visible} onOpenChange={onVisibleChange}>
|
||||
<TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider>
|
||||
@@ -62,7 +70,8 @@ export function Content({
|
||||
label: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {position} = useContext(TooltipContext)
|
||||
const {position, color} = useContext(TooltipContext)
|
||||
const style = getTooltipStyle(t, color)
|
||||
return (
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
@@ -78,28 +87,19 @@ export function Content({
|
||||
}}
|
||||
style={flatten([
|
||||
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',
|
||||
boxShadow: select(t.name, {
|
||||
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)}`,
|
||||
}),
|
||||
boxShadow: `0 0 24px ${utils.alpha(t.palette.black, 0.2)}`,
|
||||
},
|
||||
])}>
|
||||
<Popover.Arrow
|
||||
width={ARROW_SIZE}
|
||||
height={ARROW_SIZE / 2}
|
||||
fill={select(t.name, {
|
||||
light: t.atoms.bg.backgroundColor,
|
||||
dark: t.atoms.bg_contrast_100.backgroundColor,
|
||||
dim: t.atoms.bg_contrast_100.backgroundColor,
|
||||
})}
|
||||
fill={style.surface}
|
||||
/>
|
||||
<View style={[a.px_md, a.py_sm, {maxWidth: BUBBLE_MAX_WIDTH}]}>
|
||||
{children}
|
||||
@@ -109,16 +109,23 @@ export function Content({
|
||||
)
|
||||
}
|
||||
|
||||
export function TextBubble({children}: {children: React.ReactNode}) {
|
||||
const c = Children.toArray(children)
|
||||
export function BubbleText({
|
||||
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 (
|
||||
<Content label={c.join(' ')}>
|
||||
<Content label={label}>
|
||||
<View style={[a.gap_xs]}>
|
||||
{c.map((child, i) => (
|
||||
<Text key={i} style={[a.text_sm, a.leading_snug]}>
|
||||
{child}
|
||||
<Text style={[a.text_sm, a.leading_snug, {color: style.text}]}>
|
||||
{children}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</Content>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {atoms as a, flatten, useBreakpoints, web} from '#/alf'
|
||||
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 {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
@@ -17,11 +18,7 @@ import {useAnalytics} from '#/analytics'
|
||||
const welcomeModalBg = require('../../assets/images/welcome-modal-bg.jpg')
|
||||
|
||||
interface WelcomeModalProps {
|
||||
control: {
|
||||
isOpen: boolean
|
||||
open: () => void
|
||||
close: () => void
|
||||
}
|
||||
control: WelcomeModalControl
|
||||
}
|
||||
|
||||
export function WelcomeModal({control}: WelcomeModalProps) {
|
||||
@@ -107,7 +104,7 @@ export function WelcomeModal({control}: WelcomeModalProps) {
|
||||
a.p_0,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<Logo width={26} />
|
||||
<Logo allowVariants={false} width={26} />
|
||||
<Text
|
||||
style={[
|
||||
a.text_2xl,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {useCallback, useEffect, useState} from 'react'
|
||||
import {type ModerationOpts} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
|
||||
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 {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
|
||||
import * as Tooltip from '#/components/Tooltip'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {SubscribeProfileDialog} from './SubscribeProfileDialog'
|
||||
@@ -25,7 +22,7 @@ export function SubscribeProfileButton({
|
||||
moderationOpts: ModerationOpts
|
||||
disableHint?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const requireEmailVerification = useRequireEmailVerification()
|
||||
const subscribeDialogControl = useDialogControl()
|
||||
const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] =
|
||||
@@ -84,18 +81,15 @@ export function SubscribeProfileButton({
|
||||
size="small"
|
||||
color={tooltipVisible ? 'primary_subtle' : 'secondary'}
|
||||
shape="round"
|
||||
label={_(msg`Get notified when ${name} posts`)}
|
||||
label={l`Get notified when ${name} posts`}
|
||||
onPress={wrappedOnPress}>
|
||||
<ButtonIcon icon={Icon} size="md" />
|
||||
</Button>
|
||||
</Tooltip.Target>
|
||||
<Tooltip.TextBubble>
|
||||
<Text>
|
||||
<Tooltip.BubbleText label={l`Get notified about new posts`}>
|
||||
<Trans>Get notified about new posts</Trans>
|
||||
</Text>
|
||||
</Tooltip.TextBubble>
|
||||
</Tooltip.BubbleText>
|
||||
</Tooltip.Outer>
|
||||
|
||||
<SubscribeProfileDialog
|
||||
control={subscribeDialogControl}
|
||||
profile={profile}
|
||||
|
||||
@@ -296,6 +296,7 @@ function Inner() {
|
||||
setLanguageError(false)
|
||||
}}
|
||||
items={KWS_SUPPORTED_LANGS}
|
||||
disabledBlueskySupportedLanguageSanitization
|
||||
/>
|
||||
|
||||
{languageError && (
|
||||
@@ -342,11 +343,12 @@ function convertToKWSSupportedLanguage(
|
||||
// pt-PT is pt (pt-BR is supported independently)
|
||||
case 'pt-PT':
|
||||
return 'pt'
|
||||
// only chinese (simplified) is supported, map all chinese variants
|
||||
// map Chinese variants to simplified or traditional
|
||||
case 'zh-Hans-CN':
|
||||
return 'zh-Hans'
|
||||
case 'zh-Hant-HK':
|
||||
case 'zh-Hant-TW':
|
||||
return 'zh-Hans'
|
||||
return 'zh-Hant'
|
||||
default:
|
||||
// try and map directly - if undefined, they will have to pick from the dropdown
|
||||
return KWS_SUPPORTED_LANGS.find(v => v.value === appLanguage)?.value
|
||||
|
||||
@@ -7,21 +7,36 @@ export const urls = {
|
||||
export const KWS_SUPPORTED_LANGS = [
|
||||
{value: 'en', label: 'English'},
|
||||
{value: 'ar', label: 'العربية'},
|
||||
{value: 'zh-Hans', label: '简体中文'},
|
||||
{value: 'cs', label: 'Čeština'},
|
||||
{value: 'nl', label: 'Nederlands'},
|
||||
{value: 'tl', label: 'Filipino'},
|
||||
{value: 'fr', label: 'Français'},
|
||||
{value: 'bg', label: 'български'},
|
||||
{value: 'cs', label: 'čeština'},
|
||||
{value: 'da', label: 'dansk'},
|
||||
{value: 'de', label: 'Deutsch'},
|
||||
{value: 'el', label: 'Ελληνικά'},
|
||||
{value: 'es', label: 'español'},
|
||||
{value: 'es-MX', label: 'español de México'},
|
||||
{value: 'fi', label: 'suomi'},
|
||||
{value: 'fil', label: 'Filipino'},
|
||||
{value: 'fr', label: 'français'},
|
||||
{value: 'hi', label: 'हिंदी'},
|
||||
{value: 'hu', label: 'magyar'},
|
||||
{value: 'id', label: 'Bahasa Indonesia'},
|
||||
{value: 'it', label: 'Italiano'},
|
||||
{value: 'it', label: 'italiano'},
|
||||
{value: 'ja', label: '日本語'},
|
||||
{value: 'ko', label: '한국어'},
|
||||
{value: 'pt', label: 'Português'},
|
||||
{value: 'pt-BR', label: 'Português (Brasil)'},
|
||||
{value: 'ru', label: 'Русский'},
|
||||
{value: 'es', label: 'Español'},
|
||||
{value: 'tr', label: 'Türkçe'},
|
||||
{value: 'ms', label: 'Bahasa Melayu'},
|
||||
{value: 'nl', label: 'Nederlands'},
|
||||
{value: 'no', label: 'norsk'},
|
||||
{value: 'pl', label: 'polski'},
|
||||
{value: 'pt', label: 'português'},
|
||||
{value: 'pt-BR', label: 'português do Brasil'},
|
||||
{value: 'ro', label: 'română'},
|
||||
{value: 'ru', label: 'русский'},
|
||||
{value: 'sv', label: 'svenska'},
|
||||
{value: 'th', label: 'ภาษาไทย'},
|
||||
{value: 'tl', label: 'Tagalog'},
|
||||
{value: 'tr', label: 'Türkçe'},
|
||||
{value: 'uk', label: 'українська'},
|
||||
{value: 'vi', label: 'Tiếng Việt'},
|
||||
{value: 'zh-Hans', label: '简体中文'},
|
||||
{value: 'zh-Hant', label: '繁體中文'},
|
||||
]
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ function SigninDialogInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
||||
a.gap_sm,
|
||||
a.pb_lg,
|
||||
]}>
|
||||
<Logo width={36} />
|
||||
<Logo allowVariants={false} width={36} />
|
||||
<View style={{paddingTop: 6}}>
|
||||
<Logotype width={120} fill={t.atoms.text.color} />
|
||||
</View>
|
||||
|
||||
@@ -246,6 +246,8 @@ let MessageItem = ({
|
||||
|
||||
const rt = new RichTextAPI({text: message.text, facets: message.facets})
|
||||
|
||||
const isEmojiOnly = isOnlyEmoji(message.text)
|
||||
|
||||
const hasEmbed =
|
||||
AppBskyEmbedRecord.isView(message.embed) ||
|
||||
ChatBskyEmbedJoinLink.isView(message.embed)
|
||||
@@ -261,16 +263,33 @@ let MessageItem = ({
|
||||
const topRadiusSV = useSharedValue(targetTopRadius)
|
||||
|
||||
const showDisplayName =
|
||||
isGroupChat && !isFromSelf && isFirstInCluster && !isOnlyEmoji(message.text)
|
||||
isGroupChat && !isFromSelf && isFirstInCluster && !isEmojiOnly
|
||||
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(() => {
|
||||
bottomRadiusSV.set(withTiming(targetBottomRadius, {duration: 300}))
|
||||
}, [targetBottomRadius, bottomRadiusSV])
|
||||
bottomRadiusSV.set(
|
||||
isEmojiOnly
|
||||
? targetBottomRadius
|
||||
: withTiming(targetBottomRadius, {duration: 300}),
|
||||
)
|
||||
}, [targetBottomRadius, bottomRadiusSV, isEmojiOnly])
|
||||
|
||||
useEffect(() => {
|
||||
topRadiusSV.set(withTiming(targetTopRadius, {duration: 300}))
|
||||
}, [targetTopRadius, topRadiusSV])
|
||||
topRadiusSV.set(
|
||||
isEmojiOnly
|
||||
? targetTopRadius
|
||||
: withTiming(targetTopRadius, {duration: 300}),
|
||||
)
|
||||
}, [targetTopRadius, topRadiusSV, isEmojiOnly])
|
||||
|
||||
// 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`
|
||||
@@ -521,7 +540,7 @@ let MessageItem = ({
|
||||
accessibilityHint={l`Double tap or long press the message to add a reaction`}
|
||||
style={[
|
||||
!isFromSelf && isGroupChat && a.ml_sm,
|
||||
!isOnlyEmoji(message.text) && [
|
||||
!isEmojiOnly && [
|
||||
a.rounded_xl,
|
||||
a.py_sm,
|
||||
a.px_md,
|
||||
@@ -536,7 +555,7 @@ let MessageItem = ({
|
||||
highlightStyle,
|
||||
],
|
||||
]}>
|
||||
{replyTo && !isOnlyEmoji(message.text) ? (
|
||||
{replyTo && !isEmojiOnly ? (
|
||||
<ReplyQuote
|
||||
replyTo={replyTo}
|
||||
isFromSelf={isFromSelf}
|
||||
@@ -553,7 +572,7 @@ let MessageItem = ({
|
||||
// glyph, then pull the bottom up by the same amount so
|
||||
// the glyph bottom-aligns with the avatar instead of
|
||||
// sitting above its line-box baseline.
|
||||
isOnlyEmoji(message.text) && [
|
||||
isEmojiOnly && [
|
||||
a.leading_tight,
|
||||
// Visually align bottom of the emoji with the avatar
|
||||
!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')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export type WelcomeModalControl = {
|
||||
isOpen: boolean
|
||||
open: () => void
|
||||
close: () => void
|
||||
}
|
||||
@@ -2,8 +2,9 @@ import {useEffect, useState} from 'react'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {type WelcomeModalControl} from './useWelcomeModal.shared'
|
||||
|
||||
export function useWelcomeModal() {
|
||||
export function useWelcomeModal(): WelcomeModalControl {
|
||||
const {hasSession} = useSession()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
|
||||
@@ -485,10 +485,18 @@ function GalleryImage({
|
||||
loading={index === 0 ? 'eager' : 'lazy'}
|
||||
style={[dims]}
|
||||
onLoad={e => {
|
||||
/*
|
||||
* Only sync from the loaded thumb if we don't already have a
|
||||
* 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 && ar !== aspectRatio) {
|
||||
if (ar !== undefined) {
|
||||
setAspectRatio(ar)
|
||||
}
|
||||
}
|
||||
onThumbDims(index, {
|
||||
width: e.source.width,
|
||||
height: e.source.height,
|
||||
|
||||
@@ -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}} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||