Merge remote-tracking branch 'origin/main' into app-2100
@@ -25,10 +25,10 @@ jobs:
|
|||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Setup Docker buildx
|
- name: Setup Docker buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
|
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ env.USERNAME}}
|
username: ${{ env.USERNAME}}
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract Docker metadata
|
- name: Extract Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: build-and-push
|
id: build-and-push
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ jobs:
|
|||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Setup Docker buildx
|
- name: Setup Docker buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
|
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ env.USERNAME }}
|
username: ${{ env.USERNAME }}
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract Docker metadata
|
- name: Extract Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: build-and-push
|
id: build-and-push
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ jobs:
|
|||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Setup Docker buildx
|
- name: Setup Docker buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
|
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ env.USERNAME}}
|
username: ${{ env.USERNAME}}
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract Docker metadata
|
- name: Extract Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: build-and-push
|
id: build-and-push
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ jobs:
|
|||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Setup Docker buildx
|
- name: Setup Docker buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
|
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ env.USERNAME}}
|
username: ${{ env.USERNAME}}
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract Docker metadata
|
- name: Extract Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: build-and-push
|
id: build-and-push
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ jobs:
|
|||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Setup Docker buildx
|
- name: Setup Docker buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
|
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ env.USERNAME}}
|
username: ${{ env.USERNAME}}
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract Docker metadata
|
- name: Extract Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: build-and-push
|
id: build-and-push
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
|
|
||||||
- name: ☕️ Assert Cocoapods version
|
- name: ☕️ Assert Cocoapods version
|
||||||
run: |
|
run: |
|
||||||
EXPECTED=1.16.2
|
EXPECTED=1.17.0
|
||||||
ACTUAL=$(pod --version)
|
ACTUAL=$(pod --version)
|
||||||
if [ "$ACTUAL" != "$EXPECTED" ]; then
|
if [ "$ACTUAL" != "$EXPECTED" ]; then
|
||||||
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
|
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ jobs:
|
|||||||
|
|
||||||
- name: ☕️ Assert Cocoapods version
|
- name: ☕️ Assert Cocoapods version
|
||||||
run: |
|
run: |
|
||||||
EXPECTED=1.16.2
|
EXPECTED=1.17.0
|
||||||
ACTUAL=$(pod --version)
|
ACTUAL=$(pod --version)
|
||||||
if [ "$ACTUAL" != "$EXPECTED" ]; then
|
if [ "$ACTUAL" != "$EXPECTED" ]; then
|
||||||
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
|
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ jobs:
|
|||||||
aws-region: us-east-2
|
aws-region: us-east-2
|
||||||
|
|
||||||
- name: Claude
|
- name: Claude
|
||||||
uses: anthropics/claude-code-action@4633baf5267540f3f8cb58b684f79901d564c280 # v1.0.160
|
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1.0.166
|
||||||
with:
|
with:
|
||||||
use_bedrock: 'true'
|
use_bedrock: 'true'
|
||||||
additional_permissions: |
|
additional_permissions: |
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
aws-region: us-east-2
|
aws-region: us-east-2
|
||||||
|
|
||||||
- name: Claude review
|
- name: Claude review
|
||||||
uses: anthropics/claude-code-action@4633baf5267540f3f8cb58b684f79901d564c280 # v1.0.160
|
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1.0.166
|
||||||
with:
|
with:
|
||||||
use_bedrock: 'true'
|
use_bedrock: 'true'
|
||||||
additional_permissions: |
|
additional_permissions: |
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
job: [lint, prettier, typecheck]
|
job:
|
||||||
|
[lint, prettier, 'typecheck:ios', 'typecheck:android', 'typecheck:web']
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ jobs:
|
|||||||
core.setOutput('head-ref', pr.data.head.ref);
|
core.setOutput('head-ref', pr.data.head.ref);
|
||||||
|
|
||||||
- name: 💬 Drop a comment
|
- name: 💬 Drop a comment
|
||||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||||
with:
|
with:
|
||||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||||
number: ${{ github.event.issue.number }}
|
number: ${{ github.event.issue.number }}
|
||||||
@@ -193,7 +193,7 @@ jobs:
|
|||||||
RUNTIME_VERSION:
|
RUNTIME_VERSION:
|
||||||
|
|
||||||
- name: 💬 Drop a comment
|
- name: 💬 Drop a comment
|
||||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||||
env:
|
env:
|
||||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||||
with:
|
with:
|
||||||
@@ -210,7 +210,7 @@ jobs:
|
|||||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||||
|
|
||||||
- name: 💬 Drop a comment
|
- name: 💬 Drop a comment
|
||||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||||
if: failure()
|
if: failure()
|
||||||
with:
|
with:
|
||||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||||
|
|||||||
@@ -19,8 +19,47 @@ concurrency:
|
|||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Populate this from main so every PR can restore the same trusted baseline.
|
||||||
|
webpack-analyzer-base:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
if: ${{ github.event_name == 'push' }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: ⬇️ Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
|
|
||||||
|
- name: 🔧 Setup Node
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: package.json
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: ⬇️ Get base stats from cache
|
||||||
|
id: get-base-stats
|
||||||
|
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: stats.json
|
||||||
|
key: stats-base-main-${{ github.sha }}
|
||||||
|
|
||||||
|
- name: 🔦 Generate stats file for base commit
|
||||||
|
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
pnpm install
|
||||||
|
pnpm intl:build
|
||||||
|
pnpm generate-webpack-stats-file
|
||||||
|
|
||||||
|
- name: ⬆️ Save base stats to cache
|
||||||
|
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||||||
|
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: stats.json
|
||||||
|
key: stats-base-main-${{ github.sha }}
|
||||||
|
|
||||||
webpack-analyzer:
|
webpack-analyzer:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
|
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -71,15 +110,18 @@ jobs:
|
|||||||
|
|
||||||
- name: ⬇️ Get base stats from cache
|
- name: ⬇️ Get base stats from cache
|
||||||
id: get-base-stats
|
id: get-base-stats
|
||||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
# Restore-only prevents PR-scoped fallback builds from creating caches.
|
||||||
|
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
path: stats-base.json
|
path: stats.json
|
||||||
key: stats-base-${{ steps.base-commit.outputs.base-commit }}
|
key: stats-base-main-${{ steps.base-commit.outputs.base-commit }}
|
||||||
|
|
||||||
- name: Restore to base commit
|
- name: Restore to base commit
|
||||||
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||||||
|
env:
|
||||||
|
BASE_COMMIT: ${{ steps.base-commit.outputs.base-commit }}
|
||||||
run: |
|
run: |
|
||||||
git reset HEAD~
|
git reset "$BASE_COMMIT"
|
||||||
git restore .
|
git restore .
|
||||||
|
|
||||||
- name: 🔦 Generate stats file from base commit
|
- name: 🔦 Generate stats file from base commit
|
||||||
@@ -88,18 +130,17 @@ jobs:
|
|||||||
pnpm install
|
pnpm install
|
||||||
pnpm intl:build
|
pnpm intl:build
|
||||||
pnpm generate-webpack-stats-file
|
pnpm generate-webpack-stats-file
|
||||||
mv stats.json stats-base.json
|
|
||||||
|
|
||||||
- name: % Get diff
|
- name: % Get diff
|
||||||
id: get-diff
|
id: get-diff
|
||||||
uses: NejcZdovc/bundle-size-diff@5321de41d2d62a7b0f4d6e60f59d1280a0034160 # v1.1.0
|
uses: NejcZdovc/bundle-size-diff@5321de41d2d62a7b0f4d6e60f59d1280a0034160 # v1.1.0
|
||||||
with:
|
with:
|
||||||
base_path: "stats-base.json"
|
base_path: "stats.json"
|
||||||
pr_path: "../stats-new.json"
|
pr_path: "../stats-new.json"
|
||||||
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
|
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
|
||||||
|
|
||||||
- name: 💬 Drop a comment
|
- name: 💬 Drop a comment
|
||||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||||
with:
|
with:
|
||||||
header: bundle-diff
|
header: bundle-diff
|
||||||
message: |
|
message: |
|
||||||
@@ -140,7 +181,7 @@ jobs:
|
|||||||
profile: pull-request
|
profile: pull-request
|
||||||
|
|
||||||
- name: 💬 Drop a comment
|
- name: 💬 Drop a comment
|
||||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||||
if: ${{ steps.fingerprint.outputs.includes-changes }}
|
if: ${{ steps.fingerprint.outputs.includes-changes }}
|
||||||
with:
|
with:
|
||||||
header: fingerprint-diff
|
header: fingerprint-diff
|
||||||
@@ -158,7 +199,7 @@ jobs:
|
|||||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||||
|
|
||||||
- name: 💬 Delete comment
|
- name: 💬 Delete comment
|
||||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||||
with:
|
with:
|
||||||
header: fingerprint-diff
|
header: fingerprint-diff
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ google-services.json
|
|||||||
# Performance results (Flashlight)
|
# Performance results (Flashlight)
|
||||||
.perf/
|
.perf/
|
||||||
|
|
||||||
# ESLint
|
# Oxlint
|
||||||
.eslintcache
|
.oxlintcache
|
||||||
|
|
||||||
# i18n
|
# i18n
|
||||||
src/locale/locales/_build/
|
src/locale/locales/_build/
|
||||||
|
|||||||
@@ -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
|
# Testing & Quality
|
||||||
# IMPORTANT: Always use these pnpm scripts, never call the underlying tools directly
|
# IMPORTANT: Always use these pnpm scripts, never call the underlying tools directly
|
||||||
pnpm test # Run Jest tests
|
pnpm test # Run Jest tests
|
||||||
pnpm lint # Run ESLint
|
pnpm lint # Run Oxlint
|
||||||
pnpm typecheck # Run TypeScript type checking
|
pnpm typecheck # Run TypeScript type checking
|
||||||
pnpm prettier # Run Prettier for code formatting
|
pnpm prettier # Run Prettier for code formatting
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ should go in `/screens` (not `/view/screens`) to encourage better organization
|
|||||||
and separation from legacy code.
|
and separation from legacy code.
|
||||||
|
|
||||||
For complex screens that have specific components or data needs that _are not
|
For complex screens that have specific components or data needs that _are not
|
||||||
shared by other screens_, we encourage subdirectoreis within `/screens/<name>`
|
shared by other screens_, we encourage subdirectories within `/screens/<name>`
|
||||||
e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and
|
e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and
|
||||||
`/screens/ProfileScreen/components/`.
|
`/screens/ProfileScreen/components/`.
|
||||||
|
|
||||||
@@ -126,44 +126,14 @@ eventually.
|
|||||||
Typically JS style for variables, functions, etc. We use ProudCamelCase for
|
Typically JS style for variables, functions, etc. We use ProudCamelCase for
|
||||||
components, and camelCase directories and files.
|
components, and camelCase directories and files.
|
||||||
|
|
||||||
When organizing new code, consider if it fits into a single file, or if it
|
For "macro" cases in `/features`, `/screens`, or `/components`, co-locate related
|
||||||
should be broken down into multiple files. For "macro" component cases, or
|
code in a directory with an `index.tsx` main component plus sibling
|
||||||
things that live in `/features` or `/screens`, we often follow a pattern of
|
components/hooks/utils (e.g. `screens/ProfileScreen/index.tsx` +
|
||||||
having an `index.tsx` for the main component, and then co-locating related
|
`screens/ProfileScreen/components/`). Keep related code together so it lives where
|
||||||
components, hooks, and utilities in the same directory. For example:
|
someone would look for it. Don't overdo it: a component that fits in one file
|
||||||
|
should just be `Component.tsx`, not `Component/index.tsx`.
|
||||||
|
|
||||||
```
|
Platform-specific files are covered under "Platform-Specific Code" below.
|
||||||
src
|
|
||||||
├── screens/
|
|
||||||
│ ├── ProfileScreen/
|
|
||||||
│ │ ├── index.tsx # Main screen component
|
|
||||||
│ │ ├── components/ # Sub-components used only by this screen
|
|
||||||
```
|
|
||||||
|
|
||||||
Similar patterns can be found in `/features` and `/components`. The idea here is
|
|
||||||
to keep related code together and make it easier to navigate.
|
|
||||||
|
|
||||||
You should ask yourself: if someone new was looking for the code related to this
|
|
||||||
feature or screen, where would they expect to find it? Organizing code in a way
|
|
||||||
that matches developer expectations can make the codebase much more
|
|
||||||
approachable. Being able to say "Live Now stuff lives in `/features/liveNow`" is
|
|
||||||
easier to understand than having it scattered across multiple directories.
|
|
||||||
|
|
||||||
No need to go overboard with this. If a component or feature fits into a single
|
|
||||||
file, there's no reason to have a `/Component/index.tsx` file when it could just
|
|
||||||
be `/Component.tsx`. Use your judgment based on the complexity and amount of
|
|
||||||
related code.
|
|
||||||
|
|
||||||
#### Platform Specific Files
|
|
||||||
|
|
||||||
We have conflicting patterns in the app for this. The preferred approach is to
|
|
||||||
group platform-specific files into a directory as much as possible. For example,
|
|
||||||
rather than having `Component.tsx`, `Component.web.tsx`, and
|
|
||||||
`Component.native.tsx` in the same directory, we prefer to have a `Component/`
|
|
||||||
directory with `index.tsx`, `index.web.tsx`, and `index.native.tsx`. This keeps
|
|
||||||
related code together and gives us a better visual cue that there are probably
|
|
||||||
other files contained within this "macro" feature, whereas `Component.tsx` on
|
|
||||||
its own looks more like a single component file.
|
|
||||||
|
|
||||||
### Comments
|
### Comments
|
||||||
|
|
||||||
@@ -204,88 +174,40 @@ const fallbackDate = maximumDate ? toSimpleDateString(maximumDate) : today
|
|||||||
|
|
||||||
### Documentation and Tests Within Features
|
### Documentation and Tests Within Features
|
||||||
|
|
||||||
For larger features or components, it's helpful to include a README.md file
|
For larger features or components, co-locate documentation and tests with the
|
||||||
within the directory that explains the purpose of the feature, how it works, and
|
code. A `README.md` in the directory (the `/Component/index.tsx` pattern lends
|
||||||
any important implementation details. The `/Component/index.tsx` pattern lends
|
itself well to this) can document the whole feature, and feature-specific tests
|
||||||
itself well to this, since the `index.tsx` can be the main component file, and
|
belong alongside it as `Component.test.tsx` or in a `__tests__/` subdirectory.
|
||||||
the `README.md` can provide documentation for the whole feature. This is
|
Both are optional.
|
||||||
optional, but can be a nice way to keep documentation close to the code it
|
|
||||||
describes.
|
|
||||||
|
|
||||||
Similarly, if there are tests that are specific to a component or feature, it
|
|
||||||
can be helpful to include them in the same directory, either as
|
|
||||||
`Component.test.tsx` or in a `__tests__/` subdirectory. This keeps everything
|
|
||||||
related to the component or feature in one place and makes it easier to find and
|
|
||||||
maintain tests.
|
|
||||||
|
|
||||||
## Styling System (ALF)
|
## Styling System (ALF)
|
||||||
|
|
||||||
ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens.
|
ALF is the custom design system. Tailwind-inspired naming with underscores
|
||||||
|
instead of hyphens. Static atoms (`atoms as a`) are theme-independent; theme
|
||||||
|
atoms/palette come from `useTheme()` (`t.atoms.bg`, `t.palette.primary_500`).
|
||||||
|
Style props take an array of atoms + theme atoms + raw styles.
|
||||||
|
|
||||||
### Basic Usage
|
Order atoms by: flexbox (`a.flex_row`), spacing (`a.px_md`), text (`a.font_bold`),
|
||||||
|
themes (`t.atoms.text`), then raw styles (`{backgroundColor: t.palette.primary_500}`).
|
||||||
Generally, order atoms by:
|
|
||||||
|
|
||||||
- Flexbox configuration, e.g., `a.flex_row`
|
|
||||||
- Spacing, e.g., `a.px_md`
|
|
||||||
- Text styles, e.g., `a.font_bold`
|
|
||||||
- Themes, e.g., `t.atoms.text`,
|
|
||||||
- Raw styles, e.g., `{backgroundColor: t.palette.primary_500}`
|
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]} />
|
||||||
return (
|
|
||||||
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
|
|
||||||
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_high]}>Hello</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key Concepts
|
### Key Concepts
|
||||||
|
|
||||||
**Static Atoms** – Theme-independent styles imported from `atoms`:
|
Static atoms live in `a.*` (e.g. `a.flex_row`, `a.p_md`, `a.rounded_md`,
|
||||||
|
`a.text_lg`). Theme atoms/palette come from `useTheme()` (`t.atoms.bg`,
|
||||||
|
`t.atoms.text`, `t.atoms.border_contrast_low`, `t.palette.primary_500`).
|
||||||
|
|
||||||
```tsx
|
**Platform utilities** (`import {web, native, ios, android, platform} from '#/alf'`)
|
||||||
import {atoms as a} from '#/alf'
|
return conditional styles inline in a style array: `web({cursor: 'pointer'})`,
|
||||||
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
|
`native({paddingBottom: 20})`, `platform({ios: {...}, android: {...}, web: {...}})`.
|
||||||
```
|
|
||||||
|
|
||||||
**Theme Atoms** – Theme-dependent colors from `useTheme()`:
|
**Breakpoints:** `const {gtPhone, gtMobile, gtTablet} = useBreakpoints()` from `#/alf`.
|
||||||
|
|
||||||
```tsx
|
|
||||||
const t = useTheme()
|
|
||||||
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
|
|
||||||
// t.palette.primary_500, t.palette.negative_400, etc.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Platform Utilities** – For platform-specific styles:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import {web, native, ios, android, platform} from '#/alf'
|
|
||||||
|
|
||||||
const styles = [
|
|
||||||
a.p_md,
|
|
||||||
web({cursor: 'pointer'}),
|
|
||||||
native({paddingBottom: 20}),
|
|
||||||
platform({ios: {...}, android: {...}, web: {...}}),
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Breakpoints** – Responsive design:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import {useBreakpoints} from '#/alf'
|
|
||||||
|
|
||||||
const {gtPhone, gtMobile, gtTablet} = useBreakpoints()
|
|
||||||
if (gtMobile) {
|
|
||||||
// Tablet or desktop layout
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Naming Conventions
|
### Naming Conventions
|
||||||
|
|
||||||
@@ -306,176 +228,67 @@ if (gtMobile) {
|
|||||||
```tsx
|
```tsx
|
||||||
import {Fragment} from 'react'
|
import {Fragment} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
function MyComponent({foo = []}: {foo?: string[]}) {
|
function MyComponent({items = []}: {items?: string[]}) {
|
||||||
const {t: l} = useLingui()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View><Text><Trans>Example</Trans><Text></View>
|
|
||||||
<View>
|
<View>
|
||||||
{foo.map((foo, index) => (
|
<Text>
|
||||||
<Fragment key={foo}>
|
<Trans>Example</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Fragment key={item}>
|
||||||
<Text>{index}</Text>
|
<Text>{index}</Text>
|
||||||
<Text>{foo}</Text>
|
<Text>{item}</Text>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dialog Component
|
### Dialog Component
|
||||||
|
|
||||||
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
|
Lives in `#/components/Dialog`. Bottom sheet on native, modal on web. Manage
|
||||||
|
state with `useDialogControl()`. `Dialog.Handle` renders native-only, `Dialog.Close`
|
||||||
```tsx
|
web-only. CRITICAL: run any post-close action inside the `control.close(() => ...)`
|
||||||
import * as Dialog from '#/components/Dialog'
|
callback (see Footguns). Compound-component usage; canonical example in any dialog
|
||||||
|
under `#/components`.
|
||||||
function MyFeature() {
|
|
||||||
const control = Dialog.useDialogControl()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button label="Open" onPress={control.open}>
|
|
||||||
<ButtonText>Open Dialog</ButtonText>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Dialog.Outer control={control}>
|
|
||||||
{/* Typically the inner part is in its own component */}
|
|
||||||
<DialogInner />
|
|
||||||
</Dialog.Outer>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogInner() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Dialog.Handle /> {/* Native-only drag handle */}
|
|
||||||
<Dialog.ScrollableInner label={l`My Dialog`}>
|
|
||||||
<Dialog.Header>
|
|
||||||
<Dialog.HeaderText>Title</Dialog.HeaderText>
|
|
||||||
</Dialog.Header>
|
|
||||||
<Text>Dialog content here</Text>
|
|
||||||
<Button label="Done" onPress={() => control.close()}>
|
|
||||||
<ButtonText>Done</ButtonText>
|
|
||||||
</Button>
|
|
||||||
<Dialog.Close /> {/* Web-only X button in top left */}
|
|
||||||
</Dialog.ScrollableInner>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Menu Component
|
### Menu Component
|
||||||
|
|
||||||
Menus render as a dropdown on web and a bottom sheet dialog on native.
|
Lives in `#/components/Menu`. Dropdown on web, bottom sheet dialog on native.
|
||||||
|
`Menu.Divider` is web-only, `Menu.ContainerItem` native-only. Compound API
|
||||||
```tsx
|
(`Menu.Root` / `Menu.Trigger` / `Menu.Outer` / `Menu.Group` / `Menu.Item`); grep
|
||||||
import * as Menu from '#/components/Menu'
|
existing usages across the app for a canonical example.
|
||||||
|
|
||||||
function MyMenu() {
|
|
||||||
return (
|
|
||||||
<Menu.Root>
|
|
||||||
<Menu.Trigger label="Open menu">
|
|
||||||
{({props}) => (
|
|
||||||
<Button {...props} label="Menu">
|
|
||||||
<ButtonIcon icon={DotsHorizontal} />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Menu.Trigger>
|
|
||||||
|
|
||||||
<Menu.Outer>
|
|
||||||
<Menu.Group>
|
|
||||||
<Menu.Item label="Edit" onPress={handleEdit}>
|
|
||||||
<Menu.ItemIcon icon={Pencil} />
|
|
||||||
<Menu.ItemText>Edit</Menu.ItemText>
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item label="Delete" onPress={handleDelete}>
|
|
||||||
<Menu.ItemIcon icon={Trash} />
|
|
||||||
<Menu.ItemText>Delete</Menu.ItemText>
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Group>
|
|
||||||
</Menu.Outer>
|
|
||||||
</Menu.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Button Component
|
### Button Component
|
||||||
|
|
||||||
```tsx
|
`import {Button, ButtonText, ButtonIcon} from '#/components/Button'`. Props:
|
||||||
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
|
||||||
|
|
||||||
// Solid primary button (most common)
|
|
||||||
<Button label="Save" onPress={handleSave} color="primary" size="large">
|
|
||||||
<ButtonText>Save</ButtonText>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
// With icon
|
|
||||||
<Button label="Share" onPress={handleShare} color="secondary" size="small">
|
|
||||||
<ButtonIcon icon={Share} />
|
|
||||||
<ButtonText>Share</ButtonText>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
// Icon-only button
|
|
||||||
<Button label="Close" onPress={handleClose} color="secondary" size="small" shape="round">
|
|
||||||
<ButtonIcon icon={XIcon} />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
// Ghost variant (deprecated - use color prop)
|
|
||||||
<Button label="Cancel" variant="ghost" color="secondary" size="small">
|
|
||||||
<ButtonText>Cancel</ButtonText>
|
|
||||||
</Button>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Button Props:**
|
|
||||||
|
|
||||||
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
|
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
|
||||||
- `size`: `'tiny'` | `'small'` | `'large'`
|
- `size`: `'tiny'` | `'small'` | `'large'`
|
||||||
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
|
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
|
||||||
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`)
|
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, prefer `color`)
|
||||||
|
|
||||||
### Typography
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import {Text, H1, H2, P} from '#/components/Typography'
|
|
||||||
|
|
||||||
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
|
|
||||||
<P>Paragraph text with default styling.</P>
|
|
||||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>Custom text</Text>
|
|
||||||
|
|
||||||
// For text with emoji, add the emoji prop. User-generated text (e.g. display names)
|
|
||||||
// will almost certainly contain emoji, so only omit it when the text is static and
|
|
||||||
// does not contain an emoji
|
|
||||||
<Text emoji>Hello! 👋</Text>
|
|
||||||
```
|
|
||||||
|
|
||||||
The `Text` component's default style is `[a.text_sm, a.leading_snug, t.atoms.text]`.
|
|
||||||
|
|
||||||
### TextField
|
### TextField
|
||||||
|
|
||||||
```tsx
|
Compound component at `#/components/forms/TextField` (`TextField.LabelText`,
|
||||||
import * as TextField from '#/components/forms/TextField'
|
`TextField.Root`, `TextField.Icon`, `TextField.Input`). Prefer `defaultValue` over
|
||||||
|
`value` (see Footguns).
|
||||||
|
|
||||||
<TextField.LabelText>Email</TextField.LabelText>
|
### Typography
|
||||||
<TextField.Root>
|
|
||||||
<TextField.Icon icon={AtSign} />
|
`import {Text, H1, H2, P} from '#/components/Typography'`. The `Text` default style
|
||||||
<TextField.Input
|
is `[a.text_sm, a.leading_snug, t.atoms.text]`. Pass the `emoji` prop to any `Text`
|
||||||
label="Email address"
|
that may contain emoji - user-generated text (display names etc.) almost always
|
||||||
placeholder="you@example.com"
|
does, so only omit it for static, emoji-free strings: `<Text emoji>Hello!</Text>`.
|
||||||
defaultValue={email}
|
|
||||||
onChangeText={setEmail}
|
|
||||||
keyboardType="email-address"
|
|
||||||
autoCapitalize="none"
|
|
||||||
/>
|
|
||||||
</TextField.Root>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Internationalization (i18n)
|
## Internationalization (i18n)
|
||||||
|
|
||||||
@@ -549,201 +362,49 @@ pnpm intl:compile # Compile translations for runtime
|
|||||||
|
|
||||||
### TanStack Query (Data Fetching)
|
### TanStack Query (Data Fetching)
|
||||||
|
|
||||||
```tsx
|
Follow the established pattern in `src/state/queries/`; `src/state/queries/feed.ts`
|
||||||
// src/state/queries/profile.ts
|
is a good canonical reference (it uses `createQueryKey`, matching key roots,
|
||||||
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
`useInfiniteQuery`, and `persistedVersion`).
|
||||||
|
|
||||||
import {createQueryKey} from '#/state/queries/util'
|
- Build query keys with `createQueryKey(root, args)` (from `#/state/queries/util`)
|
||||||
|
using an object for `args`. The key root variable should match the hook name.
|
||||||
/*
|
- Naming conventions: `use[Name]Query` for queries, `use[Name]Mutation` for
|
||||||
* Query key name should match the query hook name for consistency
|
mutations, `use[Name]CacheMutation` for helpers that mutate cached data directly.
|
||||||
*/
|
- Stale times come from `STALE` in `src/state/queries/index.ts`: `STALE.SECONDS.FIFTEEN`,
|
||||||
const profileQueryKeyRoot = 'profile'
|
`STALE.MINUTES.ONE`, `STALE.MINUTES.FIVE`, `STALE.HOURS.ONE`, `STALE.INFINITY`.
|
||||||
|
- Paginated atproto APIs (those returning a `cursor`) use `useInfiniteQuery` with
|
||||||
/*
|
`getNextPageParam: page => page.cursor`; flatten results with
|
||||||
* Use object params and createQueryKey helper for better readability and to
|
`data?.pages.flatMap(page => page.items) ?? []`.
|
||||||
* avoid bugs with parameter order or types.
|
- Persist a query across restarts by passing options:
|
||||||
*/
|
`createQueryKey(root, args, {persistedVersion: n})`. Bumping `n` clears the old
|
||||||
export const createProfileQueryKey = (args: {did: string}) =>
|
persisted data and refetches - do this whenever the data shape changes.
|
||||||
createQueryKey(profileQueryKeyRoot, args)
|
- Error handling in mutations: don't log network errors (just inform the user),
|
||||||
|
handle typed XRPC errors specifically (e.g. `err instanceof SomeNsid.SomeError`),
|
||||||
/*
|
and send unexpected errors to `logger.error('...', {safeMessage: error})`.
|
||||||
* Query hook should be named use[Name]Query, where [Name] describes the data
|
|
||||||
* being fetched. This is not a strict requirement, but it's a helpful
|
|
||||||
* convention for discoverability
|
|
||||||
*/
|
|
||||||
export function useProfileQuery({did}: {did: string}) {
|
|
||||||
const agent = useAgent()
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: createProfileQueryKey({did}),
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await agent.getProfile({actor: did})
|
|
||||||
return res.data
|
|
||||||
},
|
|
||||||
staleTime: STALE.MINUTES.FIVE,
|
|
||||||
enabled: !!did,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Mutation hook should match the name of the query hook, but with "Mutation"
|
|
||||||
* suffix. This is not a strict requirement, but it's a helpful convention for
|
|
||||||
* discoverability and consistency.
|
|
||||||
*/
|
|
||||||
export function useProfileMutation() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async data => {
|
|
||||||
// Update logic
|
|
||||||
},
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: createProfileQueryKey({did: variables.did}),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onError: error => {
|
|
||||||
if (isNetworkError(error)) {
|
|
||||||
// don't log, but inform user
|
|
||||||
} else if (error instanceof AppBskyExampleProcedure.ExampleError) {
|
|
||||||
// XRPC APIs often have typed errors, allows nicer handling
|
|
||||||
} else {
|
|
||||||
// Log unexpected errors to Sentry
|
|
||||||
logger.error('Error updating profile', {safeMessage: error})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* If cache mutation is needed, include specific interfaces for the specific
|
|
||||||
* mutations you require adjacent to the source queries. Naming should be
|
|
||||||
* descriptive of the mutation's purpose, e.g. use[Name]CacheMutation. This is
|
|
||||||
* not a strict requirement, but it's a helpful convention for discoverability
|
|
||||||
* and consistency.
|
|
||||||
*/
|
|
||||||
export function useProfileCacheMutation() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return (data: Partial<Profile>) => {
|
|
||||||
queryClient.setQueryData(
|
|
||||||
createProfileQueryKey({did: data.did}),
|
|
||||||
oldData => {
|
|
||||||
if (!oldData) return oldData
|
|
||||||
return {...oldData, ...data}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Stale Time Constants** (from `src/state/queries/index.ts`):
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
STALE.SECONDS.FIFTEEN // 15 seconds
|
|
||||||
STALE.MINUTES.ONE // 1 minute
|
|
||||||
STALE.MINUTES.FIVE // 5 minutes
|
|
||||||
STALE.HOURS.ONE // 1 hour
|
|
||||||
STALE.INFINITY // Never stale
|
|
||||||
```
|
|
||||||
|
|
||||||
**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
export function useDraftsQuery() {
|
|
||||||
const agent = useAgent()
|
|
||||||
|
|
||||||
return useInfiniteQuery({
|
|
||||||
queryKey: createQueryKey('drafts'),
|
|
||||||
queryFn: async ({pageParam}) => {
|
|
||||||
const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam})
|
|
||||||
return res.data
|
|
||||||
},
|
|
||||||
initialPageParam: undefined as string | undefined,
|
|
||||||
getNextPageParam: page => page.cursor,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []`
|
|
||||||
|
|
||||||
**Persisted Queries**
|
|
||||||
|
|
||||||
To persist query data across app restarts, `createQueryKey` supports a third
|
|
||||||
parameter called `options`, which has a `persistedVersion` property. When this
|
|
||||||
property is set to a number, the query will be persisted.
|
|
||||||
|
|
||||||
When this property is updated (e.g. incremented), the persisted data will be cleared and replaced with the new data from the query function. This is useful for cases where the shape of the data has changed and old persisted data would no longer be valid.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
export const createProfileQueryKey = (args: {did: string}) =>
|
|
||||||
createQueryKey(profileQueryKeyRoot, args, {persistedVersion: 1})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Preferences (React Context)
|
### Preferences (React Context)
|
||||||
|
|
||||||
```tsx
|
Boolean/simple UI preferences are exposed as paired hooks from `#/state/preferences`,
|
||||||
// Simple boolean preference pattern
|
e.g. `useAutoplayDisabled()` / `useSetAutoplayDisabled()`.
|
||||||
import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
|
|
||||||
|
|
||||||
function SettingsScreen() {
|
|
||||||
const autoplayDisabled = useAutoplayDisabled()
|
|
||||||
const setAutoplayDisabled = useSetAutoplayDisabled()
|
|
||||||
|
|
||||||
return <Toggle value={autoplayDisabled} onValueChange={setAutoplayDisabled} />
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Session State
|
### Session State
|
||||||
|
|
||||||
```tsx
|
`import {useSession, useAgent} from '#/state/session'`. `useSession()` gives
|
||||||
import {useSession, useAgent} from '#/state/session'
|
`hasSession` and `currentAccount`; `useAgent()` gives the atproto agent for API calls.
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const {hasSession, currentAccount} = useSession()
|
|
||||||
const agent = useAgent()
|
|
||||||
|
|
||||||
if (!hasSession) {
|
|
||||||
return <LoginPrompt />
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use agent for API calls
|
|
||||||
const response = await agent.getProfile({actor: currentAccount.did})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
Navigation uses React Navigation with type-safe route parameters.
|
React Navigation with type-safe route params. Type a screen with
|
||||||
|
`NativeStackScreenProps<CommonNavigatorParams, 'X'>` (`route`/`navigation` come
|
||||||
```tsx
|
from props; params via `route.params`). Navigate programmatically with
|
||||||
// Screen component
|
`useNavigation()`, or the `navigate` helper from `#/Navigation`. Config lives in
|
||||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
`src/Navigation.tsx`, routes in `src/routes.ts`, types in `src/lib/routes/types.ts`.
|
||||||
import {type CommonNavigatorParams} from '#/lib/routes/types'
|
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
|
|
||||||
|
|
||||||
export function ProfileScreen({route, navigation}: Props) {
|
|
||||||
const {name} = route.params // Type-safe params
|
|
||||||
|
|
||||||
return <Layout.Screen>{/* Screen content */}</Layout.Screen>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Programmatic navigation
|
|
||||||
import {useNavigation} from '@react-navigation/native'
|
|
||||||
|
|
||||||
const navigation = useNavigation()
|
|
||||||
navigation.navigate('Profile', {name: 'alice.bsky.social'})
|
|
||||||
|
|
||||||
// Or use the navigate helper
|
|
||||||
import {navigate} from '#/Navigation'
|
|
||||||
navigate('Profile', {name: 'alice.bsky.social'})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Platform-Specific Code
|
## Platform-Specific Code
|
||||||
|
|
||||||
Use file extensions for platform-specific implementations:
|
Use file extensions for platform-specific implementations. The bundler resolves
|
||||||
|
them automatically - just import the base path normally, never a conditional
|
||||||
|
`require()`.
|
||||||
|
|
||||||
```
|
```
|
||||||
Component.tsx # Shared/default
|
Component.tsx # Shared/default
|
||||||
@@ -753,12 +414,11 @@ Component.ios.tsx # iOS-only
|
|||||||
Component.android.tsx # Android-only
|
Component.android.tsx # Android-only
|
||||||
```
|
```
|
||||||
|
|
||||||
Example from Dialog:
|
Prefer grouping variants into a `Component/` directory (`index.tsx`,
|
||||||
|
`index.web.tsx`, `index.native.tsx`) rather than sibling `Component.web.tsx` files,
|
||||||
- `src/components/Dialog/index.tsx` – Native (uses BottomSheet)
|
so the shared surface reads as one "macro" module (e.g. `src/components/Dialog/index.tsx`
|
||||||
- `src/components/Dialog/index.web.tsx` – Web (uses modal with Radix primitives)
|
native vs `index.web.tsx` web). The app has both patterns; the directory form is
|
||||||
|
preferred for new code.
|
||||||
**Important:** The bundler automatically resolves platform-specific files. Just import normally:
|
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// CORRECT - bundler picks storage.ts or storage.web.ts automatically
|
// CORRECT - bundler picks storage.ts or storage.web.ts automatically
|
||||||
@@ -770,15 +430,7 @@ const storage = IS_NATIVE
|
|||||||
: require('#/state/drafts/storage.web')
|
: require('#/state/drafts/storage.web')
|
||||||
```
|
```
|
||||||
|
|
||||||
Platform detection (for runtime logic, not imports):
|
Runtime platform detection (not for imports): `import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'`.
|
||||||
|
|
||||||
```tsx
|
|
||||||
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
|
|
||||||
|
|
||||||
if (IS_NATIVE) {
|
|
||||||
// Native-specific logic
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Import Aliases
|
## Import Aliases
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ WORKDIR /app
|
|||||||
COPY ./bskyogcard/package.json ./
|
COPY ./bskyogcard/package.json ./
|
||||||
COPY ./bskyogcard/pnpm-lock.yaml ./
|
COPY ./bskyogcard/pnpm-lock.yaml ./
|
||||||
COPY ./bskyogcard/pnpm-workspace.yaml ./
|
COPY ./bskyogcard/pnpm-workspace.yaml ./
|
||||||
RUN npm install --global pnpm@11.10.0
|
RUN npm install --global pnpm@11.13.1
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY ./bskyogcard ./
|
COPY ./bskyogcard ./
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
|
|||||||
RUN \. "$NVM_DIR/nvm.sh" && \
|
RUN \. "$NVM_DIR/nvm.sh" && \
|
||||||
nvm install $NODE_VERSION && \
|
nvm install $NODE_VERSION && \
|
||||||
nvm use $NODE_VERSION && \
|
nvm use $NODE_VERSION && \
|
||||||
npm install --global pnpm@11.10.0 && \
|
npm install --global pnpm@11.13.1 && \
|
||||||
pnpm install --frozen-lockfile && \
|
pnpm install --frozen-lockfile && \
|
||||||
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
|
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
|
||||||
pnpm intl:build && \
|
pnpm intl:build && \
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<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": {
|
"devEngines": {
|
||||||
"packageManager": {
|
"packageManager": {
|
||||||
"name": "pnpm",
|
"name": "pnpm",
|
||||||
"version": "11.10.0",
|
"version": "11.13.1",
|
||||||
"onFail": "warn"
|
"onFail": "warn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,52 +7,52 @@ importers:
|
|||||||
configDependencies: {}
|
configDependencies: {}
|
||||||
packageManagerDependencies:
|
packageManagerDependencies:
|
||||||
'@pnpm/exe':
|
'@pnpm/exe':
|
||||||
specifier: 11.5.2
|
specifier: 11.13.1
|
||||||
version: 11.5.2
|
version: 11.13.1
|
||||||
pnpm:
|
pnpm:
|
||||||
specifier: 11.5.2
|
specifier: 11.13.1
|
||||||
version: 11.5.2
|
version: 11.13.1
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@pnpm/exe@11.5.2':
|
'@pnpm/exe@11.13.1':
|
||||||
resolution: {integrity: sha512-4UFnP2rhNu1xjAQ+I1GdIUUEtCJuTYJlbpiWSFA4POAID3Lpt+2vrjImWO7eOJ7iCY3vpc4TFe2IW3sAolW4Kg==}
|
resolution: {integrity: sha512-P4euEK6lOFnd5oTHEc5M/HhvyF4XUhTnVsklEcM6rmY0QJxPD6xbT+u1+gskEIBp4nSRorz20IJQtAU1Nerggg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@pnpm/linux-arm64@11.5.2':
|
'@pnpm/linux-arm64@11.13.1':
|
||||||
resolution: {integrity: sha512-MbJySnu2y9cCBqlODLjUlZ87JnRC3Inq40rvGHWJSrSQ0PnuHeSw2NDMnLI8Hf9hCY+ooussRc5iiR4IAkjUvg==}
|
resolution: {integrity: sha512-wB8zloqrYrudPyuA5qbuTCnJGe4eETPwqOjoPjoyyyvA4zFI5XfLpxgqOOcaY5UJBoqzckcGpRVDwhSRfsQ/6A==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@pnpm/linux-x64@11.5.2':
|
'@pnpm/linux-x64@11.13.1':
|
||||||
resolution: {integrity: sha512-g6g2BGpQA47wUACy6B1MdeSHPtnl6x4AeCg0IOWQ7xXorEtC+VRiSHhLpA5kByFGeSwyYh/nLc7mLul5DAaELw==}
|
resolution: {integrity: sha512-A+wnEvzfWEvanXiwww3tnOPmtjPSrrf5tOP6vk8+K0BRFEe/Df0oPytm2nWgGcn5iwPnqtr1Btkof913McnSPA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@pnpm/linuxstatic-arm64@11.5.2':
|
'@pnpm/linuxstatic-arm64@11.13.1':
|
||||||
resolution: {integrity: sha512-xTxs9BLxYW39BPNGnmvYCUBnMPWm4mzmzujmdYbpRxDnBXrx55qPR5K/3LSohX7VrmsdDrYxuH6AmG1AaOlIfA==}
|
resolution: {integrity: sha512-k4t65VeqRX4COMFe45TF58CVmCpmAsKZShaR1HobmUeleo98mWTctggKolrA2MHcVUeSS+12yB5Urb3uDazhmw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@pnpm/linuxstatic-x64@11.5.2':
|
'@pnpm/linuxstatic-x64@11.13.1':
|
||||||
resolution: {integrity: sha512-RGmmc/SoGLD90gmOHcU85UEKNoNRstLvizli4wzDASmETz/VeqJOqU5nD1YBgjzcP72sUMS352dh4bmzTfKyvQ==}
|
resolution: {integrity: sha512-A65GqPzwCl0bAMk3kRWfbjSRBm5RRaqR2oMxV/9AYZrwO0X9yEfngbLBISCPHjt6/Qe4nH7DFemyhy6yODYwEw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@pnpm/macos-arm64@11.5.2':
|
'@pnpm/macos-arm64@11.13.1':
|
||||||
resolution: {integrity: sha512-gW3A2jRlC3SJRw8qX2SAzjMIu9o98daTSqCKzeeYcjF/uEbtbz3dn4HqYrYffBnenKbc4hsgZQmNOHAvUKIlSg==}
|
resolution: {integrity: sha512-MJvOtyGOWSfBoqdVEfAH8ljmHs13mt82k/UxN4f+q7koDxJRR2n4Nie6Og6RwbnbaubCz0Fh2bTeL1+MxDSFpA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@pnpm/win-arm64@11.5.2':
|
'@pnpm/win-arm64@11.13.1':
|
||||||
resolution: {integrity: sha512-+VJCDoH/pRzLXBikwjvxgAnGfQufT8EALBX8cfSmrwD40JABUZvgPtjBjde7OwEoK/XwtlH8w+ZceFV0K3/YHQ==}
|
resolution: {integrity: sha512-kl/g1cCKOJPe4HntspyrAJW0LRco0UHnVfxHSspezo4Zj4AanJAZ8WzLqfa6/w3lBSKHTEN4x0pb3m4J7B7Vpw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@pnpm/win-x64@11.5.2':
|
'@pnpm/win-x64@11.13.1':
|
||||||
resolution: {integrity: sha512-zgglREh75RbFgV/E0tNRS03ElX+hJOV43KRSSeaboxtj3ei1rrguxOgOCXUs/GsizoHVsuD+qXGABE4Kc4GMCg==}
|
resolution: {integrity: sha512-Bcb14NeBlbHS2Gq1qr8VnCiAz5eC1lYzXOls7zH0bnV0Taaj4/xyfm0HVO4dn9R2TQtVtz1qnBZHHL9PDFumqQ==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
@@ -116,45 +116,45 @@ packages:
|
|||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
pnpm@11.5.2:
|
pnpm@11.13.1:
|
||||||
resolution: {integrity: sha512-ccYx44IGbvwlYl1c8CkHXeB7YbN/bic1D72Esb2lhkyMGWetwoB3a0XDCnFcA1mjvgj+9C1bsJ4rmQKZeWkpFg==}
|
resolution: {integrity: sha512-svx2g7imUlQU59E+G6KMqt3elr9m7FQL+ut+cCuB8+C+TR8pXt9/n+A5Z0Co3ORQnFgt33mJH0VD/qMtN2RfJQ==}
|
||||||
engines: {node: '>=22.13'}
|
engines: {node: '>=22.13'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@pnpm/exe@11.5.2':
|
'@pnpm/exe@11.13.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@reflink/reflink': 0.1.19
|
'@reflink/reflink': 0.1.19
|
||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@pnpm/linux-arm64': 11.5.2
|
'@pnpm/linux-arm64': 11.13.1
|
||||||
'@pnpm/linux-x64': 11.5.2
|
'@pnpm/linux-x64': 11.13.1
|
||||||
'@pnpm/linuxstatic-arm64': 11.5.2
|
'@pnpm/linuxstatic-arm64': 11.13.1
|
||||||
'@pnpm/linuxstatic-x64': 11.5.2
|
'@pnpm/linuxstatic-x64': 11.13.1
|
||||||
'@pnpm/macos-arm64': 11.5.2
|
'@pnpm/macos-arm64': 11.13.1
|
||||||
'@pnpm/win-arm64': 11.5.2
|
'@pnpm/win-arm64': 11.13.1
|
||||||
'@pnpm/win-x64': 11.5.2
|
'@pnpm/win-x64': 11.13.1
|
||||||
|
|
||||||
'@pnpm/linux-arm64@11.5.2':
|
'@pnpm/linux-arm64@11.13.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/linux-x64@11.5.2':
|
'@pnpm/linux-x64@11.13.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/linuxstatic-arm64@11.5.2':
|
'@pnpm/linuxstatic-arm64@11.13.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/linuxstatic-x64@11.5.2':
|
'@pnpm/linuxstatic-x64@11.13.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/macos-arm64@11.5.2':
|
'@pnpm/macos-arm64@11.13.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/win-arm64@11.5.2':
|
'@pnpm/win-arm64@11.13.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/win-x64@11.5.2':
|
'@pnpm/win-x64@11.13.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@reflink/reflink-darwin-arm64@0.1.19':
|
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||||
@@ -194,7 +194,7 @@ snapshots:
|
|||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
pnpm@11.5.2: {}
|
pnpm@11.13.1: {}
|
||||||
|
|
||||||
---
|
---
|
||||||
lockfileVersion: '9.0'
|
lockfileVersion: '9.0'
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
import js from '@eslint/js'
|
|
||||||
import tseslint from 'typescript-eslint'
|
|
||||||
import {defineConfig} from 'eslint/config'
|
|
||||||
import react from 'eslint-plugin-react'
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
// @ts-expect-error no types
|
|
||||||
import reactNative from 'eslint-plugin-react-native'
|
|
||||||
// @ts-expect-error no types
|
|
||||||
import reactNativeA11y from 'eslint-plugin-react-native-a11y'
|
|
||||||
import simpleImportSort from 'eslint-plugin-simple-import-sort'
|
|
||||||
import importX from 'eslint-plugin-import-x'
|
|
||||||
import lingui from 'eslint-plugin-lingui'
|
|
||||||
import reactCompiler from 'eslint-plugin-react-compiler'
|
|
||||||
import bskyInternal from 'eslint-plugin-bsky-internal'
|
|
||||||
import globals from 'globals'
|
|
||||||
import tsParser from '@typescript-eslint/parser'
|
|
||||||
|
|
||||||
export default defineConfig(
|
|
||||||
/**
|
|
||||||
* Global ignores
|
|
||||||
*/
|
|
||||||
{
|
|
||||||
ignores: [
|
|
||||||
'**/__mocks__/*.ts',
|
|
||||||
'ios/**',
|
|
||||||
'android/**',
|
|
||||||
'coverage/**',
|
|
||||||
'*.lock',
|
|
||||||
'.husky/**',
|
|
||||||
'patches/**',
|
|
||||||
'*.html',
|
|
||||||
'bskyweb/**',
|
|
||||||
'bskyembed/**',
|
|
||||||
'bskyogcard/**',
|
|
||||||
'src/locale/locales/_build/**',
|
|
||||||
'src/locale/locales/**/*.js',
|
|
||||||
'*.e2e.ts',
|
|
||||||
'*.e2e.tsx',
|
|
||||||
'eslint.config.mjs',
|
|
||||||
'babel.config.js',
|
|
||||||
'metro.config.js',
|
|
||||||
'.jscodeshift/**',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base configurations
|
|
||||||
*/
|
|
||||||
js.configs.recommended,
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
reactHooks.configs.flat.recommended,
|
|
||||||
importX.flatConfigs.recommended,
|
|
||||||
importX.flatConfigs.typescript,
|
|
||||||
importX.flatConfigs['react-native'],
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main configuration for all JS/TS/JSX/TSX files
|
|
||||||
*/
|
|
||||||
{
|
|
||||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
|
||||||
plugins: {
|
|
||||||
react,
|
|
||||||
'react-native': reactNative,
|
|
||||||
'react-native-a11y': reactNativeA11y,
|
|
||||||
'simple-import-sort': simpleImportSort,
|
|
||||||
// @ts-expect-error - not sure why
|
|
||||||
lingui,
|
|
||||||
'react-compiler': reactCompiler,
|
|
||||||
'bsky-internal': bskyInternal,
|
|
||||||
},
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 'latest',
|
|
||||||
sourceType: 'module',
|
|
||||||
globals: {
|
|
||||||
...globals.browser,
|
|
||||||
...globals.node,
|
|
||||||
},
|
|
||||||
parserOptions: {
|
|
||||||
parser: tsParser,
|
|
||||||
projectService: true,
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
ecmaFeatures: {
|
|
||||||
jsx: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
react: {
|
|
||||||
version: 'detect',
|
|
||||||
},
|
|
||||||
componentWrapperFunctions: ['observer'],
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
/**
|
|
||||||
* Custom rules
|
|
||||||
*/
|
|
||||||
'bsky-internal/avoid-unwrapped-text': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
impliedTextComponents: [
|
|
||||||
'H1',
|
|
||||||
'H2',
|
|
||||||
'H3',
|
|
||||||
'H4',
|
|
||||||
'H5',
|
|
||||||
'H6',
|
|
||||||
'P',
|
|
||||||
'Admonition',
|
|
||||||
'Admonition.Admonition',
|
|
||||||
'Toast.Action',
|
|
||||||
'AgeAssuranceAdmonition',
|
|
||||||
'Span',
|
|
||||||
'StackedButton',
|
|
||||||
],
|
|
||||||
impliedTextProps: [],
|
|
||||||
suggestedTextWrappers: {
|
|
||||||
Button: 'ButtonText',
|
|
||||||
'ToggleButton.Button': 'ToggleButton.ButtonText',
|
|
||||||
'SegmentedControl.Item': 'SegmentedControl.ItemText',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'bsky-internal/use-exact-imports': 'error',
|
|
||||||
'bsky-internal/use-prefixed-imports': 'error',
|
|
||||||
'bsky-internal/lingui-msg-rule': 'error',
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React & React Native
|
|
||||||
*/
|
|
||||||
...react.configs.recommended.rules,
|
|
||||||
...react.configs['jsx-runtime'].rules,
|
|
||||||
'react/hook-use-state': 'warn',
|
|
||||||
'react/no-unescaped-entities': 'off',
|
|
||||||
'react/prop-types': 'off',
|
|
||||||
'react-native/no-inline-styles': 'off',
|
|
||||||
...reactNativeA11y.configs.all.rules,
|
|
||||||
'react-compiler/react-compiler': 'warn',
|
|
||||||
'react-hooks/set-state-in-effect': 'error',
|
|
||||||
'react-hooks/purity': 'error',
|
|
||||||
'react-hooks/refs': 'error',
|
|
||||||
'react-hooks/immutability': 'error',
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import sorting
|
|
||||||
*/
|
|
||||||
'simple-import-sort/imports': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
groups: [
|
|
||||||
// Side effect imports.
|
|
||||||
['^\\u0000'],
|
|
||||||
// Node.js builtins prefixed with `node:`.
|
|
||||||
['^node:'],
|
|
||||||
// Packages.
|
|
||||||
// Things that start with a letter (or digit or underscore), or `@` followed by a letter.
|
|
||||||
// React/React Native prioritized, followed by expo
|
|
||||||
// Followed by all packages excluding unprefixed relative ones
|
|
||||||
[
|
|
||||||
'^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)',
|
|
||||||
'^(expo(.*)$)|^(expo$)',
|
|
||||||
'^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w',
|
|
||||||
],
|
|
||||||
// Relative imports.
|
|
||||||
// Ideally, anything that starts with a dot or #
|
|
||||||
// due to unprefixed relative imports being used, we whitelist the relative paths we use
|
|
||||||
// (?:$|\\/) matches end of string or /
|
|
||||||
[
|
|
||||||
'^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)',
|
|
||||||
'^(?:#\\/)?view(?:$|\\/)',
|
|
||||||
'^(?:#\\/)?screens(?:$|\\/)',
|
|
||||||
'^(?:#\\/)?alf(?:$|\\/)',
|
|
||||||
'^(?:#\\/)?components(?:$|\\/)',
|
|
||||||
'^#\\/',
|
|
||||||
'^\\.',
|
|
||||||
],
|
|
||||||
// anything else - hopefully we don't have any of these
|
|
||||||
['^'],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'simple-import-sort/exports': 'error',
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import linting
|
|
||||||
*/
|
|
||||||
'import-x/consistent-type-specifier-style': ['warn', 'prefer-inline'],
|
|
||||||
'import-x/no-unresolved': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
/*
|
|
||||||
* The `postinstall` hook runs `compile-if-needed` locally, but not in
|
|
||||||
* CI. For CI-sake, ignore this.
|
|
||||||
*/
|
|
||||||
ignore: ['^#\/locale\/locales\/.+\/messages'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'import-x/no-extraneous-dependencies': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
whitelist: [
|
|
||||||
// test files only
|
|
||||||
'@jest/globals',
|
|
||||||
// we only use a really simple util from this, and we know it will be present
|
|
||||||
'expo-modules-core',
|
|
||||||
// this is a dep for @atproto/api, but we absolutely need them in sync, so just
|
|
||||||
// rely on the transient version
|
|
||||||
'@atproto/common-web',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'import-x/no-nodejs-modules': 'error',
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TypeScript-specific rules
|
|
||||||
*/
|
|
||||||
'no-unused-vars': 'off', // off, we use TS-specific rule below
|
|
||||||
'@typescript-eslint/no-unused-vars': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
argsIgnorePattern: '^_',
|
|
||||||
varsIgnorePattern: '^_.+',
|
|
||||||
caughtErrors: 'none',
|
|
||||||
ignoreRestSiblings: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'@typescript-eslint/consistent-type-imports': [
|
|
||||||
'warn',
|
|
||||||
{prefer: 'type-imports', fixStyle: 'inline-type-imports'},
|
|
||||||
],
|
|
||||||
'@typescript-eslint/no-require-imports': 'off',
|
|
||||||
'@typescript-eslint/no-unused-expressions': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
allowTernary: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
/**
|
|
||||||
* Maintain previous behavior via eslint-suppressions.json - these are
|
|
||||||
* stricter in typescript-eslint v8. `off` ones are a bit too nit-picky.
|
|
||||||
*/
|
|
||||||
'@typescript-eslint/no-explicit-any': 'error',
|
|
||||||
'@typescript-eslint/ban-ts-comment': 'off',
|
|
||||||
'@typescript-eslint/no-empty-object-type': 'off',
|
|
||||||
'@typescript-eslint/no-unsafe-function-type': 'off',
|
|
||||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
|
||||||
'@typescript-eslint/unbound-method': 'off',
|
|
||||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
|
||||||
'@typescript-eslint/no-unsafe-return': 'off',
|
|
||||||
'@typescript-eslint/no-unsafe-member-access': 'error',
|
|
||||||
'@typescript-eslint/no-unsafe-call': 'error',
|
|
||||||
'@typescript-eslint/no-floating-promises': 'error',
|
|
||||||
'@typescript-eslint/no-misused-promises': 'error',
|
|
||||||
'@typescript-eslint/require-await': 'error',
|
|
||||||
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
|
|
||||||
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
|
|
||||||
'@typescript-eslint/no-redundant-type-constituents': 'error',
|
|
||||||
'@typescript-eslint/no-duplicate-type-constituents': 'error',
|
|
||||||
'@typescript-eslint/no-base-to-string': 'error',
|
|
||||||
'@typescript-eslint/prefer-promise-reject-errors': 'error',
|
|
||||||
'@typescript-eslint/await-thenable': 'error',
|
|
||||||
|
|
||||||
'no-restricted-imports': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
paths: [
|
|
||||||
{
|
|
||||||
name: 'react',
|
|
||||||
importNames: ['React', 'default'],
|
|
||||||
message:
|
|
||||||
'React is already in the global type namespace. Use named imports for runtime modules.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turn off rules that we haven't enforced thus far
|
|
||||||
*/
|
|
||||||
'no-empty-pattern': 'off',
|
|
||||||
'no-async-promise-executor': 'off',
|
|
||||||
'no-constant-binary-expression': 'warn',
|
|
||||||
'prefer-const': 'off',
|
|
||||||
'no-empty': 'off',
|
|
||||||
'no-unsafe-optional-chaining': 'off',
|
|
||||||
'no-prototype-builtins': 'off',
|
|
||||||
'no-var': 'off',
|
|
||||||
'prefer-rest-params': 'off',
|
|
||||||
'no-case-declarations': 'off',
|
|
||||||
'no-irregular-whitespace': 'off',
|
|
||||||
'no-useless-escape': 'off',
|
|
||||||
'no-sparse-arrays': 'off',
|
|
||||||
'no-fallthrough': 'off',
|
|
||||||
'no-control-regex': 'off',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* bskyogcard, dev-env - server-side, Node.js imports are fine
|
|
||||||
*/
|
|
||||||
{
|
|
||||||
files: ['bskyogcard/**/*.{js,jsx,ts,tsx}', 'dev-env/**/*.{js,jsx,ts,tsx}'],
|
|
||||||
rules: {
|
|
||||||
'import-x/no-nodejs-modules': 'off',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test files configuration
|
|
||||||
*/
|
|
||||||
{
|
|
||||||
files: ['**/__tests__/**/*.{js,jsx,ts,tsx}', '**/*.test.{js,jsx,ts,tsx}'],
|
|
||||||
languageOptions: {
|
|
||||||
globals: {
|
|
||||||
...globals.jest,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
const {RuleTester} = require('eslint')
|
const {RuleTester} = require('oxlint/plugins-dev')
|
||||||
const tseslint = require('typescript-eslint')
|
|
||||||
const avoidUnwrappedText = require('../avoid-unwrapped-text')
|
const avoidUnwrappedText = require('../avoid-unwrapped-text')
|
||||||
|
|
||||||
const ruleTester = new RuleTester({
|
const ruleTester = new RuleTester({
|
||||||
|
eslintCompat: true,
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: tseslint.parser,
|
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
ecmaFeatures: {
|
lang: 'tsx',
|
||||||
jsx: true,
|
|
||||||
},
|
|
||||||
ecmaVersion: 'latest',
|
|
||||||
sourceType: 'module',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
const {RuleTester} = require('eslint')
|
const {RuleTester} = require('oxlint/plugins-dev')
|
||||||
const tseslint = require('typescript-eslint')
|
|
||||||
const linguiMsgRule = require('../lingui-msg-rule')
|
const linguiMsgRule = require('../lingui-msg-rule')
|
||||||
|
|
||||||
const ruleTester = new RuleTester({
|
const ruleTester = new RuleTester({
|
||||||
|
eslintCompat: true,
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: tseslint.parser,
|
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
ecmaFeatures: {
|
lang: 'tsx',
|
||||||
jsx: true,
|
|
||||||
},
|
|
||||||
ecmaVersion: 'latest',
|
|
||||||
sourceType: 'module',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -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 IMAGE_EXTENSIONS: [String] = ["png", "jpg", "jpeg", "gif", "heic"]
|
||||||
let MOVIE_EXTENSIONS: [String] = ["mov", "mp4", "m4v"]
|
let MOVIE_EXTENSIONS: [String] = ["mov", "mp4", "m4v"]
|
||||||
|
let MAX_IMAGES = 10
|
||||||
|
|
||||||
enum URLType: String, CaseIterable {
|
enum URLType: String, CaseIterable {
|
||||||
case image
|
case image
|
||||||
@@ -71,17 +72,12 @@ class ShareViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleImages(items: [NSItemProvider]) async {
|
private func handleImages(items: [NSItemProvider]) async {
|
||||||
let firstFourItems: [NSItemProvider]
|
let itemsToProcess = Array(items.prefix(MAX_IMAGES))
|
||||||
if items.count < 4 {
|
|
||||||
firstFourItems = items
|
|
||||||
} else {
|
|
||||||
firstFourItems = Array(items[0...3])
|
|
||||||
}
|
|
||||||
|
|
||||||
var valid = true
|
var valid = true
|
||||||
var imageUris = ""
|
var imageUris = ""
|
||||||
|
|
||||||
for (index, item) in firstFourItems.enumerated() {
|
for (index, item) in itemsToProcess.enumerated() {
|
||||||
var imageUriInfo: String?
|
var imageUriInfo: String?
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -100,7 +96,7 @@ class ShareViewController: UIViewController {
|
|||||||
|
|
||||||
if let imageUriInfo = imageUriInfo {
|
if let imageUriInfo = imageUriInfo {
|
||||||
imageUris.append(imageUriInfo)
|
imageUris.append(imageUriInfo)
|
||||||
if index < items.count - 1 {
|
if index < itemsToProcess.count - 1 {
|
||||||
imageUris.append(",")
|
imageUris.append(",")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -121,7 +117,6 @@ class ShareViewController: UIViewController {
|
|||||||
let firstItem = items.first
|
let firstItem = items.first
|
||||||
|
|
||||||
if let dataUrl = try? await firstItem?.loadItem(forTypeIdentifier: "public.movie") as? URL {
|
if let dataUrl = try? await firstItem?.loadItem(forTypeIdentifier: "public.movie") as? URL {
|
||||||
let ext = String(dataUrl.lastPathComponent.split(separator: ".").last ?? "mp4")
|
|
||||||
if let videoUriInfo = saveVideoWithInfo(dataUrl),
|
if let videoUriInfo = saveVideoWithInfo(dataUrl),
|
||||||
let url = URL(string: "\(self.appScheme)://intent/compose?videoUri=\(videoUriInfo)") {
|
let url = URL(string: "\(self.appScheme)://intent/compose?videoUri=\(videoUriInfo)") {
|
||||||
_ = self.openURL(url)
|
_ = self.openURL(url)
|
||||||
|
|||||||
@@ -32,9 +32,12 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
|||||||
var cornerRadius: CGFloat?
|
var cornerRadius: CGFloat?
|
||||||
var sourceViewTag: Int?
|
var sourceViewTag: Int?
|
||||||
var minHeight = 0.0
|
var minHeight = 0.0
|
||||||
var maxHeight: CGFloat! {
|
// getScreenHeight() is nil when no window scene is connected yet (e.g. during
|
||||||
|
// prewarming or a background launch). A nil here previously trapped in
|
||||||
|
// clampHeight, so fall back to the full screen bounds instead.
|
||||||
|
var maxHeight: CGFloat = UIScreen.main.bounds.height {
|
||||||
didSet {
|
didSet {
|
||||||
let screenHeight = Util.getScreenHeight() ?? 0
|
let screenHeight = Util.getScreenHeight() ?? UIScreen.main.bounds.height
|
||||||
if maxHeight > screenHeight {
|
if maxHeight > screenHeight {
|
||||||
maxHeight = screenHeight
|
maxHeight = screenHeight
|
||||||
}
|
}
|
||||||
@@ -77,7 +80,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
|||||||
|
|
||||||
required init (appContext: AppContext? = nil) {
|
required init (appContext: AppContext? = nil) {
|
||||||
super.init(appContext: appContext)
|
super.init(appContext: appContext)
|
||||||
self.maxHeight = Util.getScreenHeight()
|
self.maxHeight = Util.getScreenHeight() ?? UIScreen.main.bounds.height
|
||||||
self.touchHandler = RCTTouchHandler(bridge: appContext?.reactBridge)
|
self.touchHandler = RCTTouchHandler(bridge: appContext?.reactBridge)
|
||||||
SheetManager.shared.add(self)
|
SheetManager.shared.add(self)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
|
import {Component} from 'react'
|
||||||
|
|
||||||
import {type BottomSheetViewProps} from './BottomSheet.types'
|
import {type BottomSheetViewProps} from './BottomSheet.types'
|
||||||
|
|
||||||
export function BottomSheetNativeComponent(_: BottomSheetViewProps) {
|
export class BottomSheetNativeComponent extends Component<BottomSheetViewProps> {
|
||||||
|
/*
|
||||||
|
* 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')
|
throw new Error('BottomSheetNativeComponent is not available on web')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,15 @@ export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo
|
|||||||
throw new NotImplementedError()
|
throw new NotImplementedError()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReferrerInfo(): ReferrerInfo | null {
|
/*
|
||||||
|
* Promise-returning for parity with Android, whose native referrer API only
|
||||||
|
* exposes a promise.
|
||||||
|
*/
|
||||||
|
export function getReferrerInfo(): Promise<ReferrerInfo | null> {
|
||||||
|
return Promise.resolve(getReferrerInfoSync())
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReferrerInfoSync(): ReferrerInfo | null {
|
||||||
const referrer = SharedPrefs.getString('referrer')
|
const referrer = SharedPrefs.getString('referrer')
|
||||||
if (referrer) {
|
if (referrer) {
|
||||||
SharedPrefs.removeValue('referrer')
|
SharedPrefs.removeValue('referrer')
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo
|
|||||||
throw new NotImplementedError()
|
throw new NotImplementedError()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReferrerInfo(): ReferrerInfo | null {
|
export function getReferrerInfo(): Promise<ReferrerInfo | null> {
|
||||||
throw new NotImplementedError()
|
throw new NotImplementedError()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo
|
|||||||
throw new NotImplementedError()
|
throw new NotImplementedError()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReferrerInfo(): ReferrerInfo | null {
|
/*
|
||||||
|
* Promise-returning for parity with Android, whose native referrer API only
|
||||||
|
* exposes a promise.
|
||||||
|
*/
|
||||||
|
export function getReferrerInfo(): Promise<ReferrerInfo | null> {
|
||||||
|
return Promise.resolve(getReferrerInfoSync())
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReferrerInfoSync(): ReferrerInfo | null {
|
||||||
if (
|
if (
|
||||||
Platform.OS === 'web' &&
|
Platform.OS === 'web' &&
|
||||||
// for ssr
|
// for ssr
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import java.io.FileOutputStream
|
|||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
|
||||||
private const val TAG = "ExpoReceiveAndroidIntents"
|
private const val TAG = "ExpoReceiveAndroidIntents"
|
||||||
|
private const val MAX_IMAGES = 10
|
||||||
|
|
||||||
enum class AttachmentType {
|
enum class AttachmentType {
|
||||||
IMAGE,
|
IMAGE,
|
||||||
@@ -100,12 +101,12 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
|||||||
intent
|
intent
|
||||||
.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
?.filterIsInstance<Uri>()
|
?.filterIsInstance<Uri>()
|
||||||
?.take(4)
|
?.take(MAX_IMAGES)
|
||||||
} else {
|
} else {
|
||||||
intent
|
intent
|
||||||
.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
|
.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
|
||||||
?.filterIsInstance<Uri>()
|
?.filterIsInstance<Uri>()
|
||||||
?.take(4)
|
?.take(MAX_IMAGES)
|
||||||
}
|
}
|
||||||
|
|
||||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bsky.app",
|
"name": "bsky.app",
|
||||||
"version": "1.127.1",
|
"version": "1.129.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.18.0"
|
"node": ">=24.18.0"
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"devEngines": {
|
"devEngines": {
|
||||||
"packageManager": {
|
"packageManager": {
|
||||||
"name": "pnpm",
|
"name": "pnpm",
|
||||||
"version": "11.10.0",
|
"version": "11.13.1",
|
||||||
"onFail": "warn"
|
"onFail": "warn"
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
@@ -59,10 +59,13 @@
|
|||||||
"test-watch": "NODE_ENV=test jest --watchAll",
|
"test-watch": "NODE_ENV=test jest --watchAll",
|
||||||
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
||||||
"test-coverage": "NODE_ENV=test jest --coverage",
|
"test-coverage": "NODE_ENV=test jest --coverage",
|
||||||
"lint": "eslint --cache --quiet src modules",
|
"lint": "oxlint --quiet src modules",
|
||||||
"lint-native": "swiftlint ./modules && ktlint ./modules",
|
"lint-native": "swiftlint ./modules && ktlint ./modules",
|
||||||
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
|
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
|
||||||
"typecheck": "tsgo --project ./tsconfig.check.json",
|
"typecheck": "pnpm run typecheck:ios && pnpm run typecheck:android && pnpm run typecheck:web",
|
||||||
|
"typecheck:ios": "tsgo --project ./tsconfig.check.ios.json",
|
||||||
|
"typecheck:android": "tsgo --project ./tsconfig.check.android.json",
|
||||||
|
"typecheck:web": "tsgo --project ./tsconfig.check.web.json",
|
||||||
"e2e:mock-server": "cd dev-env && pnpm start",
|
"e2e:mock-server": "cd dev-env && pnpm start",
|
||||||
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
|
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
|
||||||
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
|
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
|
||||||
@@ -93,9 +96,9 @@
|
|||||||
"prettier": "prettier --check ."
|
"prettier": "prettier --check ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.20.25",
|
"@atproto/api": "0.20.28",
|
||||||
"@atproto/common-web": "0.5.3",
|
"@atproto/common-web": "0.5.6",
|
||||||
"@atproto/syntax": "0.6.4",
|
"@atproto/syntax": "0.7.2",
|
||||||
"@bitdrift/react-native": "^0.6.8",
|
"@bitdrift/react-native": "^0.6.8",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@bsky.app/alf": "^0.1.14",
|
"@bsky.app/alf": "^0.1.14",
|
||||||
@@ -128,7 +131,7 @@
|
|||||||
"@react-navigation/bottom-tabs": "^7.15.5",
|
"@react-navigation/bottom-tabs": "^7.15.5",
|
||||||
"@react-navigation/native": "^7.1.33",
|
"@react-navigation/native": "^7.1.33",
|
||||||
"@react-navigation/native-stack": "^7.14.4",
|
"@react-navigation/native-stack": "^7.14.4",
|
||||||
"@sentry/react-native": "~6.20.0",
|
"@sentry/react-native": "~8.18.0",
|
||||||
"@tanstack/query-async-storage-persister": "^5.96.2",
|
"@tanstack/query-async-storage-persister": "^5.96.2",
|
||||||
"@tanstack/react-query": "^5.96.2",
|
"@tanstack/react-query": "^5.96.2",
|
||||||
"@tanstack/react-query-persist-client": "^5.96.2",
|
"@tanstack/react-query-persist-client": "^5.96.2",
|
||||||
@@ -140,7 +143,6 @@
|
|||||||
"@tiptap/extension-paragraph": "^2.9.1",
|
"@tiptap/extension-paragraph": "^2.9.1",
|
||||||
"@tiptap/extension-placeholder": "^2.9.1",
|
"@tiptap/extension-placeholder": "^2.9.1",
|
||||||
"@tiptap/extension-text": "^2.9.1",
|
"@tiptap/extension-text": "^2.9.1",
|
||||||
"@tiptap/html": "^2.9.1",
|
|
||||||
"@tiptap/pm": "^2.9.1",
|
"@tiptap/pm": "^2.9.1",
|
||||||
"@tiptap/react": "^2.9.1",
|
"@tiptap/react": "^2.9.1",
|
||||||
"@tiptap/suggestion": "^2.9.1",
|
"@tiptap/suggestion": "^2.9.1",
|
||||||
@@ -257,7 +259,6 @@
|
|||||||
"@babel/preset-env": "^7.26.0",
|
"@babel/preset-env": "^7.26.0",
|
||||||
"@babel/runtime": "^7.26.0",
|
"@babel/runtime": "^7.26.0",
|
||||||
"@crowdin/cli": "^4.14.1",
|
"@crowdin/cli": "^4.14.1",
|
||||||
"@eslint/js": "^9.39.2",
|
|
||||||
"@lingui/babel-plugin-lingui-macro": "^5.9.2",
|
"@lingui/babel-plugin-lingui-macro": "^5.9.2",
|
||||||
"@lingui/cli": "^5.9.2",
|
"@lingui/cli": "^5.9.2",
|
||||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||||
@@ -277,14 +278,8 @@
|
|||||||
"babel-plugin-module-resolver": "^5.0.2",
|
"babel-plugin-module-resolver": "^5.0.2",
|
||||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||||
"babel-preset-expo": "~54.0.10",
|
"babel-preset-expo": "~54.0.10",
|
||||||
"eslint": "^9.39.2",
|
|
||||||
"eslint-import-resolver-typescript": "^4.4.4",
|
|
||||||
"eslint-plugin-bsky-internal": "link:eslint",
|
"eslint-plugin-bsky-internal": "link:eslint",
|
||||||
"eslint-plugin-import-x": "^4.16.2",
|
|
||||||
"eslint-plugin-lingui": "^0.12.0",
|
|
||||||
"eslint-plugin-react": "^7.37.5",
|
|
||||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
|
||||||
"eslint-plugin-react-native": "^5.0.0",
|
"eslint-plugin-react-native": "^5.0.0",
|
||||||
"eslint-plugin-react-native-a11y": "^3.5.1",
|
"eslint-plugin-react-native-a11y": "^3.5.1",
|
||||||
"eslint-plugin-simple-import-sort": "^13.0.0",
|
"eslint-plugin-simple-import-sort": "^13.0.0",
|
||||||
@@ -295,14 +290,15 @@
|
|||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-expo": "~54.0.17",
|
"jest-expo": "~54.0.17",
|
||||||
"jest-junit": "^16.0.0",
|
"jest-junit": "^16.0.0",
|
||||||
"lint-staged": "^13.2.3",
|
"lint-staged": "^17.0.8",
|
||||||
|
"oxlint": "^1.73.0",
|
||||||
|
"oxlint-tsgolint": "^0.24.0",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"react-native-dotenv": "^3.4.11",
|
"react-native-dotenv": "^3.4.11",
|
||||||
"react-refresh": "^0.14.0",
|
"react-refresh": "^0.14.0",
|
||||||
"svgo": "^3.3.2",
|
"svgo": "^4.0.2",
|
||||||
"ts-plugin-sort-import-suggestions": "^1.0.4",
|
"ts-plugin-sort-import-suggestions": "^1.0.4",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"typescript-eslint": "^8.58.0",
|
|
||||||
"webpack-bundle-analyzer": "^4.10.1"
|
"webpack-bundle-analyzer": "^4.10.1"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
@@ -367,7 +363,7 @@
|
|||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*{.js,.jsx,.ts,.tsx}": [
|
"*{.js,.jsx,.ts,.tsx}": [
|
||||||
"eslint --cache --fix"
|
"oxlint --fix"
|
||||||
],
|
],
|
||||||
"*{.js,.jsx,.ts,.tsx,.css}": [
|
"*{.js,.jsx,.ts,.tsx,.css}": [
|
||||||
"prettier --cache --write --ignore-unknown"
|
"prettier --cache --write --ignore-unknown"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
diff --git a/cli.js b/cli.js
|
||||||
|
index 2a2e6afbd46a6a5182918373d71853193945ff31..44e5888e869003438fc7ef4931b9992f2896a23f 100755
|
||||||
|
--- a/cli.js
|
||||||
|
+++ b/cli.js
|
||||||
|
@@ -304,6 +304,9 @@ for (const [assetGroupName, assets] of Object.entries(groupedAssets)) {
|
||||||
|
if (isHermes) {
|
||||||
|
args.push('--debug-id-reference');
|
||||||
|
}
|
||||||
|
+ if (process.env.SENTRY_DIST) {
|
||||||
|
+ args.push('--dist', process.env.SENTRY_DIST);
|
||||||
|
+ }
|
||||||
|
args.push(...assets);
|
||||||
|
|
||||||
|
const result = spawnSync(sentryCliBin, args, {
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# @sentry/expo-upload-sourcemaps patch
|
||||||
|
|
||||||
|
Adds `--dist $SENTRY_DIST` to the `sentry-cli sourcemaps upload` call, when the
|
||||||
|
env var is set. Symbolication matches on debug IDs so this isn't strictly
|
||||||
|
required, but it keeps OTA sourcemap artifacts associated with the commit hash
|
||||||
|
(`dist`) in the Sentry UI, matching the runtime `dist` set in
|
||||||
|
`src/logger/sentry/setup/index.ts`.
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
diff --git a/dist/js/tools/sentryMetroSerializer.js b/dist/js/tools/sentryMetroSerializer.js
|
|
||||||
index d7f2350196fc008f9f1ce530e224fe3d052347e9..4ce7b6614e38c8af747ebcf166dba556affea3de 100644
|
|
||||||
--- a/dist/js/tools/sentryMetroSerializer.js
|
|
||||||
+++ b/dist/js/tools/sentryMetroSerializer.js
|
|
||||||
@@ -12,12 +12,9 @@ exports.createSentryMetroSerializer = exports.unstable_beforeAssetSerializationP
|
|
||||||
const crypto = require("crypto");
|
|
||||||
const utils_1 = require("./utils");
|
|
||||||
const utils_2 = require("./vendor/metro/utils");
|
|
||||||
-let countLines;
|
|
||||||
-try {
|
|
||||||
- countLines = require('metro/private/lib/countLines');
|
|
||||||
-}
|
|
||||||
-catch (e) {
|
|
||||||
- countLines = require('metro/src/lib/countLines');
|
|
||||||
+const newline = /\r\n?|\n|\u2028|\u2029/g;
|
|
||||||
+function countLines(string) {
|
|
||||||
+ return (string.match(newline) || []).length + 1;
|
|
||||||
}
|
|
||||||
const DEBUG_ID_PLACE_HOLDER = '__debug_id_place_holder__';
|
|
||||||
const DEBUG_ID_MODULE_PATH = '__debugid__';
|
|
||||||
diff --git a/scripts/expo-upload-sourcemaps.js b/scripts/expo-upload-sourcemaps.js
|
|
||||||
index b3783b572171482778d31a96b8d6ebadbcc8783b..d5e3e45477c07b5419285237372d99ddd83c56a6 100755
|
|
||||||
--- a/scripts/expo-upload-sourcemaps.js
|
|
||||||
+++ b/scripts/expo-upload-sourcemaps.js
|
|
||||||
@@ -218,7 +218,7 @@ for (const [assetGroupName, assets] of Object.entries(groupedAssets)) {
|
|
||||||
|
|
||||||
const isHermes = assets.find(asset => asset.endsWith('.hbc'));
|
|
||||||
const windowsCallback = process.platform === "win32" ? 'node ' : '';
|
|
||||||
- execSync(`${windowsCallback}${sentryCliBin} sourcemaps upload ${isHermes ? '--debug-id-reference' : ''} ${assets.join(' ')}`, {
|
|
||||||
+ execSync(`${windowsCallback}${sentryCliBin} sourcemaps upload ${isHermes ? '--debug-id-reference' : ''} ${assets.join(' ')} --dist ${process.env.SENTRY_DIST}`, {
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
[SENTRY_PROJECT]: sentryProject,
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# @sentry/react-native/scripts/expo-upload-sourcemaps.js patch
|
|
||||||
|
|
||||||
Lets us specify the output directory for the sourcemaps via an environment variable.
|
|
||||||
|
|
||||||
# @sentry/react-native/dist/js/tools/sentryMetroSerializer.js patch
|
|
||||||
|
|
||||||
Patch of this: https://github.com/getsentry/sentry-react-native/issues/5180#issuecomment-3311772038
|
|
||||||
|
|
||||||
Will be fixed in an upcoming release of @sentry/react-native - remove when available.
|
|
||||||
@@ -55,3 +55,15 @@ index 06829bd00dbded262e1871aaf6db3ae0cfa9d1b1..1b158185a6d7e907aa18ddc806902fcc
|
|||||||
invalidate();
|
invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diff --git a/lib/typescript/ReactNativeSVG.web.d.ts b/lib/typescript/ReactNativeSVG.web.d.ts
|
||||||
|
deleted file mode 100644
|
||||||
|
index e2001975fa97398fb159d2f85f45d067200f575b..0000000000000000000000000000000000000000
|
||||||
|
diff --git a/lib/typescript/ReactNativeSVG.web.d.ts.map b/lib/typescript/ReactNativeSVG.web.d.ts.map
|
||||||
|
deleted file mode 100644
|
||||||
|
index c81af4416af5ee3eaca77b51bfc2952b30d49c8a..0000000000000000000000000000000000000000
|
||||||
|
diff --git a/lib/typescript/elements.web.d.ts b/lib/typescript/elements.web.d.ts
|
||||||
|
deleted file mode 100644
|
||||||
|
index cf4083fbf749b2b002fb649d80b33ac74763847b..0000000000000000000000000000000000000000
|
||||||
|
diff --git a/lib/typescript/elements.web.d.ts.map b/lib/typescript/elements.web.d.ts.map
|
||||||
|
deleted file mode 100644
|
||||||
|
index 29602aa47ad903bc7aaa111462a61dd862c4476d..0000000000000000000000000000000000000000
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ allowBuilds:
|
|||||||
'esbuild': true
|
'esbuild': true
|
||||||
'unrs-resolver': true
|
'unrs-resolver': true
|
||||||
patchedDependencies:
|
patchedDependencies:
|
||||||
'@sentry/react-native@6.20.0': patches/@sentry__react-native@6.20.0.patch
|
'@sentry/expo-upload-sourcemaps@8.18.0': patches/@sentry__expo-upload-sourcemaps@8.18.0.patch
|
||||||
expo-age-range@0.2.18: patches/expo-age-range@0.2.18.patch
|
expo-age-range@0.2.18: patches/expo-age-range@0.2.18.patch
|
||||||
'expo-glass-effect@55.0.8': patches/expo-glass-effect@55.0.8.patch
|
'expo-glass-effect@55.0.8': patches/expo-glass-effect@55.0.8.patch
|
||||||
'expo-haptics@15.0.8': patches/expo-haptics@15.0.8.patch
|
'expo-haptics@15.0.8': patches/expo-haptics@15.0.8.patch
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
|||||||
import {MessagesProvider} from '#/state/messages'
|
import {MessagesProvider} from '#/state/messages'
|
||||||
import {init as initPersistedState} from '#/state/persisted'
|
import {init as initPersistedState} from '#/state/persisted'
|
||||||
import {Provider as PrefsStateProvider} from '#/state/preferences'
|
import {Provider as PrefsStateProvider} from '#/state/preferences'
|
||||||
|
import {BetaUserStorageSync} from '#/state/preferences/beta-user-sync'
|
||||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||||
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
||||||
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
|
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
|
||||||
@@ -152,6 +153,7 @@ function InnerApp() {
|
|||||||
key={currentAccount?.did}>
|
key={currentAccount?.did}>
|
||||||
<AnalyticsFeaturesContext>
|
<AnalyticsFeaturesContext>
|
||||||
<QueryProvider currentDid={currentAccount?.did}>
|
<QueryProvider currentDid={currentAccount?.did}>
|
||||||
|
<BetaUserStorageSync />
|
||||||
<PolicyUpdateOverlayProvider>
|
<PolicyUpdateOverlayProvider>
|
||||||
<LiveEventsProvider>
|
<LiveEventsProvider>
|
||||||
<AgeAssuranceV2Provider>
|
<AgeAssuranceV2Provider>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
|||||||
import {MessagesProvider} from '#/state/messages'
|
import {MessagesProvider} from '#/state/messages'
|
||||||
import {init as initPersistedState} from '#/state/persisted'
|
import {init as initPersistedState} from '#/state/persisted'
|
||||||
import {Provider as PrefsStateProvider} from '#/state/preferences'
|
import {Provider as PrefsStateProvider} from '#/state/preferences'
|
||||||
|
import {BetaUserStorageSync} from '#/state/preferences/beta-user-sync'
|
||||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||||
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
||||||
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
|
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
|
||||||
@@ -132,6 +133,7 @@ function InnerApp() {
|
|||||||
key={currentAccount?.did}>
|
key={currentAccount?.did}>
|
||||||
<AnalyticsFeaturesContext>
|
<AnalyticsFeaturesContext>
|
||||||
<QueryProvider currentDid={currentAccount?.did}>
|
<QueryProvider currentDid={currentAccount?.did}>
|
||||||
|
<BetaUserStorageSync />
|
||||||
<PolicyUpdateOverlayProvider>
|
<PolicyUpdateOverlayProvider>
|
||||||
<LiveEventsProvider>
|
<LiveEventsProvider>
|
||||||
<AgeAssuranceV2Provider>
|
<AgeAssuranceV2Provider>
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
|
|||||||
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
|
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
|
||||||
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
|
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
|
||||||
import {AutomationLabelSettingsScreen} from '#/screens/Settings/AutomationLabelSettings'
|
import {AutomationLabelSettingsScreen} from '#/screens/Settings/AutomationLabelSettings'
|
||||||
|
import {BetaFeaturesSettingsScreen} from '#/screens/Settings/BetaFeaturesSettings'
|
||||||
import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings'
|
import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings'
|
||||||
import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences'
|
import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences'
|
||||||
import {FindContactsSettingsScreen} from '#/screens/Settings/FindContactsSettings'
|
import {FindContactsSettingsScreen} from '#/screens/Settings/FindContactsSettings'
|
||||||
@@ -405,6 +406,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
|||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="BetaFeaturesSettings"
|
||||||
|
getComponent={() => BetaFeaturesSettingsScreen}
|
||||||
|
options={{
|
||||||
|
title: title(msg`Beta features`),
|
||||||
|
requireAuth: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="AutomationLabelSettings"
|
name="AutomationLabelSettings"
|
||||||
getComponent={() => AutomationLabelSettingsScreen}
|
getComponent={() => AutomationLabelSettingsScreen}
|
||||||
@@ -994,7 +1003,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (IS_WEB) {
|
if (IS_WEB) {
|
||||||
const referrerInfo = Referrer.getReferrerInfo()
|
void Referrer.getReferrerInfo().then(referrerInfo => {
|
||||||
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
|
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
|
||||||
ax.metric('deepLink:referrerReceived', {
|
ax.metric('deepLink:referrerReceived', {
|
||||||
to: window.location.href,
|
to: window.location.href,
|
||||||
@@ -1002,6 +1011,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
|||||||
hostname: referrerInfo?.hostname,
|
hostname: referrerInfo?.hostname,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// temp, just testing
|
// temp, just testing
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import {MMKV} from '@bsky.app/react-native-mmkv'
|
import {MMKV} from '@bsky.app/react-native-mmkv'
|
||||||
import {setPolyfills} from '@growthbook/growthbook'
|
import {setPolyfills} from '@growthbook/growthbook'
|
||||||
import {GrowthBook} from '@growthbook/growthbook-react'
|
import {GrowthBook} from '@growthbook/growthbook-react'
|
||||||
|
import {type I18n} from '@lingui/core'
|
||||||
|
import {msg} from '@lingui/core/macro'
|
||||||
|
|
||||||
import {Logger} from '#/logger'
|
import {Logger} from '#/logger'
|
||||||
|
import {Features} from '#/analytics/features/types'
|
||||||
import {getNavigationMetadata, type Metadata} from '#/analytics/metadata'
|
import {getNavigationMetadata, type Metadata} from '#/analytics/metadata'
|
||||||
import * as env from '#/env'
|
import * as env from '#/env'
|
||||||
|
|
||||||
@@ -11,12 +14,14 @@ export {Features} from '#/analytics/features/types'
|
|||||||
const logger = Logger.create(Logger.Context.Growthbook)
|
const logger = Logger.create(Logger.Context.Growthbook)
|
||||||
const CACHE = new MMKV({id: 'bsky_features_cache'})
|
const CACHE = new MMKV({id: 'bsky_features_cache'})
|
||||||
|
|
||||||
|
const BETA_USER_ATTRIBUTE = 'isBetaUser'
|
||||||
|
|
||||||
setPolyfills({
|
setPolyfills({
|
||||||
localStorage: {
|
localStorage: {
|
||||||
getItem: key => {
|
getItem: key => {
|
||||||
return CACHE.getString(key) ?? null
|
return CACHE.getString(key) ?? null
|
||||||
},
|
},
|
||||||
setItem: async (key, value) => {
|
setItem: (key, value) => {
|
||||||
CACHE.set(key, value)
|
CACHE.set(key, value)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -44,15 +49,13 @@ export const features = new GrowthBook({
|
|||||||
* that case, we may see a flash of uncustomized content until the
|
* that case, we may see a flash of uncustomized content until the
|
||||||
* initialization completes.
|
* initialization completes.
|
||||||
*/
|
*/
|
||||||
export const init = new Promise<void>(async y => {
|
export const init = features.init({timeout: TIMEOUT_INIT}).then(res => {
|
||||||
const res = await features.init({timeout: TIMEOUT_INIT})
|
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
logger.warn('GrowthBook initialization failed or timed out', {
|
logger.warn('GrowthBook initialization failed or timed out', {
|
||||||
source: res.source,
|
source: res.source,
|
||||||
safeMessage: res.error?.toString(),
|
safeMessage: res.error?.toString(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
y()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +71,89 @@ export async function refresh({strategy}: {strategy: FeatureFetchStrategy}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFeatures() {
|
||||||
|
return features.getFeatures()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFeatureDescription(feature: Features, i18n: I18n) {
|
||||||
|
switch (feature) {
|
||||||
|
case Features.PostThreadKnownLikersEnable:
|
||||||
|
return {
|
||||||
|
key: feature,
|
||||||
|
name: i18n._(
|
||||||
|
msg({
|
||||||
|
message: 'Social proofing on posts',
|
||||||
|
comment: 'Name for a feature flag',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
description: i18n._(
|
||||||
|
msg({
|
||||||
|
message: 'Spot posts your friends and follows have liked.',
|
||||||
|
comment: 'Description of a feature flag (Social proofing on posts)',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks a GrowthBook condition tree to determine whether it targets the given
|
||||||
|
* attribute. Conditions can nest via the logical operators `$and`, `$or`,
|
||||||
|
* `$nor` (arrays of sub-conditions) and `$not` (a single sub-condition), so a
|
||||||
|
* flat scan of the top-level keys would miss e.g.
|
||||||
|
* `{$and: [{isBetaUser: true}, ...]}`. Dot-notation access (e.g.
|
||||||
|
* `isBetaUser.foo`) counts as targeting the attribute as well.
|
||||||
|
*/
|
||||||
|
function conditionTargetsAttribute(
|
||||||
|
condition: unknown,
|
||||||
|
attribute: string,
|
||||||
|
): boolean {
|
||||||
|
if (!condition || typeof condition !== 'object') return false
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(condition)) {
|
||||||
|
if (key === attribute || key.startsWith(`${attribute}.`)) return true
|
||||||
|
|
||||||
|
if (key === '$and' || key === '$or' || key === '$nor') {
|
||||||
|
if (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.some(sub => conditionTargetsAttribute(sub, attribute))
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else if (key === '$not') {
|
||||||
|
if (conditionTargetsAttribute(value, attribute)) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTargetedFeatures(i18n: I18n) {
|
||||||
|
const allFeatures = features.getFeatures()
|
||||||
|
const targetedFeatures: {key: Features; name: string; description: string}[] =
|
||||||
|
[]
|
||||||
|
for (const [featureKey, feature] of Object.entries(allFeatures)) {
|
||||||
|
// Check if the feature contains any rules
|
||||||
|
if (!feature.rules) continue
|
||||||
|
|
||||||
|
// Determine if any rule targets the beta user attribute
|
||||||
|
const hasTargeting = feature.rules.some(rule =>
|
||||||
|
conditionTargetsAttribute(rule.condition, BETA_USER_ATTRIBUTE),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hasTargeting) {
|
||||||
|
const featureName = getFeatureDescription(featureKey as Features, i18n)
|
||||||
|
if (featureName) {
|
||||||
|
targetedFeatures.push(featureName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetedFeatures
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts our metadata into GrowthBook attributes and sets them. GrowthBook
|
* Converts our metadata into GrowthBook attributes and sets them. GrowthBook
|
||||||
* attributes are manually configured in the GrowthBook dashboard. So these
|
* attributes are manually configured in the GrowthBook dashboard. So these
|
||||||
@@ -80,7 +166,7 @@ export function setAttributes({
|
|||||||
session,
|
session,
|
||||||
preferences,
|
preferences,
|
||||||
}: Metadata) {
|
}: Metadata) {
|
||||||
features.setAttributes({
|
void features.setAttributes({
|
||||||
deviceId: base.deviceId,
|
deviceId: base.deviceId,
|
||||||
sessionId: base.sessionId,
|
sessionId: base.sessionId,
|
||||||
platform: base.platform,
|
platform: base.platform,
|
||||||
@@ -92,5 +178,6 @@ export function setAttributes({
|
|||||||
appLanguage: preferences?.appLanguage,
|
appLanguage: preferences?.appLanguage,
|
||||||
contentLanguages: preferences?.contentLanguages,
|
contentLanguages: preferences?.contentLanguages,
|
||||||
currentScreen: getNavigationMetadata()?.currentScreen,
|
currentScreen: getNavigationMetadata()?.currentScreen,
|
||||||
|
isBetaUser: base.isBetaUser,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* If a feature is in the beta program, be sure to add a localized description
|
||||||
|
* for it via getFeatureDescription().
|
||||||
|
*/
|
||||||
export enum Features {
|
export enum Features {
|
||||||
// core flags
|
// core flags
|
||||||
IsBskyTeam = 'is_bsky_team',
|
IsBskyTeam = 'is_bsky_team',
|
||||||
@@ -12,8 +16,9 @@ export enum Features {
|
|||||||
GroupChatsDisable = 'group_chats:disable',
|
GroupChatsDisable = 'group_chats:disable',
|
||||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||||
SearchV2Enable = 'search_v2:enable',
|
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
|
||||||
AdvancedSearchV2Enable = 'advanced_search_v2:enable',
|
PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable',
|
||||||
|
CustomLogoJapanEnable = 'custom_logo:japan:enable',
|
||||||
|
|
||||||
AATest = 'aa-test',
|
AATest = 'aa-test',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import {createContext, useContext, useMemo} from 'react'
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useSyncExternalStore,
|
||||||
|
} from 'react'
|
||||||
import {Platform} from 'react-native'
|
import {Platform} from 'react-native'
|
||||||
import {type Result} from '@growthbook/growthbook-react'
|
import {type Result} from '@growthbook/growthbook-react'
|
||||||
|
|
||||||
@@ -26,7 +32,7 @@ import {type Metrics, metrics} from '#/analytics/metrics'
|
|||||||
import * as refParams from '#/analytics/misc/refParams'
|
import * as refParams from '#/analytics/misc/refParams'
|
||||||
import * as env from '#/env'
|
import * as env from '#/env'
|
||||||
import {useGeolocationServiceResponse} from '#/geolocation/service'
|
import {useGeolocationServiceResponse} from '#/geolocation/service'
|
||||||
import {device} from '#/storage'
|
import {account, device} from '#/storage'
|
||||||
|
|
||||||
export * as utils from '#/analytics/utils'
|
export * as utils from '#/analytics/utils'
|
||||||
export const features = {init, refresh}
|
export const features = {init, refresh}
|
||||||
@@ -120,6 +126,38 @@ Context.displayName = 'AnalyticsContext'
|
|||||||
*/
|
*/
|
||||||
export const setupDeviceId = getAndMigrateDeviceId()
|
export const setupDeviceId = getAndMigrateDeviceId()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the per-account cached `isBetaUser` flag for `did`, kept in sync with
|
||||||
|
* writes from `BetaUserStorageSync` and the beta settings toggle.
|
||||||
|
*
|
||||||
|
* This deliberately does not use `useStorage`, whose `useState` seeds once and
|
||||||
|
* only updates via the change listener. The consuming `AnalyticsContext` lives
|
||||||
|
* above the `<Fragment key={did}>` remount breaker, so on an account switch it
|
||||||
|
* re-renders (with a new did) rather than remounting. `useStorage` would keep
|
||||||
|
* serving the previous account's seeded value until a write happened to fire
|
||||||
|
* its listener, leaking a beta account's flag into a non-beta account. Reading
|
||||||
|
* via `useSyncExternalStore` re-evaluates `getSnapshot` every render, so the
|
||||||
|
* value is always correct for the current did.
|
||||||
|
*/
|
||||||
|
function useAccountIsBetaUser(did: string | undefined): boolean | undefined {
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(onChange: () => void) => {
|
||||||
|
if (!did) return () => {}
|
||||||
|
const sub = account.addOnValueChangedListener(
|
||||||
|
[did, 'isBetaUser'],
|
||||||
|
onChange,
|
||||||
|
)
|
||||||
|
return () => sub.remove()
|
||||||
|
},
|
||||||
|
[did],
|
||||||
|
)
|
||||||
|
const getSnapshot = useCallback(() => {
|
||||||
|
if (!did) return undefined
|
||||||
|
return account.get([did, 'isBetaUser'])
|
||||||
|
}, [did])
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Analytics context provider. Decorates the parent analytics context with
|
* Analytics context provider. Decorates the parent analytics context with
|
||||||
* additional metadata. Nesting should be done carefully and sparingly.
|
* additional metadata. Nesting should be done carefully and sparingly.
|
||||||
@@ -141,6 +179,16 @@ export function AnalyticsContext({
|
|||||||
const sessionId = useSessionId()
|
const sessionId = useSessionId()
|
||||||
const geolocation = useGeolocationServiceResponse()
|
const geolocation = useGeolocationServiceResponse()
|
||||||
const parentContext = useContext(Context)
|
const parentContext = useContext(Context)
|
||||||
|
/*
|
||||||
|
* `isBetaUser` is account-specific, so it's cached per account. Read it
|
||||||
|
* scoped to the did for this render's session (from the `metadata` prop when
|
||||||
|
* set, otherwise inherited from the parent context). Without a did (e.g.
|
||||||
|
* logged out, or the top-level context above the session provider) there's
|
||||||
|
* no value, so beta-gated features are never evaluated for an ineligible or
|
||||||
|
* absent account.
|
||||||
|
*/
|
||||||
|
const did = metadata?.session?.did ?? parentContext.metadata.session?.did
|
||||||
|
const isBetaUser = useAccountIsBetaUser(did)
|
||||||
const childContext = useMemo(() => {
|
const childContext = useMemo(() => {
|
||||||
const combinedMetadata = {
|
const combinedMetadata = {
|
||||||
...parentContext.metadata,
|
...parentContext.metadata,
|
||||||
@@ -148,6 +196,7 @@ export function AnalyticsContext({
|
|||||||
base: {
|
base: {
|
||||||
...parentContext.metadata.base,
|
...parentContext.metadata.base,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
isBetaUser,
|
||||||
},
|
},
|
||||||
geolocation,
|
geolocation,
|
||||||
}
|
}
|
||||||
@@ -166,7 +215,7 @@ export function AnalyticsContext({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
return context
|
return context
|
||||||
}, [sessionId, geolocation, parentContext, metadata])
|
}, [parentContext, metadata, sessionId, isBetaUser, geolocation])
|
||||||
return <Context.Provider value={childContext}>{children}</Context.Provider>
|
return <Context.Provider value={childContext}>{children}</Context.Provider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type BaseMetadata = {
|
|||||||
bundleDate: number
|
bundleDate: number
|
||||||
referrerSrc: string
|
referrerSrc: string
|
||||||
referrerUrl: string
|
referrerUrl: string
|
||||||
|
isBetaUser?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GeolocationMetadata = Geolocation
|
export type GeolocationMetadata = Geolocation
|
||||||
@@ -66,7 +67,7 @@ export function getMetadataForLogger({
|
|||||||
base,
|
base,
|
||||||
geolocation,
|
geolocation,
|
||||||
session,
|
session,
|
||||||
}: Metadata): Record<string, any> {
|
}: Metadata): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
deviceId: base.deviceId,
|
deviceId: base.deviceId,
|
||||||
sessionId: base.sessionId,
|
sessionId: base.sessionId,
|
||||||
|
|||||||
@@ -1461,4 +1461,6 @@ export type Events = {
|
|||||||
jobId?: string
|
jobId?: string
|
||||||
elapsedInPhaseMs: number
|
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 {atoms as a, useTheme} from '#/alf'
|
||||||
import * as Menu from '#/components/Menu'
|
import * as Menu from '#/components/Menu'
|
||||||
|
import {type TriggerProps as MenuTriggerProps} from '#/components/Menu/types'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {type AuxiliaryViewProps} from './types'
|
import {type AuxiliaryViewProps, type TriggerProps} from './types'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ContainerItem,
|
ContainerItem,
|
||||||
@@ -16,7 +17,6 @@ export {
|
|||||||
ItemText,
|
ItemText,
|
||||||
LabelText,
|
LabelText,
|
||||||
Root,
|
Root,
|
||||||
Trigger,
|
|
||||||
useMenuContext as useContextMenuContext,
|
useMenuContext as useContextMenuContext,
|
||||||
useMenuControl as useContextMenuControl,
|
useMenuControl as useContextMenuControl,
|
||||||
} from '#/components/Menu'
|
} from '#/components/Menu'
|
||||||
@@ -30,14 +30,37 @@ export function AuxiliaryView({}: AuxiliaryViewProps) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* On web the context menu is just a Menu; contentLabel, onTap, style, and
|
||||||
|
* swipeGesture only apply to the native press-and-hold presentation.
|
||||||
|
*/
|
||||||
|
export function Trigger({children, label, hint, role}: TriggerProps) {
|
||||||
|
return (
|
||||||
|
<Menu.Trigger label={label} hint={hint} role={role}>
|
||||||
|
{/*
|
||||||
|
* Menu supplies the same web-arm child props shape as ContextMenu's
|
||||||
|
* TriggerChildProps; only the native arms of the two unions differ,
|
||||||
|
* and those never occur here.
|
||||||
|
*/}
|
||||||
|
{children as unknown as MenuTriggerProps['children']}
|
||||||
|
</Menu.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Outer({
|
export function Outer({
|
||||||
children,
|
children,
|
||||||
label,
|
label,
|
||||||
|
align: _align,
|
||||||
style,
|
style,
|
||||||
onCloseAutoFocus,
|
onCloseAutoFocus,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
label?: string
|
label?: string
|
||||||
|
/**
|
||||||
|
* Native positions the menu against the message bubble explicitly; the web
|
||||||
|
* dropdown is anchored by radix, so this is accepted only for parity.
|
||||||
|
*/
|
||||||
|
align?: 'left' | 'right'
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
onCloseAutoFocus?: (event: Event) => void
|
onCloseAutoFocus?: (event: Event) => void
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type GestureResponderEvent,
|
type GestureResponderEvent,
|
||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
Pressable,
|
Pressable,
|
||||||
|
type ScrollView,
|
||||||
type StyleProp,
|
type StyleProp,
|
||||||
View,
|
View,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
@@ -24,6 +25,7 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
|||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useA11y} from '#/state/a11y'
|
import {useA11y} from '#/state/a11y'
|
||||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||||
|
import {type ListMethods} from '#/view/com/util/List'
|
||||||
import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf'
|
import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf'
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
import {Context} from '#/components/Dialog/context'
|
import {Context} from '#/components/Dialog/context'
|
||||||
@@ -224,11 +226,21 @@ export function Inner({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScrollableInner = Inner
|
/*
|
||||||
|
* There is no inner ScrollView on web - the ref is accepted for parity with
|
||||||
|
* the native variant and never attached, so native-only scrolling code in
|
||||||
|
* shared callers stays a no-op here.
|
||||||
|
*/
|
||||||
|
export function ScrollableInner({
|
||||||
|
ref: _ref,
|
||||||
|
...props
|
||||||
|
}: DialogInnerProps & {ref?: React.Ref<ScrollView>}) {
|
||||||
|
return <Inner {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
export const InnerFlatList = forwardRef<
|
export const InnerFlatList = forwardRef<
|
||||||
FlatList,
|
ListMethods,
|
||||||
FlatListProps<any> & {label: string} & {
|
FlatListProps<any> & {label?: string} & {
|
||||||
webInnerStyle?: StyleProp<ViewStyle>
|
webInnerStyle?: StyleProp<ViewStyle>
|
||||||
webInnerContentContainerStyle?: StyleProp<ViewStyle>
|
webInnerContentContainerStyle?: StyleProp<ViewStyle>
|
||||||
footer?: React.ReactNode
|
footer?: React.ReactNode
|
||||||
@@ -247,7 +259,11 @@ export const InnerFlatList = forwardRef<
|
|||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
return (
|
return (
|
||||||
<Inner
|
<Inner
|
||||||
label={label}
|
/*
|
||||||
|
* Most shared callers cannot pass a label since the native variant has
|
||||||
|
* no such prop; aria-label is simply absent for them, as before.
|
||||||
|
*/
|
||||||
|
label={label as string}
|
||||||
style={[
|
style={[
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
a.px_0,
|
a.px_0,
|
||||||
@@ -256,7 +272,13 @@ export const InnerFlatList = forwardRef<
|
|||||||
]}
|
]}
|
||||||
contentContainerStyle={[a.h_full, a.px_0, webInnerContentContainerStyle]}>
|
contentContainerStyle={[a.h_full, a.px_0, webInnerContentContainerStyle]}>
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={ref}
|
/*
|
||||||
|
* The FlatList instance satisfies the (web) ListMethods interface
|
||||||
|
* shared callers hold their refs as, except scrollToTop, which no
|
||||||
|
* platform-agnostic caller can use since the native ListMethods
|
||||||
|
* (FlatList) lacks it too.
|
||||||
|
*/
|
||||||
|
ref={ref as React.Ref<FlatList>}
|
||||||
style={[a.h_full, gtMobile ? a.px_2xl : a.px_xl, style]}
|
style={[a.h_full, gtMobile ? a.px_2xl : a.px_xl, style]}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -321,7 +343,11 @@ export function Close() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Handle() {
|
/*
|
||||||
|
* The drag handle only exists on the native bottom sheet; props are accepted
|
||||||
|
* for parity with the native variant.
|
||||||
|
*/
|
||||||
|
export function Handle(_props: {difference?: boolean; fill?: string}) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import {useState} from 'react'
|
import {useState} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
|
import type Animated from 'react-native-reanimated'
|
||||||
|
import {type AnimatedRef, type SharedValue} from 'react-native-reanimated'
|
||||||
|
|
||||||
import {useTheme} from '#/alf'
|
import {useTheme} from '#/alf'
|
||||||
import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid'
|
import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid'
|
||||||
@@ -18,6 +20,10 @@ interface SortableListProps<T> {
|
|||||||
onDragEnd?: () => void
|
onDragEnd?: () => void
|
||||||
/** Fixed row height used for position math. */
|
/** Fixed row height used for position math. */
|
||||||
itemHeight: number
|
itemHeight: number
|
||||||
|
/** Ref to the parent Animated.ScrollView for auto-scroll. Ignored on web. */
|
||||||
|
scrollRef?: AnimatedRef<Animated.ScrollView>
|
||||||
|
/** Scroll offset shared value from useScrollViewOffset. Ignored on web. */
|
||||||
|
scrollOffset?: SharedValue<number>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SortableList<T>({
|
export function SortableList<T>({
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import {useCallback} from 'react'
|
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
@@ -14,26 +13,31 @@ export function LanguageSelect({
|
|||||||
value: l.code2,
|
value: l.code2,
|
||||||
})),
|
})),
|
||||||
label,
|
label,
|
||||||
|
disabledBlueskySupportedLanguageSanitization = false,
|
||||||
}: {
|
}: {
|
||||||
value?: string
|
value?: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
items?: {label: string; value: string}[]
|
items?: {label: string; value: string}[]
|
||||||
label?: string
|
label?: string
|
||||||
|
disabledBlueskySupportedLanguageSanitization?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const selectValue =
|
||||||
|
value && !disabledBlueskySupportedLanguageSanitization
|
||||||
|
? sanitizeAppLanguageSetting(value)
|
||||||
|
: value
|
||||||
|
|
||||||
const handleOnChange = useCallback(
|
const handleOnChange = (value: string) => {
|
||||||
(value: string) => {
|
|
||||||
if (!value) return
|
if (!value) return
|
||||||
onChange(sanitizeAppLanguageSetting(value))
|
onChange(
|
||||||
},
|
disabledBlueskySupportedLanguageSanitization
|
||||||
[onChange],
|
? value
|
||||||
|
: sanitizeAppLanguageSetting(value),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select.Root
|
<Select.Root value={selectValue} onValueChange={handleOnChange}>
|
||||||
value={value ? sanitizeAppLanguageSetting(value) : undefined}
|
|
||||||
onValueChange={handleOnChange}>
|
|
||||||
<Select.Trigger label={_(msg`Select language`)}>
|
<Select.Trigger label={_(msg`Select language`)}>
|
||||||
<Select.ValueText placeholder={_(msg`Select language`)} />
|
<Select.ValueText placeholder={_(msg`Select language`)} />
|
||||||
<Select.Icon />
|
<Select.Icon />
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {msg} from '@lingui/core/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {HITSLOP_30} from '#/lib/constants'
|
|
||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {useSetDrawerOpen} from '#/state/shell'
|
import {useSetDrawerOpen} from '#/state/shell'
|
||||||
import {useIsWithinSplitView} from '#/screens/Messages/components/splitView/context'
|
import {useIsWithinSplitView} from '#/screens/Messages/components/splitView/context'
|
||||||
@@ -33,6 +32,8 @@ import {Text} from '#/components/Typography'
|
|||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_IOS} from '#/env'
|
import {IS_IOS} from '#/env'
|
||||||
|
|
||||||
|
const HEADER_BUTTON_HITSLOP = {top: 30, bottom: 10, left: 30, right: 30}
|
||||||
|
|
||||||
export function Outer({
|
export function Outer({
|
||||||
children,
|
children,
|
||||||
noBottomBorder,
|
noBottomBorder,
|
||||||
@@ -136,7 +137,7 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
shape="round"
|
shape="round"
|
||||||
onPress={onPressBack}
|
onPress={onPressBack}
|
||||||
hitSlop={HITSLOP_30}
|
hitSlop={HEADER_BUTTON_HITSLOP}
|
||||||
style={[
|
style={[
|
||||||
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
|
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
|
||||||
a.bg_transparent,
|
a.bg_transparent,
|
||||||
@@ -170,7 +171,7 @@ export function MenuButton() {
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
shape="square"
|
shape="square"
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
hitSlop={HITSLOP_30}
|
hitSlop={HEADER_BUTTON_HITSLOP}
|
||||||
style={[
|
style={[
|
||||||
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
|
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
|
||||||
a.bg_transparent,
|
a.bg_transparent,
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import {
|
|||||||
import {Portal} from '#/components/Portal'
|
import {Portal} from '#/components/Portal'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export {type DialogControlProps as MenuControlProps} from '#/components/Dialog'
|
||||||
|
|
||||||
export {useMenuContext}
|
export {useMenuContext}
|
||||||
|
|
||||||
export function useMenuControl(): Dialog.DialogControlProps {
|
export function useMenuControl(): Dialog.DialogControlProps {
|
||||||
|
|||||||
@@ -66,8 +66,60 @@ export function Label({
|
|||||||
const isLabeler = Boolean(desc.sourceType && desc.sourceDid)
|
const isLabeler = Boolean(desc.sourceType && desc.sourceDid)
|
||||||
const isBlueskyLabel =
|
const isBlueskyLabel =
|
||||||
desc.sourceType === 'labeler' && desc.sourceDid === BSKY_LABELER_DID
|
desc.sourceType === 'labeler' && desc.sourceDid === BSKY_LABELER_DID
|
||||||
|
const avi = size === 'lg' ? 16 : 12
|
||||||
|
|
||||||
const {outer, avi, text} = useMemo(() => {
|
return (
|
||||||
|
<>
|
||||||
|
<LabelBase
|
||||||
|
label={desc.name}
|
||||||
|
size={size}
|
||||||
|
noBg={noBg}
|
||||||
|
disabled={disableDetailsDialog}
|
||||||
|
onPress={() => control.open()}
|
||||||
|
icon={
|
||||||
|
isBlueskyLabel || !isLabeler ? (
|
||||||
|
<desc.icon width={avi} fill={t.atoms.text_contrast_medium.color} />
|
||||||
|
) : (
|
||||||
|
<UserAvatar avatar={desc.sourceAvi} type="user" size={avi} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!disableDetailsDialog && (
|
||||||
|
<ModerationDetailsDialog control={control} modcause={cause} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LabelBaseProps = {
|
||||||
|
/**
|
||||||
|
* The accessibility label for the pill.
|
||||||
|
*/
|
||||||
|
label: string
|
||||||
|
/**
|
||||||
|
* The visible pill text. Defaults to `label`. Use this when the visible
|
||||||
|
* text is too terse to serve as the accessibility label, e.g. "+2".
|
||||||
|
*/
|
||||||
|
cta?: string
|
||||||
|
onPress: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
noBg?: boolean
|
||||||
|
icon?: React.ReactNode
|
||||||
|
} & CommonProps
|
||||||
|
|
||||||
|
export function LabelBase({
|
||||||
|
label,
|
||||||
|
cta = label,
|
||||||
|
onPress,
|
||||||
|
disabled,
|
||||||
|
size = 'sm',
|
||||||
|
noBg,
|
||||||
|
icon,
|
||||||
|
}: LabelBaseProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
const {outer, text} = useMemo(() => {
|
||||||
switch (size) {
|
switch (size) {
|
||||||
case 'lg': {
|
case 'lg': {
|
||||||
return {
|
return {
|
||||||
@@ -79,7 +131,6 @@ export function Label({
|
|||||||
paddingVertical: 5,
|
paddingVertical: 5,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
avi: 16,
|
|
||||||
text: [a.text_sm],
|
text: [a.text_sm],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +145,6 @@ export function Label({
|
|||||||
paddingVertical: 3,
|
paddingVertical: 3,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
avi: 12,
|
|
||||||
text: [a.text_xs],
|
text: [a.text_xs],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,14 +152,13 @@ export function Label({
|
|||||||
}, [t, size, noBg])
|
}, [t, size, noBg])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<Button
|
<Button
|
||||||
disabled={disableDetailsDialog}
|
disabled={disabled}
|
||||||
label={desc.name}
|
label={label}
|
||||||
onPress={e => {
|
onPress={e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
control.open()
|
onPress()
|
||||||
}}>
|
}}>
|
||||||
{({hovered, pressed}) => (
|
{({hovered, pressed}) => (
|
||||||
<View
|
<View
|
||||||
@@ -120,14 +169,7 @@ export function Label({
|
|||||||
outer,
|
outer,
|
||||||
(hovered || pressed) && t.atoms.bg_contrast_50,
|
(hovered || pressed) && t.atoms.bg_contrast_50,
|
||||||
]}>
|
]}>
|
||||||
{isBlueskyLabel || !isLabeler ? (
|
{icon}
|
||||||
<desc.icon
|
|
||||||
width={avi}
|
|
||||||
fill={t.atoms.text_contrast_medium.color}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<UserAvatar avatar={desc.sourceAvi} type="user" size={avi} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
emoji
|
emoji
|
||||||
@@ -138,16 +180,11 @@ export function Label({
|
|||||||
t.atoms.text_contrast_medium,
|
t.atoms.text_contrast_medium,
|
||||||
{paddingRight: 3},
|
{paddingRight: 3},
|
||||||
]}>
|
]}>
|
||||||
{desc.name}
|
{cta}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{!disableDetailsDialog && (
|
|
||||||
<ModerationDetailsDialog control={control} modcause={cause} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function Badge() {
|
|||||||
backgroundColor: t.palette.primary_25,
|
backgroundColor: t.palette.primary_25,
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
<Logo fill={t.palette.primary_600} width={14} />
|
<Logo allowVariants={false} fill={t.palette.primary_600} width={14} />
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.font_semi_bold,
|
a.font_semi_bold,
|
||||||
|
|||||||
@@ -26,12 +26,18 @@ export function VideoEmbedInnerNative({
|
|||||||
setStatus,
|
setStatus,
|
||||||
setIsLoading,
|
setIsLoading,
|
||||||
setIsActive,
|
setIsActive,
|
||||||
|
onError,
|
||||||
}: {
|
}: {
|
||||||
ref: React.Ref<{togglePlayback: () => void}>
|
ref: React.Ref<{togglePlayback: () => void}>
|
||||||
embed: AppBskyEmbedVideo.View
|
embed: AppBskyEmbedVideo.View
|
||||||
setStatus: (status: 'playing' | 'paused') => void
|
setStatus: (status: 'playing' | 'paused') => void
|
||||||
setIsLoading: (isLoading: boolean) => void
|
setIsLoading: (isLoading: boolean) => void
|
||||||
setIsActive: (isActive: boolean) => void
|
setIsActive: (isActive: boolean) => void
|
||||||
|
/**
|
||||||
|
* Called with the native error message before the component throws to the
|
||||||
|
* surrounding error boundary.
|
||||||
|
*/
|
||||||
|
onError?: (error: string) => void
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const videoRef = useRef<BlueskyVideoView>(null)
|
const videoRef = useRef<BlueskyVideoView>(null)
|
||||||
@@ -81,6 +87,7 @@ export function VideoEmbedInnerNative({
|
|||||||
setTimeRemaining(e.nativeEvent.timeRemaining)
|
setTimeRemaining(e.nativeEvent.timeRemaining)
|
||||||
}}
|
}}
|
||||||
onError={e => {
|
onError={e => {
|
||||||
|
onError?.(e.nativeEvent.error)
|
||||||
setError(e.nativeEvent.error)
|
setError(e.nativeEvent.error)
|
||||||
}}
|
}}
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
export function VideoEmbedInnerWeb() {
|
import {type VideoEmbedInnerWebProps} from './VideoEmbedInnerWeb.shared'
|
||||||
|
|
||||||
|
export {
|
||||||
|
HLSUnsupportedError,
|
||||||
|
VideoNotFoundError,
|
||||||
|
} from './VideoEmbedInnerWeb.shared'
|
||||||
|
|
||||||
|
export function VideoEmbedInnerWeb(_props: VideoEmbedInnerWebProps): never {
|
||||||
throw new Error('VideoEmbedInnerWeb may not be used on native.')
|
throw new Error('VideoEmbedInnerWeb may not be used on native.')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {type AppBskyEmbedVideo} from '@atproto/api'
|
||||||
|
|
||||||
|
export type VideoEmbedInnerWebProps = {
|
||||||
|
embed: AppBskyEmbedVideo.View
|
||||||
|
active: boolean
|
||||||
|
setActive: () => void
|
||||||
|
onScreen: boolean
|
||||||
|
lastKnownTime: React.RefObject<number | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HLSUnsupportedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('HLS is not supported')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VideoNotFoundError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Video not found')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import {useCallback, useEffect, useId, useRef, useState} from 'react'
|
import {useCallback, useEffect, useId, useRef, useState} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {type AppBskyEmbedVideo} from '@atproto/api'
|
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import type * as HlsTypes from 'hls.js'
|
import type * as HlsTypes from 'hls.js'
|
||||||
@@ -10,21 +9,25 @@ import {atoms as a} from '#/alf'
|
|||||||
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
|
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
|
||||||
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
||||||
import * as BandwidthEstimate from './bandwidth-estimate'
|
import * as BandwidthEstimate from './bandwidth-estimate'
|
||||||
|
import {
|
||||||
|
HLSUnsupportedError,
|
||||||
|
type VideoEmbedInnerWebProps,
|
||||||
|
VideoNotFoundError,
|
||||||
|
} from './VideoEmbedInnerWeb.shared'
|
||||||
import {Controls} from './web-controls/VideoControls'
|
import {Controls} from './web-controls/VideoControls'
|
||||||
|
|
||||||
|
export {
|
||||||
|
HLSUnsupportedError,
|
||||||
|
VideoNotFoundError,
|
||||||
|
} from './VideoEmbedInnerWeb.shared'
|
||||||
|
|
||||||
export function VideoEmbedInnerWeb({
|
export function VideoEmbedInnerWeb({
|
||||||
embed,
|
embed,
|
||||||
active,
|
active,
|
||||||
setActive,
|
setActive,
|
||||||
onScreen,
|
onScreen,
|
||||||
lastKnownTime,
|
lastKnownTime,
|
||||||
}: {
|
}: VideoEmbedInnerWebProps) {
|
||||||
embed: AppBskyEmbedVideo.View
|
|
||||||
active: boolean
|
|
||||||
setActive: () => void
|
|
||||||
onScreen: boolean
|
|
||||||
lastKnownTime: React.RefObject<number | undefined>
|
|
||||||
}) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
const [focused, setFocused] = useState(false)
|
const [focused, setFocused] = useState(false)
|
||||||
@@ -104,12 +107,6 @@ export function VideoEmbedInnerWeb({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HLSUnsupportedError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super('HLS is not supported')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bluesky serves HLS as MPEG-TS with H.264 + AAC. `Hls.isSupported()` is loose
|
// Bluesky serves HLS as MPEG-TS with H.264 + AAC. `Hls.isSupported()` is loose
|
||||||
// (true if MSE supports *any* of {H.264, AV1, VP9} OR *any* of {AAC, FLAC}),
|
// (true if MSE supports *any* of {H.264, AV1, VP9} OR *any* of {AAC, FLAC}),
|
||||||
// so on Linux boxes missing H.264 (e.g. no ubuntu-restricted-extras, sandboxed
|
// so on Linux boxes missing H.264 (e.g. no ubuntu-restricted-extras, sandboxed
|
||||||
@@ -138,12 +135,6 @@ function canPlayBskyVideoCodecs(): boolean {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VideoNotFoundError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super('Video not found')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type CachedPromise<T> = Promise<T> & {value: undefined | T}
|
type CachedPromise<T> = Promise<T> & {value: undefined | T}
|
||||||
const promiseForHls = import(
|
const promiseForHls = import(
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export function Controls() {
|
import {type ControlsProps} from './VideoControls.shared'
|
||||||
|
|
||||||
|
export function Controls(_props: ControlsProps): never {
|
||||||
throw new Error('VideoWebControls may not be used on native.')
|
throw new Error('VideoWebControls may not be used on native.')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type Hls from 'hls.js'
|
||||||
|
|
||||||
|
export type ControlsProps = {
|
||||||
|
videoRef: React.RefObject<HTMLVideoElement | null>
|
||||||
|
hlsRef: React.RefObject<Hls | undefined | null>
|
||||||
|
active: boolean
|
||||||
|
setActive: () => void
|
||||||
|
focused: boolean
|
||||||
|
setFocused: (focused: boolean) => void
|
||||||
|
onScreen: boolean
|
||||||
|
fullscreenRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
hlsLoading: boolean
|
||||||
|
hasSubtitleTrack: boolean
|
||||||
|
isGif: boolean
|
||||||
|
altText?: string
|
||||||
|
updateCuePositions: (controlsVisible?: boolean) => void
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import {Pressable, View} from 'react-native'
|
|||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {Trans} from '@lingui/react/macro'
|
import {Trans} from '@lingui/react/macro'
|
||||||
import type Hls from 'hls.js'
|
|
||||||
|
|
||||||
import {clamp} from '#/lib/numbers'
|
import {clamp} from '#/lib/numbers'
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +32,7 @@ import {TimeIndicator} from '../TimeIndicator'
|
|||||||
import {ControlButton} from './ControlButton'
|
import {ControlButton} from './ControlButton'
|
||||||
import {Scrubber} from './Scrubber'
|
import {Scrubber} from './Scrubber'
|
||||||
import {formatTime, useVideoElement} from './utils'
|
import {formatTime, useVideoElement} from './utils'
|
||||||
|
import {type ControlsProps} from './VideoControls.shared'
|
||||||
import {VolumeControl} from './VolumeControl'
|
import {VolumeControl} from './VolumeControl'
|
||||||
|
|
||||||
export function Controls({
|
export function Controls({
|
||||||
@@ -49,21 +49,7 @@ export function Controls({
|
|||||||
isGif,
|
isGif,
|
||||||
altText,
|
altText,
|
||||||
updateCuePositions,
|
updateCuePositions,
|
||||||
}: {
|
}: ControlsProps) {
|
||||||
videoRef: React.RefObject<HTMLVideoElement | null>
|
|
||||||
hlsRef: React.RefObject<Hls | undefined | null>
|
|
||||||
active: boolean
|
|
||||||
setActive: () => void
|
|
||||||
focused: boolean
|
|
||||||
setFocused: (focused: boolean) => void
|
|
||||||
onScreen: boolean
|
|
||||||
fullscreenRef: React.RefObject<HTMLDivElement | null>
|
|
||||||
hlsLoading: boolean
|
|
||||||
hasSubtitleTrack: boolean
|
|
||||||
isGif: boolean
|
|
||||||
altText?: string
|
|
||||||
updateCuePositions: (controlsVisible?: boolean) => void
|
|
||||||
}) {
|
|
||||||
const {
|
const {
|
||||||
play,
|
play,
|
||||||
pause,
|
pause,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {useCallback, useRef, useState} from 'react'
|
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||||
import {ActivityIndicator, View} from 'react-native'
|
import {ActivityIndicator, View} from 'react-native'
|
||||||
import {ImageBackground} from 'expo-image'
|
import {ImageBackground} from 'expo-image'
|
||||||
import {type AppBskyEmbedVideo} from '@atproto/api'
|
import {type AppBskyEmbedVideo} from '@atproto/api'
|
||||||
@@ -6,6 +6,10 @@ import {msg} from '@lingui/core/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {Trans} from '@lingui/react/macro'
|
import {Trans} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createPlaybackTelemetry,
|
||||||
|
type PlaybackTelemetry,
|
||||||
|
} from '#/lib/media/video/playbackTelemetry'
|
||||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||||
import {atoms as a, platform} from '#/alf'
|
import {atoms as a, platform} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
@@ -75,6 +79,17 @@ function InnerWrapper({embed}: Props) {
|
|||||||
const [isActive, setIsActive] = useState(false)
|
const [isActive, setIsActive] = useState(false)
|
||||||
const showSpinner = useThrottledValue(isActive && isLoading, 100)
|
const showSpinner = useThrottledValue(isActive && isLoading, 100)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Created lazily on first activation so videos that are never scrolled into
|
||||||
|
* the active position cost nothing.
|
||||||
|
*/
|
||||||
|
const telemetryRef = useRef<PlaybackTelemetry | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
telemetryRef.current?.deactivated()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const showOverlay =
|
const showOverlay =
|
||||||
!isActive ||
|
!isActive ||
|
||||||
isLoading ||
|
isLoading ||
|
||||||
@@ -89,9 +104,33 @@ function InnerWrapper({embed}: Props) {
|
|||||||
<>
|
<>
|
||||||
<VideoEmbedInnerNative
|
<VideoEmbedInnerNative
|
||||||
embed={embed}
|
embed={embed}
|
||||||
setStatus={setStatus}
|
setStatus={s => {
|
||||||
setIsLoading={setIsLoading}
|
setStatus(s)
|
||||||
setIsActive={setIsActive}
|
if (s === 'playing') {
|
||||||
|
telemetryRef.current?.playing()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
setIsLoading={loading => {
|
||||||
|
setIsLoading(loading)
|
||||||
|
if (!loading) {
|
||||||
|
telemetryRef.current?.ready()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
setIsActive={active => {
|
||||||
|
setIsActive(active)
|
||||||
|
if (active) {
|
||||||
|
telemetryRef.current ??= createPlaybackTelemetry({
|
||||||
|
surface: 'feed',
|
||||||
|
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
|
||||||
|
})
|
||||||
|
telemetryRef.current.activated()
|
||||||
|
} else {
|
||||||
|
telemetryRef.current?.deactivated()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onError={error => {
|
||||||
|
telemetryRef.current?.error(error)
|
||||||
|
}}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
<ImageBackground
|
<ImageBackground
|
||||||
|
|||||||
@@ -321,7 +321,11 @@ export function QuoteEmbed({
|
|||||||
linkDisabled
|
linkDisabled
|
||||||
/>
|
/>
|
||||||
{moderation ? (
|
{moderation ? (
|
||||||
<PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} />
|
<PostAlerts
|
||||||
|
post={quote}
|
||||||
|
modui={moderation.ui('contentView')}
|
||||||
|
style={[a.py_xs]}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{richText ? (
|
{richText ? (
|
||||||
<RichText
|
<RichText
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const BookmarkButton = memo(function BookmarkButton({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const save = async ({disableUndo}: {disableUndo?: boolean} = {}) => {
|
const save = async () => {
|
||||||
try {
|
try {
|
||||||
await bookmark({
|
await bookmark({
|
||||||
action: 'create',
|
action: 'create',
|
||||||
@@ -59,25 +59,6 @@ export const BookmarkButton = memo(function BookmarkButton({
|
|||||||
logContext,
|
logContext,
|
||||||
feedDescriptor,
|
feedDescriptor,
|
||||||
})
|
})
|
||||||
|
|
||||||
toast.show(
|
|
||||||
<toast.Outer>
|
|
||||||
<toast.Icon />
|
|
||||||
<toast.Text>
|
|
||||||
<Trans>Post saved</Trans>
|
|
||||||
</toast.Text>
|
|
||||||
{!disableUndo && (
|
|
||||||
<toast.Action
|
|
||||||
label={undoLabel}
|
|
||||||
onPress={() => remove({disableUndo: true})}>
|
|
||||||
{undoLabel}
|
|
||||||
</toast.Action>
|
|
||||||
)}
|
|
||||||
</toast.Outer>,
|
|
||||||
{
|
|
||||||
type: 'success',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const {raw, clean} = cleanError(e)
|
const {raw, clean} = cleanError(e)
|
||||||
toast.show(clean || raw || e, {
|
toast.show(clean || raw || e, {
|
||||||
@@ -86,7 +67,7 @@ export const BookmarkButton = memo(function BookmarkButton({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const remove = async ({disableUndo}: {disableUndo?: boolean} = {}) => {
|
const remove = async () => {
|
||||||
try {
|
try {
|
||||||
await bookmark({
|
await bookmark({
|
||||||
action: 'delete',
|
action: 'delete',
|
||||||
@@ -106,13 +87,9 @@ export const BookmarkButton = memo(function BookmarkButton({
|
|||||||
<toast.Text>
|
<toast.Text>
|
||||||
<Trans>Removed from saved posts</Trans>
|
<Trans>Removed from saved posts</Trans>
|
||||||
</toast.Text>
|
</toast.Text>
|
||||||
{!disableUndo && (
|
<toast.Action label={undoLabel} onPress={() => save()}>
|
||||||
<toast.Action
|
|
||||||
label={undoLabel}
|
|
||||||
onPress={() => save({disableUndo: true})}>
|
|
||||||
{undoLabel}
|
{undoLabel}
|
||||||
</toast.Action>
|
</toast.Action>
|
||||||
)}
|
|
||||||
</toast.Outer>,
|
</toast.Outer>,
|
||||||
)
|
)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
type TextStyleProp,
|
type TextStyleProp,
|
||||||
useTheme,
|
useTheme,
|
||||||
type ViewStyleProp,
|
type ViewStyleProp,
|
||||||
|
web,
|
||||||
} from '#/alf'
|
} from '#/alf'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -257,6 +258,7 @@ function InlineNameAndHandle({
|
|||||||
a.leading_tight,
|
a.leading_tight,
|
||||||
a.flex_shrink_0,
|
a.flex_shrink_0,
|
||||||
{maxWidth: '70%'},
|
{maxWidth: '70%'},
|
||||||
|
web({direction: 'ltr', unicodeBidi: 'isolate'}),
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}>
|
numberOfLines={1}>
|
||||||
{forceLTR(name)}
|
{forceLTR(name)}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
|||||||
import {Default as ProfileCard} from '#/components/ProfileCard'
|
import {Default as ProfileCard} from '#/components/ProfileCard'
|
||||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||||
|
|
||||||
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
|
function keyExtractor(item: AppBskyActorDefs.ProfileView, index: number) {
|
||||||
return `${item.did}-${index}`
|
return `${item.did}-${index}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ export const ProfilesList = forwardRef<SectionRef, ProfilesListProps>(
|
|||||||
const renderItem = ({
|
const renderItem = ({
|
||||||
item,
|
item,
|
||||||
index,
|
index,
|
||||||
}: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => {
|
}: ListRenderItemInfo<AppBskyActorDefs.ProfileView>) => {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {useCallback, useEffect, useImperativeHandle, useState} from 'react'
|
import {useCallback, useEffect, useImperativeHandle, useState} from 'react'
|
||||||
import {
|
import {
|
||||||
findNodeHandle,
|
|
||||||
type ListRenderItemInfo,
|
type ListRenderItemInfo,
|
||||||
type StyleProp,
|
type StyleProp,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
@@ -26,6 +25,7 @@ import {
|
|||||||
type EmptyStateButtonProps,
|
type EmptyStateButtonProps,
|
||||||
} from '#/view/com/util/EmptyState'
|
} from '#/view/com/util/EmptyState'
|
||||||
import {List, type ListRef} from '#/view/com/util/List'
|
import {List, type ListRef} from '#/view/com/util/List'
|
||||||
|
import {findListNativeTag} from '#/view/com/util/listNativeTag'
|
||||||
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||||
import {atoms as a, ios, useTheme} from '#/alf'
|
import {atoms as a, ios, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
@@ -57,7 +57,7 @@ interface ProfileFeedgensProps {
|
|||||||
emptyStateIcon?: React.ComponentType<any> | React.ReactElement
|
emptyStateIcon?: React.ComponentType<any> | React.ReactElement
|
||||||
}
|
}
|
||||||
|
|
||||||
function keyExtractor(item: AppBskyGraphDefs.StarterPackView) {
|
function keyExtractor(item: AppBskyGraphDefs.StarterPackViewBasic) {
|
||||||
return item.uri
|
return item.uri
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,13 +138,16 @@ export function ProfileStarterPacks({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (IS_IOS && enabled && scrollElRef.current) {
|
if (IS_IOS && enabled && scrollElRef.current) {
|
||||||
const nativeTag = findNodeHandle(scrollElRef.current)
|
const nativeTag = findListNativeTag(scrollElRef.current)
|
||||||
setScrollViewTag(nativeTag)
|
setScrollViewTag(nativeTag)
|
||||||
}
|
}
|
||||||
}, [enabled, scrollElRef, setScrollViewTag])
|
}, [enabled, scrollElRef, setScrollViewTag])
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({item, index}: ListRenderItemInfo<AppBskyGraphDefs.StarterPackView>) => {
|
({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
}: ListRenderItemInfo<AppBskyGraphDefs.StarterPackViewBasic>) => {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function QrCode({
|
|||||||
<Trans>
|
<Trans>
|
||||||
on
|
on
|
||||||
<View style={[a.flex_row, a.align_center, {gap: 6}]}>
|
<View style={[a.flex_row, a.align_center, {gap: 6}]}>
|
||||||
<Logo width={25} fill="white" />
|
<Logo allowVariants={false} width={25} fill="white" />
|
||||||
<View style={[{marginTop: 3.5}]}>
|
<View style={[{marginTop: 3.5}]}>
|
||||||
<Logotype width={72} fill="white" />
|
<Logotype width={72} fill="white" />
|
||||||
</View>
|
</View>
|
||||||
@@ -129,7 +129,11 @@ export function QrCodeInner({link}: {link: string}) {
|
|||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
padding: 4,
|
padding: 4,
|
||||||
}}>
|
}}>
|
||||||
<Logo width={logoArea.width - 14} height={logoArea.height - 14} />
|
<Logo
|
||||||
|
allowVariants={false}
|
||||||
|
width={logoArea.width - 14}
|
||||||
|
height={logoArea.height - 14}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<QRCode
|
<QRCode
|
||||||
|
|||||||
@@ -1,6 +1,41 @@
|
|||||||
import {atoms as a} from '#/alf'
|
import {atoms as a, select, type Theme} from '#/alf'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual variant for the tooltip surface. `default` is a neutral floating card;
|
||||||
|
* `primary` is a subtle blue surface matching the `primary_subtle` button.
|
||||||
|
*/
|
||||||
|
export type TooltipColor = 'default' | 'primary'
|
||||||
|
|
||||||
export const BUBBLE_MAX_WIDTH = 240
|
export const BUBBLE_MAX_WIDTH = 240
|
||||||
export const ARROW_SIZE = 12
|
export const ARROW_SIZE = 12
|
||||||
export const ARROW_HALF_SIZE = ARROW_SIZE / 2
|
export const ARROW_HALF_SIZE = ARROW_SIZE / 2
|
||||||
export const MIN_EDGE_SPACE = a.px_lg.paddingLeft
|
export const MIN_EDGE_SPACE = a.px_lg.paddingLeft
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the surface (background/arrow fill) and text colors for a tooltip
|
||||||
|
* variant. Kept here so the native and web implementations stay in sync.
|
||||||
|
*/
|
||||||
|
export function getTooltipStyle(t: Theme, color: TooltipColor) {
|
||||||
|
if (color === 'primary') {
|
||||||
|
return {
|
||||||
|
surface: t.palette.primary_50,
|
||||||
|
text: t.palette.primary_600,
|
||||||
|
border: {
|
||||||
|
color: t.atoms.border_contrast_low.borderColor,
|
||||||
|
width: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
surface: select(t.name, {
|
||||||
|
light: t.atoms.bg.backgroundColor,
|
||||||
|
dark: t.atoms.bg_contrast_100.backgroundColor,
|
||||||
|
dim: t.atoms.bg_contrast_100.backgroundColor,
|
||||||
|
}),
|
||||||
|
text: t.atoms.text.color,
|
||||||
|
border: {
|
||||||
|
color: undefined,
|
||||||
|
width: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ export function Content() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextBubble() {
|
export function BubbleText() {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Children,
|
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
@@ -14,14 +13,16 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
|||||||
|
|
||||||
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
||||||
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
|
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
|
||||||
import {atoms as a, select, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {useOnGesture} from '#/components/hooks/useOnGesture'
|
import {useOnGesture} from '#/components/hooks/useOnGesture'
|
||||||
import {createPortalGroup, Portal as RootPortal} from '#/components/Portal'
|
import {createPortalGroup, Portal as RootPortal} from '#/components/Portal'
|
||||||
import {
|
import {
|
||||||
ARROW_HALF_SIZE,
|
ARROW_HALF_SIZE,
|
||||||
ARROW_SIZE,
|
ARROW_SIZE,
|
||||||
BUBBLE_MAX_WIDTH,
|
BUBBLE_MAX_WIDTH,
|
||||||
|
getTooltipStyle,
|
||||||
MIN_EDGE_SPACE,
|
MIN_EDGE_SPACE,
|
||||||
|
type TooltipColor,
|
||||||
} from '#/components/Tooltip/const'
|
} from '#/components/Tooltip/const'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
@@ -56,10 +57,10 @@ SheetCompatProvider.displayName = 'TooltipSheetCompatProvider'
|
|||||||
* These are native specific values, not shared with web
|
* These are native specific values, not shared with web
|
||||||
*/
|
*/
|
||||||
const ARROW_VISUAL_OFFSET = ARROW_SIZE / 1.25 // vibes-based, slightly off the target
|
const ARROW_VISUAL_OFFSET = ARROW_SIZE / 1.25 // vibes-based, slightly off the target
|
||||||
const BUBBLE_SHADOW_OFFSET = ARROW_SIZE / 3 // vibes-based, provide more shadow beneath tip
|
|
||||||
|
|
||||||
type TooltipContextType = {
|
type TooltipContextType = {
|
||||||
position: 'top' | 'bottom'
|
position: 'top' | 'bottom'
|
||||||
|
color: TooltipColor
|
||||||
visible: boolean
|
visible: boolean
|
||||||
onVisibleChange: (visible: boolean) => void
|
onVisibleChange: (visible: boolean) => void
|
||||||
}
|
}
|
||||||
@@ -79,6 +80,7 @@ type TargetContextType = {
|
|||||||
|
|
||||||
const TooltipContext = createContext<TooltipContextType>({
|
const TooltipContext = createContext<TooltipContextType>({
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
|
color: 'default',
|
||||||
visible: false,
|
visible: false,
|
||||||
onVisibleChange: () => {},
|
onVisibleChange: () => {},
|
||||||
})
|
})
|
||||||
@@ -94,11 +96,13 @@ TargetContext.displayName = 'TargetContext'
|
|||||||
export function Outer({
|
export function Outer({
|
||||||
children,
|
children,
|
||||||
position = 'bottom',
|
position = 'bottom',
|
||||||
|
color = 'default',
|
||||||
visible: requestVisible,
|
visible: requestVisible,
|
||||||
onVisibleChange,
|
onVisibleChange,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
position?: 'top' | 'bottom'
|
position?: 'top' | 'bottom'
|
||||||
|
color?: TooltipColor
|
||||||
visible: boolean
|
visible: boolean
|
||||||
onVisibleChange: (visible: boolean) => void
|
onVisibleChange: (visible: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -126,8 +130,8 @@ export function Outer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ctx = useMemo(
|
const ctx = useMemo(
|
||||||
() => ({position, visible, onVisibleChange}),
|
() => ({position, color, visible, onVisibleChange}),
|
||||||
[position, visible, onVisibleChange],
|
[position, color, visible, onVisibleChange],
|
||||||
)
|
)
|
||||||
const targetCtx = useMemo(
|
const targetCtx = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -149,13 +153,13 @@ export function Outer({
|
|||||||
|
|
||||||
export function Target({children}: {children: React.ReactNode}) {
|
export function Target({children}: {children: React.ReactNode}) {
|
||||||
const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
|
const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
|
||||||
const [hasLayedOut, setHasLayedOut] = useState(false)
|
const [hasLaidOut, setHasLaidOut] = useState(false)
|
||||||
const targetRef = useRef<View>(null)
|
const targetRef = useRef<View>(null)
|
||||||
const containerRef = useContext(TooltipProviderContext)
|
const containerRef = useContext(TooltipProviderContext)
|
||||||
const keyboardIsOpen = useIsKeyboardVisible()
|
const keyboardIsOpen = useIsKeyboardVisible()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldMeasure || !hasLayedOut) return
|
if (!shouldMeasure || !hasLaidOut) return
|
||||||
/*
|
/*
|
||||||
* Once opened, measure the dimensions and position of the target
|
* Once opened, measure the dimensions and position of the target
|
||||||
*/
|
*/
|
||||||
@@ -179,7 +183,7 @@ export function Target({children}: {children: React.ReactNode}) {
|
|||||||
}, [
|
}, [
|
||||||
shouldMeasure,
|
shouldMeasure,
|
||||||
setTargetMeasurements,
|
setTargetMeasurements,
|
||||||
hasLayedOut,
|
hasLaidOut,
|
||||||
containerRef,
|
containerRef,
|
||||||
keyboardIsOpen,
|
keyboardIsOpen,
|
||||||
])
|
])
|
||||||
@@ -188,7 +192,7 @@ export function Target({children}: {children: React.ReactNode}) {
|
|||||||
<View
|
<View
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
ref={targetRef}
|
ref={targetRef}
|
||||||
onLayout={() => setHasLayedOut(true)}>
|
onLayout={() => setHasLaidOut(true)}>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
@@ -201,7 +205,7 @@ export function Content({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
label: string
|
label: string
|
||||||
}) {
|
}) {
|
||||||
const {position, visible, onVisibleChange} = useContext(TooltipContext)
|
const {position, color, visible, onVisibleChange} = useContext(TooltipContext)
|
||||||
const {targetMeasurements} = useContext(TargetContext)
|
const {targetMeasurements} = useContext(TargetContext)
|
||||||
const isWithinProvider = !!useContext(TooltipProviderContext)
|
const isWithinProvider = !!useContext(TooltipProviderContext)
|
||||||
const requestClose = useCallback(() => {
|
const requestClose = useCallback(() => {
|
||||||
@@ -217,8 +221,9 @@ export function Content({
|
|||||||
<Bubble
|
<Bubble
|
||||||
label={label}
|
label={label}
|
||||||
position={position}
|
position={position}
|
||||||
|
color={color}
|
||||||
/*
|
/*
|
||||||
* Gotta pass these in here. Inside the Bubble, we're Potal-ed outside
|
* Gotta pass these in here. Inside the Bubble, we're Portal-ed outside
|
||||||
* the context providers.
|
* the context providers.
|
||||||
*/
|
*/
|
||||||
targetMeasurements={targetMeasurements}
|
targetMeasurements={targetMeasurements}
|
||||||
@@ -233,12 +238,14 @@ function Bubble({
|
|||||||
children,
|
children,
|
||||||
label,
|
label,
|
||||||
position,
|
position,
|
||||||
|
color,
|
||||||
requestClose,
|
requestClose,
|
||||||
targetMeasurements,
|
targetMeasurements,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
label: string
|
label: string
|
||||||
position: TooltipContextType['position']
|
position: TooltipContextType['position']
|
||||||
|
color: TooltipColor
|
||||||
requestClose: () => void
|
requestClose: () => void
|
||||||
targetMeasurements: Exclude<
|
targetMeasurements: Exclude<
|
||||||
TargetContextType['targetMeasurements'],
|
TargetContextType['targetMeasurements'],
|
||||||
@@ -246,6 +253,7 @@ function Bubble({
|
|||||||
>
|
>
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const style = getTooltipStyle(t, color)
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const dimensions = useWindowDimensions()
|
const dimensions = useWindowDimensions()
|
||||||
const [bubbleMeasurements, setBubbleMeasurements] = useState<
|
const [bubbleMeasurements, setBubbleMeasurements] = useState<
|
||||||
@@ -383,21 +391,19 @@ function Bubble({
|
|||||||
]}>
|
]}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={ZoomIn.easing(Easing.out(Easing.exp))}
|
entering={ZoomIn.easing(Easing.out(Easing.exp))}
|
||||||
style={{transformOrigin: oppposite(position)}}>
|
style={{transformOrigin: opposite(position)}}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.absolute,
|
a.absolute,
|
||||||
a.top_0,
|
a.top_0,
|
||||||
a.z_10,
|
a.z_10,
|
||||||
t.atoms.bg,
|
|
||||||
select(t.name, {
|
|
||||||
light: t.atoms.bg,
|
|
||||||
dark: t.atoms.bg_contrast_100,
|
|
||||||
dim: t.atoms.bg_contrast_100,
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
|
backgroundColor: style.surface,
|
||||||
borderTopLeftRadius: a.rounded_2xs.borderRadius,
|
borderTopLeftRadius: a.rounded_2xs.borderRadius,
|
||||||
borderBottomRightRadius: a.rounded_2xs.borderRadius,
|
borderBottomRightRadius: a.rounded_2xs.borderRadius,
|
||||||
|
borderColor: style.border.color,
|
||||||
|
borderTopWidth: style.border.width,
|
||||||
|
borderLeftWidth: style.border.width,
|
||||||
width: ARROW_SIZE,
|
width: ARROW_SIZE,
|
||||||
height: ARROW_SIZE,
|
height: ARROW_SIZE,
|
||||||
transform: [{rotate: '45deg'}],
|
transform: [{rotate: '45deg'}],
|
||||||
@@ -410,21 +416,12 @@ function Bubble({
|
|||||||
style={[
|
style={[
|
||||||
a.px_md,
|
a.px_md,
|
||||||
a.py_sm,
|
a.py_sm,
|
||||||
a.rounded_sm,
|
a.rounded_md,
|
||||||
select(t.name, {
|
t.atoms.shadow_xs,
|
||||||
light: t.atoms.bg,
|
|
||||||
dark: t.atoms.bg_contrast_100,
|
|
||||||
dim: t.atoms.bg_contrast_100,
|
|
||||||
}),
|
|
||||||
t.atoms.shadow_md,
|
|
||||||
{
|
{
|
||||||
shadowOpacity: 0.2,
|
backgroundColor: style.surface,
|
||||||
shadowOffset: {
|
borderColor: style.border.color,
|
||||||
width: 0,
|
borderWidth: style.border.width,
|
||||||
height:
|
|
||||||
BUBBLE_SHADOW_OFFSET *
|
|
||||||
(coords.computedPosition === 'bottom' ? -1 : 1),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onLayout={e => {
|
onLayout={e => {
|
||||||
@@ -440,7 +437,7 @@ function Bubble({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function oppposite(position: 'top' | 'bottom') {
|
function opposite(position: 'top' | 'bottom') {
|
||||||
switch (position) {
|
switch (position) {
|
||||||
case 'top':
|
case 'top':
|
||||||
return 'center bottom'
|
return 'center bottom'
|
||||||
@@ -451,16 +448,23 @@ function oppposite(position: 'top' | 'bottom') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextBubble({children}: {children: React.ReactNode}) {
|
export function BubbleText({
|
||||||
const c = Children.toArray(children)
|
children,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {color} = useContext(TooltipContext)
|
||||||
|
const style = getTooltipStyle(t, color)
|
||||||
|
// eslint-disable-next-line bsky-internal/avoid-unwrapped-text
|
||||||
return (
|
return (
|
||||||
<Content label={c.join(' ')}>
|
<Content label={label}>
|
||||||
<View style={[a.gap_xs]}>
|
<View style={[a.gap_xs]}>
|
||||||
{c.map((child, i) => (
|
<Text style={[a.text_sm, a.leading_snug, {color: style.text}]}>
|
||||||
<Text key={i} style={[a.text_sm, a.leading_snug]}>
|
{children}
|
||||||
{child}
|
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
</Content>
|
</Content>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import {Children, createContext, useContext, useMemo} from 'react'
|
import {createContext, useContext, useMemo} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {utils} from '@bsky.app/alf'
|
import {utils} from '@bsky.app/alf'
|
||||||
import {Popover} from 'radix-ui'
|
import {Popover} from 'radix-ui'
|
||||||
|
|
||||||
import {atoms as a, flatten, select, useTheme} from '#/alf'
|
import {atoms as a, flatten, useTheme} from '#/alf'
|
||||||
import {
|
import {
|
||||||
ARROW_SIZE,
|
ARROW_SIZE,
|
||||||
BUBBLE_MAX_WIDTH,
|
BUBBLE_MAX_WIDTH,
|
||||||
|
getTooltipStyle,
|
||||||
MIN_EDGE_SPACE,
|
MIN_EDGE_SPACE,
|
||||||
|
type TooltipColor,
|
||||||
} from '#/components/Tooltip/const'
|
} from '#/components/Tooltip/const'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
@@ -19,26 +21,32 @@ Provider.displayName = 'TooltipProvider'
|
|||||||
|
|
||||||
type TooltipContextType = {
|
type TooltipContextType = {
|
||||||
position: 'top' | 'bottom'
|
position: 'top' | 'bottom'
|
||||||
|
color: TooltipColor
|
||||||
onVisibleChange: (open: boolean) => void
|
onVisibleChange: (open: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const TooltipContext = createContext<Pick<TooltipContextType, 'position'>>({
|
const TooltipContext = createContext<
|
||||||
|
Pick<TooltipContextType, 'position' | 'color'>
|
||||||
|
>({
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
|
color: 'default',
|
||||||
})
|
})
|
||||||
TooltipContext.displayName = 'TooltipContext'
|
TooltipContext.displayName = 'TooltipContext'
|
||||||
|
|
||||||
export function Outer({
|
export function Outer({
|
||||||
children,
|
children,
|
||||||
position = 'bottom',
|
position = 'bottom',
|
||||||
|
color = 'default',
|
||||||
visible,
|
visible,
|
||||||
onVisibleChange,
|
onVisibleChange,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
position?: 'top' | 'bottom'
|
position?: 'top' | 'bottom'
|
||||||
|
color?: TooltipColor
|
||||||
visible: boolean
|
visible: boolean
|
||||||
onVisibleChange: (visible: boolean) => void
|
onVisibleChange: (visible: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
const ctx = useMemo(() => ({position}), [position])
|
const ctx = useMemo(() => ({position, color}), [position, color])
|
||||||
return (
|
return (
|
||||||
<Popover.Root open={visible} onOpenChange={onVisibleChange}>
|
<Popover.Root open={visible} onOpenChange={onVisibleChange}>
|
||||||
<TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider>
|
<TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider>
|
||||||
@@ -62,7 +70,8 @@ export function Content({
|
|||||||
label: string
|
label: string
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {position} = useContext(TooltipContext)
|
const {position, color} = useContext(TooltipContext)
|
||||||
|
const style = getTooltipStyle(t, color)
|
||||||
return (
|
return (
|
||||||
<Popover.Portal>
|
<Popover.Portal>
|
||||||
<Popover.Content
|
<Popover.Content
|
||||||
@@ -78,28 +87,19 @@ export function Content({
|
|||||||
}}
|
}}
|
||||||
style={flatten([
|
style={flatten([
|
||||||
a.rounded_sm,
|
a.rounded_sm,
|
||||||
select(t.name, {
|
|
||||||
light: t.atoms.bg,
|
|
||||||
dark: t.atoms.bg_contrast_100,
|
|
||||||
dim: t.atoms.bg_contrast_100,
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
|
backgroundColor: style.surface,
|
||||||
|
borderColor: style.border.color,
|
||||||
|
borderWidth: style.border.width,
|
||||||
|
borderStyle: 'solid',
|
||||||
minWidth: 'max-content',
|
minWidth: 'max-content',
|
||||||
boxShadow: select(t.name, {
|
boxShadow: `0 0 24px ${utils.alpha(t.palette.black, 0.2)}`,
|
||||||
light: `0 0 24px ${utils.alpha(t.palette.black, 0.2)}`,
|
|
||||||
dark: `0 0 24px ${utils.alpha(t.palette.black, 0.2)}`,
|
|
||||||
dim: `0 0 24px ${utils.alpha(t.palette.black, 0.2)}`,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
])}>
|
])}>
|
||||||
<Popover.Arrow
|
<Popover.Arrow
|
||||||
width={ARROW_SIZE}
|
width={ARROW_SIZE}
|
||||||
height={ARROW_SIZE / 2}
|
height={ARROW_SIZE / 2}
|
||||||
fill={select(t.name, {
|
fill={style.surface}
|
||||||
light: t.atoms.bg.backgroundColor,
|
|
||||||
dark: t.atoms.bg_contrast_100.backgroundColor,
|
|
||||||
dim: t.atoms.bg_contrast_100.backgroundColor,
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
<View style={[a.px_md, a.py_sm, {maxWidth: BUBBLE_MAX_WIDTH}]}>
|
<View style={[a.px_md, a.py_sm, {maxWidth: BUBBLE_MAX_WIDTH}]}>
|
||||||
{children}
|
{children}
|
||||||
@@ -109,16 +109,23 @@ export function Content({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextBubble({children}: {children: React.ReactNode}) {
|
export function BubbleText({
|
||||||
const c = Children.toArray(children)
|
children,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {color} = useContext(TooltipContext)
|
||||||
|
const style = getTooltipStyle(t, color)
|
||||||
|
// eslint-disable-next-line bsky-internal/avoid-unwrapped-text
|
||||||
return (
|
return (
|
||||||
<Content label={c.join(' ')}>
|
<Content label={label}>
|
||||||
<View style={[a.gap_xs]}>
|
<View style={[a.gap_xs]}>
|
||||||
{c.map((child, i) => (
|
<Text style={[a.text_sm, a.leading_snug, {color: style.text}]}>
|
||||||
<Text key={i} style={[a.text_sm, a.leading_snug]}>
|
{children}
|
||||||
{child}
|
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
</Content>
|
</Content>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
|||||||
import {Logo} from '#/view/icons/Logo'
|
import {Logo} from '#/view/icons/Logo'
|
||||||
import {atoms as a, flatten, useBreakpoints, web} from '#/alf'
|
import {atoms as a, flatten, useBreakpoints, web} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import {type WelcomeModalControl} from '#/components/hooks/useWelcomeModal.shared'
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
@@ -17,11 +18,7 @@ import {useAnalytics} from '#/analytics'
|
|||||||
const welcomeModalBg = require('../../assets/images/welcome-modal-bg.jpg')
|
const welcomeModalBg = require('../../assets/images/welcome-modal-bg.jpg')
|
||||||
|
|
||||||
interface WelcomeModalProps {
|
interface WelcomeModalProps {
|
||||||
control: {
|
control: WelcomeModalControl
|
||||||
isOpen: boolean
|
|
||||||
open: () => void
|
|
||||||
close: () => void
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WelcomeModal({control}: WelcomeModalProps) {
|
export function WelcomeModal({control}: WelcomeModalProps) {
|
||||||
@@ -107,7 +104,7 @@ export function WelcomeModal({control}: WelcomeModalProps) {
|
|||||||
a.p_0,
|
a.p_0,
|
||||||
]}>
|
]}>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
<Logo width={26} />
|
<Logo allowVariants={false} width={26} />
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.text_2xl,
|
a.text_2xl,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import {useCallback, useEffect, useState} from 'react'
|
import {useCallback, useEffect, useState} from 'react'
|
||||||
import {type ModerationOpts} from '@atproto/api'
|
import {type ModerationOpts} from '@atproto/api'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {Trans} from '@lingui/react/macro'
|
|
||||||
|
|
||||||
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
|
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
|
||||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||||
@@ -11,7 +9,6 @@ import {useDialogControl} from '#/components/Dialog'
|
|||||||
import {BellPlus_Stroke2_Corner0_Rounded as BellPlusIcon} from '#/components/icons/BellPlus'
|
import {BellPlus_Stroke2_Corner0_Rounded as BellPlusIcon} from '#/components/icons/BellPlus'
|
||||||
import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
|
import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
|
||||||
import * as Tooltip from '#/components/Tooltip'
|
import * as Tooltip from '#/components/Tooltip'
|
||||||
import {Text} from '#/components/Typography'
|
|
||||||
import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
|
import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
|
||||||
import type * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
import {SubscribeProfileDialog} from './SubscribeProfileDialog'
|
import {SubscribeProfileDialog} from './SubscribeProfileDialog'
|
||||||
@@ -25,7 +22,7 @@ export function SubscribeProfileButton({
|
|||||||
moderationOpts: ModerationOpts
|
moderationOpts: ModerationOpts
|
||||||
disableHint?: boolean
|
disableHint?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const requireEmailVerification = useRequireEmailVerification()
|
const requireEmailVerification = useRequireEmailVerification()
|
||||||
const subscribeDialogControl = useDialogControl()
|
const subscribeDialogControl = useDialogControl()
|
||||||
const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] =
|
const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] =
|
||||||
@@ -84,18 +81,15 @@ export function SubscribeProfileButton({
|
|||||||
size="small"
|
size="small"
|
||||||
color={tooltipVisible ? 'primary_subtle' : 'secondary'}
|
color={tooltipVisible ? 'primary_subtle' : 'secondary'}
|
||||||
shape="round"
|
shape="round"
|
||||||
label={_(msg`Get notified when ${name} posts`)}
|
label={l`Get notified when ${name} posts`}
|
||||||
onPress={wrappedOnPress}>
|
onPress={wrappedOnPress}>
|
||||||
<ButtonIcon icon={Icon} size="md" />
|
<ButtonIcon icon={Icon} size="md" />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip.Target>
|
</Tooltip.Target>
|
||||||
<Tooltip.TextBubble>
|
<Tooltip.BubbleText label={l`Get notified about new posts`}>
|
||||||
<Text>
|
|
||||||
<Trans>Get notified about new posts</Trans>
|
<Trans>Get notified about new posts</Trans>
|
||||||
</Text>
|
</Tooltip.BubbleText>
|
||||||
</Tooltip.TextBubble>
|
|
||||||
</Tooltip.Outer>
|
</Tooltip.Outer>
|
||||||
|
|
||||||
<SubscribeProfileDialog
|
<SubscribeProfileDialog
|
||||||
control={subscribeDialogControl}
|
control={subscribeDialogControl}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
|
|||||||
@@ -296,6 +296,7 @@ function Inner() {
|
|||||||
setLanguageError(false)
|
setLanguageError(false)
|
||||||
}}
|
}}
|
||||||
items={KWS_SUPPORTED_LANGS}
|
items={KWS_SUPPORTED_LANGS}
|
||||||
|
disabledBlueskySupportedLanguageSanitization
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{languageError && (
|
{languageError && (
|
||||||
@@ -342,11 +343,12 @@ function convertToKWSSupportedLanguage(
|
|||||||
// pt-PT is pt (pt-BR is supported independently)
|
// pt-PT is pt (pt-BR is supported independently)
|
||||||
case 'pt-PT':
|
case 'pt-PT':
|
||||||
return 'pt'
|
return 'pt'
|
||||||
// only chinese (simplified) is supported, map all chinese variants
|
// map Chinese variants to simplified or traditional
|
||||||
case 'zh-Hans-CN':
|
case 'zh-Hans-CN':
|
||||||
|
return 'zh-Hans'
|
||||||
case 'zh-Hant-HK':
|
case 'zh-Hant-HK':
|
||||||
case 'zh-Hant-TW':
|
case 'zh-Hant-TW':
|
||||||
return 'zh-Hans'
|
return 'zh-Hant'
|
||||||
default:
|
default:
|
||||||
// try and map directly - if undefined, they will have to pick from the dropdown
|
// try and map directly - if undefined, they will have to pick from the dropdown
|
||||||
return KWS_SUPPORTED_LANGS.find(v => v.value === appLanguage)?.value
|
return KWS_SUPPORTED_LANGS.find(v => v.value === appLanguage)?.value
|
||||||
|
|||||||
@@ -7,21 +7,36 @@ export const urls = {
|
|||||||
export const KWS_SUPPORTED_LANGS = [
|
export const KWS_SUPPORTED_LANGS = [
|
||||||
{value: 'en', label: 'English'},
|
{value: 'en', label: 'English'},
|
||||||
{value: 'ar', label: 'العربية'},
|
{value: 'ar', label: 'العربية'},
|
||||||
{value: 'zh-Hans', label: '简体中文'},
|
{value: 'bg', label: 'български'},
|
||||||
{value: 'cs', label: 'Čeština'},
|
{value: 'cs', label: 'čeština'},
|
||||||
{value: 'nl', label: 'Nederlands'},
|
{value: 'da', label: 'dansk'},
|
||||||
{value: 'tl', label: 'Filipino'},
|
|
||||||
{value: 'fr', label: 'Français'},
|
|
||||||
{value: 'de', label: 'Deutsch'},
|
{value: 'de', label: 'Deutsch'},
|
||||||
|
{value: 'el', label: 'Ελληνικά'},
|
||||||
|
{value: 'es', label: 'español'},
|
||||||
|
{value: 'es-MX', label: 'español de México'},
|
||||||
|
{value: 'fi', label: 'suomi'},
|
||||||
|
{value: 'fil', label: 'Filipino'},
|
||||||
|
{value: 'fr', label: 'français'},
|
||||||
|
{value: 'hi', label: 'हिंदी'},
|
||||||
|
{value: 'hu', label: 'magyar'},
|
||||||
{value: 'id', label: 'Bahasa Indonesia'},
|
{value: 'id', label: 'Bahasa Indonesia'},
|
||||||
{value: 'it', label: 'Italiano'},
|
{value: 'it', label: 'italiano'},
|
||||||
{value: 'ja', label: '日本語'},
|
{value: 'ja', label: '日本語'},
|
||||||
{value: 'ko', label: '한국어'},
|
{value: 'ko', label: '한국어'},
|
||||||
{value: 'pt', label: 'Português'},
|
{value: 'ms', label: 'Bahasa Melayu'},
|
||||||
{value: 'pt-BR', label: 'Português (Brasil)'},
|
{value: 'nl', label: 'Nederlands'},
|
||||||
{value: 'ru', label: 'Русский'},
|
{value: 'no', label: 'norsk'},
|
||||||
{value: 'es', label: 'Español'},
|
{value: 'pl', label: 'polski'},
|
||||||
{value: 'tr', label: 'Türkçe'},
|
{value: 'pt', label: 'português'},
|
||||||
|
{value: 'pt-BR', label: 'português do Brasil'},
|
||||||
|
{value: 'ro', label: 'română'},
|
||||||
|
{value: 'ru', label: 'русский'},
|
||||||
|
{value: 'sv', label: 'svenska'},
|
||||||
{value: 'th', label: 'ภาษาไทย'},
|
{value: 'th', label: 'ภาษาไทย'},
|
||||||
|
{value: 'tl', label: 'Tagalog'},
|
||||||
|
{value: 'tr', label: 'Türkçe'},
|
||||||
|
{value: 'uk', label: 'українська'},
|
||||||
{value: 'vi', label: 'Tiếng Việt'},
|
{value: 'vi', label: 'Tiếng Việt'},
|
||||||
|
{value: 'zh-Hans', label: '简体中文'},
|
||||||
|
{value: 'zh-Hant', label: '繁體中文'},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
export function FindContactsFlow() {
|
import {type Action, type State} from './state'
|
||||||
|
|
||||||
|
export function FindContactsFlow(_props: {
|
||||||
|
state: State
|
||||||
|
dispatch: React.ActionDispatch<[Action]>
|
||||||
|
onBack?: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
context: 'Onboarding' | 'Standalone'
|
||||||
|
}): never {
|
||||||
throw new Error('FindContactsFlow is not available on web')
|
throw new Error('FindContactsFlow is not available on web')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function SigninDialogInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
|||||||
a.gap_sm,
|
a.gap_sm,
|
||||||
a.pb_lg,
|
a.pb_lg,
|
||||||
]}>
|
]}>
|
||||||
<Logo width={36} />
|
<Logo allowVariants={false} width={36} />
|
||||||
<View style={{paddingTop: 6}}>
|
<View style={{paddingTop: 6}}>
|
||||||
<Logotype width={120} fill={t.atoms.text.color} />
|
<Logotype width={120} fill={t.atoms.text.color} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -246,6 +246,8 @@ let MessageItem = ({
|
|||||||
|
|
||||||
const rt = new RichTextAPI({text: message.text, facets: message.facets})
|
const rt = new RichTextAPI({text: message.text, facets: message.facets})
|
||||||
|
|
||||||
|
const isEmojiOnly = isOnlyEmoji(message.text)
|
||||||
|
|
||||||
const hasEmbed =
|
const hasEmbed =
|
||||||
AppBskyEmbedRecord.isView(message.embed) ||
|
AppBskyEmbedRecord.isView(message.embed) ||
|
||||||
ChatBskyEmbedJoinLink.isView(message.embed)
|
ChatBskyEmbedJoinLink.isView(message.embed)
|
||||||
@@ -261,16 +263,33 @@ let MessageItem = ({
|
|||||||
const topRadiusSV = useSharedValue(targetTopRadius)
|
const topRadiusSV = useSharedValue(targetTopRadius)
|
||||||
|
|
||||||
const showDisplayName =
|
const showDisplayName =
|
||||||
isGroupChat && !isFromSelf && isFirstInCluster && !isOnlyEmoji(message.text)
|
isGroupChat && !isFromSelf && isFirstInCluster && !isEmojiOnly
|
||||||
const showAvatar = isGroupChat && !isFromSelf && isLastInCluster
|
const showAvatar = isGroupChat && !isFromSelf && isLastInCluster
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Emoji-only messages have no bubble background (see the `!isOnlyEmoji` gate
|
||||||
|
* on the bubble styling below), so the corner-radius animation is invisible
|
||||||
|
* overhead for them. Worse, on Android the resulting re-layout of the parent
|
||||||
|
* Animated.View re-measures the enlarged emoji `<Text>` and some Android
|
||||||
|
* device's text stack drops the trailing glyph on that second pass (while
|
||||||
|
* keeping its reserved width). Set the radii directly for emoji-only messages
|
||||||
|
* so nothing re-lays-out the glyph after its initial paint.
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRadiusSV.set(withTiming(targetBottomRadius, {duration: 300}))
|
bottomRadiusSV.set(
|
||||||
}, [targetBottomRadius, bottomRadiusSV])
|
isEmojiOnly
|
||||||
|
? targetBottomRadius
|
||||||
|
: withTiming(targetBottomRadius, {duration: 300}),
|
||||||
|
)
|
||||||
|
}, [targetBottomRadius, bottomRadiusSV, isEmojiOnly])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
topRadiusSV.set(withTiming(targetTopRadius, {duration: 300}))
|
topRadiusSV.set(
|
||||||
}, [targetTopRadius, topRadiusSV])
|
isEmojiOnly
|
||||||
|
? targetTopRadius
|
||||||
|
: withTiming(targetTopRadius, {duration: 300}),
|
||||||
|
)
|
||||||
|
}, [targetTopRadius, topRadiusSV, isEmojiOnly])
|
||||||
|
|
||||||
// Flash the message background when it's been scrolled to (e.g. by tapping a
|
// Flash the message background when it's been scrolled to (e.g. by tapping a
|
||||||
// reply that quotes it), so it's easy to spot. Keyed on the highlight `key`
|
// reply that quotes it), so it's easy to spot. Keyed on the highlight `key`
|
||||||
@@ -521,7 +540,7 @@ let MessageItem = ({
|
|||||||
accessibilityHint={l`Double tap or long press the message to add a reaction`}
|
accessibilityHint={l`Double tap or long press the message to add a reaction`}
|
||||||
style={[
|
style={[
|
||||||
!isFromSelf && isGroupChat && a.ml_sm,
|
!isFromSelf && isGroupChat && a.ml_sm,
|
||||||
!isOnlyEmoji(message.text) && [
|
!isEmojiOnly && [
|
||||||
a.rounded_xl,
|
a.rounded_xl,
|
||||||
a.py_sm,
|
a.py_sm,
|
||||||
a.px_md,
|
a.px_md,
|
||||||
@@ -536,7 +555,7 @@ let MessageItem = ({
|
|||||||
highlightStyle,
|
highlightStyle,
|
||||||
],
|
],
|
||||||
]}>
|
]}>
|
||||||
{replyTo && !isOnlyEmoji(message.text) ? (
|
{replyTo && !isEmojiOnly ? (
|
||||||
<ReplyQuote
|
<ReplyQuote
|
||||||
replyTo={replyTo}
|
replyTo={replyTo}
|
||||||
isFromSelf={isFromSelf}
|
isFromSelf={isFromSelf}
|
||||||
@@ -553,7 +572,7 @@ let MessageItem = ({
|
|||||||
// glyph, then pull the bottom up by the same amount so
|
// glyph, then pull the bottom up by the same amount so
|
||||||
// the glyph bottom-aligns with the avatar instead of
|
// the glyph bottom-aligns with the avatar instead of
|
||||||
// sitting above its line-box baseline.
|
// sitting above its line-box baseline.
|
||||||
isOnlyEmoji(message.text) && [
|
isEmojiOnly && [
|
||||||
a.leading_tight,
|
a.leading_tight,
|
||||||
// Visually align bottom of the emoji with the avatar
|
// Visually align bottom of the emoji with the avatar
|
||||||
!isFromSelf &&
|
!isFromSelf &&
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export function useWelcomeModal() {
|
import {type WelcomeModalControl} from './useWelcomeModal.shared'
|
||||||
|
|
||||||
|
export function useWelcomeModal(): WelcomeModalControl {
|
||||||
throw new Error('useWelcomeModal is web only')
|
throw new Error('useWelcomeModal is web only')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type WelcomeModalControl = {
|
||||||
|
isOpen: boolean
|
||||||
|
open: () => void
|
||||||
|
close: () => void
|
||||||
|
}
|
||||||
@@ -2,8 +2,9 @@ import {useEffect, useState} from 'react'
|
|||||||
|
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {IS_WEB} from '#/env'
|
import {IS_WEB} from '#/env'
|
||||||
|
import {type WelcomeModalControl} from './useWelcomeModal.shared'
|
||||||
|
|
||||||
export function useWelcomeModal() {
|
export function useWelcomeModal(): WelcomeModalControl {
|
||||||
const {hasSession} = useSession()
|
const {hasSession} = useSession()
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
|||||||
@@ -485,10 +485,18 @@ function GalleryImage({
|
|||||||
loading={index === 0 ? 'eager' : 'lazy'}
|
loading={index === 0 ? 'eager' : 'lazy'}
|
||||||
style={[dims]}
|
style={[dims]}
|
||||||
onLoad={e => {
|
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)
|
const ar = getAspectRatio(e.source)
|
||||||
if (ar && ar !== aspectRatio) {
|
if (ar !== undefined) {
|
||||||
setAspectRatio(ar)
|
setAspectRatio(ar)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
onThumbDims(index, {
|
onThumbDims(index, {
|
||||||
width: e.source.width,
|
width: e.source.width,
|
||||||
height: e.source.height,
|
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}} />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||