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

This commit is contained in:
vineyardbovines
2026-07-20 09:38:24 -04:00
279 changed files with 42632 additions and 34435 deletions
@@ -25,10 +25,10 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
@@ -36,7 +36,7 @@ jobs:
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -51,7 +51,7 @@ jobs:
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
@@ -26,10 +26,10 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME }}
@@ -37,7 +37,7 @@ jobs:
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -46,7 +46,7 @@ jobs:
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
@@ -25,10 +25,10 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
@@ -36,7 +36,7 @@ jobs:
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -45,7 +45,7 @@ jobs:
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
@@ -25,10 +25,10 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
@@ -36,7 +36,7 @@ jobs:
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -45,7 +45,7 @@ jobs:
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: true
@@ -25,10 +25,10 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
@@ -36,7 +36,7 @@ jobs:
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -45,7 +45,7 @@ jobs:
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: true
+1 -1
View File
@@ -114,7 +114,7 @@ jobs:
- name: ☕️ Assert Cocoapods version
run: |
EXPECTED=1.16.2
EXPECTED=1.17.0
ACTUAL=$(pod --version)
if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
@@ -232,7 +232,7 @@ jobs:
- name: ☕️ Assert Cocoapods version
run: |
EXPECTED=1.16.2
EXPECTED=1.17.0
ACTUAL=$(pod --version)
if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
+1 -1
View File
@@ -65,7 +65,7 @@ jobs:
aws-region: us-east-2
- name: Claude
uses: anthropics/claude-code-action@4633baf5267540f3f8cb58b684f79901d564c280 # v1.0.160
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1.0.166
with:
use_bedrock: 'true'
additional_permissions: |
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
aws-region: us-east-2
- name: Claude review
uses: anthropics/claude-code-action@4633baf5267540f3f8cb58b684f79901d564c280 # v1.0.160
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1.0.166
with:
use_bedrock: 'true'
additional_permissions: |
+2 -1
View File
@@ -21,7 +21,8 @@ jobs:
strategy:
fail-fast: false
matrix:
job: [lint, prettier, typecheck]
job:
[lint, prettier, 'typecheck:ios', 'typecheck:android', 'typecheck:web']
steps:
- name: Check out Git repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+3 -3
View File
@@ -106,7 +106,7 @@ jobs:
core.setOutput('head-ref', pr.data.head.ref);
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }}
@@ -193,7 +193,7 @@ jobs:
RUNTIME_VERSION:
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
with:
@@ -210,7 +210,7 @@ jobs:
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
if: failure()
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
+51 -10
View File
@@ -19,8 +19,47 @@ concurrency:
permissions: {}
jobs:
# Populate this from main so every PR can restore the same trusted baseline.
webpack-analyzer-base:
runs-on: ubuntu-24.04
if: ${{ github.event_name == 'push' }}
permissions:
contents: read
steps:
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
- name: ⬇️ Get base stats from cache
id: get-base-stats
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: stats.json
key: stats-base-main-${{ github.sha }}
- name: 🔦 Generate stats file for base commit
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
run: |
pnpm install
pnpm intl:build
pnpm generate-webpack-stats-file
- name: ⬆️ Save base stats to cache
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: stats.json
key: stats-base-main-${{ github.sha }}
webpack-analyzer:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
permissions:
contents: read
@@ -71,15 +110,18 @@ jobs:
- name: ⬇️ Get base stats from cache
id: get-base-stats
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
# Restore-only prevents PR-scoped fallback builds from creating caches.
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: stats-base.json
key: stats-base-${{ steps.base-commit.outputs.base-commit }}
path: stats.json
key: stats-base-main-${{ steps.base-commit.outputs.base-commit }}
- name: Restore to base commit
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
env:
BASE_COMMIT: ${{ steps.base-commit.outputs.base-commit }}
run: |
git reset HEAD~
git reset "$BASE_COMMIT"
git restore .
- name: 🔦 Generate stats file from base commit
@@ -88,18 +130,17 @@ jobs:
pnpm install
pnpm intl:build
pnpm generate-webpack-stats-file
mv stats.json stats-base.json
- name: Get diff
id: get-diff
uses: NejcZdovc/bundle-size-diff@5321de41d2d62a7b0f4d6e60f59d1280a0034160 # v1.1.0
with:
base_path: "stats-base.json"
base_path: "stats.json"
pr_path: "../stats-new.json"
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
with:
header: bundle-diff
message: |
@@ -140,7 +181,7 @@ jobs:
profile: pull-request
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
if: ${{ steps.fingerprint.outputs.includes-changes }}
with:
header: fingerprint-diff
@@ -158,7 +199,7 @@ jobs:
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Delete comment
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
if: ${{ !steps.fingerprint.outputs.includes-changes }}
with:
header: fingerprint-diff
+2 -2
View File
@@ -108,8 +108,8 @@ google-services.json
# Performance results (Flashlight)
.perf/
# ESLint
.eslintcache
# Oxlint
.oxlintcache
# i18n
src/locale/locales/_build/
+407
View File
@@ -0,0 +1,407 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"typescript",
"react",
"import"
],
"categories": {
"correctness": "off"
},
"options": {
"typeAware": true
},
"env": {
"builtin": true
},
"settings": {
"import-x/extensions": [
".ts",
".tsx",
".cts",
".mts",
".js",
".jsx",
".cjs",
".mjs"
],
"import-x/external-module-folders": [
"node_modules",
"node_modules/@types"
],
"import-x/parsers": {
"@typescript-eslint/parser": [
".ts",
".tsx",
".cts",
".mts"
]
},
"import-x/resolver": {
"node": {
"extensions": [
".js",
".web.js",
".ios.js",
".android.js"
]
}
}
},
"ignorePatterns": [
"**/__mocks__/*.ts",
"ios/**",
"android/**",
"coverage/**",
"*.lock",
".husky/**",
"patches/**",
"*.html",
"bskyweb/**",
"bskyembed/**",
"bskyogcard/**",
"src/locale/locales/_build/**",
"src/locale/locales/**/*.js",
"*.e2e.ts",
"*.e2e.tsx",
"eslint.config.mjs",
".jscodeshift/**"
],
"rules": {
"constructor-super": "error",
"for-direction": "error",
"getter-return": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unexpected-multiline": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": "error",
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error",
"no-array-constructor": "error",
"no-unused-expressions": "error",
"import/namespace": "off",
"import/default": "error",
"import/no-named-as-default": "warn",
"import/no-named-as-default-member": "warn",
"import/no-duplicates": "warn",
"react/rules-of-hooks": "error",
"react/exhaustive-deps": "warn",
"typescript/await-thenable": "error",
"typescript/ban-ts-comment": "error",
"typescript/no-array-delete": "error",
"typescript/no-base-to-string": "error",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-duplicate-type-constituents": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "error",
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-floating-promises": "error",
"typescript/no-for-in-array": "error",
"typescript/no-implied-eval": "error",
"typescript/no-misused-new": "error",
"typescript/no-misused-promises": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-redundant-type-constituents": "error",
"typescript/no-require-imports": "error",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-assertion": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-argument": "error",
"typescript/no-unsafe-assignment": "error",
"typescript/no-unsafe-call": "error",
"typescript/no-unsafe-declaration-merging": "error",
"typescript/no-unsafe-enum-comparison": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-unsafe-member-access": "error",
"typescript/no-unsafe-return": "error",
"typescript/no-unsafe-unary-minus": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/only-throw-error": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/prefer-promise-reject-errors": "error",
"typescript/require-await": "error",
"typescript/restrict-plus-operands": "error",
"typescript/restrict-template-expressions": "error",
"typescript/triple-slash-reference": "error",
"typescript/unbound-method": "error"
},
"overrides": [
{
"files": [
"**/*.ts",
"**/*.tsx",
"**/*.mts",
"**/*.cts"
],
"rules": {
"constructor-super": "off",
"getter-return": "off",
"no-class-assign": "off",
"no-const-assign": "off",
"no-dupe-class-members": "off",
"no-dupe-keys": "off",
"no-func-assign": "off",
"no-import-assign": "off",
"no-new-native-nonconstructor": "off",
"no-obj-calls": "off",
"no-redeclare": "off",
"no-setter-return": "off",
"no-this-before-super": "off",
"no-unreachable": "off",
"no-unsafe-negation": "off",
"no-var": "error",
"no-with": "off",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error"
}
},
{
"files": [
"**/*.{js,jsx,ts,tsx}"
],
"rules": {
"bsky-internal/avoid-unwrapped-text": [
"error",
{
"impliedTextComponents": [
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"P",
"Admonition",
"Admonition.Admonition",
"Toast.Action",
"AgeAssuranceAdmonition",
"Span",
"StackedButton"
],
"impliedTextProps": [],
"suggestedTextWrappers": {
"Button": "ButtonText",
"ToggleButton.Button": "ToggleButton.ButtonText",
"SegmentedControl.Item": "SegmentedControl.ItemText"
}
}
],
"bsky-internal/use-exact-imports": "error",
"bsky-internal/use-prefixed-imports": "error",
"bsky-internal/lingui-msg-rule": "error",
"react/display-name": "error",
"react/jsx-key": "error",
"react/jsx-no-comment-textnodes": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-target-blank": "error",
"react/jsx-no-undef": "error",
"react/no-children-prop": "error",
"react/no-danger-with-children": "error",
"react/no-direct-mutation-state": "error",
"react/no-find-dom-node": "error",
"react/no-is-mounted": "error",
"react/no-render-return-value": "error",
"react/no-string-refs": "error",
"react/no-unescaped-entities": "off",
"react/no-unknown-property": "error",
"react/no-unsafe": "off",
"react/react-in-jsx-scope": "off",
"react/hook-use-state": "warn",
"react-native/no-inline-styles": "off",
"react-native-a11y/has-accessibility-hint": "error",
"react-native-a11y/has-accessibility-props": "error",
"react-native-a11y/has-valid-accessibility-actions": "error",
"react-native-a11y/has-valid-accessibility-component-type": "error",
"react-native-a11y/has-valid-accessibility-descriptors": "error",
"react-native-a11y/has-valid-accessibility-role": "error",
"react-native-a11y/has-valid-accessibility-state": "error",
"react-native-a11y/has-valid-accessibility-states": "error",
"react-native-a11y/has-valid-accessibility-traits": "error",
"react-native-a11y/has-valid-accessibility-value": "error",
"react-native-a11y/no-nested-touchables": "error",
"react-native-a11y/has-valid-accessibility-ignores-invert-colors": "error",
"react-native-a11y/has-valid-accessibility-live-region": "error",
"react-native-a11y/has-valid-important-for-accessibility": "error",
"react-compiler/react-compiler": "warn",
"simple-import-sort/imports": [
"error",
{
"groups": [
[
"^\\u0000"
],
[
"^node:"
],
[
"^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)",
"^(expo(.*)$)|^(expo$)",
"^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w"
],
[
"^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)",
"^(?:#\\/)?view(?:$|\\/)",
"^(?:#\\/)?screens(?:$|\\/)",
"^(?:#\\/)?alf(?:$|\\/)",
"^(?:#\\/)?components(?:$|\\/)",
"^#\\/",
"^\\."
],
[
"^"
]
]
}
],
"simple-import-sort/exports": "error",
"no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_.+",
"caughtErrors": "none",
"ignoreRestSiblings": true
}
],
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "react",
"importNames": [
"React",
"default"
],
"message": "React is already in the global type namespace. Use named imports for runtime modules."
}
]
}
],
"no-empty-pattern": "off",
"no-async-promise-executor": "off",
"no-constant-binary-expression": "warn",
"prefer-const": "off",
"no-empty": "off",
"no-unsafe-optional-chaining": "off",
"no-prototype-builtins": "off",
"no-var": "off",
"prefer-rest-params": "off",
"no-case-declarations": "off",
"no-irregular-whitespace": "off",
"no-useless-escape": "off",
"no-sparse-arrays": "off",
"no-fallthrough": "off",
"no-control-regex": "off",
"no-unused-expressions": [
"error",
{
"allowTernary": true
}
],
"import/consistent-type-specifier-style": [
"warn",
"prefer-inline"
],
"import/no-nodejs-modules": "error",
"typescript/consistent-type-imports": [
"warn",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"typescript/no-require-imports": "off",
"typescript/ban-ts-comment": "off",
"typescript/no-empty-object-type": "off",
"typescript/no-unsafe-function-type": "off",
"typescript/no-unsafe-assignment": "off",
"typescript/unbound-method": "off",
"typescript/no-unsafe-argument": "off",
"typescript/no-unsafe-return": "off"
},
"jsPlugins": [
"eslint-plugin-bsky-internal",
"eslint-plugin-react-native",
"eslint-plugin-react-native-a11y",
"eslint-plugin-react-compiler",
"eslint-plugin-simple-import-sort"
],
"env": {
"es2026": true,
"browser": true,
"node": true
}
},
{
"files": [
"bskyogcard/**/*.{js,jsx,ts,tsx}",
"dev-env/**/*.{js,jsx,ts,tsx}"
],
"rules": {
"import/no-nodejs-modules": "off"
}
},
{
"files": [
"**/__tests__/**/*.{js,jsx,ts,tsx}",
"**/*.test.{js,jsx,ts,tsx}"
],
"env": {
"jest": true
}
}
]
}
+95 -443
View File
@@ -30,7 +30,7 @@ pnpm ios # Run on iOS
# Testing & Quality
# IMPORTANT: Always use these pnpm scripts, never call the underlying tools directly
pnpm test # Run Jest tests
pnpm lint # Run ESLint
pnpm lint # Run Oxlint
pnpm typecheck # Run TypeScript type checking
pnpm prettier # Run Prettier for code formatting
@@ -82,7 +82,7 @@ should go in `/screens` (not `/view/screens`) to encourage better organization
and separation from legacy code.
For complex screens that have specific components or data needs that _are not
shared by other screens_, we encourage subdirectoreis within `/screens/<name>`
shared by other screens_, we encourage subdirectories within `/screens/<name>`
e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and
`/screens/ProfileScreen/components/`.
@@ -126,44 +126,14 @@ eventually.
Typically JS style for variables, functions, etc. We use ProudCamelCase for
components, and camelCase directories and files.
When organizing new code, consider if it fits into a single file, or if it
should be broken down into multiple files. For "macro" component cases, or
things that live in `/features` or `/screens`, we often follow a pattern of
having an `index.tsx` for the main component, and then co-locating related
components, hooks, and utilities in the same directory. For example:
For "macro" cases in `/features`, `/screens`, or `/components`, co-locate related
code in a directory with an `index.tsx` main component plus sibling
components/hooks/utils (e.g. `screens/ProfileScreen/index.tsx` +
`screens/ProfileScreen/components/`). Keep related code together so it lives where
someone would look for it. Don't overdo it: a component that fits in one file
should just be `Component.tsx`, not `Component/index.tsx`.
```
src
├── screens/
│ ├── ProfileScreen/
│ │ ├── index.tsx # Main screen component
│ │ ├── components/ # Sub-components used only by this screen
```
Similar patterns can be found in `/features` and `/components`. The idea here is
to keep related code together and make it easier to navigate.
You should ask yourself: if someone new was looking for the code related to this
feature or screen, where would they expect to find it? Organizing code in a way
that matches developer expectations can make the codebase much more
approachable. Being able to say "Live Now stuff lives in `/features/liveNow`" is
easier to understand than having it scattered across multiple directories.
No need to go overboard with this. If a component or feature fits into a single
file, there's no reason to have a `/Component/index.tsx` file when it could just
be `/Component.tsx`. Use your judgment based on the complexity and amount of
related code.
#### Platform Specific Files
We have conflicting patterns in the app for this. The preferred approach is to
group platform-specific files into a directory as much as possible. For example,
rather than having `Component.tsx`, `Component.web.tsx`, and
`Component.native.tsx` in the same directory, we prefer to have a `Component/`
directory with `index.tsx`, `index.web.tsx`, and `index.native.tsx`. This keeps
related code together and gives us a better visual cue that there are probably
other files contained within this "macro" feature, whereas `Component.tsx` on
its own looks more like a single component file.
Platform-specific files are covered under "Platform-Specific Code" below.
### Comments
@@ -204,88 +174,40 @@ const fallbackDate = maximumDate ? toSimpleDateString(maximumDate) : today
### Documentation and Tests Within Features
For larger features or components, it's helpful to include a README.md file
within the directory that explains the purpose of the feature, how it works, and
any important implementation details. The `/Component/index.tsx` pattern lends
itself well to this, since the `index.tsx` can be the main component file, and
the `README.md` can provide documentation for the whole feature. This is
optional, but can be a nice way to keep documentation close to the code it
describes.
Similarly, if there are tests that are specific to a component or feature, it
can be helpful to include them in the same directory, either as
`Component.test.tsx` or in a `__tests__/` subdirectory. This keeps everything
related to the component or feature in one place and makes it easier to find and
maintain tests.
For larger features or components, co-locate documentation and tests with the
code. A `README.md` in the directory (the `/Component/index.tsx` pattern lends
itself well to this) can document the whole feature, and feature-specific tests
belong alongside it as `Component.test.tsx` or in a `__tests__/` subdirectory.
Both are optional.
## Styling System (ALF)
ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens.
ALF is the custom design system. Tailwind-inspired naming with underscores
instead of hyphens. Static atoms (`atoms as a`) are theme-independent; theme
atoms/palette come from `useTheme()` (`t.atoms.bg`, `t.palette.primary_500`).
Style props take an array of atoms + theme atoms + raw styles.
### Basic Usage
Generally, order atoms by:
- Flexbox configuration, e.g., `a.flex_row`
- Spacing, e.g., `a.px_md`
- Text styles, e.g., `a.font_bold`
- Themes, e.g., `t.atoms.text`,
- Raw styles, e.g., `{backgroundColor: t.palette.primary_500}`
Order atoms by: flexbox (`a.flex_row`), spacing (`a.px_md`), text (`a.font_bold`),
themes (`t.atoms.text`), then raw styles (`{backgroundColor: t.palette.primary_500}`).
```tsx
import {atoms as a, useTheme} from '#/alf'
function MyComponent() {
const t = useTheme()
return (
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_high]}>Hello</Text>
</View>
)
}
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]} />
```
### Key Concepts
**Static Atoms**  Theme-independent styles imported from `atoms`:
Static atoms live in `a.*` (e.g. `a.flex_row`, `a.p_md`, `a.rounded_md`,
`a.text_lg`). Theme atoms/palette come from `useTheme()` (`t.atoms.bg`,
`t.atoms.text`, `t.atoms.border_contrast_low`, `t.palette.primary_500`).
```tsx
import {atoms as a} from '#/alf'
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
```
**Platform utilities** (`import {web, native, ios, android, platform} from '#/alf'`)
return conditional styles inline in a style array: `web({cursor: 'pointer'})`,
`native({paddingBottom: 20})`, `platform({ios: {...}, android: {...}, web: {...}})`.
**Theme Atoms**  Theme-dependent colors from `useTheme()`:
```tsx
const t = useTheme()
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
// t.palette.primary_500, t.palette.negative_400, etc.
```
**Platform Utilities**  For platform-specific styles:
```tsx
import {web, native, ios, android, platform} from '#/alf'
const styles = [
a.p_md,
web({cursor: 'pointer'}),
native({paddingBottom: 20}),
platform({ios: {...}, android: {...}, web: {...}}),
]
```
**Breakpoints**  Responsive design:
```tsx
import {useBreakpoints} from '#/alf'
const {gtPhone, gtMobile, gtTablet} = useBreakpoints()
if (gtMobile) {
// Tablet or desktop layout
}
```
**Breakpoints:** `const {gtPhone, gtMobile, gtTablet} = useBreakpoints()` from `#/alf`.
### Naming Conventions
@@ -306,176 +228,67 @@ if (gtMobile) {
```tsx
import {Fragment} from 'react'
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {Trans} from '@lingui/react/macro'
import {Text} from '#/components/Typography'
function MyComponent({foo = []}: {foo?: string[]}) {
const {t: l} = useLingui()
function MyComponent({items = []}: {items?: string[]}) {
return (
<>
<View><Text><Trans>Example</Trans><Text></View>
<View>
{foo.map((foo, index) => (
<Fragment key={foo}>
<Text>
<Trans>Example</Trans>
</Text>
</View>
<View>
{items.map((item, index) => (
<Fragment key={item}>
<Text>{index}</Text>
<Text>{foo}</Text>
<Text>{item}</Text>
</Fragment>
))}
</View>
</>
);
)
}
```
### Dialog Component
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
```tsx
import * as Dialog from '#/components/Dialog'
function MyFeature() {
const control = Dialog.useDialogControl()
return (
<>
<Button label="Open" onPress={control.open}>
<ButtonText>Open Dialog</ButtonText>
</Button>
<Dialog.Outer control={control}>
{/* Typically the inner part is in its own component */}
<DialogInner />
</Dialog.Outer>
</>
)
}
function DialogInner() {
return (
<>
<Dialog.Handle /> {/* Native-only drag handle */}
<Dialog.ScrollableInner label={l`My Dialog`}>
<Dialog.Header>
<Dialog.HeaderText>Title</Dialog.HeaderText>
</Dialog.Header>
<Text>Dialog content here</Text>
<Button label="Done" onPress={() => control.close()}>
<ButtonText>Done</ButtonText>
</Button>
<Dialog.Close /> {/* Web-only X button in top left */}
</Dialog.ScrollableInner>
</>
)
}
```
Lives in `#/components/Dialog`. Bottom sheet on native, modal on web. Manage
state with `useDialogControl()`. `Dialog.Handle` renders native-only, `Dialog.Close`
web-only. CRITICAL: run any post-close action inside the `control.close(() => ...)`
callback (see Footguns). Compound-component usage; canonical example in any dialog
under `#/components`.
### Menu Component
Menus render as a dropdown on web and a bottom sheet dialog on native.
```tsx
import * as Menu from '#/components/Menu'
function MyMenu() {
return (
<Menu.Root>
<Menu.Trigger label="Open menu">
{({props}) => (
<Button {...props} label="Menu">
<ButtonIcon icon={DotsHorizontal} />
</Button>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item label="Edit" onPress={handleEdit}>
<Menu.ItemIcon icon={Pencil} />
<Menu.ItemText>Edit</Menu.ItemText>
</Menu.Item>
<Menu.Item label="Delete" onPress={handleDelete}>
<Menu.ItemIcon icon={Trash} />
<Menu.ItemText>Delete</Menu.ItemText>
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
)
}
```
Lives in `#/components/Menu`. Dropdown on web, bottom sheet dialog on native.
`Menu.Divider` is web-only, `Menu.ContainerItem` native-only. Compound API
(`Menu.Root` / `Menu.Trigger` / `Menu.Outer` / `Menu.Group` / `Menu.Item`); grep
existing usages across the app for a canonical example.
### Button Component
```tsx
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
// Solid primary button (most common)
<Button label="Save" onPress={handleSave} color="primary" size="large">
<ButtonText>Save</ButtonText>
</Button>
// With icon
<Button label="Share" onPress={handleShare} color="secondary" size="small">
<ButtonIcon icon={Share} />
<ButtonText>Share</ButtonText>
</Button>
// Icon-only button
<Button label="Close" onPress={handleClose} color="secondary" size="small" shape="round">
<ButtonIcon icon={XIcon} />
</Button>
// Ghost variant (deprecated - use color prop)
<Button label="Cancel" variant="ghost" color="secondary" size="small">
<ButtonText>Cancel</ButtonText>
</Button>
```
**Button Props:**
`import {Button, ButtonText, ButtonIcon} from '#/components/Button'`. Props:
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
- `size`: `'tiny'` | `'small'` | `'large'`
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`)
### Typography
```tsx
import {Text, H1, H2, P} from '#/components/Typography'
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
<P>Paragraph text with default styling.</P>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>Custom text</Text>
// For text with emoji, add the emoji prop. User-generated text (e.g. display names)
// will almost certainly contain emoji, so only omit it when the text is static and
// does not contain an emoji
<Text emoji>Hello! 👋</Text>
```
The `Text` component's default style is `[a.text_sm, a.leading_snug, t.atoms.text]`.
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, prefer `color`)
### TextField
```tsx
import * as TextField from '#/components/forms/TextField'
Compound component at `#/components/forms/TextField` (`TextField.LabelText`,
`TextField.Root`, `TextField.Icon`, `TextField.Input`). Prefer `defaultValue` over
`value` (see Footguns).
<TextField.LabelText>Email</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={AtSign} />
<TextField.Input
label="Email address"
placeholder="you@example.com"
defaultValue={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
</TextField.Root>
```
### Typography
`import {Text, H1, H2, P} from '#/components/Typography'`. The `Text` default style
is `[a.text_sm, a.leading_snug, t.atoms.text]`. Pass the `emoji` prop to any `Text`
that may contain emoji - user-generated text (display names etc.) almost always
does, so only omit it for static, emoji-free strings: `<Text emoji>Hello!</Text>`.
## Internationalization (i18n)
@@ -549,201 +362,49 @@ pnpm intl:compile # Compile translations for runtime
### TanStack Query (Data Fetching)
```tsx
// src/state/queries/profile.ts
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
Follow the established pattern in `src/state/queries/`; `src/state/queries/feed.ts`
is a good canonical reference (it uses `createQueryKey`, matching key roots,
`useInfiniteQuery`, and `persistedVersion`).
import {createQueryKey} from '#/state/queries/util'
/*
* Query key name should match the query hook name for consistency
*/
const profileQueryKeyRoot = 'profile'
/*
* Use object params and createQueryKey helper for better readability and to
* avoid bugs with parameter order or types.
*/
export const createProfileQueryKey = (args: {did: string}) =>
createQueryKey(profileQueryKeyRoot, args)
/*
* Query hook should be named use[Name]Query, where [Name] describes the data
* being fetched. This is not a strict requirement, but it's a helpful
* convention for discoverability
*/
export function useProfileQuery({did}: {did: string}) {
const agent = useAgent()
return useQuery({
queryKey: createProfileQueryKey({did}),
queryFn: async () => {
const res = await agent.getProfile({actor: did})
return res.data
},
staleTime: STALE.MINUTES.FIVE,
enabled: !!did,
})
}
/*
* Mutation hook should match the name of the query hook, but with "Mutation"
* suffix. This is not a strict requirement, but it's a helpful convention for
* discoverability and consistency.
*/
export function useProfileMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async data => {
// Update logic
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: createProfileQueryKey({did: variables.did}),
})
},
onError: error => {
if (isNetworkError(error)) {
// don't log, but inform user
} else if (error instanceof AppBskyExampleProcedure.ExampleError) {
// XRPC APIs often have typed errors, allows nicer handling
} else {
// Log unexpected errors to Sentry
logger.error('Error updating profile', {safeMessage: error})
}
},
})
}
/*
* If cache mutation is needed, include specific interfaces for the specific
* mutations you require adjacent to the source queries. Naming should be
* descriptive of the mutation's purpose, e.g. use[Name]CacheMutation. This is
* not a strict requirement, but it's a helpful convention for discoverability
* and consistency.
*/
export function useProfileCacheMutation() {
const queryClient = useQueryClient()
return (data: Partial<Profile>) => {
queryClient.setQueryData(
createProfileQueryKey({did: data.did}),
oldData => {
if (!oldData) return oldData
return {...oldData, ...data}
},
)
}
}
```
**Stale Time Constants** (from `src/state/queries/index.ts`):
```tsx
STALE.SECONDS.FIFTEEN // 15 seconds
STALE.MINUTES.ONE // 1 minute
STALE.MINUTES.FIVE // 5 minutes
STALE.HOURS.ONE // 1 hour
STALE.INFINITY // Never stale
```
**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
```tsx
export function useDraftsQuery() {
const agent = useAgent()
return useInfiniteQuery({
queryKey: createQueryKey('drafts'),
queryFn: async ({pageParam}) => {
const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam})
return res.data
},
initialPageParam: undefined as string | undefined,
getNextPageParam: page => page.cursor,
})
}
```
To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []`
**Persisted Queries**
To persist query data across app restarts, `createQueryKey` supports a third
parameter called `options`, which has a `persistedVersion` property. When this
property is set to a number, the query will be persisted.
When this property is updated (e.g. incremented), the persisted data will be cleared and replaced with the new data from the query function. This is useful for cases where the shape of the data has changed and old persisted data would no longer be valid.
```tsx
export const createProfileQueryKey = (args: {did: string}) =>
createQueryKey(profileQueryKeyRoot, args, {persistedVersion: 1})
```
- Build query keys with `createQueryKey(root, args)` (from `#/state/queries/util`)
using an object for `args`. The key root variable should match the hook name.
- Naming conventions: `use[Name]Query` for queries, `use[Name]Mutation` for
mutations, `use[Name]CacheMutation` for helpers that mutate cached data directly.
- Stale times come from `STALE` in `src/state/queries/index.ts`: `STALE.SECONDS.FIFTEEN`,
`STALE.MINUTES.ONE`, `STALE.MINUTES.FIVE`, `STALE.HOURS.ONE`, `STALE.INFINITY`.
- Paginated atproto APIs (those returning a `cursor`) use `useInfiniteQuery` with
`getNextPageParam: page => page.cursor`; flatten results with
`data?.pages.flatMap(page => page.items) ?? []`.
- Persist a query across restarts by passing options:
`createQueryKey(root, args, {persistedVersion: n})`. Bumping `n` clears the old
persisted data and refetches - do this whenever the data shape changes.
- Error handling in mutations: don't log network errors (just inform the user),
handle typed XRPC errors specifically (e.g. `err instanceof SomeNsid.SomeError`),
and send unexpected errors to `logger.error('...', {safeMessage: error})`.
### Preferences (React Context)
```tsx
// Simple boolean preference pattern
import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
function SettingsScreen() {
const autoplayDisabled = useAutoplayDisabled()
const setAutoplayDisabled = useSetAutoplayDisabled()
return <Toggle value={autoplayDisabled} onValueChange={setAutoplayDisabled} />
}
```
Boolean/simple UI preferences are exposed as paired hooks from `#/state/preferences`,
e.g. `useAutoplayDisabled()` / `useSetAutoplayDisabled()`.
### Session State
```tsx
import {useSession, useAgent} from '#/state/session'
function MyComponent() {
const {hasSession, currentAccount} = useSession()
const agent = useAgent()
if (!hasSession) {
return <LoginPrompt />
}
// Use agent for API calls
const response = await agent.getProfile({actor: currentAccount.did})
}
```
`import {useSession, useAgent} from '#/state/session'`. `useSession()` gives
`hasSession` and `currentAccount`; `useAgent()` gives the atproto agent for API calls.
## Navigation
Navigation uses React Navigation with type-safe route parameters.
```tsx
// Screen component
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
export function ProfileScreen({route, navigation}: Props) {
const {name} = route.params // Type-safe params
return <Layout.Screen>{/* Screen content */}</Layout.Screen>
}
// Programmatic navigation
import {useNavigation} from '@react-navigation/native'
const navigation = useNavigation()
navigation.navigate('Profile', {name: 'alice.bsky.social'})
// Or use the navigate helper
import {navigate} from '#/Navigation'
navigate('Profile', {name: 'alice.bsky.social'})
```
React Navigation with type-safe route params. Type a screen with
`NativeStackScreenProps<CommonNavigatorParams, 'X'>` (`route`/`navigation` come
from props; params via `route.params`). Navigate programmatically with
`useNavigation()`, or the `navigate` helper from `#/Navigation`. Config lives in
`src/Navigation.tsx`, routes in `src/routes.ts`, types in `src/lib/routes/types.ts`.
## Platform-Specific Code
Use file extensions for platform-specific implementations:
Use file extensions for platform-specific implementations. The bundler resolves
them automatically - just import the base path normally, never a conditional
`require()`.
```
Component.tsx # Shared/default
@@ -753,12 +414,11 @@ Component.ios.tsx # iOS-only
Component.android.tsx # Android-only
```
Example from Dialog:
- `src/components/Dialog/index.tsx`  Native (uses BottomSheet)
- `src/components/Dialog/index.web.tsx Web (uses modal with Radix primitives)
**Important:** The bundler automatically resolves platform-specific files. Just import normally:
Prefer grouping variants into a `Component/` directory (`index.tsx`,
`index.web.tsx`, `index.native.tsx`) rather than sibling `Component.web.tsx` files,
so the shared surface reads as one "macro" module (e.g. `src/components/Dialog/index.tsx`
native vs `index.web.tsx` web). The app has both patterns; the directory form is
preferred for new code.
```tsx
// CORRECT - bundler picks storage.ts or storage.web.ts automatically
@@ -770,15 +430,7 @@ const storage = IS_NATIVE
: require('#/state/drafts/storage.web')
```
Platform detection (for runtime logic, not imports):
```tsx
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
if (IS_NATIVE) {
// Native-specific logic
}
```
Runtime platform detection (not for imports): `import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'`.
## Import Aliases
+1 -1
View File
@@ -9,7 +9,7 @@ WORKDIR /app
COPY ./bskyogcard/package.json ./
COPY ./bskyogcard/pnpm-lock.yaml ./
COPY ./bskyogcard/pnpm-workspace.yaml ./
RUN npm install --global pnpm@11.10.0
RUN npm install --global pnpm@11.13.1
RUN pnpm install --frozen-lockfile
COPY ./bskyogcard ./
+1 -1
View File
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \
npm install --global pnpm@11.10.0 && \
npm install --global pnpm@11.13.1 && \
pnpm install --frozen-lockfile && \
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
pnpm intl:build && \
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#006AFF" d="M16 2.5a4 4 0 0 1 4 4v12.995c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2 2 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#006aff" d="M16 2.5a4 4 0 0 1 4 4v12.995c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2 2 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8Z"/></svg>

Before

Width:  |  Height:  |  Size: 250 B

After

Width:  |  Height:  |  Size: 250 B

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

After

Width:  |  Height:  |  Size: 4.5 KiB

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

Before

Width:  |  Height:  |  Size: 426 B

After

Width:  |  Height:  |  Size: 426 B

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

Before

Width:  |  Height:  |  Size: 491 B

After

Width:  |  Height:  |  Size: 491 B

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

Before

Width:  |  Height:  |  Size: 557 B

After

Width:  |  Height:  |  Size: 557 B

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

Before

Width:  |  Height:  |  Size: 838 B

After

Width:  |  Height:  |  Size: 838 B

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

Before

Width:  |  Height:  |  Size: 579 B

After

Width:  |  Height:  |  Size: 579 B

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

Before

Width:  |  Height:  |  Size: 355 B

After

Width:  |  Height:  |  Size: 355 B

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

Before

Width:  |  Height:  |  Size: 233 B

After

Width:  |  Height:  |  Size: 233 B

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

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

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

Before

Width:  |  Height:  |  Size: 354 B

After

Width:  |  Height:  |  Size: 354 B

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

Before

Width:  |  Height:  |  Size: 733 B

After

Width:  |  Height:  |  Size: 733 B

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

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