Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9143920f0 | |||
| 0e63434bb4 | |||
| eb8f5fc4bc | |||
| 2ad84e540f | |||
| 97c2c516bd | |||
| 34827ef644 | |||
| 691619f297 | |||
| c2e5a9093c | |||
| 9b89a9adba | |||
| 22d657986d | |||
| abde10b7bd | |||
| 51bcacb20b | |||
| b662cd43b5 | |||
| 66ec855efc | |||
| d9e76c3954 | |||
| be70a31f4c | |||
| 2b9deeeb6a | |||
| d5e335e575 | |||
| 76a928a7af | |||
| 9eb78c0f6c | |||
| fb8bda861b | |||
| afd267bd2b | |||
| 93cb42b9f2 | |||
| 549e3d7a7d | |||
| 74a89832a3 | |||
| 19b0d89313 | |||
| 98e135f489 | |||
| fbb0ee38c2 | |||
| 7bd27e4180 | |||
| 07a810945d | |||
| 63b284030a | |||
| 3b6fbf315b | |||
| 97169dbc35 | |||
| 22a4958321 | |||
| a9df815bd0 | |||
| a53df16e82 | |||
| 4e3d1704a6 | |||
| 723a60e979 | |||
| 325246ec6c | |||
| 10b027c35b | |||
| 8d64ab9d4b | |||
| b80d09000f | |||
| f38f84e1a3 | |||
| 8d7a7369aa | |||
| 77bbe8d8a0 | |||
| 850765bc8d | |||
| 8ca8176eb1 | |||
| c71a4f8360 | |||
| b2de086c3b | |||
| 933ba1c373 | |||
| 0bd6a99613 | |||
| f62e54ec82 | |||
| 676f3f9e1a | |||
| 26ce5f0934 | |||
| 44b1ab08b5 | |||
| c70baff709 | |||
| cbf0d89128 | |||
| 86f0bedfbe |
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: Setup denis CLI
|
||||
description: >
|
||||
Download and verify the denis OTA publish binary from the (private)
|
||||
bluesky-social/tango releases and put it on PATH. Uses a short-lived GitHub
|
||||
App token scoped to contents:read on tango, since the default GITHUB_TOKEN
|
||||
cannot read a private repo's releases.
|
||||
|
||||
inputs:
|
||||
release-tag:
|
||||
description: denis release tag in bluesky-social/tango to download
|
||||
required: true
|
||||
app-id:
|
||||
description: GitHub App ID for the token used to read tango releases
|
||||
required: true
|
||||
private-key:
|
||||
description: GitHub App private key
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: 🔑 Mint tango read token
|
||||
id: tango-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ inputs.app-id }}
|
||||
private-key: ${{ inputs.private-key }}
|
||||
repositories: tango
|
||||
permission-contents: read
|
||||
|
||||
- name: ⬇️ Download and verify denis binary
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.tango-token.outputs.token }}
|
||||
RELEASE_TAG: ${{ inputs.release-tag }}
|
||||
run: |
|
||||
BIN_DIR="$RUNNER_TEMP/denis-bin"
|
||||
mkdir -p "$BIN_DIR"
|
||||
cd "$BIN_DIR"
|
||||
gh release download "$RELEASE_TAG" \
|
||||
--repo bluesky-social/tango \
|
||||
--pattern denis-linux-amd64 \
|
||||
--pattern denis-linux-amd64.sha256 \
|
||||
--clobber
|
||||
# Verify before making it executable / putting it on PATH.
|
||||
sha256sum -c denis-linux-amd64.sha256
|
||||
mv denis-linux-amd64 denis
|
||||
chmod +x denis
|
||||
echo "$BIN_DIR" >> "$GITHUB_PATH"
|
||||
@@ -136,7 +136,7 @@ wait_for_port 1986 "the E2E mock-server manager"
|
||||
|
||||
phase "Starting Metro"
|
||||
EXPO_PUBLIC_ENV=e2e \
|
||||
NODE_ENV=test \
|
||||
NODE_ENV=development \
|
||||
RN_SRC_EXT=e2e.ts,e2e.tsx \
|
||||
pnpm exec expo start --dev-client --clear --port 8081 \
|
||||
>"$artifact_dir/metro.log" 2>&1 &
|
||||
|
||||
@@ -21,20 +21,20 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: ⬇️ Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Setup Docker buildx
|
||||
- name: 🔧 Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
- name: 🔑 Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
password: ${{ env.PASSWORD }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
- name: 🏷️ Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
@@ -43,13 +43,13 @@ jobs:
|
||||
tags: |
|
||||
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
|
||||
|
||||
- name: Env
|
||||
- name: 📝 Env
|
||||
id: env
|
||||
run: |
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: 🚀 Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
|
||||
@@ -22,20 +22,20 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: ⬇️ Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Setup Docker buildx
|
||||
- name: 🔧 Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
- name: 🔑 Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME }}
|
||||
password: ${{ env.PASSWORD }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
- name: 🏷️ Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
tags: |
|
||||
type=sha,enable=true,priority=100,prefix=bskyweb:,suffix=,format=long
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: 🚀 Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
|
||||
@@ -21,20 +21,20 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: ⬇️ Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Setup Docker buildx
|
||||
- name: 🔧 Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
- name: 🔑 Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
password: ${{ env.PASSWORD }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
- name: 🏷️ Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
tags: |
|
||||
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: 🚀 Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
|
||||
@@ -21,20 +21,20 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: ⬇️ Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Setup Docker buildx
|
||||
- name: 🔧 Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
- name: 🔑 Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
password: ${{ env.PASSWORD }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
- name: 🏷️ Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
tags: |
|
||||
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: 🚀 Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
|
||||
@@ -21,20 +21,20 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: ⬇️ Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Setup Docker buildx
|
||||
- name: 🔧 Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
- name: 🔑 Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
password: ${{ env.PASSWORD }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
- name: 🏷️ Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
tags: |
|
||||
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: 🚀 Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
|
||||
@@ -170,22 +170,6 @@ jobs:
|
||||
payload: |
|
||||
{"text": "Android ${{ inputs.profile || 'testflight-android' }} build submitted to Google Play!\n```Version Number: ${{ needs.build.outputs.package-version }}\nBuild Number: ${{ needs.build.outputs.version-code }}```"}
|
||||
|
||||
# Record the commit only after a successful submit, so a failed submit doesn't
|
||||
# advance the "most recent testflight" marker.
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
if: ${{ inputs.profile == 'testflight-android' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
key: most-recent-testflight-commit
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
if: ${{ inputs.profile == 'testflight-android' }}
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
# Runs in parallel with submit: the QA APK shouldn't be blocked by a Play submission failure.
|
||||
universalApk:
|
||||
name: Build universal APK
|
||||
|
||||
@@ -254,22 +254,6 @@ jobs:
|
||||
payload: |
|
||||
{"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ needs.build.outputs.package-version }}\nBuild Number: ${{ needs.build.outputs.build-number }}```"}
|
||||
|
||||
# Record the commit only after a successful submit, so a failed submit doesn't advance
|
||||
# the baseline used for the next testflight build's changelog.
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
if: ${{ inputs.profile == 'testflight' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
key: most-recent-testflight-commit
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
if: ${{ inputs.profile == 'testflight' }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
distribute:
|
||||
name: Assign build to TestFlight group
|
||||
# fastlane and jq ship preinstalled on the macOS runner image, and this step mostly idles
|
||||
|
||||
@@ -22,11 +22,23 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# denis release tag in bluesky-social/tango whose linux-amd64 binary this
|
||||
# workflow downloads to publish OTA bundles. Bump this one line to roll denis.
|
||||
env:
|
||||
DENIS_RELEASE_TAG: denis-v0.1.1
|
||||
|
||||
jobs:
|
||||
bundleDeploy:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
name: Bundle and Deploy EAS Update
|
||||
runs-on: ubuntu-latest
|
||||
# id-token: write lets this job mint an OIDC token to assume the denis
|
||||
# publish role; actions: read loads the fingerprint baseline artifact;
|
||||
# contents: read is still needed for the checkout.
|
||||
permissions:
|
||||
id-token: write
|
||||
actions: read
|
||||
contents: read
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-deploy
|
||||
cancel-in-progress: true
|
||||
@@ -36,7 +48,7 @@ jobs:
|
||||
steps.version.outputs.version-changed }}
|
||||
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
- name: 🔑 Check for EXPO_TOKEN
|
||||
run: >
|
||||
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
|
||||
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
|
||||
@@ -91,23 +103,69 @@ jobs:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: ⬇️ Load fingerprint baseline
|
||||
id: baseline
|
||||
if: ${{ (inputs.channel || 'testflight') == 'testflight' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY_ID: ${{ github.repository_id }}
|
||||
run: |
|
||||
url=$(gh api \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/artifacts?name=testflight-native-fingerprint&per_page=100" \
|
||||
--jq "[.artifacts[] | select(
|
||||
.expired == false and
|
||||
.workflow_run.head_branch == \"main\" and
|
||||
.workflow_run.head_repository_id == (\$ENV.REPOSITORY_ID | tonumber)
|
||||
)] | max_by(.created_at) | .archive_download_url" \
|
||||
2>/dev/null || true)
|
||||
|
||||
if [ -n "$url" ] && [ "$url" != "null" ]; then
|
||||
mkdir baseline-artifact
|
||||
if curl -sSL -H "Authorization: Bearer $GH_TOKEN" -o baseline.zip "$url" \
|
||||
&& unzip -q baseline.zip -d baseline-artifact; then
|
||||
if jq -e '.sources | type == "array"' \
|
||||
baseline-artifact/native-fingerprint.json >/dev/null; then
|
||||
echo "path=baseline-artifact/native-fingerprint.json" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::warning::Ignoring invalid fingerprint baseline artifact."
|
||||
fi
|
||||
else
|
||||
echo "::warning::Could not download fingerprint baseline artifact."
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: 📷 Check fingerprint and install dependencies
|
||||
id: fingerprint
|
||||
uses: bluesky-social/github-actions/fingerprint-native@b5556913e4aef3964cfd5936d0add3fc0d809bdb # v0.2.0
|
||||
uses: bluesky-social/github-actions/fingerprint-native@abc6a46eb4badf243f55bfd7d6cec42722456300 # v0.3.0
|
||||
with:
|
||||
profile: ${{ inputs.channel || 'testflight' }}
|
||||
previous-commit-tag: ${{ inputs.runtimeVersion }}
|
||||
# The recordBaseline job uploads this marker after a successful deploy;
|
||||
# on the native path, that requires both builds to succeed. A missing
|
||||
# marker forces native builds so they can seed the baseline safely.
|
||||
baseline-fingerprint-path: ${{ steps.baseline.outputs.path }}
|
||||
|
||||
# Hand the full fingerprint to recordBaseline through a short-lived
|
||||
# artifact. It is uploaded unconditionally but promoted to the persistent
|
||||
# baseline only after both native builds succeed.
|
||||
- name: 🚀 Upload native fingerprint
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: native-fingerprint-${{ github.run_id }}
|
||||
path: ${{ steps.fingerprint.outputs.current-fingerprint-path }}
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
uses: ./.github/actions/compile-i18n
|
||||
|
||||
- name: Lint check
|
||||
- name: 🧹 Lint check
|
||||
run: pnpm lint
|
||||
|
||||
- name: Prettier check
|
||||
- name: 💅 Prettier check
|
||||
run: pnpm prettier --check .
|
||||
|
||||
- name: Type check
|
||||
- name: 🔎 Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
@@ -146,7 +204,57 @@ jobs:
|
||||
SENTRY_DIST=${{ steps.env.outputs.bundle-identifier }}
|
||||
pnpm export
|
||||
|
||||
- name: 📦 Package Bundle and 🚀 Deploy
|
||||
# Pin ONE bundle version for both publishes below. Each script used to call
|
||||
# `date +%s` itself, so the same bytes reached denis and ota1 under versions
|
||||
# seconds apart (observed: 1785102575 vs 1785102614). The version is part of
|
||||
# the asset URL path, so each origin then served a manifest referencing a
|
||||
# path only it had -- meaning a manifest fetched from one origin and assets
|
||||
# fetched from the other 404. Both scripts fall back to `date +%s` when this
|
||||
# is unset, so single-publisher callers are unaffected.
|
||||
- name: 🔢 Pin bundle version
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
run: echo "BUNDLE_VERSION=$(date +%s)" >> "$GITHUB_ENV"
|
||||
|
||||
# denis on EKS has been the sole origin for updates.bsky.app since
|
||||
# 2026-07-26, so it publishes FIRST: it is the path that actually serves
|
||||
# clients. The legacy ota1 upload runs after it, and exists only so that
|
||||
# rolling the Bunny origin back to ota1 would find current bundles there.
|
||||
#
|
||||
# The ordering is load-bearing, not cosmetic. While the legacy step ran
|
||||
# first, its failure skipped these steps and nothing reached EITHER origin
|
||||
# -- the dual-write took down the working path with it. Both steps are
|
||||
# still required to pass, so a stale ota1 remains a loud failure, but the
|
||||
# publish that serves users has already landed before the legacy one can
|
||||
# fail.
|
||||
#
|
||||
# Both halves are removed together when ota1 is decommissioned (Phase 5).
|
||||
- name: ☁️ Configure AWS credentials (denis)
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::007404326489:role/denis-ci-publish
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: ⬇️ Setup denis CLI
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
uses: ./.github/actions/setup-denis
|
||||
with:
|
||||
release-tag: ${{ env.DENIS_RELEASE_TAG }}
|
||||
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
|
||||
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
|
||||
|
||||
- name: 🚀 Publish OTA to denis (S3)
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
run: pnpm use-build-number bash scripts/denisPublish.sh
|
||||
env:
|
||||
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
|
||||
CHANNEL_NAME: ${{ inputs.channel || 'testflight' }}
|
||||
|
||||
- name: 📦 Package Bundle and 🚀 Deploy (legacy ota1)
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
run: pnpm use-build-number bash scripts/bundleUpdate.sh
|
||||
@@ -155,20 +263,6 @@ jobs:
|
||||
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
|
||||
CHANNEL_NAME: ${{ inputs.channel || 'testflight' }}
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
key: most-recent-testflight-commit
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
buildIfNecessaryIOS:
|
||||
name: Build and Submit iOS
|
||||
needs: [bundleDeploy]
|
||||
@@ -230,3 +324,48 @@ jobs:
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
|
||||
# Advance the fingerprint baseline only after BOTH native builds have shipped
|
||||
# the new native surface. This replaces the old actions/cache baseline, which
|
||||
# only advanced on cache eviction and so silently froze - freezing meant every
|
||||
# fingerprint looked changed and OTA updates stopped deploying entirely.
|
||||
#
|
||||
# On the native-build path, this runs only after both builds succeed. A
|
||||
# successful OTA deploy also records its fingerprint, refreshing the
|
||||
# persistent marker's retention without changing the native baseline.
|
||||
#
|
||||
# The persistent artifact replaces the old actions/cache marker without
|
||||
# requiring a PAT or mutable repository variable. Each successful deploy adds
|
||||
# an immutable marker; the next run reads the newest non-expired one using the
|
||||
# built-in GITHUB_TOKEN.
|
||||
recordBaseline:
|
||||
name: Record fingerprint baseline
|
||||
runs-on: ubuntu-latest
|
||||
needs: [bundleDeploy, buildIfNecessaryIOS, buildIfNecessaryAndroid]
|
||||
if: ${{ always() &&
|
||||
(inputs.channel || 'testflight') == 'testflight' &&
|
||||
needs.bundleDeploy.result == 'success' &&
|
||||
(needs.bundleDeploy.outputs.changes-detected != 'true' ||
|
||||
(needs.buildIfNecessaryIOS.result == 'success' &&
|
||||
needs.buildIfNecessaryAndroid.result == 'success')) &&
|
||||
github.repository == 'bluesky-social/social-app' }}
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- name: ⬇️ Download native fingerprint
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: native-fingerprint-${{ github.run_id }}
|
||||
|
||||
- name: 🧐 Validate native fingerprint
|
||||
run: >
|
||||
jq -e '.sources | type == "array"' native-fingerprint.json >/dev/null ||
|
||||
(echo "::error::native fingerprint artifact was invalid; refusing to record it as the baseline." && exit 1)
|
||||
|
||||
- name: 🚀 Record fingerprint baseline
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: testflight-native-fingerprint
|
||||
path: native-fingerprint.json
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -53,18 +53,18 @@ jobs:
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: ⬇️ Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
- name: ☁️ Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Claude
|
||||
- name: 🤖 Claude
|
||||
uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1.0.171
|
||||
with:
|
||||
use_bedrock: 'true'
|
||||
|
||||
@@ -39,18 +39,18 @@ jobs:
|
||||
cancel-in-progress: true
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: ⬇️ Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
- name: ☁️ Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Claude review
|
||||
- name: 🤖 Claude review
|
||||
uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1.0.171
|
||||
with:
|
||||
use_bedrock: 'true'
|
||||
|
||||
@@ -17,32 +17,32 @@ jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Git Checkout
|
||||
- name: ⬇️ Git Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Set up Go tooling
|
||||
- name: 🔧 Set up Go tooling
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: bskyweb/go.mod
|
||||
cache-dependency-path: bskyweb/go.sum
|
||||
- name: Dummy Static Files
|
||||
- name: 📄 Dummy Static Files
|
||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
|
||||
- name: Check
|
||||
- name: 🔍 Check
|
||||
run: cd bskyweb/ && make check
|
||||
- name: Build (binary)
|
||||
- name: 🏗️ Build (binary)
|
||||
run: cd bskyweb/ && make build
|
||||
- name: Test
|
||||
- name: 🧪 Test
|
||||
run: cd bskyweb/ && make test
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Git Checkout
|
||||
- name: ⬇️ Git Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Set up Go tooling
|
||||
- name: 🔧 Set up Go tooling
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: bskyweb/go.mod
|
||||
cache-dependency-path: bskyweb/go.sum
|
||||
- name: Dummy Static Files
|
||||
- name: 📄 Dummy Static Files
|
||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
|
||||
- name: Lint
|
||||
- name: 🧹 Lint
|
||||
run: cd bskyweb/ && make lint
|
||||
|
||||
+13
-13
@@ -24,9 +24,9 @@ jobs:
|
||||
job:
|
||||
[lint, prettier, 'typecheck:ios', 'typecheck:android', 'typecheck:web']
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
- name: ⬇️ Check out Git repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Verify Node version pins match package.json
|
||||
- name: 🔍 Verify Node version pins match package.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected=$(node -p "require('./package.json').engines.node.replace(/[^0-9.]/g, '')")
|
||||
@@ -52,16 +52,16 @@ jobs:
|
||||
check "eas.json" "$v"
|
||||
exit $rc
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- name: Install node
|
||||
- name: 🔧 Install node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
- name: pnpm install
|
||||
- name: 📦 pnpm install
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Check & compile i18n
|
||||
- name: 🔤 Check & compile i18n
|
||||
run: pnpm intl:build
|
||||
- name: Lint checks
|
||||
- name: 🧹 Lint checks
|
||||
run: pnpm ${{ matrix.job }}
|
||||
# Aggregates the matrix results into a single stable check name so branch
|
||||
# protection can require "Run linters" regardless of how many matrix jobs run.
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
needs: [linting]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require linting to have succeeded
|
||||
- name: ✅ Require linting to have succeeded
|
||||
env:
|
||||
RESULT: ${{ needs.linting.result }}
|
||||
run: |
|
||||
@@ -87,19 +87,19 @@ jobs:
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
- name: ⬇️ Check out Git repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- name: Install node
|
||||
- name: 🔧 Install node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
- name: pnpm install
|
||||
- name: 📦 pnpm install
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Check & compile i18n
|
||||
- name: 🔤 Check & compile i18n
|
||||
run: pnpm intl:build
|
||||
- name: Run tests
|
||||
- name: 🧪 Run tests
|
||||
run: |
|
||||
NODE_ENV=test pnpm test --forceExit --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
# Aggregates the sharded test results into a single stable check name so branch
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
needs: [testing]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require testing to have succeeded
|
||||
- name: ✅ Require testing to have succeeded
|
||||
env:
|
||||
RESULT: ${{ needs.testing.result }}
|
||||
run: |
|
||||
|
||||
@@ -28,41 +28,41 @@ jobs:
|
||||
runs-on: macos-26-xlarge
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Select Xcode 26.4
|
||||
- name: 🛠️ Select Xcode 26.4
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
|
||||
with:
|
||||
xcode-version: "26.4"
|
||||
|
||||
- name: Prepare E2E configuration
|
||||
- name: 🧰 Prepare E2E configuration
|
||||
run: |
|
||||
mkdir -p artifacts/ios
|
||||
echo "Installing dependencies" > artifacts/ios/phase.txt
|
||||
cp .env.example .env.test
|
||||
cp .env.example .env.development
|
||||
cp google-services.json.example google-services.json
|
||||
|
||||
- name: Set up Expo project
|
||||
- name: 🔧 Set up Expo project
|
||||
uses: ./.github/actions/setup-expo-project
|
||||
with:
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Set up Java 17
|
||||
- name: ☕️ Set up Java 17
|
||||
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Install dev-env dependencies
|
||||
- name: 📦 Install dev-env dependencies
|
||||
run: pnpm --dir dev-env install --frozen-lockfile 2>&1 | tee artifacts/ios/dependencies.log
|
||||
|
||||
- name: Compile translations
|
||||
- name: 🔤 Compile translations
|
||||
uses: ./.github/actions/compile-i18n
|
||||
|
||||
- name: Install Maestro 2.6.1
|
||||
- name: 📥 Install Maestro 2.6.1
|
||||
run: |
|
||||
echo "Installing Maestro" > artifacts/ios/phase.txt
|
||||
curl -fsSL -o "$RUNNER_TEMP/maestro.zip" \
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
"$RUNNER_TEMP/maestro/bin/maestro" --version | tee artifacts/ios/maestro-version.log
|
||||
test "$("$RUNNER_TEMP/maestro/bin/maestro" --version)" = "$MAESTRO_VERSION"
|
||||
|
||||
- name: Boot one iOS simulator
|
||||
- name: 📱 Boot one iOS simulator
|
||||
run: |
|
||||
echo "Booting iOS simulator" > artifacts/ios/phase.txt
|
||||
device_name="iPhone 17"
|
||||
@@ -112,10 +112,10 @@ jobs:
|
||||
xcrun simctl bootstatus "$udid" -b
|
||||
echo "Using $device_name on $runtime_name ($udid)"
|
||||
|
||||
- name: Mark iOS development client build phase
|
||||
- name: 🏷️ Mark iOS development client build phase
|
||||
run: echo "Building the iOS development client" > artifacts/ios/phase.txt
|
||||
|
||||
- name: Build iOS development client
|
||||
- name: 🏗️ Build iOS development client
|
||||
uses: ./.github/actions/eas-local-build
|
||||
with:
|
||||
platform: ios
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
output: ${{ runner.temp }}/nightly-e2e-ios.tar.gz
|
||||
log-path: artifacts/ios/build.log
|
||||
|
||||
- name: Install iOS development client
|
||||
- name: 📲 Install iOS development client
|
||||
run: |
|
||||
build_contents="$RUNNER_TEMP/nightly-e2e-ios-build"
|
||||
mkdir -p "$build_contents"
|
||||
@@ -135,14 +135,14 @@ jobs:
|
||||
fi
|
||||
xcrun simctl install "$IOS_UDID" "$app_path" 2>&1 | tee -a artifacts/ios/build.log
|
||||
|
||||
- name: Run iOS Maestro suite
|
||||
- name: 🧪 Run iOS Maestro suite
|
||||
run: .github/scripts/run-nightly-e2e.sh ios "$IOS_UDID"
|
||||
|
||||
- name: Clean up iOS services and simulator
|
||||
- name: 🧹 Clean up iOS services and simulator
|
||||
if: always()
|
||||
run: .github/scripts/cleanup-nightly-e2e.sh ios "${IOS_UDID:-}"
|
||||
|
||||
- name: Upload iOS E2E artifacts
|
||||
- name: 🚀 Upload iOS E2E artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
@@ -158,36 +158,36 @@ jobs:
|
||||
runs-on: Linux-x64-32core
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare E2E configuration
|
||||
- name: 🧰 Prepare E2E configuration
|
||||
run: |
|
||||
mkdir -p artifacts/android
|
||||
echo "Installing dependencies" > artifacts/android/phase.txt
|
||||
cp .env.example .env.test
|
||||
cp .env.example .env.development
|
||||
cp google-services.json.example google-services.json
|
||||
|
||||
- name: Set up Expo project
|
||||
- name: 🔧 Set up Expo project
|
||||
uses: ./.github/actions/setup-expo-project
|
||||
with:
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Set up Java 17
|
||||
- name: ☕️ Set up Java 17
|
||||
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Install dev-env dependencies
|
||||
- name: 📦 Install dev-env dependencies
|
||||
run: pnpm --dir dev-env install --frozen-lockfile 2>&1 | tee artifacts/android/dependencies.log
|
||||
|
||||
- name: Compile translations
|
||||
- name: 🔤 Compile translations
|
||||
uses: ./.github/actions/compile-i18n
|
||||
|
||||
- name: Install Maestro 2.6.1
|
||||
- name: 📥 Install Maestro 2.6.1
|
||||
run: |
|
||||
echo "Installing Maestro" > artifacts/android/phase.txt
|
||||
curl -fsSL -o "$RUNNER_TEMP/maestro.zip" \
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
"$RUNNER_TEMP/maestro/bin/maestro" --version | tee artifacts/android/maestro-version.log
|
||||
test "$("$RUNNER_TEMP/maestro/bin/maestro" --version)" = "$MAESTRO_VERSION"
|
||||
|
||||
- name: Install and boot one Android emulator
|
||||
- name: 📱 Install and boot one Android emulator
|
||||
run: |
|
||||
echo "Booting Android emulator" > artifacts/android/phase.txt
|
||||
android_sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-/usr/local/lib/android/sdk}}"
|
||||
@@ -299,10 +299,10 @@ jobs:
|
||||
adb -s emulator-5554 shell settings put global transition_animation_scale 0
|
||||
adb -s emulator-5554 shell settings put global animator_duration_scale 0
|
||||
|
||||
- name: Mark Android development client build phase
|
||||
- name: 🏷️ Mark Android development client build phase
|
||||
run: echo "Building the Android development client" > artifacts/android/phase.txt
|
||||
|
||||
- name: Build Android development client
|
||||
- name: 🏗️ Build Android development client
|
||||
uses: ./.github/actions/eas-local-build
|
||||
with:
|
||||
platform: android
|
||||
@@ -310,15 +310,15 @@ jobs:
|
||||
output: ${{ runner.temp }}/nightly-e2e-android.apk
|
||||
log-path: artifacts/android/build.log
|
||||
|
||||
- name: Install Android development client
|
||||
- name: 📲 Install Android development client
|
||||
run: |
|
||||
adb -s emulator-5554 install -r "$RUNNER_TEMP/nightly-e2e-android.apk" \
|
||||
2>&1 | tee -a artifacts/android/build.log
|
||||
|
||||
- name: Run Android Maestro suite
|
||||
- name: 🧪 Run Android Maestro suite
|
||||
run: .github/scripts/run-nightly-e2e.sh android emulator-5554
|
||||
|
||||
- name: Capture emulator crash diagnostics
|
||||
- name: 🩺 Capture emulator crash diagnostics
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
@@ -338,11 +338,11 @@ jobs:
|
||||
ls -la /tmp/android-runner/emu-crash-*.db 2>/dev/null || echo "No crash database found"
|
||||
} > artifacts/android/emulator-diagnostics.log 2>&1
|
||||
|
||||
- name: Clean up Android services and emulator
|
||||
- name: 🧹 Clean up Android services and emulator
|
||||
if: always()
|
||||
run: .github/scripts/cleanup-nightly-e2e.sh android emulator-5554
|
||||
|
||||
- name: Upload Android E2E artifacts
|
||||
- name: 🚀 Upload Android E2E artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
@@ -360,26 +360,26 @@ jobs:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download iOS artifacts
|
||||
- name: ⬇️ Download iOS artifacts
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: nightly-e2e-ios-${{ github.run_id }}
|
||||
path: downloaded-artifacts/ios
|
||||
|
||||
- name: Download Android artifacts
|
||||
- name: ⬇️ Download Android artifacts
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: nightly-e2e-android-${{ github.run_id }}
|
||||
path: downloaded-artifacts/android
|
||||
|
||||
- name: Resolve artifact links
|
||||
- name: 🔗 Resolve artifact links
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -395,7 +395,7 @@ jobs:
|
||||
}' artifact-response.json > artifact-links.json
|
||||
fi
|
||||
|
||||
- name: Summarize platform results
|
||||
- name: 📊 Summarize platform results
|
||||
id: summary
|
||||
env:
|
||||
IOS_STATUS: ${{ needs.ios.result }}
|
||||
@@ -415,7 +415,7 @@ jobs:
|
||||
echo "payload=$(jq -c .payload e2e-summary.json)" >> "$GITHUB_OUTPUT"
|
||||
jq -r .githubSummary e2e-summary.json >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Notify Slack of E2E failures
|
||||
- name: 🔔 Notify Slack of E2E failures
|
||||
if: steps.summary.outputs.notify == 'true'
|
||||
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
|
||||
with:
|
||||
|
||||
@@ -15,26 +15,26 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
- name: ⬇️ Check out Git repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}}
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- name: Install node
|
||||
- name: 🔧 Install node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
- name: pnpm install
|
||||
- name: 📦 pnpm install
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Extract language strings
|
||||
- name: 🔤 Extract language strings
|
||||
run: pnpm intl:extract
|
||||
- name: Create commit
|
||||
- name: 📝 Create commit
|
||||
uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0
|
||||
with:
|
||||
commit_message: Nightly source-language update
|
||||
file_pattern: ./src/locale/locales/en/messages.po
|
||||
- name: Push source lang to Crowdin
|
||||
- name: 🚀 Push source lang to Crowdin
|
||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
with:
|
||||
upload_sources: true
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
---
|
||||
name: PR Comment Trigger
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
# Permissions are granted per-job below; anything unlisted defaults to none
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
handle-comment:
|
||||
if: github.event.issue.pull_request
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should-deploy: ${{ steps.check-org.outputs.result }}
|
||||
|
||||
steps:
|
||||
- name: Check if bot is mentioned
|
||||
id: check-mention
|
||||
env:
|
||||
COMMENT: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
if [[ "$COMMENT" == *"@github-actions"* ]] || \
|
||||
[[ "$COMMENT" == *"github-actions[bot]"* ]]; then
|
||||
bot_mentioned=true
|
||||
else
|
||||
bot_mentioned=false
|
||||
fi
|
||||
|
||||
|
||||
if [[ "$COMMENT" == *"ota"* ]]; then
|
||||
has_ota=true
|
||||
else
|
||||
has_ota=false
|
||||
fi
|
||||
|
||||
|
||||
if [[ "$bot_mentioned" == "true" ]] && [[ "$has_ota" == "true" ]]; then
|
||||
echo "mentioned=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "mentioned=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check commenter has write access
|
||||
if: steps.check-mention.outputs.mentioned == 'true'
|
||||
id: check-org
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
try {
|
||||
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: context.payload.comment.user.login
|
||||
});
|
||||
|
||||
const hasAccess = ['admin', 'write'].includes(perm.permission);
|
||||
console.log(`User has ${perm.permission} access`);
|
||||
|
||||
return hasAccess;
|
||||
} catch(error) {
|
||||
console.log('User has no repository access');
|
||||
return false;
|
||||
}
|
||||
|
||||
bundle-deploy:
|
||||
name: Bundle and Deploy EAS Update
|
||||
runs-on: ubuntu-latest
|
||||
needs: [handle-comment]
|
||||
if: needs.handle-comment.outputs.should-deploy == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Get PR HEAD SHA
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
id: pr-info
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: process.env.ISSUE_NUMBER,
|
||||
});
|
||||
|
||||
// This workflow runs with repo secrets in scope, so never build
|
||||
// code from a fork: the commenter authorizes the deploy, but a
|
||||
// fork controls what code would run during it
|
||||
const expected = `${context.repo.owner}/${context.repo.repo}`;
|
||||
const head = pr.data.head.repo?.full_name;
|
||||
if (head !== expected) {
|
||||
core.setFailed(`OTA deploys are only allowed for branches in ${expected}, not forks (got ${head})`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`PR HEAD SHA: ${pr.data.head.sha}`);
|
||||
console.log(`PR HEAD REF: ${pr.data.head.ref}`);
|
||||
|
||||
core.setOutput('head-sha', pr.data.head.sha);
|
||||
core.setOutput('head-ref', pr.data.head.ref);
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
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 }}
|
||||
message: |
|
||||
An OTA deployment has been requested and is now running for `${{ steps.pr-info.outputs.head-sha }}`.
|
||||
|
||||
[Here is some music to listen to while you wait...](https://www.youtube.com/watch?v=VBlFHuCzPgY)
|
||||
---
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
|
||||
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ steps.pr-info.outputs.head-sha }}
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: pnpm intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
|
||||
- name: Lint check
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
|
||||
|
||||
- name: Env
|
||||
id: env
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_ENV=testflight" >> .env
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env
|
||||
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
|
||||
echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ Create Bundle
|
||||
run: >
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
|
||||
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
|
||||
pnpm export
|
||||
|
||||
- name: 📦 Package Bundle and 🚀 Deploy
|
||||
run: pnpm use-build-number bash scripts/bundleUpdate.sh
|
||||
env:
|
||||
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
|
||||
CHANNEL_NAME: pull-request-${{ github.event.issue.number }}
|
||||
RUNTIME_VERSION:
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
with:
|
||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||
number: ${{ github.event.issue.number }}
|
||||
message: |
|
||||
Your requested OTA deployment was successful! You may now apply it by either scanning the QR code or opening the deep link below in your browser:
|
||||
|
||||
<img src="https://bsky-qr.vercel.app?channel=pull-request-$ISSUE_NUMBER" width=300 height=300>
|
||||
|
||||
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.issue.number }}`
|
||||
---
|
||||
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||
if: failure()
|
||||
with:
|
||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||
number: ${{ github.event.issue.number }}
|
||||
message: |
|
||||
Your requested OTA deployment was unsuccessful. See action logs for more details.
|
||||
---
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
@@ -15,9 +15,14 @@ concurrency:
|
||||
|
||||
# Permissions are granted per-job below; anything unlisted defaults to none.
|
||||
# pull-requests: write is needed by sticky-pull-request-comment to post the
|
||||
# bundle-size and fingerprint diffs
|
||||
# bundle-size and fingerprint diffs and the PR OTA install link
|
||||
permissions: {}
|
||||
|
||||
# denis release tag in bluesky-social/tango whose linux-amd64 binary the PR OTA
|
||||
# job downloads. Bump this one line to roll denis.
|
||||
env:
|
||||
DENIS_RELEASE_TAG: denis-v0.1.1
|
||||
|
||||
jobs:
|
||||
# Populate this from main so every PR can restore the same trusted baseline.
|
||||
webpack-analyzer-base:
|
||||
@@ -78,7 +83,7 @@ jobs:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Ensure tracking relevant branches and checkout base
|
||||
- name: 🌿 Ensure tracking relevant branches and checkout base
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
@@ -86,13 +91,13 @@ jobs:
|
||||
git checkout $HEAD_REF
|
||||
git checkout $BASE_REF
|
||||
|
||||
- name: Get the base commit
|
||||
- name: 🔍 Get the base commit
|
||||
id: base-commit
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: echo base-commit=$(git log -n 1 $BASE_REF --pretty=format:'%H') >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Merge PR commit
|
||||
- name: 🔀 Merge PR commit
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
run: |
|
||||
@@ -116,7 +121,7 @@ jobs:
|
||||
path: stats.json
|
||||
key: stats-base-main-${{ steps.base-commit.outputs.base-commit }}
|
||||
|
||||
- name: Restore to base commit
|
||||
- name: ⏪ Restore to base commit
|
||||
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||||
env:
|
||||
BASE_COMMIT: ${{ steps.base-commit.outputs.base-commit }}
|
||||
@@ -155,6 +160,10 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
outputs:
|
||||
# Empty when the native surface is unchanged, 'true' when it changed.
|
||||
# publish-pr-ota gates on this.
|
||||
includes-changes: ${{ steps.fingerprint.outputs.includes-changes }}
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
@@ -205,6 +214,16 @@ jobs:
|
||||
header: fingerprint-diff
|
||||
delete: true
|
||||
|
||||
# publish-pr-ota is skipped once the fingerprint changes, so any install
|
||||
# link left over from an earlier fingerprint-clean commit on this PR now
|
||||
# points at a bundle that no longer matches the PR. Drop it.
|
||||
- name: 💬 Delete stale OTA install comment
|
||||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||
if: ${{ steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
header: pull-request-ota
|
||||
delete: true
|
||||
|
||||
- name: 🏷️ Label as fingerprint changed
|
||||
if: ${{ steps.fingerprint.outputs.includes-changes }}
|
||||
env:
|
||||
@@ -220,3 +239,129 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
gh pr edit "$PR_NUMBER" --remove-label "bot: fingerprint changed" || true
|
||||
|
||||
# Automatic per-PR OTA preview, published to the pull-request-<N> channel on
|
||||
# denis. Replaces the old `@github-actions ota` comment trigger. Gated to
|
||||
# same-repo PRs (fork guard): a branch can only exist in this repo if someone
|
||||
# with write access pushed it, so an outside contributor (who can only open a
|
||||
# PR from a fork) never runs this job with the denis publish role in scope.
|
||||
# This matches the fork-guard gate the other jobs in this workflow use;
|
||||
# author_association is deliberately NOT checked (it can't identify a private
|
||||
# org member and would skip their PRs).
|
||||
#
|
||||
# Bot authors are excluded: Dependabot pushes in-repo branches, so it passes
|
||||
# the fork guard, but GitHub withholds repo secrets from Dependabot-triggered
|
||||
# runs. EXPO_TOKEN is then empty and the job fails at setup — a red check on
|
||||
# every dependabot PR. There is no OTA preview worth publishing for a
|
||||
# dependency bump anyway.
|
||||
#
|
||||
# Gated on a clean fingerprint-native run: an OTA can only carry JS, so once
|
||||
# the native surface changes the published bundle no longer represents the PR
|
||||
# and installing it on a store/TestFlight client is misleading at best. Those
|
||||
# PRs need a native build instead. A skipped or failed fingerprint job also
|
||||
# skips this one - without a verdict we can't say the OTA is representative.
|
||||
publish-pr-ota:
|
||||
name: Publish PR OTA to denis
|
||||
needs: fingerprint-native
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.user.type != 'Bot' &&
|
||||
needs.fingerprint-native.outputs.includes-changes != 'true'
|
||||
concurrency:
|
||||
group: pr-ota-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: 🛠️ Setup Expo project
|
||||
uses: ./.github/actions/setup-expo-project
|
||||
with:
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
uses: ./.github/actions/compile-i18n
|
||||
|
||||
- name: ✏️ Write environment variables
|
||||
id: env
|
||||
uses: ./.github/actions/write-env
|
||||
with:
|
||||
env-token: ${{ secrets.ENV_TOKEN }}
|
||||
sentry-dsn: ${{ secrets.SENTRY_DSN }}
|
||||
bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }}
|
||||
gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
|
||||
google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
|
||||
expo-public-env: testflight
|
||||
|
||||
- name: 🏗️ Create Bundle
|
||||
run: >
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.release-version }}
|
||||
SENTRY_DIST=${{ steps.env.outputs.bundle-identifier }}
|
||||
pnpm export
|
||||
|
||||
- name: ☁️ Configure AWS credentials (denis, PR-scoped)
|
||||
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::007404326489:role/denis-ci-publish-pr
|
||||
aws-region: us-east-2
|
||||
# Defense-in-depth: the base role is already scoped to pr/*, but narrow
|
||||
# this session further to just THIS PR's prefix so a bug can't write to
|
||||
# another PR's objects or the prod tree.
|
||||
inline-session-policy: |-
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:PutObject", "s3:DeleteObject"],
|
||||
"Resource": "arn:aws:s3:::bsky-denis-ota-prod/pr/${{ github.event.pull_request.number }}/*"
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:ListBucket",
|
||||
"Resource": "arn:aws:s3:::bsky-denis-ota-prod",
|
||||
"Condition": {
|
||||
"StringLike": { "s3:prefix": "pr/${{ github.event.pull_request.number }}/*" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
- name: ⬇️ Setup denis CLI
|
||||
uses: ./.github/actions/setup-denis
|
||||
with:
|
||||
release-tag: ${{ env.DENIS_RELEASE_TAG }}
|
||||
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
|
||||
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
|
||||
|
||||
- name: 🚀 Publish OTA to denis (S3)
|
||||
run: pnpm use-build-number bash scripts/denisPublish.sh
|
||||
env:
|
||||
RUNTIME_VERSION: ''
|
||||
CHANNEL_NAME: pull-request-${{ github.event.pull_request.number }}
|
||||
|
||||
comment-pr-ota:
|
||||
name: Comment PR OTA install link
|
||||
needs: publish-pr-ota
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: 💬 Drop OTA install comment
|
||||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||
with:
|
||||
header: pull-request-ota
|
||||
message: |
|
||||
The OTA deployment for this PR was successful! You may now apply it by either scanning the QR code or opening the deep link below in your browser:
|
||||
|
||||
<img src="https://bsky-qr.vercel.app?channel=pull-request-${{ github.event.pull_request.number }}" width="300" height="300" alt="QR code for the PR OTA deployment">
|
||||
|
||||
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.pull_request.number }}`
|
||||
|
||||
@@ -13,14 +13,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
steps:
|
||||
- name: Checkout public repo
|
||||
- name: ⬇️ Checkout public repo
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Don't persist the checkout auth header; the push below authenticates
|
||||
# with the app token embedded in the remote URL instead
|
||||
persist-credentials: false
|
||||
- name: Generate GitHub App Token
|
||||
- name: 🔑 Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
# .github/workflows/, which GitHub refuses to push without it.
|
||||
permission-contents: write
|
||||
permission-workflows: write
|
||||
- name: Push to internal repo
|
||||
- name: 🚀 Push to internal repo
|
||||
env:
|
||||
TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
|
||||
@@ -14,33 +14,33 @@ jobs:
|
||||
name: No manual pnpm-lock.yaml edits
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out PR HEAD
|
||||
- name: ⬇️ Check out PR HEAD
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch base branch
|
||||
- name: ⬇️ Fetch base branch
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: git fetch origin $BASE_REF --depth=1
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: Install node
|
||||
- name: 🔧 Install node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Reset pnpm-lock.yaml to base
|
||||
- name: ⏪ Reset pnpm-lock.yaml to base
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: git show "origin/$BASE_REF:pnpm-lock.yaml" > pnpm-lock.yaml
|
||||
|
||||
- name: pnpm install
|
||||
- name: 📦 pnpm install
|
||||
# Fine to skip scripts since we don't run any code
|
||||
run: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
- name: Verify pnpm-lock.yaml
|
||||
- name: 🔍 Verify pnpm-lock.yaml
|
||||
run: |
|
||||
git diff --quiet --exit-code || {
|
||||
echo '::error::`pnpm-lock.yaml` does not match what pnpm would generate given the base `pnpm-lock.yaml` and the head `package.json`.'
|
||||
|
||||
@@ -20,12 +20,12 @@ jobs:
|
||||
name: Audit workflows with zizmor
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
- name: ⬇️ Check out Git repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor
|
||||
- name: 🛡️ Run zizmor
|
||||
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
||||
with:
|
||||
# Annotate the PR directly instead of uploading SARIF to the
|
||||
|
||||
@@ -134,3 +134,6 @@ bskyweb/static/media/*.svg
|
||||
# superpowers plugin plans/specs — local-only workspace
|
||||
docs/superpowers/
|
||||
.claude/worktrees
|
||||
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
|
||||
@@ -55,7 +55,6 @@ module.exports = function (_config) {
|
||||
icon: './assets/app-icons/ios_icon_default_next.png',
|
||||
userInterfaceStyle: 'automatic',
|
||||
primaryColor: '#006AFF',
|
||||
newArchEnabled: false,
|
||||
ios: {
|
||||
supportsTablet: false,
|
||||
bundleIdentifier: 'xyz.blueskyweb.app',
|
||||
|
||||
+14
-12
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* @param {import("@babel/core").ConfigAPI} api
|
||||
* @returns {import("@babel/core").InputOptions}
|
||||
*/
|
||||
module.exports = function (api) {
|
||||
api.cache(true)
|
||||
const isTestEnv = process.env.NODE_ENV === 'test'
|
||||
return {
|
||||
presets: [
|
||||
[
|
||||
@@ -10,7 +12,7 @@ module.exports = function (api) {
|
||||
native: {
|
||||
// Disable ESM -> CJS compilation because Metro takes care of it.
|
||||
// However, we need it in Jest tests since those run without Metro.
|
||||
disableImportExportTransform: !isTestEnv,
|
||||
disableImportExportTransform: !api.env('test'),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -29,15 +31,15 @@ module.exports = function (api) {
|
||||
},
|
||||
},
|
||||
],
|
||||
'react-native-reanimated/plugin', // NOTE: this plugin MUST be last
|
||||
|
||||
// cannot use `env` field because it will put them after
|
||||
// the `react-native-worklets/plugin` plugin
|
||||
...(api.env('test')
|
||||
? ['@babel/plugin-transform-class-static-block']
|
||||
: []),
|
||||
...(api.env('production') ? ['transform-remove-console'] : []),
|
||||
|
||||
'react-native-worklets/plugin', // NOTE: this plugin MUST be last
|
||||
],
|
||||
env: {
|
||||
production: {
|
||||
plugins: ['transform-remove-console'],
|
||||
},
|
||||
test: {
|
||||
plugins: ['@babel/plugin-transform-class-static-block'],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ require (
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/uuid v1.4.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/ipfs/bbloom v0.0.4 // indirect
|
||||
|
||||
+6
-5
@@ -17,6 +17,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etly
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
|
||||
github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
|
||||
github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU=
|
||||
@@ -40,10 +42,10 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGa
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
|
||||
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
|
||||
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
|
||||
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -186,7 +188,6 @@ github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
[
|
||||
{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"relation": [
|
||||
"delegate_permission/common.handle_all_urls",
|
||||
"delegate_permission/common.get_login_creds"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "xyz.blueskyweb.app",
|
||||
"sha256_cert_fingerprints":
|
||||
["C1:4D:3C:6B:B5:D6:D9:AE:CF:C5:0B:BC:C1:9B:29:6D:D4:E6:87:46:36:D5:4C:1A:64:1C:14:08:BF:7E:F9:62", "FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C"]
|
||||
"sha256_cert_fingerprints": [
|
||||
"C1:4D:3C:6B:B5:D6:D9:AE:CF:C5:0B:BC:C1:9B:29:6D:D4:E6:87:46:36:D5:4C:1A:64:1C:14:08:BF:7E:F9:62",
|
||||
"FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+3
-1
@@ -11,7 +11,9 @@
|
||||
"two_letters_code": {
|
||||
"pt": "pt-PT",
|
||||
"pt-BR": "pt-BR",
|
||||
"en-CA": "en-CA",
|
||||
"en-GB": "en-GB",
|
||||
"fr-CA": "fr-CA",
|
||||
"zh-CN": "zh-CN",
|
||||
"zh-TW": "zh-TW",
|
||||
"zh-HK": "zh-HK"
|
||||
@@ -19,4 +21,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+16
-5
@@ -52,7 +52,18 @@ have already been deployed for this release, incremement the branch name e.g.
|
||||
Cherry pick in the commits that need to be deployed on top of the most recent
|
||||
release or OTA.
|
||||
|
||||
### 5. Manually set build numbers
|
||||
### 5. Pull translations
|
||||
|
||||
Since translators may have added new strings, and positions within the code may
|
||||
have shifted, it's typically best to pull the latest translations.
|
||||
|
||||
Run this and commit the result as the last commit on the OTA branch.
|
||||
|
||||
```sh
|
||||
pnpm intl:release
|
||||
```
|
||||
|
||||
### 6. Manually set build numbers
|
||||
|
||||
Log in to the EAS CLI with `eas login` and manually set the build numbers to the
|
||||
values you found in **Step 1**.
|
||||
@@ -75,7 +86,7 @@ values you found in **Step 1**.
|
||||
👉 **Save the previous values,** in this case `1011` and `641`, so you can reset
|
||||
them after the OTA completes.
|
||||
|
||||
### 6. Run the GitHub actions
|
||||
### 7. Run the GitHub actions
|
||||
You'll need to run two separate actions: one to deploy the iOS/Android OTA
|
||||
itself, and one to build the web Docker container.
|
||||
|
||||
@@ -102,13 +113,13 @@ and run the action.
|
||||
| ----- | --- |
|
||||
| Select your OTA branch `1.x.0-ota-x` and click "Run workflow" |  |
|
||||
|
||||
### 7. Deploy web
|
||||
### 8. Deploy web
|
||||
|
||||
Once the web Docker container build finishes, go to your `1.x.0-ota-x` branch,
|
||||
copy the most recent commit hash. Post this hash in `#ops-deploys` and request
|
||||
someone with web deploy access deploy the built container.
|
||||
|
||||
### 8. Confirm successful deployment
|
||||
### 9. Confirm successful deployment
|
||||
|
||||
In about five minutes, the new deployment should be deployed and devices will
|
||||
begin downloading and installing in the background.
|
||||
@@ -119,7 +130,7 @@ build from your device and re-install from the App Store. Then, you'll need to:
|
||||
- Quit and reopen the app
|
||||
- Check the `Settings > About` page and confirm the hash matches the most recent hash on your OTA branch
|
||||
|
||||
### 9. Reset build numbers
|
||||
### 10. Reset build numbers
|
||||
|
||||
Grab the build numbers you saved in **Step 5** and reverse the EAS CLI commands
|
||||
to reset the build numbers.
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
},
|
||||
"env": {
|
||||
"EXPO_PUBLIC_ENV": "e2e",
|
||||
"NODE_ENV": "test",
|
||||
"NODE_ENV": "development",
|
||||
"RN_SRC_EXT": "e2e.ts,e2e.tsx"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -13,11 +13,13 @@ export default defineConfig({
|
||||
'de',
|
||||
'el',
|
||||
'en-GB',
|
||||
'en-CA',
|
||||
'eo',
|
||||
'es',
|
||||
'eu',
|
||||
'fi',
|
||||
'fr',
|
||||
'fr-CA',
|
||||
'fy',
|
||||
'ga',
|
||||
'gd',
|
||||
|
||||
@@ -6,7 +6,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
// Views
|
||||
private var sheetVc: SheetViewController?
|
||||
private var innerView: UIView?
|
||||
private var touchHandler: RCTTouchHandler?
|
||||
private var touchHandler: RCTSurfaceTouchHandler?
|
||||
|
||||
// Native content height observation (eliminates JS bridge round-trip)
|
||||
private var contentHeightObservation: NSKeyValueObservation?
|
||||
@@ -81,33 +81,37 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
required init (appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
self.maxHeight = Util.getScreenHeight() ?? UIScreen.main.bounds.height
|
||||
self.touchHandler = RCTTouchHandler(bridge: appContext?.reactBridge)
|
||||
self.touchHandler = RCTSurfaceTouchHandler()
|
||||
SheetManager.shared.add(self)
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.destroy()
|
||||
}
|
||||
|
||||
override func mountChildComponentView(
|
||||
_ childComponentView: UIView,
|
||||
index: Int
|
||||
) {
|
||||
self.innerView = childComponentView
|
||||
touchHandler?.attach(to: childComponentView)
|
||||
}
|
||||
|
||||
override func unmountChildComponentView(
|
||||
_ childComponentView: UIView,
|
||||
index: Int
|
||||
) {
|
||||
touchHandler?.detach(from: childComponentView)
|
||||
|
||||
// We don't want this view to actually get added to the tree, so we'll simply store it for adding
|
||||
// to the SheetViewController
|
||||
override func insertReactSubview(_ subview: UIView!, at atIndex: Int) {
|
||||
self.touchHandler?.attach(to: subview)
|
||||
self.innerView = subview
|
||||
childComponentView.removeFromSuperview()
|
||||
if self.innerView === childComponentView {
|
||||
self.innerView = nil
|
||||
}
|
||||
}
|
||||
|
||||
// We'll grab the content height from here so we know the initial detent to set
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
guard let innerView = self.innerView else {
|
||||
return
|
||||
}
|
||||
|
||||
if innerView.subviews.count != 1 {
|
||||
return
|
||||
}
|
||||
|
||||
self.present()
|
||||
}
|
||||
|
||||
@@ -117,7 +121,10 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
self.isClosing = false
|
||||
self.isOpen = false
|
||||
self.sheetVc = nil
|
||||
self.touchHandler?.detach(from: self.innerView)
|
||||
|
||||
if let innerView = self.innerView {
|
||||
self.touchHandler?.detach(from: innerView)
|
||||
}
|
||||
self.touchHandler = nil
|
||||
self.innerView = nil
|
||||
SheetManager.shared.remove(self)
|
||||
@@ -146,8 +153,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
|
||||
if #available(iOS 26.0, *),
|
||||
let tag = self.sourceViewTag,
|
||||
let bridge = self.appContext?.reactBridge,
|
||||
let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: tag)) {
|
||||
let sourceView = self.appContext?.findView(withTag: tag, ofType: UIView.self) {
|
||||
sheetVc.preferredTransition = .zoom { _ in
|
||||
return sourceView
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
#if __has_include(<React/RCTSurfaceTouchHandler.h>)
|
||||
#import <React/RCTSurfaceTouchHandler.h>
|
||||
#endif
|
||||
@@ -1,116 +0,0 @@
|
||||
# expo-scroll-forwarder
|
||||
|
||||
An Expo native module that forwards scroll gestures from a UIView to a UIScrollView on iOS. This enables custom scroll behaviors by allowing a non-scrollable view to control a scrollable view's scroll position.
|
||||
|
||||
## What It Does
|
||||
|
||||
This module solves a specific interaction problem: allowing a fixed header or overlay view to respond to scroll gestures and forward them to an underlying scroll view. The primary use case in the Bluesky app is the profile screen, where the profile header sits above a scrollable content area and can be dragged to scroll the content below it.
|
||||
|
||||
Key behaviors:
|
||||
- Captures pan gestures on a wrapper view and translates them to scroll offsets on a target scroll view
|
||||
- Implements physics-based deceleration animations that match native scroll behavior
|
||||
- Supports pull-to-refresh interactions with haptic feedback
|
||||
- Prevents gesture conflicts with iOS swipe-back navigation by only activating on vertical pans
|
||||
- Provides rubber-band damping when scrolling past content bounds
|
||||
|
||||
## Architecture
|
||||
|
||||
The module consists of three main parts:
|
||||
|
||||
### 1. Native iOS Implementation (Swift)
|
||||
|
||||
**ExpoScrollForwarderView.swift** - The core native view component that:
|
||||
- Attaches a UIPanGestureRecognizer to intercept scroll gestures
|
||||
- Finds and references the target RCTScrollView using its React Native tag
|
||||
- Implements custom scroll physics including velocity-based decay animation
|
||||
- Manages gesture recognizer delegation to prevent conflicts with system gestures
|
||||
- Handles pull-to-refresh activation at -130pt scroll offset with haptic feedback
|
||||
|
||||
**ExpoScrollForwarderModule.swift** - The Expo module definition that:
|
||||
- Registers the view component with Expo
|
||||
- Exposes the `scrollViewTag` prop to specify which scroll view to control
|
||||
|
||||
### 2. TypeScript Interface
|
||||
|
||||
**ExpoScrollForwarderView.tsx** - Platform-specific implementations:
|
||||
- **iOS (.ios.tsx)**: Wraps the native view manager from expo-modules-core
|
||||
- **Default (.tsx)**: No-op wrapper that just renders children (for Android/Web compatibility)
|
||||
|
||||
**ExpoScrollForwarder.types.ts** - TypeScript type definitions:
|
||||
- `scrollViewTag`: The React Native tag of the scroll view to control
|
||||
- `children`: The content to render (typically a header component)
|
||||
|
||||
### 3. Module Configuration
|
||||
|
||||
**expo-module.config.json** - Declares iOS-only platform support
|
||||
|
||||
**ExpoScrollForwarder.podspec** - CocoaPods specification for iOS dependency management
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import {ExpoScrollForwarderView} from 'expo-scroll-forwarder'
|
||||
|
||||
function ProfileScreen() {
|
||||
const scrollViewTag = useRef(null)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ExpoScrollForwarderView scrollViewTag={scrollViewTag.current}>
|
||||
<ProfileHeader />
|
||||
</ExpoScrollForwarderView>
|
||||
|
||||
<ScrollView ref={scrollViewTag}>
|
||||
{/* Scrollable content */}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The `scrollViewTag` prop must be the React Native tag (numeric identifier) of the target scroll view. The module uses this to locate the native UIScrollView instance.
|
||||
|
||||
## Platform Support
|
||||
|
||||
- **iOS**: Full native implementation with custom scroll physics
|
||||
- **Android**: No-op wrapper (renders children without scroll forwarding)
|
||||
- **Web**: No-op wrapper (renders children without scroll forwarding)
|
||||
|
||||
The module is designed to enhance iOS UX while gracefully degrading on other platforms.
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### Gesture Recognition
|
||||
- Only activates when pan velocity is more vertical than horizontal (`abs(velocity.y) > abs(velocity.x)`)
|
||||
- Delegates to UIGestureRecognizerDelegate to prevent simultaneous recognition with navigation swipe-back
|
||||
- Adds tap/long-press recognizers to the scroll view to cancel ongoing animations
|
||||
|
||||
### Scroll Physics
|
||||
- Implements custom decay animation at 120fps using a Timer
|
||||
- Velocity decay factor: 0.9875 per frame
|
||||
- Velocity clamped to +/- 5000 points/second
|
||||
- Rubber-band damping: offsets below 0 are reduced by 55%
|
||||
- Animation stops when velocity drops below 5 points/second
|
||||
|
||||
### Pull-to-Refresh
|
||||
- Triggers at -130pt scroll offset
|
||||
- Provides haptic feedback (UIImpactFeedbackGenerator, light style)
|
||||
- Calls refresh control via `RCTRefreshControl.forwarderBeginRefreshing()`
|
||||
|
||||
### Scroll View Management
|
||||
- Dynamically finds scroll view using `AppContext.findView(withTag:ofType:)`
|
||||
- Properly cleans up gesture recognizers when switching between scroll views
|
||||
- Maintains references to both the scroll view and its refresh control
|
||||
|
||||
## Files Overview
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `ios/ExpoScrollForwarderView.swift` | Native iOS view implementation with gesture handling and scroll physics |
|
||||
| `ios/ExpoScrollForwarderModule.swift` | Expo module registration and prop definitions |
|
||||
| `ios/ExpoScrollForwarder.podspec` | CocoaPods dependency specification |
|
||||
| `src/ExpoScrollForwarderView.ios.tsx` | TypeScript wrapper for iOS native view |
|
||||
| `src/ExpoScrollForwarderView.tsx` | Default no-op implementation for other platforms |
|
||||
| `src/ExpoScrollForwarder.types.ts` | TypeScript type definitions |
|
||||
| `index.ts` | Module entry point |
|
||||
| `expo-module.config.json` | Expo module configuration |
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"platforms": ["ios"],
|
||||
"ios": {
|
||||
"modules": ["ExpoScrollForwarderModule"]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {ExpoScrollForwarderView} from './src/ExpoScrollForwarderView'
|
||||
@@ -1,21 +0,0 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ExpoScrollForwarder'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Forward scroll gesture from UIView to UIScrollView'
|
||||
s.description = 'Forward scroll gesture from UIView to UIScrollView'
|
||||
s.author = 'bluesky-social'
|
||||
s.homepage = 'https://github.com/bluesky-social/social-app'
|
||||
s.platforms = { :ios => '13.4', :tvos => '13.4' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
@@ -1,13 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class ExpoScrollForwarderModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoScrollForwarder")
|
||||
|
||||
View(ExpoScrollForwarderView.self) {
|
||||
Prop("scrollViewTag") { (view: ExpoScrollForwarderView, prop: Int) in
|
||||
view.scrollViewTag = prop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
import React
|
||||
|
||||
// This view will be used as a native component. Make sure to inherit from `ExpoView`
|
||||
// to apply the proper styling (e.g. border radius and shadows).
|
||||
class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
|
||||
var scrollViewTag: Int? {
|
||||
didSet {
|
||||
self.tryFindScrollView()
|
||||
}
|
||||
}
|
||||
|
||||
private var rctScrollView: RCTScrollView?
|
||||
private var rctRefreshCtrl: RCTRefreshControl?
|
||||
private var cancelGestureRecognizers: [UIGestureRecognizer]?
|
||||
private var animTimer: Timer?
|
||||
private var initialOffset: CGFloat = 0.0
|
||||
private var didImpact: Bool = false
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
|
||||
let pg = UIPanGestureRecognizer(target: self, action: #selector(callOnPan(_:)))
|
||||
pg.delegate = self
|
||||
self.addGestureRecognizer(pg)
|
||||
|
||||
let tg = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
|
||||
tg.isEnabled = false
|
||||
tg.delegate = self
|
||||
|
||||
let lpg = UILongPressGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
|
||||
lpg.minimumPressDuration = 0.01
|
||||
lpg.isEnabled = false
|
||||
lpg.delegate = self
|
||||
|
||||
self.cancelGestureRecognizers = [lpg, tg]
|
||||
}
|
||||
|
||||
// We don't want to recognize the scroll pan gesture and the swipe back gesture together
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer is UIPanGestureRecognizer, otherGestureRecognizer is UIPanGestureRecognizer {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// We only want the "scroll" gesture to happen whenever the pan is vertical, otherwise it will
|
||||
// interfere with the native swipe back gesture.
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else {
|
||||
return true
|
||||
}
|
||||
|
||||
let velocity = gestureRecognizer.velocity(in: self)
|
||||
return abs(velocity.y) > abs(velocity.x)
|
||||
}
|
||||
|
||||
// This will be used to cancel the scroll animation whenever we tap inside of the header. We don't need another
|
||||
// recognizer for this one.
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
self.stopTimer()
|
||||
}
|
||||
|
||||
// This will be used to cancel the animation whenever we press inside of the scroll view. We don't want to change
|
||||
// the scroll view gesture's delegate, so we add an additional recognizer to detect this.
|
||||
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) {
|
||||
self.stopTimer()
|
||||
}
|
||||
|
||||
@IBAction func callOnPan(_ sender: UIPanGestureRecognizer) {
|
||||
guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else {
|
||||
return
|
||||
}
|
||||
|
||||
let translation = sender.translation(in: self).y
|
||||
|
||||
if sender.state == .began {
|
||||
if sv.contentOffset.y < 0 {
|
||||
sv.contentOffset.y = 0
|
||||
}
|
||||
|
||||
self.initialOffset = sv.contentOffset.y
|
||||
}
|
||||
|
||||
if sender.state == .changed {
|
||||
sv.contentOffset.y = self.dampenOffset(-translation + self.initialOffset)
|
||||
|
||||
if sv.contentOffset.y <= -130, !didImpact {
|
||||
let generator = UIImpactFeedbackGenerator(style: .light)
|
||||
generator.impactOccurred()
|
||||
|
||||
self.didImpact = true
|
||||
}
|
||||
}
|
||||
|
||||
if sender.state == .ended {
|
||||
let velocity = sender.velocity(in: self).y
|
||||
self.didImpact = false
|
||||
|
||||
if sv.contentOffset.y <= -130 {
|
||||
self.rctRefreshCtrl?.forwarderBeginRefreshing()
|
||||
return
|
||||
}
|
||||
|
||||
// A check for a velocity under 250 prevents animations from occurring when they wouldn't in a normal
|
||||
// scroll view
|
||||
if abs(velocity) < 250, sv.contentOffset.y >= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
self.startDecayAnimation(translation, velocity)
|
||||
}
|
||||
}
|
||||
|
||||
func startDecayAnimation(_ translation: CGFloat, _ velocity: CGFloat) {
|
||||
guard let sv = self.rctScrollView?.scrollView else {
|
||||
return
|
||||
}
|
||||
|
||||
var velocity = velocity
|
||||
|
||||
self.enableCancelGestureRecognizers()
|
||||
|
||||
if velocity > 0 {
|
||||
velocity = min(velocity, 5000)
|
||||
} else {
|
||||
velocity = max(velocity, -5000)
|
||||
}
|
||||
|
||||
var animTranslation = -translation
|
||||
self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { _ in
|
||||
velocity *= 0.9875
|
||||
animTranslation = (-velocity / 120) + animTranslation
|
||||
|
||||
let nextOffset = self.dampenOffset(animTranslation + self.initialOffset)
|
||||
|
||||
if nextOffset <= 0 {
|
||||
if self.initialOffset <= 1 {
|
||||
self.scrollToOffset(0)
|
||||
} else {
|
||||
sv.contentOffset.y = 0
|
||||
}
|
||||
|
||||
self.stopTimer()
|
||||
return
|
||||
} else {
|
||||
sv.contentOffset.y = nextOffset
|
||||
}
|
||||
|
||||
if abs(velocity) < 5 {
|
||||
self.stopTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dampenOffset(_ offset: CGFloat) -> CGFloat {
|
||||
if offset < 0 {
|
||||
return offset - (offset * 0.55)
|
||||
}
|
||||
|
||||
return offset
|
||||
}
|
||||
|
||||
func tryFindScrollView() {
|
||||
guard let scrollViewTag = scrollViewTag else {
|
||||
return
|
||||
}
|
||||
|
||||
// Before we switch to a different scrollview, we always want to remove the cancel gesture recognizer.
|
||||
// Otherwise we might end up with duplicates when we switch back to that scrollview.
|
||||
self.removeCancelGestureRecognizers()
|
||||
|
||||
self.rctScrollView = self.appContext?
|
||||
.findView(withTag: scrollViewTag, ofType: RCTScrollView.self)
|
||||
self.rctRefreshCtrl = self.rctScrollView?.scrollView.refreshControl as? RCTRefreshControl
|
||||
|
||||
self.addCancelGestureRecognizers()
|
||||
}
|
||||
|
||||
func addCancelGestureRecognizers() {
|
||||
self.cancelGestureRecognizers?.forEach { r in
|
||||
self.rctScrollView?.scrollView?.addGestureRecognizer(r)
|
||||
}
|
||||
}
|
||||
|
||||
func removeCancelGestureRecognizers() {
|
||||
self.cancelGestureRecognizers?.forEach { r in
|
||||
self.rctScrollView?.scrollView?.removeGestureRecognizer(r)
|
||||
}
|
||||
}
|
||||
|
||||
func enableCancelGestureRecognizers() {
|
||||
self.cancelGestureRecognizers?.forEach { r in
|
||||
r.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
func disableCancelGestureRecognizers() {
|
||||
self.cancelGestureRecognizers?.forEach { r in
|
||||
r.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
func scrollToOffset(_ offset: Int, animated: Bool = true) {
|
||||
self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated)
|
||||
}
|
||||
|
||||
func stopTimer() {
|
||||
self.disableCancelGestureRecognizers()
|
||||
self.animTimer?.invalidate()
|
||||
self.animTimer = nil
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export interface ExpoScrollForwarderViewProps {
|
||||
scrollViewTag: number | null
|
||||
children: React.ReactNode
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import {requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {type ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
|
||||
|
||||
const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =
|
||||
requireNativeViewManager('ExpoScrollForwarder')
|
||||
|
||||
export function ExpoScrollForwarderView({
|
||||
children,
|
||||
...rest
|
||||
}: ExpoScrollForwarderViewProps) {
|
||||
return <NativeView {...rest}>{children}</NativeView>
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import {type ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
|
||||
|
||||
export function ExpoScrollForwarderView({
|
||||
children,
|
||||
}: React.PropsWithChildren<ExpoScrollForwarderViewProps>) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
require "json"
|
||||
|
||||
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = "ScrollForwarder"
|
||||
s.version = package["version"]
|
||||
s.summary = package["description"]
|
||||
s.homepage = package["homepage"]
|
||||
s.license = package["license"]
|
||||
s.authors = package["author"]
|
||||
|
||||
s.platforms = { :ios => min_ios_version_supported }
|
||||
s.source = { :git => ".git", :tag => "#{s.version}" }
|
||||
|
||||
s.source_files = "ios/**/*.{h,m,mm,cpp}"
|
||||
s.private_header_files = "ios/**/*.h"
|
||||
|
||||
install_modules_dependencies(s)
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
#import <React/RCTViewComponentView.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
#ifndef ScrollForwarderViewNativeComponent_h
|
||||
#define ScrollForwarderViewNativeComponent_h
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface ScrollForwarderView : RCTViewComponentView
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
#endif /* ScrollForwarderViewNativeComponent_h */
|
||||
@@ -0,0 +1,411 @@
|
||||
#import "ScrollForwarderView.h"
|
||||
|
||||
#import <React/RCTEnhancedScrollView.h>
|
||||
#import <React/RCTScrollViewComponentView.h>
|
||||
#import <react/renderer/components/ScrollForwarderViewSpec/ComponentDescriptors.h>
|
||||
#import <react/renderer/components/ScrollForwarderViewSpec/EventEmitters.h>
|
||||
#import <react/renderer/components/ScrollForwarderViewSpec/Props.h>
|
||||
#import <react/renderer/components/ScrollForwarderViewSpec/RCTComponentViewHelpers.h>
|
||||
|
||||
#import "RCTFabricComponentsPlugins.h"
|
||||
|
||||
using namespace facebook::react;
|
||||
|
||||
// How far down a pull needs to be to trigger a refresh
|
||||
static const CGFloat kPullThreshold = 130.0;
|
||||
static const CGFloat kDampingFactor = 0.55;
|
||||
// The top speed that free scrolling can have
|
||||
static const CGFloat kMaxVelocity = 5000.0;
|
||||
// Free scrolling decay. This seems to be close to the default iOS value
|
||||
static const CGFloat kVelocityDecay = 0.9875;
|
||||
// What scroll release velocity will actually trigger free scrolling
|
||||
static const CGFloat kMinimumVelocity = 5.0;
|
||||
|
||||
@interface ScrollForwarderView () <RCTScrollForwarderViewViewProtocol, UIGestureRecognizerDelegate>
|
||||
|
||||
@end
|
||||
|
||||
@implementation ScrollForwarderView {
|
||||
NSArray<UIGestureRecognizer *> * _cancelGestureRecognizers;
|
||||
RCTScrollViewComponentView * _svcv;
|
||||
CGPoint _initialOffset;
|
||||
|
||||
CADisplayLink * _displayLink;
|
||||
CGFloat _currentVelocity;
|
||||
CGFloat _accumulatedTranslation;
|
||||
|
||||
bool _didImpact;
|
||||
}
|
||||
|
||||
+ (ComponentDescriptorProvider)componentDescriptorProvider
|
||||
{
|
||||
return concreteComponentDescriptorProvider<ScrollForwarderViewComponentDescriptor>();
|
||||
}
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame
|
||||
{
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
static const auto defaultProps = std::make_shared<const ScrollForwarderViewProps>();
|
||||
_props = defaultProps;
|
||||
|
||||
UIPanGestureRecognizer *pg = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePan:)];
|
||||
pg.delegate = self;
|
||||
pg.cancelsTouchesInView = false;
|
||||
[self addGestureRecognizer:pg];
|
||||
|
||||
UITapGestureRecognizer *tg = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap:)];
|
||||
[tg setEnabled:false];
|
||||
tg.delegate = self;
|
||||
|
||||
UILongPressGestureRecognizer *lpg = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
|
||||
[lpg setMinimumPressDuration:0.01];
|
||||
[lpg setEnabled:false];
|
||||
lpg.delegate = self;
|
||||
|
||||
NSArray<UIGestureRecognizer *> *cancelGestureRecognizers = [NSArray arrayWithObjects:lpg, tg, nil];
|
||||
_cancelGestureRecognizers = cancelGestureRecognizers;
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[self stopAnimation];
|
||||
[self removeCancelGestureRecognizers];
|
||||
_svcv = nil;
|
||||
|
||||
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
|
||||
gr.delegate = nil;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)prepareForRecycle
|
||||
{
|
||||
[super prepareForRecycle];
|
||||
[self stopAnimation];
|
||||
[self removeCancelGestureRecognizers];
|
||||
_svcv = nil;
|
||||
}
|
||||
|
||||
// MARK: - Props
|
||||
|
||||
- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
|
||||
{
|
||||
const auto &oldViewProps = *std::static_pointer_cast<ScrollForwarderViewProps const>(_props);
|
||||
const auto &newViewProps = *std::static_pointer_cast<ScrollForwarderViewProps const>(props);
|
||||
|
||||
if (oldViewProps.scrollViewTag != newViewProps.scrollViewTag) {
|
||||
[self tryFindScrollView];
|
||||
}
|
||||
|
||||
if (oldViewProps.refreshing != newViewProps.refreshing) {
|
||||
if (!newViewProps.refreshing) {
|
||||
[self endRefreshing];
|
||||
}
|
||||
}
|
||||
|
||||
[super updateProps:props oldProps:oldProps];
|
||||
}
|
||||
|
||||
// MARK: - UIGestureRecognizerDelegate
|
||||
|
||||
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
|
||||
{
|
||||
if ([gestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]] && [otherGestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
|
||||
{
|
||||
if (![gestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]) {
|
||||
return YES;
|
||||
}
|
||||
|
||||
UIPanGestureRecognizer *pg = (UIPanGestureRecognizer *)gestureRecognizer;
|
||||
CGPoint velocity = [pg velocityInView:self];
|
||||
|
||||
return fabs(velocity.y) > fabs(velocity.x);
|
||||
}
|
||||
|
||||
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
|
||||
{
|
||||
[self stopAnimation];
|
||||
[super touchesBegan:touches withEvent:event];
|
||||
}
|
||||
|
||||
// MARK: - Scroll Forwarding
|
||||
|
||||
- (void)removeCancelGestureRecognizers
|
||||
{
|
||||
if (!_svcv) return;
|
||||
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
|
||||
[_svcv.scrollView removeGestureRecognizer:gr];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)addCancelGestureRecognizers
|
||||
{
|
||||
if (!_svcv) return;
|
||||
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
|
||||
[_svcv.scrollView addGestureRecognizer:gr];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)enableCancelGestureRecognizers
|
||||
{
|
||||
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
|
||||
[gr setEnabled:true];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)disableCancelGestureRecognizers
|
||||
{
|
||||
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
|
||||
[gr setEnabled:false];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)scrollToOffset:(CGPoint)offset animated:(bool)animated
|
||||
{
|
||||
if (!_svcv) return;
|
||||
[_svcv scrollToOffset:offset animated:animated];
|
||||
}
|
||||
|
||||
- (void)stopAnimation
|
||||
{
|
||||
[self disableCancelGestureRecognizers];
|
||||
[_displayLink invalidate];
|
||||
_displayLink = nil;
|
||||
}
|
||||
|
||||
- (void)handlePan:(UIPanGestureRecognizer *)gesture {
|
||||
if (!_svcv) return;
|
||||
|
||||
UIScrollView *sv = _svcv.scrollView;
|
||||
|
||||
CGPoint translation = [gesture translationInView:self];
|
||||
|
||||
if (gesture.state == UIGestureRecognizerStateBegan) {
|
||||
_didImpact = false;
|
||||
|
||||
if (sv.contentOffset.y < 0) {
|
||||
CGPoint newOffset = CGPointMake(sv.contentOffset.x, 0);
|
||||
sv.contentOffset = newOffset;
|
||||
}
|
||||
|
||||
_initialOffset = sv.contentOffset;
|
||||
}
|
||||
|
||||
if (gesture.state == UIGestureRecognizerStateChanged) {
|
||||
CGPoint newOffset = CGPointMake(sv.contentOffset.x, [self dampenOffset:(-translation.y + _initialOffset.y)]);
|
||||
sv.contentOffset = newOffset;
|
||||
|
||||
if (sv.contentOffset.y <= -kPullThreshold && !_didImpact) {
|
||||
UIImpactFeedbackGenerator *generator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
|
||||
[generator impactOccurred];
|
||||
_didImpact = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (gesture.state == UIGestureRecognizerStateEnded) {
|
||||
CGPoint velocity = [gesture velocityInView:self];
|
||||
|
||||
if (sv.contentOffset.y <= -kPullThreshold) {
|
||||
[self refresh];
|
||||
return;
|
||||
}
|
||||
|
||||
if (sv.contentOffset.y < 0) {
|
||||
CGPoint newOffset = CGPointMake(sv.contentOffset.x, 0);
|
||||
[self scrollToOffset:newOffset animated:true];
|
||||
return;
|
||||
}
|
||||
|
||||
if (abs(velocity.y) < 250 && sv.contentOffset.y >= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
[self startDecayWithInitialTranslation:translation.y velocity:velocity.y];
|
||||
}
|
||||
}
|
||||
|
||||
- (CGFloat)dampenOffset:(CGFloat)offset
|
||||
{
|
||||
if (offset < 0) {
|
||||
return offset - (offset * kDampingFactor);
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
- (void)handleTap:(UITapGestureRecognizer *)gesture {
|
||||
[self stopAnimation];
|
||||
}
|
||||
|
||||
- (void)handleLongPress:(UILongPressGestureRecognizer *)gesture {
|
||||
[self stopAnimation];
|
||||
}
|
||||
|
||||
- (void)startDecayWithInitialTranslation:(CGFloat)translation velocity:(CGFloat)startVelocity
|
||||
{
|
||||
if (!_svcv) return;
|
||||
|
||||
startVelocity = MAX(-kMaxVelocity, MIN(kMaxVelocity, startVelocity));
|
||||
_currentVelocity = startVelocity;
|
||||
_accumulatedTranslation = -translation;
|
||||
|
||||
[self enableCancelGestureRecognizers];
|
||||
|
||||
[_displayLink invalidate];
|
||||
|
||||
CADisplayLink *link = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDecayStep:)];
|
||||
|
||||
link.preferredFramesPerSecond = 60;
|
||||
[link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
|
||||
_displayLink = link;
|
||||
}
|
||||
|
||||
- (void)handleDecayStep:(CADisplayLink *)link
|
||||
{
|
||||
_currentVelocity *= kVelocityDecay;
|
||||
|
||||
CGFloat delta = -_currentVelocity / link.preferredFramesPerSecond;
|
||||
_accumulatedTranslation += delta;
|
||||
|
||||
CGFloat rawY = _accumulatedTranslation + _initialOffset.y;
|
||||
CGFloat nextY = rawY > 0 ? rawY : 0;
|
||||
|
||||
CGPoint newOffset = CGPointMake(
|
||||
_svcv.scrollView.contentOffset.x,
|
||||
nextY
|
||||
);
|
||||
_svcv.scrollView.contentOffset = newOffset;
|
||||
|
||||
if (fabs(_currentVelocity) < kMinimumVelocity || nextY <= 0) {
|
||||
[link invalidate];
|
||||
_displayLink = nil;
|
||||
[self disableCancelGestureRecognizers];
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* We use this component on profile pages. The screne consists of a header component, a scrollview with buttons to
|
||||
* switch between profile tabs, and a pager view (RNCPagerViewComponentView). Both the header and the tab bar are
|
||||
* inside the same RCTViewComponentView. The view heirarchy looks something like this:
|
||||
* - RCTViewComponentView
|
||||
* -- RNCPagerViewComponentView
|
||||
* ----- (Many views deep) RCTScrollViewComponentView
|
||||
* ------ RCTEnhancedScrollView
|
||||
* -- RCTViewComponentView
|
||||
* --- RCTViewComponentView
|
||||
* ---- ScrollForwarderView
|
||||
* --- RCTScrollViewComponentView
|
||||
* ---- RCTEnhancedScrollView
|
||||
*
|
||||
* We want to find that RCTScrollViewComponentView inside of the RNCPagerViewComponentView. To achieve this, we can
|
||||
* use self.superview.superview.superview to get to the root RCTViewComponentView, find the RNCPagerViewComponentView,
|
||||
* then iterate through that view's subviews until we find a RCTScrollViewComponentView.
|
||||
*
|
||||
* This isn't great, because if we reorder the React components, we'll need to update this logic. There's probably
|
||||
* an easier way to achieve this, similar to how we used to do it in Paper (ie, get the scroll view's tag and find that),
|
||||
* but this also comes with some benefits, eg being able to reduce a lot of the logic in the JS code and just find the
|
||||
* scrollview when subviews change.
|
||||
*/
|
||||
- (void)tryFindScrollView
|
||||
{
|
||||
[self removeCancelGestureRecognizers];
|
||||
|
||||
// The root RCTViewComponentView
|
||||
UIView *rootView = self.superview.superview.superview;
|
||||
UIView *pagerView;
|
||||
|
||||
NSString *targetClsName = @"RNCPagerViewComponentView";
|
||||
Class targetCls = NSClassFromString(targetClsName);
|
||||
|
||||
for (UIView *subview in rootView.subviews) {
|
||||
if ([subview isKindOfClass:targetCls]) {
|
||||
pagerView = subview;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pagerView) return;
|
||||
|
||||
RCTScrollViewComponentView *svcv = [self findRTCScrollViewComponentViewInView:pagerView];
|
||||
|
||||
if (!svcv) return;
|
||||
|
||||
_svcv = svcv;
|
||||
[self addCancelGestureRecognizers];
|
||||
}
|
||||
|
||||
- (RCTScrollViewComponentView *)findRTCScrollViewComponentViewInView:(UIView *)view
|
||||
{
|
||||
for (UIView *subview in view.subviews) {
|
||||
if ([subview isKindOfClass:[RCTScrollViewComponentView class]]) {
|
||||
RCTScrollViewComponentView *svcv = (RCTScrollViewComponentView *) subview;
|
||||
return svcv;
|
||||
}
|
||||
|
||||
RCTScrollViewComponentView *svcv = [self findRTCScrollViewComponentViewInView:subview];
|
||||
if (svcv) return svcv;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (UIRefreshControl *)refreshContorl
|
||||
{
|
||||
if (!_svcv) return nil;
|
||||
return _svcv.scrollView.refreshControl;
|
||||
}
|
||||
|
||||
- (void)refresh
|
||||
{
|
||||
__weak ScrollForwarderView *weakSelf = self;
|
||||
|
||||
[_svcv.scrollView.refreshControl beginRefreshing];
|
||||
|
||||
[UIView animateWithDuration:0.3
|
||||
delay:0
|
||||
options:UIViewAnimationOptionBeginFromCurrentState
|
||||
animations:^(void) {
|
||||
if (!weakSelf) return;
|
||||
|
||||
__strong ScrollForwarderView *self = weakSelf;
|
||||
|
||||
// Whenever we call this method, the scrollview will always be at a position of
|
||||
// -130 or less. Scrolling back to -80 simulates the default behavior of RCTRefreshControl
|
||||
[self->_svcv.scrollView setContentOffset:CGPointMake(0, -65)];
|
||||
}
|
||||
completion:^(__unused BOOL finished) {
|
||||
__strong ScrollForwarderView *self = weakSelf;
|
||||
|
||||
if (self->_eventEmitter != nullptr) {
|
||||
std::dynamic_pointer_cast<const facebook::react::ScrollForwarderViewEventEmitter>(self->_eventEmitter)
|
||||
->onRefresh(facebook::react::ScrollForwarderViewEventEmitter::OnRefresh{});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
- (void)endRefreshing
|
||||
{
|
||||
UIRefreshControl *rc = [self refreshContorl];
|
||||
|
||||
CGPoint newOffset = CGPointMake(_svcv.scrollView.contentOffset.x, 0.0);
|
||||
[self scrollToOffset:newOffset animated:true];
|
||||
|
||||
[rc endRefreshing];
|
||||
}
|
||||
|
||||
Class<RCTComponentViewProtocol> ScrollForwarderViewCls(void)
|
||||
{
|
||||
return ScrollForwarderView.class;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "react-native-scroll-forwarder",
|
||||
"version": "0.0.0",
|
||||
"description": "Scroll forwarder module for Bluesky profile headers",
|
||||
"main": "src/index",
|
||||
"codegenConfig": {
|
||||
"name": "ScrollForwarderViewSpec",
|
||||
"type": "all",
|
||||
"jsSrcsDir": "src",
|
||||
"ios": {
|
||||
"componentProvider": {
|
||||
"ScrollForwarderView": "ScrollForwarderView"
|
||||
}
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-native": "*"
|
||||
},
|
||||
"author": "Hailey <me@haileyok.com>",
|
||||
"license": "MIT",
|
||||
"homepage": "#readme",
|
||||
"create-react-native-library": {
|
||||
"languages": "kotlin-objc",
|
||||
"type": "fabric-view",
|
||||
"version": "0.50.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
default as NativeScrollForwarderView,
|
||||
type NativeProps,
|
||||
} from './ScrollForwarderViewNativeComponent'
|
||||
|
||||
export function ScrollForwarderView({children, ...rest}: NativeProps) {
|
||||
return (
|
||||
<NativeScrollForwarderView {...rest} style={{flex: 1}}>
|
||||
{children}
|
||||
</NativeScrollForwarderView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {type NativeProps} from './ScrollForwarderViewNativeComponent'
|
||||
|
||||
export function ScrollForwarderView({children}: NativeProps) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
codegenNativeComponent,
|
||||
type CodegenTypes,
|
||||
type ViewProps,
|
||||
} from 'react-native'
|
||||
|
||||
type OnRefreshEvent = {}
|
||||
|
||||
export interface NativeProps extends ViewProps {
|
||||
scrollViewTag: CodegenTypes.Int32 | null
|
||||
refreshing?: boolean
|
||||
onRefresh?: CodegenTypes.BubblingEventHandler<OnRefreshEvent>
|
||||
}
|
||||
|
||||
export default codegenNativeComponent<NativeProps>('ScrollForwarderView')
|
||||
@@ -0,0 +1,2 @@
|
||||
export {ScrollForwarderView} from './ScrollForwarderView'
|
||||
export * from './ScrollForwarderViewNativeComponent'
|
||||
@@ -0,0 +1 @@
|
||||
export {ScrollForwarderView} from './ScrollForwarderView'
|
||||
@@ -244,14 +244,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/Post/Embed/ImageEmbed.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 2
|
||||
@@ -290,19 +282,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/PostControls/ShareMenu/ShareMenuItems.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 3
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/PostControls/ShareMenu/index.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
@@ -1055,11 +1034,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/Profile/components/ProfileFeedHeader.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/screens/ProfileList/FeedSection.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
@@ -1406,11 +1380,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/state/queries/feed.ts": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/state/queries/handle.ts": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
@@ -1756,11 +1725,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/com/posts/PostFeed.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/com/posts/PostFeedErrorMessage.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -1772,16 +1736,6 @@
|
||||
"count": 9
|
||||
}
|
||||
},
|
||||
"src/view/com/profile/ProfileFollowers.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/com/profile/ProfileFollows.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/com/util/EmptyState.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
|
||||
+18
-16
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.129.0",
|
||||
"version": "1.129.1",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24.18.0"
|
||||
@@ -40,7 +40,7 @@
|
||||
"prepare": "is-ci || husky",
|
||||
"postinstall": "pnpm intl:compile-if-needed",
|
||||
"prebuild": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean",
|
||||
"android": "expo run:android",
|
||||
"android": "expo run:android --variant debugOptimized",
|
||||
"android:prod": "expo run:android --variant release",
|
||||
"android:profile": "BSKY_PROFILE=1 expo run:android --variant release",
|
||||
"ios": "expo run:ios",
|
||||
@@ -67,9 +67,9 @@
|
||||
"typecheck:android": "tsc --project ./tsconfig.check.android.json",
|
||||
"typecheck:web": "tsc --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",
|
||||
"e2e:start": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo start -c",
|
||||
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=development RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
|
||||
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=development RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
|
||||
"e2e:start": "EXPO_PUBLIC_ENV=e2e NODE_ENV=development RN_SRC_EXT=e2e.ts,e2e.tsx expo start -c",
|
||||
"e2e:run": "maestro test",
|
||||
"perf:test": "NODE_ENV=test maestro test",
|
||||
"perf:test:run": "NODE_ENV=test maestro test __e2e__/perf-test.yml",
|
||||
@@ -96,7 +96,7 @@
|
||||
"prettier": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.28",
|
||||
"@atproto/api": "0.20.33",
|
||||
"@atproto/common-web": "0.5.6",
|
||||
"@atproto/syntax": "0.7.2",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
@@ -105,10 +105,10 @@
|
||||
"@bsky.app/expo-dynamic-app-icon": "^1.8.5",
|
||||
"@bsky.app/expo-guess-language": "^0.2.8",
|
||||
"@bsky.app/expo-image-crop-tool": "^0.5.1",
|
||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.9",
|
||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
||||
"@bsky.app/peek-menu": "^0.3.1",
|
||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||
"@bsky.app/react-native-uitextview": "^2.5.0",
|
||||
"@bsky.app/sift": "^0.3.9",
|
||||
"@bsky.app/tapper": "^0.6.1",
|
||||
"@bsky.app/video": "0.3.6",
|
||||
@@ -158,7 +158,7 @@
|
||||
"emoji-mart": "^5.6.0",
|
||||
"emoji-regex": "^10.4.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "54.0.34",
|
||||
"expo": "54.0.35",
|
||||
"expo-age-range": "0.2.18",
|
||||
"expo-application": "~7.0.8",
|
||||
"expo-asset": "~12.0.13",
|
||||
@@ -171,7 +171,7 @@
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-glass-effect": "55.0.8",
|
||||
"expo-glass-effect": "0.1.10",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
@@ -185,7 +185,7 @@
|
||||
"expo-media-library": "~18.2.1",
|
||||
"expo-notifications": "~0.32.17",
|
||||
"expo-paste-input": "^0.2.1",
|
||||
"expo-privacy-sensitive": "^0.1.0",
|
||||
"expo-privacy-sensitive": "^0.2.0",
|
||||
"expo-screen-orientation": "~9.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-sms": "^14.0.7",
|
||||
@@ -227,28 +227,30 @@
|
||||
"react-native-device-attest": "^0.1.6",
|
||||
"react-native-drawer-layout": "^4.2.3",
|
||||
"react-native-edge-to-edge": "^1.8.1",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-keyboard-controller": "^1.21.8",
|
||||
"react-native-mmkv": "^3.3.3",
|
||||
"react-native-pager-view": "6.8.0",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
"react-native-qrcode-styled": "^0.3.3",
|
||||
"react-native-reanimated": "3.19.1",
|
||||
"react-native-reanimated": "~4.3.2",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "4.24.0",
|
||||
"react-native-scroll-forwarder": "link:./modules/react-native-scroll-forwarder",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-uitextview": "^1.4.0",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-view-shot": "^4.0.3",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-web-webview": "^1.0.2",
|
||||
"react-native-webview": "^13.15.0",
|
||||
"react-native-worklets": "0.8.3",
|
||||
"react-remove-scroll-bar": "^2.3.8",
|
||||
"react-responsive": "^10.0.1",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"setimmediate": "^1.0.5",
|
||||
"slugify": "^1.6.9",
|
||||
"sonner": "^2.0.7",
|
||||
"sonner-native": "0.21.0",
|
||||
"sonner-native": "0.26.4",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tlds": "^1.234.0",
|
||||
"tldts": "^6.1.46",
|
||||
@@ -293,7 +295,7 @@
|
||||
"jest-junit": "^16.0.0",
|
||||
"lint-staged": "^17.0.8",
|
||||
"oxlint": "^1.73.0",
|
||||
"oxlint-tsgolint": "^0.24.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"prettier": "^3.8.3",
|
||||
"react-native-dotenv": "^3.4.11",
|
||||
"react-refresh": "^0.14.0",
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
diff --git a/ios/GlassContainer.swift b/ios/GlassContainer.swift
|
||||
index 61fb67cdfa2022f57524ddde05096067055e9ee6..b2d111ef8a724b8e7d4404f3d40efce3bd6fbb6d 100644
|
||||
--- a/ios/GlassContainer.swift
|
||||
+++ b/ios/GlassContainer.swift
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright 2022-present 650 Industries. All rights reserved.
|
||||
|
||||
import ExpoModulesCore
|
||||
+import React
|
||||
|
||||
public final class GlassContainer: ExpoView {
|
||||
private var containerEffect: Any?
|
||||
@@ -46,11 +47,19 @@ public final class GlassContainer: ExpoView {
|
||||
}
|
||||
}
|
||||
|
||||
- public override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
+ // Paper: redirect children into the container effect's contentView
|
||||
+ public override func didUpdateReactSubviews() {
|
||||
+ for subview in self.reactSubviews() {
|
||||
+ containerEffectView.contentView.addSubview(subview)
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // Fabric: redirect children into the container effect's contentView
|
||||
+ @objc public func mountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
containerEffectView.contentView.insertSubview(childComponentView, at: index)
|
||||
}
|
||||
|
||||
- public override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
+ @objc public func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
childComponentView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
diff --git a/ios/GlassView.swift b/ios/GlassView.swift
|
||||
index 35cd8f320009a9e28fdbb2f55cc409734ba98f40..9587306b6fac3455ab27a5289eb62a711aa50c03 100644
|
||||
--- a/ios/GlassView.swift
|
||||
+++ b/ios/GlassView.swift
|
||||
@@ -271,11 +271,19 @@ public final class GlassView: ExpoView {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
- public override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
+ // Paper: redirect children into the glass effect's contentView
|
||||
+ public override func didUpdateReactSubviews() {
|
||||
+ for subview in self.reactSubviews() {
|
||||
+ glassEffectView.contentView.addSubview(subview)
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // Fabric: redirect children into the glass effect's contentView
|
||||
+ @objc public func mountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
glassEffectView.contentView.insertSubview(childComponentView, at: index)
|
||||
}
|
||||
|
||||
- public override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
+ @objc public func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
childComponentView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# expo-glass-effect patch
|
||||
|
||||
Patches in support for Expo SDK 54. Please delete when we update Expo
|
||||
@@ -15,3 +15,17 @@ index 480746eb7acfbe86f67547d9e1de7a5be4d5faf2..13d30cb547195993dfcb4005cc0d248d
|
||||
|
||||
#endif
|
||||
-
|
||||
diff --git a/package.json b/package.json
|
||||
index 469386dc2e81ded8994818dd4340409da1396488..36460e4e303ae56534c4d389471fc827433c7028 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -70,9 +70,6 @@
|
||||
"ios": {
|
||||
"componentProvider": {
|
||||
"RNDatePicker": "RNDatePicker"
|
||||
- },
|
||||
- "modulesProvider": {
|
||||
- "RNDatePicker": "RNDatePickerManager"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
diff --git a/apple/RNGestureHandler.mm b/apple/RNGestureHandler.mm
|
||||
index c4f760c41a9965245edcfa5e7cb781f8d2c7b66a..bf7d1fb092e22cbcedf787e606876e533879f810 100644
|
||||
--- a/apple/RNGestureHandler.mm
|
||||
+++ b/apple/RNGestureHandler.mm
|
||||
@@ -470,15 +470,19 @@ + (RNGestureHandler *)findGestureHandlerByRecognizer:(UIGestureRecognizer *)reco
|
||||
|
||||
// We may try to extract "DummyGestureHandler" in case when "otherGestureRecognizer" belongs to
|
||||
// a native view being wrapped with "NativeViewGestureHandler"
|
||||
- RNGHUIView *reactView = recognizer.view;
|
||||
- while (reactView != nil && reactView.reactTag == nil) {
|
||||
- reactView = reactView.superview;
|
||||
- }
|
||||
+ RNGHUIView *view = recognizer.view;
|
||||
+ while (view != nil) {
|
||||
+ for (UIGestureRecognizer *candidateRecognizer in view.gestureRecognizers) {
|
||||
+ if ([candidateRecognizer isKindOfClass:[RNDummyGestureRecognizer class]]) {
|
||||
+ return candidateRecognizer.gestureHandler;
|
||||
+ }
|
||||
+ }
|
||||
|
||||
- for (UIGestureRecognizer *recognizer in reactView.gestureRecognizers) {
|
||||
- if ([recognizer isKindOfClass:[RNDummyGestureRecognizer class]]) {
|
||||
- return recognizer.gestureHandler;
|
||||
+ if ([view isKindOfClass:[RCTViewComponentView class]]) {
|
||||
+ return nil;
|
||||
}
|
||||
+
|
||||
+ view = view.superview;
|
||||
}
|
||||
|
||||
return nil;
|
||||
@@ -0,0 +1,5 @@
|
||||
# react-native-gesture-handler.patch
|
||||
|
||||
Updated `findGestureHandlerByRecognizer:` in `apple/RNGestureHandler.mm` to the version from RN GH 2.32.0
|
||||
|
||||
This fixes `UIContextMenuInteraction` from `ExpoBlueskyPeekMenuView.swift`. https://github.com/software-mansion/react-native-gesture-handler/commit/fba4dcc06d71dce08b10b2afc738a2af5b01e86a
|
||||
@@ -1,3 +1,129 @@
|
||||
diff --git a/ios/Fabric/RNCPagerViewComponentView.mm b/ios/Fabric/RNCPagerViewComponentView.mm
|
||||
index 652a5c123100e7010011f07649517aa9e0cbc554..efbc3af622c4fee8267a78144b906c7b5de7859c 100644
|
||||
--- a/ios/Fabric/RNCPagerViewComponentView.mm
|
||||
+++ b/ios/Fabric/RNCPagerViewComponentView.mm
|
||||
@@ -90,6 +90,62 @@ - (void)willMoveToSuperview:(UIView *)newSuperview {
|
||||
}
|
||||
}
|
||||
|
||||
+/*
|
||||
+ * UIKit resolves several behaviors (status-bar-tap scroll-to-top, safe area
|
||||
+ * propagation, appearance callbacks) by walking parentViewController from a
|
||||
+ * view's nearest view controller up to the window's root view controller.
|
||||
+ * The Paper implementation embeds the UIPageViewController into that chain
|
||||
+ * via reactAddControllerToClosestParent:, but this Fabric implementation
|
||||
+ * leaves it orphaned (parentViewController == nil), which among other things
|
||||
+ * makes UIKit ignore every scroll view rendered inside the pager when
|
||||
+ * handling the status bar scroll-to-top tap. Attach the page view controller
|
||||
+ * to the nearest ancestor view controller to restore parity with Paper.
|
||||
+ */
|
||||
+- (void)attachNativePageViewControllerToNearestParent {
|
||||
+ if (_nativePageViewController == nil ||
|
||||
+ _nativePageViewController.parentViewController != nil) {
|
||||
+ return;
|
||||
+ }
|
||||
+ UIResponder *responder = self.nextResponder;
|
||||
+ while (responder != nil && ![responder isKindOfClass:[UIViewController class]]) {
|
||||
+ responder = responder.nextResponder;
|
||||
+ }
|
||||
+ UIViewController *parent = (UIViewController *)responder;
|
||||
+ if (parent == nil) {
|
||||
+ return;
|
||||
+ }
|
||||
+ [parent addChildViewController:_nativePageViewController];
|
||||
+ [_nativePageViewController didMoveToParentViewController:parent];
|
||||
+}
|
||||
+
|
||||
+- (void)detachNativePageViewControllerFromParent {
|
||||
+ if (_nativePageViewController.parentViewController == nil) {
|
||||
+ return;
|
||||
+ }
|
||||
+ [_nativePageViewController willMoveToParentViewController:nil];
|
||||
+ [_nativePageViewController removeFromParentViewController];
|
||||
+}
|
||||
+
|
||||
+- (void)didMoveToWindow {
|
||||
+ [super didMoveToWindow];
|
||||
+ if (self.window != nil) {
|
||||
+ [self attachNativePageViewControllerToNearestParent];
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+- (void)layoutSubviews {
|
||||
+ [super layoutSubviews];
|
||||
+ /*
|
||||
+ * On the first didMoveToWindow the ancestor view controller may not be
|
||||
+ * wired up yet (see callstack/react-native-pager-view#1089 for the same
|
||||
+ * timing issue in v8), so retry here; the attach is a cheap no-op once
|
||||
+ * the controller has a parent.
|
||||
+ */
|
||||
+ if (self.window != nil) {
|
||||
+ [self attachNativePageViewControllerToNearestParent];
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
|
||||
#pragma mark - React API
|
||||
|
||||
@@ -126,6 +182,13 @@ -(void)updateLayoutMetrics:(const facebook::react::LayoutMetrics &)layoutMetrics
|
||||
|
||||
-(void)prepareForRecycle {
|
||||
[super prepareForRecycle];
|
||||
+ /*
|
||||
+ * Undo the child view controller relationship added in
|
||||
+ * attachNativePageViewControllerToNearestParent, otherwise the parent
|
||||
+ * view controller keeps the page view controller (and its subtree) alive
|
||||
+ * after unmount.
|
||||
+ */
|
||||
+ [self detachNativePageViewControllerFromParent];
|
||||
_nativePageViewController = nil;
|
||||
_currentIndex = -1;
|
||||
}
|
||||
@@ -421,8 +484,44 @@ + (ComponentDescriptorProvider)componentDescriptorProvider
|
||||
}
|
||||
|
||||
|
||||
+/*
|
||||
+ * Finds the navigation controller managing this pager via the responder
|
||||
+ * chain, so the pager can cooperate with the controller's back gesture.
|
||||
+ */
|
||||
+- (UINavigationController *)nearestNavigationController {
|
||||
+ UIResponder *responder = self.nextResponder;
|
||||
+ while (responder != nil) {
|
||||
+ if ([responder isKindOfClass:[UINavigationController class]]) {
|
||||
+ return (UINavigationController *)responder;
|
||||
+ }
|
||||
+ responder = responder.nextResponder;
|
||||
+ }
|
||||
+ return nil;
|
||||
+}
|
||||
+
|
||||
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
|
||||
|
||||
+ // iOS 26+ full-screen back gesture (interactiveContentPopGestureRecognizer)
|
||||
+ if (@available(iOS 26.0, *)) {
|
||||
+ if (gestureRecognizer == self.panGestureRecognizer &&
|
||||
+ otherGestureRecognizer != nil &&
|
||||
+ otherGestureRecognizer == [self nearestNavigationController].interactiveContentPopGestureRecognizer) {
|
||||
+ UIPanGestureRecognizer* panGestureRecognizer = (UIPanGestureRecognizer*) gestureRecognizer;
|
||||
+ CGPoint velocity = [panGestureRecognizer velocityInView:self];
|
||||
+ BOOL isLTR = [self isLtrLayout];
|
||||
+ BOOL isBackGesture = (isLTR && velocity.x > 0) || (!isLTR && velocity.x < 0);
|
||||
+
|
||||
+ if (self.currentIndex == 0 && isBackGesture) {
|
||||
+ scrollView.panGestureRecognizer.enabled = false;
|
||||
+ } else {
|
||||
+ const auto &viewProps = *std::static_pointer_cast<const RNCViewPagerProps>(_props);
|
||||
+ scrollView.panGestureRecognizer.enabled = viewProps.scrollEnabled;
|
||||
+ }
|
||||
+
|
||||
+ return YES;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
// Recognize simultaneously only if the other gesture is RN Screen's pan gesture (one that is used to perform fullScreenGestureEnabled)
|
||||
if (gestureRecognizer == self.panGestureRecognizer && [NSStringFromClass([otherGestureRecognizer class]) isEqual: @"RNSPanGestureRecognizer"]) {
|
||||
UIPanGestureRecognizer* panGestureRecognizer = (UIPanGestureRecognizer*) gestureRecognizer;
|
||||
diff --git a/ios/RNCPagerView.m b/ios/RNCPagerView.m
|
||||
index adfc7c6f2224b898a02319d352bb4fe11a18fd7e..939bb801c5b0ca6f93b77cb0507c19d137e08e77 100644
|
||||
--- a/ios/RNCPagerView.m
|
||||
|
||||
@@ -6,6 +6,20 @@ The pager already handles `RNSPanGestureRecognizer` (react-native-screens' custo
|
||||
|
||||
This patch adds the same logic for iOS 26's native `interactiveContentPopGestureRecognizer`, so the back gesture works on the leftmost page while the pager still handles swipes on other pages.
|
||||
|
||||
The fix is applied to both implementations: `ios/RNCPagerView.m` (Paper) and `ios/Fabric/RNCPagerViewComponentView.mm` (New Architecture). The Fabric variant finds the navigation controller via the responder chain (there is no `reactViewController` helper imported there) and reads `scrollEnabled` from the Fabric props.
|
||||
|
||||
Related issues:
|
||||
- https://github.com/software-mansion/react-native-screens/issues/3512
|
||||
- https://github.com/software-mansion/react-native-screens/pull/3420
|
||||
|
||||
---
|
||||
|
||||
Also embeds the Fabric `UIPageViewController` into the view controller hierarchy (`ios/Fabric/RNCPagerViewComponentView.mm`).
|
||||
|
||||
The Paper implementation calls `reactAddControllerToClosestParent:` when embedding its `UIPageViewController`, so the controller becomes a child of the nearest ancestor view controller (e.g. `RNSScreen`). The Fabric implementation never does this - the page view controller is orphaned (`parentViewController == nil`).
|
||||
|
||||
UIKit resolves the status-bar-tap scroll-to-top gesture by walking `parentViewController`/`presentingViewController` from each candidate scroll view's nearest view controller up to the window's root (see `-[UIWindow _scrollToTopViewsUnderScreenPointIfNecessary:resultHandler:]`). With the orphaned controller that walk dead-ends, so every scroll view rendered inside a pager (all Home feeds, Profile tabs, etc.) is dropped from candidate selection and tapping the status bar no longer scrolls feeds to top. It only kept "working" when the window happened to contain exactly one other eligible scroll view, via UIKit's single-candidate fallback.
|
||||
|
||||
The patch attaches the page view controller to the nearest view controller found via the responder chain on `didMoveToWindow` (with a `layoutSubviews` retry because the ancestor controller may not be wired up on the first pass - same timing issue as callstack/react-native-pager-view#1089), and detaches it in `prepareForRecycle` to avoid leaking the controller after unmount.
|
||||
|
||||
Fixed upstream in v8 by the SwiftUI rewrite, which embeds via `reactViewController()` + `addChild` (see `PagerViewProvider.swift`).
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
diff --git a/lib/module/component/PerformanceMonitor.js b/lib/module/component/PerformanceMonitor.js
|
||||
index 9c98d6cc395419f50969753a4e4c7962b7be588f..3686a97280ac540efa0814ac4dea40358860a76f 100644
|
||||
--- a/lib/module/component/PerformanceMonitor.js
|
||||
+++ b/lib/module/component/PerformanceMonitor.js
|
||||
@@ -1,125 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
-import React, { useEffect, useRef } from 'react';
|
||||
-import { StyleSheet, TextInput, View } from 'react-native';
|
||||
-import { addWhitelistedNativeProps } from "../ConfigHelper.js";
|
||||
-import { createAnimatedComponent } from "../createAnimatedComponent/index.js";
|
||||
-import { useAnimatedProps, useFrameCallback, useSharedValue } from "../hook/index.js";
|
||||
-function createCircularDoublesBuffer(size) {
|
||||
- 'worklet';
|
||||
-
|
||||
- return {
|
||||
- next: 0,
|
||||
- buffer: new Float32Array(size),
|
||||
- size,
|
||||
- count: 0,
|
||||
- push(value) {
|
||||
- const oldValue = this.buffer[this.next];
|
||||
- const oldCount = this.count;
|
||||
- this.buffer[this.next] = value;
|
||||
- this.next = (this.next + 1) % this.size;
|
||||
- this.count = Math.min(this.size, this.count + 1);
|
||||
- return oldCount === this.size ? oldValue : null;
|
||||
- },
|
||||
- front() {
|
||||
- const notEmpty = this.count > 0;
|
||||
- if (notEmpty) {
|
||||
- const current = this.next - 1;
|
||||
- const index = current < 0 ? this.size - 1 : current;
|
||||
- return this.buffer[index];
|
||||
- }
|
||||
- return null;
|
||||
- },
|
||||
- back() {
|
||||
- const notEmpty = this.count > 0;
|
||||
- return notEmpty ? this.buffer[this.next] : null;
|
||||
- }
|
||||
- };
|
||||
-}
|
||||
-const DEFAULT_BUFFER_SIZE = 20;
|
||||
-addWhitelistedNativeProps({
|
||||
- text: true
|
||||
-});
|
||||
-const AnimatedTextInput = createAnimatedComponent(TextInput);
|
||||
-function loopAnimationFrame(fn) {
|
||||
- let lastTime = 0;
|
||||
- function loop() {
|
||||
- requestAnimationFrame(time => {
|
||||
- if (lastTime > 0) {
|
||||
- fn(lastTime, time);
|
||||
- }
|
||||
- lastTime = time;
|
||||
- requestAnimationFrame(loop);
|
||||
- });
|
||||
- }
|
||||
- loop();
|
||||
-}
|
||||
-function getFps(renderTimeInMs) {
|
||||
- 'worklet';
|
||||
-
|
||||
- return 1000 / renderTimeInMs;
|
||||
-}
|
||||
-function completeBufferRoutine(buffer, timestamp) {
|
||||
- 'worklet';
|
||||
-
|
||||
- timestamp = Math.round(timestamp);
|
||||
- const droppedTimestamp = buffer.push(timestamp) ?? timestamp;
|
||||
- const measuredRangeDuration = timestamp - droppedTimestamp;
|
||||
- return getFps(measuredRangeDuration / buffer.count);
|
||||
-}
|
||||
-function JsPerformance({
|
||||
- smoothingFrames
|
||||
-}) {
|
||||
- const jsFps = useSharedValue(null);
|
||||
- const totalRenderTime = useSharedValue(0);
|
||||
- const circularBuffer = useRef(createCircularDoublesBuffer(smoothingFrames));
|
||||
- useEffect(() => {
|
||||
- loopAnimationFrame((_, timestamp) => {
|
||||
- timestamp = Math.round(timestamp);
|
||||
- const currentFps = completeBufferRoutine(circularBuffer.current, timestamp);
|
||||
-
|
||||
- // JS fps have to be measured every 2nd frame,
|
||||
- // thus 2x multiplication has to occur here
|
||||
- jsFps.value = (currentFps * 2).toFixed(0);
|
||||
- });
|
||||
- }, [jsFps, totalRenderTime]);
|
||||
- const animatedProps = useAnimatedProps(() => {
|
||||
- const text = 'JS: ' + (jsFps.value ?? 'N/A') + ' ';
|
||||
- return {
|
||||
- text,
|
||||
- defaultValue: text
|
||||
- };
|
||||
- });
|
||||
- return <View style={styles.container}>
|
||||
- <AnimatedTextInput style={styles.text} animatedProps={animatedProps} editable={false} />
|
||||
- </View>;
|
||||
-}
|
||||
-function UiPerformance({
|
||||
- smoothingFrames
|
||||
-}) {
|
||||
- const uiFps = useSharedValue(null);
|
||||
- const circularBuffer = useSharedValue(null);
|
||||
- useFrameCallback(({
|
||||
- timestamp
|
||||
- }) => {
|
||||
- if (circularBuffer.value === null) {
|
||||
- circularBuffer.value = createCircularDoublesBuffer(smoothingFrames);
|
||||
- }
|
||||
- timestamp = Math.round(timestamp);
|
||||
- const currentFps = completeBufferRoutine(circularBuffer.value, timestamp);
|
||||
- uiFps.value = currentFps.toFixed(0);
|
||||
- });
|
||||
- const animatedProps = useAnimatedProps(() => {
|
||||
- const text = 'UI: ' + (uiFps.value ?? 'N/A') + ' ';
|
||||
- return {
|
||||
- text,
|
||||
- defaultValue: text
|
||||
- };
|
||||
- });
|
||||
- return <View style={styles.container}>
|
||||
- <AnimatedTextInput style={styles.text} animatedProps={animatedProps} editable={false} />
|
||||
- </View>;
|
||||
-}
|
||||
/**
|
||||
* A component that lets you measure fps values on JS and UI threads on both the
|
||||
* Paper and Fabric architectures.
|
||||
@@ -127,38 +7,7 @@ function UiPerformance({
|
||||
* @param smoothingFrames - Determines amount of saved frames which will be used
|
||||
* for fps value smoothing.
|
||||
*/
|
||||
-export function PerformanceMonitor({
|
||||
- smoothingFrames = DEFAULT_BUFFER_SIZE
|
||||
-}) {
|
||||
- return <View style={styles.monitor}>
|
||||
- <JsPerformance smoothingFrames={smoothingFrames} />
|
||||
- <UiPerformance smoothingFrames={smoothingFrames} />
|
||||
- </View>;
|
||||
+export function PerformanceMonitor() {
|
||||
+ return null;
|
||||
}
|
||||
-const styles = StyleSheet.create({
|
||||
- monitor: {
|
||||
- flexDirection: 'row',
|
||||
- position: 'absolute',
|
||||
- backgroundColor: '#0006',
|
||||
- zIndex: 1000
|
||||
- },
|
||||
- header: {
|
||||
- fontSize: 14,
|
||||
- color: '#ffff',
|
||||
- paddingHorizontal: 5
|
||||
- },
|
||||
- text: {
|
||||
- fontSize: 13,
|
||||
- fontVariant: ['tabular-nums'],
|
||||
- color: '#ffff',
|
||||
- fontFamily: 'monospace',
|
||||
- paddingHorizontal: 3
|
||||
- },
|
||||
- container: {
|
||||
- alignItems: 'center',
|
||||
- justifyContent: 'center',
|
||||
- flexDirection: 'row',
|
||||
- flexWrap: 'wrap'
|
||||
- }
|
||||
-});
|
||||
//# sourceMappingURL=PerformanceMonitor.js.map
|
||||
\ No newline at end of file
|
||||
diff --git a/src/component/PerformanceMonitor.tsx b/src/component/PerformanceMonitor.tsx
|
||||
index ff8fc8a947a0aeb959e21ec061882c3d190a2ce0..34dde79727765623df80dbcdab5016de6fd5d82c 100644
|
||||
--- a/src/component/PerformanceMonitor.tsx
|
||||
+++ b/src/component/PerformanceMonitor.tsx
|
||||
@@ -1,170 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
-import React, { useEffect, useRef } from 'react';
|
||||
-import { StyleSheet, TextInput, View } from 'react-native';
|
||||
-
|
||||
-import { addWhitelistedNativeProps } from '../ConfigHelper';
|
||||
-import { createAnimatedComponent } from '../createAnimatedComponent';
|
||||
-import type { FrameInfo } from '../frameCallback';
|
||||
-import { useAnimatedProps, useFrameCallback, useSharedValue } from '../hook';
|
||||
-
|
||||
-type CircularBuffer = ReturnType<typeof createCircularDoublesBuffer>;
|
||||
-function createCircularDoublesBuffer(size: number) {
|
||||
- 'worklet';
|
||||
-
|
||||
- return {
|
||||
- next: 0 as number,
|
||||
- buffer: new Float32Array(size),
|
||||
- size,
|
||||
- count: 0 as number,
|
||||
-
|
||||
- push(value: number): number | null {
|
||||
- const oldValue = this.buffer[this.next];
|
||||
- const oldCount = this.count;
|
||||
- this.buffer[this.next] = value;
|
||||
-
|
||||
- this.next = (this.next + 1) % this.size;
|
||||
- this.count = Math.min(this.size, this.count + 1);
|
||||
- return oldCount === this.size ? oldValue : null;
|
||||
- },
|
||||
-
|
||||
- front(): number | null {
|
||||
- const notEmpty = this.count > 0;
|
||||
- if (notEmpty) {
|
||||
- const current = this.next - 1;
|
||||
- const index = current < 0 ? this.size - 1 : current;
|
||||
- return this.buffer[index];
|
||||
- }
|
||||
- return null;
|
||||
- },
|
||||
-
|
||||
- back(): number | null {
|
||||
- const notEmpty = this.count > 0;
|
||||
- return notEmpty ? this.buffer[this.next] : null;
|
||||
- },
|
||||
- };
|
||||
-}
|
||||
-
|
||||
-const DEFAULT_BUFFER_SIZE = 20;
|
||||
-addWhitelistedNativeProps({ text: true });
|
||||
-const AnimatedTextInput = createAnimatedComponent(TextInput);
|
||||
-
|
||||
-function loopAnimationFrame(fn: (lastTime: number, time: number) => void) {
|
||||
- let lastTime = 0;
|
||||
-
|
||||
- function loop() {
|
||||
- requestAnimationFrame((time) => {
|
||||
- if (lastTime > 0) {
|
||||
- fn(lastTime, time);
|
||||
- }
|
||||
- lastTime = time;
|
||||
- requestAnimationFrame(loop);
|
||||
- });
|
||||
- }
|
||||
-
|
||||
- loop();
|
||||
-}
|
||||
-
|
||||
-function getFps(renderTimeInMs: number): number {
|
||||
- 'worklet';
|
||||
- return 1000 / renderTimeInMs;
|
||||
-}
|
||||
-
|
||||
-function completeBufferRoutine(
|
||||
- buffer: CircularBuffer,
|
||||
- timestamp: number
|
||||
-): number {
|
||||
- 'worklet';
|
||||
- timestamp = Math.round(timestamp);
|
||||
-
|
||||
- const droppedTimestamp = buffer.push(timestamp) ?? timestamp;
|
||||
-
|
||||
- const measuredRangeDuration = timestamp - droppedTimestamp;
|
||||
-
|
||||
- return getFps(measuredRangeDuration / buffer.count);
|
||||
-}
|
||||
-
|
||||
-function JsPerformance({ smoothingFrames }: { smoothingFrames: number }) {
|
||||
- const jsFps = useSharedValue<string | null>(null);
|
||||
- const totalRenderTime = useSharedValue(0);
|
||||
- const circularBuffer = useRef<CircularBuffer>(
|
||||
- createCircularDoublesBuffer(smoothingFrames)
|
||||
- );
|
||||
-
|
||||
- useEffect(() => {
|
||||
- loopAnimationFrame((_, timestamp) => {
|
||||
- timestamp = Math.round(timestamp);
|
||||
-
|
||||
- const currentFps = completeBufferRoutine(
|
||||
- circularBuffer.current,
|
||||
- timestamp
|
||||
- );
|
||||
-
|
||||
- // JS fps have to be measured every 2nd frame,
|
||||
- // thus 2x multiplication has to occur here
|
||||
- jsFps.value = (currentFps * 2).toFixed(0);
|
||||
- });
|
||||
- }, [jsFps, totalRenderTime]);
|
||||
-
|
||||
- const animatedProps = useAnimatedProps(() => {
|
||||
- const text = 'JS: ' + (jsFps.value ?? 'N/A') + ' ';
|
||||
- return { text, defaultValue: text };
|
||||
- });
|
||||
-
|
||||
- return (
|
||||
- <View style={styles.container}>
|
||||
- <AnimatedTextInput
|
||||
- style={styles.text}
|
||||
- animatedProps={animatedProps}
|
||||
- editable={false}
|
||||
- />
|
||||
- </View>
|
||||
- );
|
||||
-}
|
||||
-
|
||||
-function UiPerformance({ smoothingFrames }: { smoothingFrames: number }) {
|
||||
- const uiFps = useSharedValue<string | null>(null);
|
||||
- const circularBuffer = useSharedValue<CircularBuffer | null>(null);
|
||||
-
|
||||
- useFrameCallback(({ timestamp }: FrameInfo) => {
|
||||
- if (circularBuffer.value === null) {
|
||||
- circularBuffer.value = createCircularDoublesBuffer(smoothingFrames);
|
||||
- }
|
||||
-
|
||||
- timestamp = Math.round(timestamp);
|
||||
-
|
||||
- const currentFps = completeBufferRoutine(circularBuffer.value, timestamp);
|
||||
-
|
||||
- uiFps.value = currentFps.toFixed(0);
|
||||
- });
|
||||
-
|
||||
- const animatedProps = useAnimatedProps(() => {
|
||||
- const text = 'UI: ' + (uiFps.value ?? 'N/A') + ' ';
|
||||
- return { text, defaultValue: text };
|
||||
- });
|
||||
-
|
||||
- return (
|
||||
- <View style={styles.container}>
|
||||
- <AnimatedTextInput
|
||||
- style={styles.text}
|
||||
- animatedProps={animatedProps}
|
||||
- editable={false}
|
||||
- />
|
||||
- </View>
|
||||
- );
|
||||
-}
|
||||
-
|
||||
-export type PerformanceMonitorProps = {
|
||||
- /**
|
||||
- * Sets amount of previous frames used for smoothing at highest expectedFps.
|
||||
- *
|
||||
- * Automatically scales down at lower frame rates.
|
||||
- *
|
||||
- * Affects jumpiness of the FPS measurements value.
|
||||
- */
|
||||
- smoothingFrames?: number;
|
||||
-};
|
||||
-
|
||||
/**
|
||||
* A component that lets you measure fps values on JS and UI threads on both the
|
||||
* Paper and Fabric architectures.
|
||||
@@ -172,40 +7,6 @@ export type PerformanceMonitorProps = {
|
||||
* @param smoothingFrames - Determines amount of saved frames which will be used
|
||||
* for fps value smoothing.
|
||||
*/
|
||||
-export function PerformanceMonitor({
|
||||
- smoothingFrames = DEFAULT_BUFFER_SIZE,
|
||||
-}: PerformanceMonitorProps) {
|
||||
- return (
|
||||
- <View style={styles.monitor}>
|
||||
- <JsPerformance smoothingFrames={smoothingFrames} />
|
||||
- <UiPerformance smoothingFrames={smoothingFrames} />
|
||||
- </View>
|
||||
- );
|
||||
+export function PerformanceMonitor() {
|
||||
+ return null;
|
||||
}
|
||||
-
|
||||
-const styles = StyleSheet.create({
|
||||
- monitor: {
|
||||
- flexDirection: 'row',
|
||||
- position: 'absolute',
|
||||
- backgroundColor: '#0006',
|
||||
- zIndex: 1000,
|
||||
- },
|
||||
- header: {
|
||||
- fontSize: 14,
|
||||
- color: '#ffff',
|
||||
- paddingHorizontal: 5,
|
||||
- },
|
||||
- text: {
|
||||
- fontSize: 13,
|
||||
- fontVariant: ['tabular-nums'],
|
||||
- color: '#ffff',
|
||||
- fontFamily: 'monospace',
|
||||
- paddingHorizontal: 3,
|
||||
- },
|
||||
- container: {
|
||||
- alignItems: 'center',
|
||||
- justifyContent: 'center',
|
||||
- flexDirection: 'row',
|
||||
- flexWrap: 'wrap',
|
||||
- },
|
||||
-});
|
||||
@@ -0,0 +1,500 @@
|
||||
diff --git a/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.cpp b/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.cpp
|
||||
index 531f0dc7b4eeb9b29cb2255d8444da02a74c35b7..534f419fce55c39a09a7eebfb7ab3c53f8a16637 100644
|
||||
--- a/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.cpp
|
||||
+++ b/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.cpp
|
||||
@@ -1,8 +1,10 @@
|
||||
#include <reanimated/Fabric/updates/AnimatedPropsRegistry.h>
|
||||
#include <reanimated/Tools/FeatureFlags.h>
|
||||
|
||||
+#include <functional>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
+#include <vector>
|
||||
|
||||
namespace reanimated {
|
||||
|
||||
@@ -25,25 +27,59 @@ void AnimatedPropsRegistry::update(jsi::Runtime &rt, const jsi::Value &operation
|
||||
addUpdatesToBatch(shadowNode, jsi::dynamicFromValue(rt, updates));
|
||||
|
||||
if constexpr (StaticFeatureFlags::getFlag("FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS")) {
|
||||
- timestampMap_[shadowNode->getTag()] = timestamp;
|
||||
+ const auto tag = shadowNode->getTag();
|
||||
+ timestampMap_[tag] = timestamp;
|
||||
+ // If JS already has a `settledProps` snapshot for this tag, it is now
|
||||
+ // stale — schedule a refresh on the next `collectSettledUpdates`.
|
||||
+ if (syncedTags_.erase(tag) > 0) {
|
||||
+ invalidatedTags_.insert(tag);
|
||||
+ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-jsi::Value AnimatedPropsRegistry::getUpdatesOlderThanTimestamp(
|
||||
- jsi::Runtime &rt,
|
||||
- const double timestamp,
|
||||
- const double cleanupTimestamp) {
|
||||
+jsi::Value AnimatedPropsRegistry::collectSettledUpdates(jsi::Runtime &rt, const double settledTimestamp) {
|
||||
std::lock_guard<std::mutex> lock{mutex_};
|
||||
- removeUpdatesOlderThanTimestamp(cleanupTimestamp);
|
||||
|
||||
std::vector<std::pair<Tag, std::reference_wrapper<const folly::dynamic>>> updates;
|
||||
|
||||
- for (const auto &[viewTag, pair] : updatesRegistry_) {
|
||||
- auto it = timestampMap_.find(viewTag);
|
||||
- if (it != timestampMap_.end() && it->second < timestamp) {
|
||||
- updates.emplace_back(viewTag, std::cref(pair.second));
|
||||
+ for (auto it = updatesRegistry_.begin(); it != updatesRegistry_.end();) {
|
||||
+ const auto viewTag = it->first;
|
||||
+
|
||||
+ if (syncedTags_.contains(viewTag)) {
|
||||
+ // React already has the latest value for this tag (synced on a previous
|
||||
+ // call, so the `settledProps` state is committed by now) — the registry
|
||||
+ // entry is redundant. `syncedTags_` is intentionally retained to detect
|
||||
+ // re-animation staleness. Note that `syncedTags_` and `invalidatedTags_`
|
||||
+ // are disjoint — `update()` moves tags from the former to the latter.
|
||||
+ timestampMap_.erase(viewTag);
|
||||
+ it = updatesRegistry_.erase(it);
|
||||
+ continue;
|
||||
+ }
|
||||
+
|
||||
+ const auto timestampIt = timestampMap_.find(viewTag);
|
||||
+ if (timestampIt == timestampMap_.end()) {
|
||||
+ ++it;
|
||||
+ continue;
|
||||
+ }
|
||||
+ const bool isSettled = timestampIt->second < settledTimestamp;
|
||||
+ const auto invalidatedIt = invalidatedTags_.find(viewTag);
|
||||
+ const bool isInvalidated = invalidatedIt != invalidatedTags_.end();
|
||||
+ if (isSettled || isInvalidated) {
|
||||
+ updates.emplace_back(viewTag, std::cref(it->second.second));
|
||||
+ if (isSettled) {
|
||||
+ // Only settled-path tags are tracked as "synced" so that an ongoing
|
||||
+ // animation doesn't re-trigger an invalidation/sync on every GC tick.
|
||||
+ syncedTags_.insert(viewTag);
|
||||
+ }
|
||||
+ if (isInvalidated) {
|
||||
+ // Only erase serviced invalidations; if a tag was invalidated but the
|
||||
+ // matching update batch hasn't been flushed into updatesRegistry_ yet,
|
||||
+ // we leave the entry so the next sync picks it up.
|
||||
+ invalidatedTags_.erase(invalidatedIt);
|
||||
+ }
|
||||
}
|
||||
+ ++it;
|
||||
}
|
||||
|
||||
const jsi::Array array(rt, updates.size());
|
||||
@@ -58,22 +94,11 @@ jsi::Value AnimatedPropsRegistry::getUpdatesOlderThanTimestamp(
|
||||
return jsi::Value(rt, array);
|
||||
}
|
||||
|
||||
-void AnimatedPropsRegistry::removeUpdatesOlderThanTimestamp(const double timestamp) {
|
||||
- for (auto it = timestampMap_.begin(); it != timestampMap_.end();) {
|
||||
- const auto viewTag = it->first;
|
||||
- const auto viewTimestamp = it->second;
|
||||
- if (viewTimestamp < timestamp) {
|
||||
- it = timestampMap_.erase(it);
|
||||
- updatesRegistry_.erase(viewTag);
|
||||
- } else {
|
||||
- it++;
|
||||
- }
|
||||
- }
|
||||
-}
|
||||
-
|
||||
void AnimatedPropsRegistry::removeTag(const Tag tag) {
|
||||
updatesRegistry_.erase(tag);
|
||||
timestampMap_.erase(tag);
|
||||
+ syncedTags_.erase(tag);
|
||||
+ invalidatedTags_.erase(tag);
|
||||
}
|
||||
|
||||
} // namespace reanimated
|
||||
diff --git a/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.h b/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.h
|
||||
index 2c6c0e13604c9421e147d7eea7f4a4752288011c..8cd67f118501c2786b94d76541aea29a14ba8c16 100644
|
||||
--- a/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.h
|
||||
+++ b/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.h
|
||||
@@ -4,10 +4,8 @@
|
||||
|
||||
#include <react/renderer/uimanager/UIManager.h>
|
||||
|
||||
-#include <memory>
|
||||
-#include <string>
|
||||
#include <unordered_map>
|
||||
-#include <vector>
|
||||
+#include <unordered_set>
|
||||
|
||||
namespace reanimated {
|
||||
|
||||
@@ -15,13 +13,22 @@ class AnimatedPropsRegistry : public UpdatesRegistry {
|
||||
public:
|
||||
void update(jsi::Runtime &rt, const jsi::Value &operations, double timestamp);
|
||||
|
||||
- /// Also removes updates older than `cleanupTimestamp` from the registry.
|
||||
- jsi::Value getUpdatesOlderThanTimestamp(jsi::Runtime &rt, double timestamp, double cleanupTimestamp);
|
||||
+ /// Returns updates that settled (received no update since `settledTimestamp`)
|
||||
+ /// or whose synced `settledProps` snapshot was invalidated by a fresh update.
|
||||
+ /// Also evicts entries that have already been synced to React — by the time
|
||||
+ /// of the next call, the corresponding `settledProps` state is guaranteed to
|
||||
+ /// be committed, so the registry entries are redundant.
|
||||
+ jsi::Value collectSettledUpdates(jsi::Runtime &rt, double settledTimestamp);
|
||||
|
||||
private:
|
||||
std::unordered_map<Tag, double> timestampMap_; // viewTag -> timestamp, protected by `mutex_`
|
||||
+ // Tags whose latest values have already been pushed to React `settledProps`.
|
||||
+ // Intentionally retained after eviction to detect re-animation staleness.
|
||||
+ std::unordered_set<Tag> syncedTags_;
|
||||
+ // Tags that were synced to React but received a fresh worklet update since;
|
||||
+ // their `settledProps` are stale and need to be refreshed on the next sync.
|
||||
+ std::unordered_set<Tag> invalidatedTags_;
|
||||
|
||||
- void removeUpdatesOlderThanTimestamp(double timestamp);
|
||||
void removeTag(Tag tag) override;
|
||||
};
|
||||
|
||||
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
|
||||
index 096218ab9659955cd6272c97181bce3c893ed591..1a8e25fc8295b3ac943130709bf063ea41a50585 100644
|
||||
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
|
||||
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
|
||||
@@ -57,11 +57,11 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
|
||||
const SharedComponentDescriptorRegistry &componentDescriptorRegistry,
|
||||
const std::shared_ptr<const ContextContainer> &contextContainer,
|
||||
jsi::Runtime &uiRuntime,
|
||||
- const std::shared_ptr<UIScheduler> &uiScheduler
|
||||
+ const std::shared_ptr<UIScheduler> &uiScheduler,
|
||||
+ const std::shared_ptr<facebook::react::UIManager> &uiManager
|
||||
#ifdef ANDROID
|
||||
,
|
||||
const PreserveMountedTagsFunction &filterUnmountedTagsFunction,
|
||||
- const std::shared_ptr<facebook::react::UIManager> &uiManager,
|
||||
const std::shared_ptr<facebook::react::CallInvoker> &jsInvoker
|
||||
#endif
|
||||
)
|
||||
@@ -69,11 +69,11 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
|
||||
contextContainer_(contextContainer),
|
||||
componentDescriptorRegistry_(componentDescriptorRegistry),
|
||||
uiRuntime_(uiRuntime),
|
||||
- uiScheduler_(uiScheduler)
|
||||
+ uiScheduler_(uiScheduler),
|
||||
+ uiManager_(uiManager)
|
||||
#ifdef ANDROID
|
||||
,
|
||||
preserveMountedTags_(filterUnmountedTagsFunction),
|
||||
- uiManager_(uiManager),
|
||||
jsInvoker_(jsInvoker)
|
||||
#endif
|
||||
{
|
||||
@@ -93,10 +93,10 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
|
||||
SharedComponentDescriptorRegistry componentDescriptorRegistry_;
|
||||
jsi::Runtime &uiRuntime_;
|
||||
const std::shared_ptr<UIScheduler> uiScheduler_;
|
||||
+ std::shared_ptr<facebook::react::UIManager> uiManager_;
|
||||
PreserveMountedTagsFunction preserveMountedTags_;
|
||||
|
||||
#ifdef ANDROID
|
||||
- std::shared_ptr<facebook::react::UIManager> uiManager_;
|
||||
std::shared_ptr<facebook::react::CallInvoker> jsInvoker_;
|
||||
|
||||
void restoreOpacityInCaseOfFlakyEnteringAnimation(SurfaceId surfaceId) const;
|
||||
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
|
||||
index eca44e4cf651d16e9741806004ce9119c85d07d6..e39c79a5d7b52659106ed6fe6fbcbbc048bf4787 100644
|
||||
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
|
||||
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
|
||||
@@ -66,11 +66,11 @@ struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon,
|
||||
const SharedComponentDescriptorRegistry &componentDescriptorRegistry,
|
||||
const std::shared_ptr<const ContextContainer> &contextContainer,
|
||||
jsi::Runtime &uiRuntime,
|
||||
- const std::shared_ptr<UIScheduler> &uiScheduler
|
||||
+ const std::shared_ptr<UIScheduler> &uiScheduler,
|
||||
+ const std::shared_ptr<UIManager> &uiManager
|
||||
#ifdef ANDROID
|
||||
,
|
||||
const PreserveMountedTagsFunction &filterUnmountedTagsFunction,
|
||||
- const std::shared_ptr<UIManager> &uiManager,
|
||||
const std::shared_ptr<CallInvoker> &jsInvoker
|
||||
#endif
|
||||
)
|
||||
@@ -79,11 +79,11 @@ struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon,
|
||||
componentDescriptorRegistry,
|
||||
contextContainer,
|
||||
uiRuntime,
|
||||
- uiScheduler
|
||||
+ uiScheduler,
|
||||
+ uiManager
|
||||
#ifdef ANDROID
|
||||
,
|
||||
filterUnmountedTagsFunction,
|
||||
- uiManager,
|
||||
jsInvoker
|
||||
#endif
|
||||
),
|
||||
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
|
||||
index 83ef7430b923b6b3b99368ee8072168769110cd0..2affd12822ab19bdc90963d3ce8ca1e6bb0d43b7 100644
|
||||
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
|
||||
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
|
||||
@@ -2,6 +2,7 @@
|
||||
#include <reanimated/NativeModules/ReanimatedModuleProxy.h>
|
||||
|
||||
#include <react/renderer/animations/utils.h>
|
||||
+#include <react/renderer/mounting/ShadowTree.h>
|
||||
#include <react/renderer/mounting/ShadowViewMutation.h>
|
||||
|
||||
#include <memory>
|
||||
@@ -53,14 +54,37 @@ std::optional<MountingTransaction> LayoutAnimationsProxy_Legacy::pullTransaction
|
||||
|
||||
parseRemoveMutations(movedViews, mutations, roots);
|
||||
|
||||
- auto shouldAnimate = !surfacesToRemove_.contains(surfaceId);
|
||||
- surfacesToRemove_.erase(surfaceId);
|
||||
+ // Consume the teardown mark only on the transaction that actually clears
|
||||
+ // the root — pulls emitted for animation frames must not eat it early.
|
||||
+ auto shouldAnimate = true;
|
||||
+ const auto removesRootChildren = std::ranges::any_of(mutations, [surfaceId](const auto &mutation) {
|
||||
+ return mutation.type == ShadowViewMutation::Remove && mutation.parentTag == surfaceId;
|
||||
+ });
|
||||
+ if (removesRootChildren) {
|
||||
+ shouldAnimate = surfacesToRemove_.erase(surfaceId) == 0;
|
||||
+ }
|
||||
handleRemovals(filteredMutations, roots, deadNodes, shouldAnimate);
|
||||
|
||||
handleUpdatesAndEnterings(filteredMutations, movedViews, mutations, propsParserContext, surfaceId);
|
||||
|
||||
addOngoingAnimations(surfaceId, filteredMutations);
|
||||
|
||||
+ // The LayoutAnimationDriver can emit a final keyframe update in the same
|
||||
+ // transaction as the deferred Remove/Delete it withheld for a delete
|
||||
+ // animation. We emit removals before updates, so such an update would
|
||||
+ // otherwise reach the mounting layer after its view was deleted.
|
||||
+ std::unordered_set<Tag> deletedTags;
|
||||
+ for (const auto &mutation : filteredMutations) {
|
||||
+ if (mutation.type == ShadowViewMutation::Delete) {
|
||||
+ deletedTags.insert(mutation.oldChildShadowView.tag);
|
||||
+ }
|
||||
+ }
|
||||
+ if (!deletedTags.empty()) {
|
||||
+ std::erase_if(filteredMutations, [&deletedTags](const auto &mutation) {
|
||||
+ return mutation.type == ShadowViewMutation::Update && deletedTags.contains(mutation.newChildShadowView.tag);
|
||||
+ });
|
||||
+ }
|
||||
+
|
||||
return MountingTransaction{surfaceId, transactionNumber, std::move(filteredMutations), telemetry};
|
||||
}
|
||||
|
||||
@@ -947,23 +971,22 @@ inline bool MutationNode::isMutationNode() {
|
||||
return true;
|
||||
}
|
||||
|
||||
-// UIManagerAnimationDelegate
|
||||
-
|
||||
-void LayoutAnimationsProxy_Legacy::uiManagerDidConfigureNextLayoutAnimation(
|
||||
- jsi::Runtime &runtime,
|
||||
- const RawValue &config,
|
||||
- const jsi::Value &successCallbackValue,
|
||||
- const jsi::Value &failureCallbackValue) const {}
|
||||
+// UIManagerCommitHook
|
||||
|
||||
-void LayoutAnimationsProxy_Legacy::setComponentDescriptorRegistry(
|
||||
- const SharedComponentDescriptorRegistry &componentDescriptorRegistry) {}
|
||||
-
|
||||
-bool LayoutAnimationsProxy_Legacy::shouldAnimateFrame() const {
|
||||
- return false;
|
||||
-}
|
||||
-
|
||||
-void LayoutAnimationsProxy_Legacy::stopSurface(SurfaceId surfaceId) {
|
||||
- surfacesToRemove_.insert(surfaceId);
|
||||
+// Surface teardown commits an empty root (SurfaceHandler::stop) before the
|
||||
+// teardown transaction is pulled — mark it so pullTransaction skips exit
|
||||
+// animations. Reading the ShadowTreeRegistry here instead would deadlock (#8579).
|
||||
+RootShadowNode::Unshared LayoutAnimationsProxy_Legacy::shadowTreeWillCommit(
|
||||
+ const ShadowTree &shadowTree,
|
||||
+ const RootShadowNode::Shared & /*oldRootShadowNode*/,
|
||||
+ const RootShadowNode::Unshared &newRootShadowNode) noexcept {
|
||||
+ auto lock = std::unique_lock<std::recursive_mutex>(mutex);
|
||||
+ if (newRootShadowNode->getChildren().empty()) {
|
||||
+ surfacesToRemove_.insert(shadowTree.getSurfaceId());
|
||||
+ } else {
|
||||
+ surfacesToRemove_.erase(shadowTree.getSurfaceId());
|
||||
+ }
|
||||
+ return newRootShadowNode;
|
||||
}
|
||||
|
||||
} // namespace reanimated
|
||||
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h
|
||||
index e9a5e9959e89ec33cee179ddb907c17f6dfbd3de..a2c89041518cd71e8ba5ac62ef89c0022d197c9b 100644
|
||||
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h
|
||||
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h
|
||||
@@ -3,8 +3,8 @@
|
||||
#include <react/renderer/componentregistry/ComponentDescriptorFactory.h>
|
||||
#include <react/renderer/mounting/MountingOverrideDelegate.h>
|
||||
#include <react/renderer/scheduler/Scheduler.h>
|
||||
-#include <react/renderer/uimanager/UIManagerAnimationDelegate.h>
|
||||
#include <react/renderer/uimanager/UIManagerBinding.h>
|
||||
+#include <react/renderer/uimanager/UIManagerCommitHook.h>
|
||||
#include <reanimated/Compat/WorkletsApi.h>
|
||||
#include <reanimated/LayoutAnimations/LayoutAnimationsManager.h>
|
||||
#include <reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h>
|
||||
@@ -102,7 +102,7 @@ struct SurfaceContext {
|
||||
};
|
||||
|
||||
struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
|
||||
- public UIManagerAnimationDelegate,
|
||||
+ public UIManagerCommitHook,
|
||||
public std::enable_shared_from_this<LayoutAnimationsProxy_Legacy> {
|
||||
mutable std::unordered_map<Tag, std::shared_ptr<Node>> nodeForTag_;
|
||||
mutable std::recursive_mutex mutex;
|
||||
@@ -116,11 +116,11 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
|
||||
const SharedComponentDescriptorRegistry &componentDescriptorRegistry,
|
||||
const std::shared_ptr<const ContextContainer> &contextContainer,
|
||||
jsi::Runtime &uiRuntime,
|
||||
- const std::shared_ptr<UIScheduler> &uiScheduler
|
||||
+ const std::shared_ptr<UIScheduler> &uiScheduler,
|
||||
+ const std::shared_ptr<UIManager> &uiManager
|
||||
#ifdef ANDROID
|
||||
,
|
||||
const PreserveMountedTagsFunction &filterUnmountedTagsFunction,
|
||||
- const std::shared_ptr<UIManager> &uiManager,
|
||||
const std::shared_ptr<CallInvoker> &jsInvoker
|
||||
#endif
|
||||
)
|
||||
@@ -129,14 +129,19 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
|
||||
componentDescriptorRegistry,
|
||||
contextContainer,
|
||||
uiRuntime,
|
||||
- uiScheduler
|
||||
+ uiScheduler,
|
||||
+ uiManager
|
||||
#ifdef ANDROID
|
||||
,
|
||||
filterUnmountedTagsFunction,
|
||||
- uiManager,
|
||||
jsInvoker
|
||||
#endif
|
||||
) {
|
||||
+ uiManager->registerCommitHook(*this);
|
||||
+ }
|
||||
+
|
||||
+ ~LayoutAnimationsProxy_Legacy() override {
|
||||
+ uiManager_->unregisterCommitHook(*this);
|
||||
}
|
||||
|
||||
void startEnteringAnimation(const int tag, ShadowViewMutation &mutation) const;
|
||||
@@ -202,19 +207,15 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
|
||||
const TransactionTelemetry &telemetry,
|
||||
ShadowViewMutationList mutations) const override;
|
||||
|
||||
- // UIManagerAnimationDelegate
|
||||
-
|
||||
- void uiManagerDidConfigureNextLayoutAnimation(
|
||||
- jsi::Runtime &runtime,
|
||||
- const RawValue &config,
|
||||
- const jsi::Value &successCallbackValue,
|
||||
- const jsi::Value &failureCallbackValue) const override;
|
||||
-
|
||||
- void setComponentDescriptorRegistry(const SharedComponentDescriptorRegistry &componentDescriptorRegistry) override;
|
||||
+ // UIManagerCommitHook
|
||||
|
||||
- bool shouldAnimateFrame() const override;
|
||||
+ void commitHookWasRegistered(const UIManager &uiManager) noexcept override {}
|
||||
+ void commitHookWasUnregistered(const UIManager &uiManager) noexcept override {}
|
||||
|
||||
- void stopSurface(SurfaceId surfaceId) override;
|
||||
+ RootShadowNode::Unshared shadowTreeWillCommit(
|
||||
+ const ShadowTree &shadowTree,
|
||||
+ const RootShadowNode::Shared &oldRootShadowNode,
|
||||
+ const RootShadowNode::Unshared &newRootShadowNode) noexcept override;
|
||||
};
|
||||
|
||||
} // namespace reanimated
|
||||
diff --git a/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp b/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
|
||||
index 9ade22bf773005613048a00c47b35767628e86c6..f3415e824da1a8da5c83762415ca54646bd6429f 100644
|
||||
--- a/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
|
||||
+++ b/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
|
||||
@@ -524,15 +524,13 @@ jsi::Value ReanimatedModuleProxy::getSettledUpdates(jsi::Runtime &rt) {
|
||||
StaticFeatureFlags::getFlag("FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS") &&
|
||||
"getSettledUpdates requires FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS static feature flag to be enabled");
|
||||
|
||||
+ constexpr double SETTLED_ANIMATION_THRESHOLD_MS = 1000;
|
||||
+
|
||||
// TODO(future): use unified timestamp
|
||||
const auto currentTimestamp = getAnimationTimestamp_();
|
||||
|
||||
- // TODO: fix bug when threshold difference is smaller than 1 second
|
||||
// TODO(future): flush updates from CSS animations and CSS transitions registries
|
||||
- // TODO(future): find a better way to obtain timestamp for removing updates
|
||||
- // TODO(future): move removing old updates to separate method
|
||||
- return animatedPropsRegistry_->getUpdatesOlderThanTimestamp(
|
||||
- rt, currentTimestamp - 1000 /* 1 second */, currentTimestamp - 2000 /* 2 seconds */);
|
||||
+ return animatedPropsRegistry_->collectSettledUpdates(rt, currentTimestamp - SETTLED_ANIMATION_THRESHOLD_MS);
|
||||
}
|
||||
|
||||
bool ReanimatedModuleProxy::handleEvent(
|
||||
@@ -1306,11 +1304,11 @@ void ReanimatedModuleProxy::initializeLayoutAnimationsProxy() {
|
||||
componentDescriptorRegistry,
|
||||
scheduler->getContextContainer(),
|
||||
getJSIRuntimeFromWorkletRuntime(uiRuntime_),
|
||||
- uiScheduler_
|
||||
+ uiScheduler_,
|
||||
+ uiManager_
|
||||
#ifdef ANDROID
|
||||
,
|
||||
filterUnmountedTagsFunction_,
|
||||
- uiManager_,
|
||||
jsInvoker_
|
||||
#endif
|
||||
);
|
||||
@@ -1319,22 +1317,19 @@ void ReanimatedModuleProxy::initializeLayoutAnimationsProxy() {
|
||||
#endif
|
||||
layoutAnimationsProxy_ = std::move(layoutAnimationsProxyExperimental);
|
||||
} else {
|
||||
- auto layoutAnimationsProxyLegacy = std::make_shared<LayoutAnimationsProxy_Legacy>(
|
||||
+ layoutAnimationsProxy_ = std::make_shared<LayoutAnimationsProxy_Legacy>(
|
||||
layoutAnimationsManager_,
|
||||
componentDescriptorRegistry,
|
||||
scheduler->getContextContainer(),
|
||||
getJSIRuntimeFromWorkletRuntime(uiRuntime_),
|
||||
- uiScheduler_
|
||||
+ uiScheduler_,
|
||||
+ uiManager_
|
||||
#ifdef ANDROID
|
||||
,
|
||||
filterUnmountedTagsFunction_,
|
||||
- uiManager_,
|
||||
jsInvoker_
|
||||
#endif
|
||||
);
|
||||
- // TODO (future): support in experimental
|
||||
- uiManager_->setAnimationDelegate(layoutAnimationsProxyLegacy.get());
|
||||
- layoutAnimationsProxy_ = std::move(layoutAnimationsProxyLegacy);
|
||||
}
|
||||
}
|
||||
}
|
||||
diff --git a/src/PropsRegistryGarbageCollector.ts b/src/PropsRegistryGarbageCollector.ts
|
||||
index f917ce5a8586c02855f1d8d9ae73154592d22510..32148fbac8a9224ffec6edc784b48938da9585fb 100644
|
||||
--- a/src/PropsRegistryGarbageCollector.ts
|
||||
+++ b/src/PropsRegistryGarbageCollector.ts
|
||||
@@ -11,7 +11,6 @@ import { ReanimatedModule } from './ReanimatedModule';
|
||||
const FLUSH_INTERVAL_MS = 500;
|
||||
|
||||
export const PropsRegistryGarbageCollector = {
|
||||
- viewsCount: 0,
|
||||
viewsMap: new Map<number, IAnimatedComponentInternal>(),
|
||||
intervalId: null as NodeJS.Timeout | null,
|
||||
|
||||
@@ -25,16 +24,14 @@ export const PropsRegistryGarbageCollector = {
|
||||
return;
|
||||
}
|
||||
this.viewsMap.set(viewTag, component);
|
||||
- this.viewsCount++;
|
||||
- if (this.viewsCount === 1) {
|
||||
+ if (this.viewsMap.size === 1) {
|
||||
this.registerInterval();
|
||||
}
|
||||
},
|
||||
|
||||
unregisterView(viewTag: number) {
|
||||
- this.viewsMap.delete(viewTag);
|
||||
- this.viewsCount--;
|
||||
- if (this.viewsCount === 0) {
|
||||
+ const deleted = this.viewsMap.delete(viewTag);
|
||||
+ if (deleted && this.viewsMap.size === 0) {
|
||||
this.unregisterInterval();
|
||||
}
|
||||
},
|
||||
@@ -0,0 +1,65 @@
|
||||
# react-native-reanimated@4.3.2.patch
|
||||
|
||||
Backports of two merged upstream PRs:
|
||||
|
||||
1. PR 9901 (`LayoutAnimation.configureNext` compatibility)
|
||||
2. PR 9971 (stale `settledProps` on worklet re-animation / after app resume)
|
||||
|
||||
## 1. Backport of PR 9901
|
||||
|
||||
Backport of https://github.com/software-mansion/react-native-reanimated/pull/9901
|
||||
("refactor(LayoutAnimations): stop taking over UIManagerAnimationDelegate").
|
||||
|
||||
Reanimated's legacy `LayoutAnimationsProxy_Legacy` registered itself as the
|
||||
`UIManagerAnimationDelegate` only to receive `stopSurface`. Occupying that slot
|
||||
overwrites the `LayoutAnimationDriver` that React Native installs there, which
|
||||
silently breaks `LayoutAnimation.configureNext` for the whole app.
|
||||
|
||||
The patch makes the proxy detect surface teardown itself via a
|
||||
`UIManagerCommitHook` (a commit with an empty root marks the surface in
|
||||
`surfacesToRemove_`), frees the animation-delegate slot, and drops final
|
||||
keyframe `Update` mutations for views deleted in the same transaction (a
|
||||
deterministic `configureNext` delete-animation crash found in this app).
|
||||
`uiManager` moves from Android-only to shared constructor args since the hook
|
||||
registration needs it on both platforms.
|
||||
|
||||
Only the `packages/react-native-reanimated` part of the PR is included (the
|
||||
`apps/fabric-example` hunk is not part of the published package), and the
|
||||
include hunk in `LayoutAnimationsProxy_Legacy.cpp` was adjusted to the 4.3.2
|
||||
release sources.
|
||||
|
||||
## 2. Backport of PR 9971 (stale `settledProps`)
|
||||
|
||||
Verbatim application of
|
||||
https://github.com/software-mansion/react-native-reanimated/pull/9971, the
|
||||
4.3-stable cherry-pick of
|
||||
https://github.com/software-mansion/react-native-reanimated/pull/9527
|
||||
("Fix stale settledProps on worklet re-animation"). Fixes the Android DM
|
||||
composer "phantom jump"
|
||||
(https://github.com/software-mansion/react-native-reanimated/issues/9574).
|
||||
|
||||
Background: with `FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS`, once an
|
||||
animation settles its final props are handed to JS (polled every 500 ms by
|
||||
`PropsRegistryGarbageCollector`) and stored in React component state
|
||||
(`settledProps`), after which the React-side snapshot becomes the sole owner
|
||||
of the value.
|
||||
|
||||
The PR replaces `getUpdatesOlderThanTimestamp` (which evicted registry
|
||||
entries on a wall-clock 1 s/2 s window) with `collectSettledUpdates`:
|
||||
|
||||
- `syncedTags_` / `invalidatedTags_` track which tags React already has a
|
||||
snapshot for; when a previously-synced view re-animates, its stale snapshot
|
||||
is refreshed on the next GC tick instead of waiting for the new value to
|
||||
settle.
|
||||
- Eviction is no longer time-based. An entry is only evicted on the tick
|
||||
*after* it was returned to JS (once its `settledProps` commit is
|
||||
guaranteed), so a missed timer window (app backgrounded, JS thread blocked)
|
||||
can no longer destroy a settled value before it reaches React. This
|
||||
replaces the ad-hoc eviction guard an earlier version of this patch added
|
||||
on top of the pre-merge PR 9527.
|
||||
- `PropsRegistryGarbageCollector` drops the separate `viewsCount` counter
|
||||
(which could desync when nested animated components unregister a tag that
|
||||
was never registered, stopping the GC interval while views remain) in favor
|
||||
of `viewsMap.size`. Only `src/` is touched, matching the PR; Metro bundles
|
||||
the app from `src/` via the package's `react-native` field, and the stale
|
||||
`lib/` copy is unreachable (the feature is native-only).
|
||||
@@ -1,35 +0,0 @@
|
||||
diff --git a/ios/RNUITextViewShadow.swift b/ios/RNUITextViewShadow.swift
|
||||
index c34ba712ca628ed8cf2db0f9fc332810ec86d34d..3602856dc8cd926b5321b4ecb109be9c00a23fe6 100644
|
||||
--- a/ios/RNUITextViewShadow.swift
|
||||
+++ b/ios/RNUITextViewShadow.swift
|
||||
@@ -159,13 +159,25 @@ class RNUITextViewShadow: RCTShadowView {
|
||||
let maxSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(MAXFLOAT))
|
||||
let textSize = self.attributedText.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, context: nil)
|
||||
|
||||
- var totalLines = self.lineHeight == 0.0 ? 0 : Int(ceil(textSize.height / self.lineHeight))
|
||||
-
|
||||
- if self.numberOfLines != 0, totalLines > self.numberOfLines {
|
||||
- totalLines = self.numberOfLines
|
||||
+ var finalHeight: CGFloat
|
||||
+
|
||||
+ if self.numberOfLines != 0 && self.lineHeight != 0.0 {
|
||||
+ // numberOfLines is set with custom line height - need to calculate lines and snap to lineHeight multiples
|
||||
+ // NOTE: this calculation can be inaccurate with fractional font sizes
|
||||
+ var totalLines = Int(ceil(textSize.height / self.lineHeight))
|
||||
+ if totalLines > self.numberOfLines {
|
||||
+ totalLines = self.numberOfLines
|
||||
+ }
|
||||
+ finalHeight = CGFloat(totalLines) * self.lineHeight
|
||||
+ } else {
|
||||
+ // Either no numberOfLines limit, or no custom lineHeight - use actual text height
|
||||
+ // (numberOfLines without custom lineHeight is handled by the UITextView's textContainer.maximumNumberOfLines)
|
||||
+ finalHeight = textSize.height
|
||||
}
|
||||
|
||||
- self.frameSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(CGFloat(totalLines) * self.lineHeight))
|
||||
+ finalHeight = ceil(finalHeight)
|
||||
+
|
||||
+ self.frameSize = CGSize(width: CGFloat(maxWidth), height: finalHeight)
|
||||
return YGSize(width: Float(self.frameSize.width), height: Float(self.frameSize.height))
|
||||
}
|
||||
|
||||
@@ -1,21 +1,82 @@
|
||||
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
|
||||
index 914a2494a57923fbf185644b7e2bb8aca8848e56..0deac55f22350f5e8377d8963fb1c2434bf6abfd 100644
|
||||
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
|
||||
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
|
||||
@@ -19,6 +19,8 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
*/
|
||||
@interface RCTPullToRefreshViewComponentView : RCTViewComponentView <RCTCustomPullToRefreshViewProtocol>
|
||||
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
|
||||
index c593d9ee2155a826352ebca34845aa5792b2eec3..3c26cd737f21116ff0aa48190e97e6c0649b5fac 100644
|
||||
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
|
||||
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
|
||||
@@ -101,6 +101,20 @@ - (void)setContentOffset:(CGPoint)contentOffset
|
||||
RCTSanitizeNaNValue(contentOffset.y, @"scrollView.contentOffset.y"));
|
||||
}
|
||||
|
||||
+- (void)beginRefreshingProgrammatically;
|
||||
+- (void)setCenterContent:(BOOL)centerContent
|
||||
+{
|
||||
+ if (_centerContent != centerContent) {
|
||||
+ _centerContent = centerContent;
|
||||
+ [self centerContentIfNeeded];
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
@end
|
||||
+- (void)setContentSize:(CGSize)contentSize
|
||||
+{
|
||||
+ [super setContentSize:contentSize];
|
||||
+ [self centerContentIfNeeded];
|
||||
+}
|
||||
+
|
||||
- (void)setFrame:(CGRect)frame
|
||||
{
|
||||
[super setFrame:frame];
|
||||
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
|
||||
index 0d231bc8aa938da296eb3b981e8ac9595a43b87f..be0a10d9c4de1892fa00bcbf8d63d739b66d8ffe 100644
|
||||
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
|
||||
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
|
||||
@@ -76,7 +76,17 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
|
||||
return;
|
||||
}
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
- const auto &oldConcreteProps = static_cast<const PullToRefreshViewProps &>(*_props);
|
||||
+ /*
|
||||
+ * TODO: Remove after upgrading React Native to 0.82+ (fixed upstream by
|
||||
+ * facebook/react-native#52615, #52584 and #53231).
|
||||
+ * Diff against oldProps instead of _props. During the initial-layout replay
|
||||
+ * from layoutSubviews, _props already holds the new props, so diffing
|
||||
+ * against it is a no-op and tintColor/progressViewOffset are never applied
|
||||
+ * on mount (facebook/react-native#56343). oldProps is null-guarded because
|
||||
+ * the create-mutation path passes nullptr.
|
||||
+ */
|
||||
+ const auto &oldConcreteProps = static_cast<const PullToRefreshViewProps &>(
|
||||
+ oldProps ? *oldProps : *PullToRefreshViewShadowNode::defaultSharedProps());
|
||||
const auto &newConcreteProps = static_cast<const PullToRefreshViewProps &>(*props);
|
||||
|
||||
if (newConcreteProps.tintColor != oldConcreteProps.tintColor) {
|
||||
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
|
||||
index 1494fd225aff1fa0429e917404d6b4ca5fc961c5..df643f5c844ad2e684de5161528eba17f4a188d0 100644
|
||||
index 1494fd225aff1fa0429e917404d6b4ca5fc961c5..d0cce700090245444f8ce51e517d5ceca09526f6 100644
|
||||
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
|
||||
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
|
||||
@@ -1038,6 +1038,11 @@ - (void)_adjustForMaintainVisibleContentPosition
|
||||
@@ -380,7 +380,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
|
||||
|
||||
MAP_SCROLL_VIEW_PROP(zoomScale);
|
||||
|
||||
- if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset) {
|
||||
+ // When disabling centerContent, reset inset to prop value
|
||||
+ // (enabling is handled automatically by the setCenterContent: setter)
|
||||
+ if (oldScrollViewProps.centerContent && !newScrollViewProps.centerContent) {
|
||||
+ _scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
|
||||
+ }
|
||||
+
|
||||
+ // Only apply contentInset from props if centerContent is disabled
|
||||
+ // When centerContent is enabled, the inset is calculated by centerContentIfNeeded
|
||||
+ if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset && !newScrollViewProps.centerContent) {
|
||||
_scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
|
||||
}
|
||||
|
||||
@@ -507,7 +515,7 @@ - (UIView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event
|
||||
}
|
||||
}
|
||||
|
||||
- return isPointInside ? self : nil;
|
||||
+ return isPointInside ? _scrollView : nil;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -1038,6 +1046,11 @@ - (void)_adjustForMaintainVisibleContentPosition
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,20 +89,18 @@ index 1494fd225aff1fa0429e917404d6b4ca5fc961c5..df643f5c844ad2e684de5161528eba17
|
||||
|
||||
Class<RCTComponentViewProtocol> RCTScrollViewCls(void)
|
||||
diff --git a/React/Views/RefreshControl/RCTRefreshControl.h b/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
index e9b330fa7c29c42653a3b0191d0f8a1b13b2d3de..ec5f58c887bfd949f1279ef1c31352e0b465e9ec 100644
|
||||
index e9b330fa7c29c42653a3b0191d0f8a1b13b2d3de..5fbb2e05cadfc06fd7a18bf52b81bc399e92f3ca 100644
|
||||
--- a/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
+++ b/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
@@ -15,5 +15,8 @@
|
||||
@@ -15,5 +15,6 @@
|
||||
@property (nonatomic, copy) NSString *title;
|
||||
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
|
||||
@property (nonatomic, weak) UIScrollView *scrollView;
|
||||
+@property (nonatomic, copy) UIColor *customTintColor;
|
||||
+
|
||||
+- (void)forwarderBeginRefreshing;
|
||||
|
||||
@end
|
||||
diff --git a/React/Views/RefreshControl/RCTRefreshControl.m b/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
index 53bfd04703502d5b8e932c47a528bb03cd79d330..ff1b1ed5e060bcf0d91528c6d3c2c5c8acf24967 100644
|
||||
index 53bfd04703502d5b8e932c47a528bb03cd79d330..e2e0c9f4e5d1a3a3b178a7ec69aa63e3039b6dec 100644
|
||||
--- a/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
+++ b/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
@@ -23,6 +23,7 @@ @implementation RCTRefreshControl {
|
||||
@@ -65,7 +124,7 @@ index 53bfd04703502d5b8e932c47a528bb03cd79d330..ff1b1ed5e060bcf0d91528c6d3c2c5c8
|
||||
- (void)didMoveToWindow
|
||||
{
|
||||
[super didMoveToWindow];
|
||||
@@ -221,4 +228,50 @@ - (void)refreshControlValueChanged
|
||||
@@ -221,4 +228,16 @@ - (void)refreshControlValueChanged
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,40 +139,6 @@ index 53bfd04703502d5b8e932c47a528bb03cd79d330..ff1b1ed5e060bcf0d91528c6d3c2c5c8
|
||||
+ [super setTintColor:tintColor];
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+// This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native
|
||||
+// libraries to perform a refresh of a scrollview and access the refresh control's onRefresh
|
||||
+// function.
|
||||
+- (void)forwarderBeginRefreshing
|
||||
+{
|
||||
+ _refreshingProgrammatically = NO;
|
||||
+
|
||||
+ [self sizeToFit];
|
||||
+
|
||||
+ if (!self.scrollView) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ UIScrollView *scrollView = (UIScrollView *)self.scrollView;
|
||||
+
|
||||
+ [UIView animateWithDuration:0.3
|
||||
+ delay:0
|
||||
+ options:UIViewAnimationOptionBeginFromCurrentState
|
||||
+ animations:^(void) {
|
||||
+ // Whenever we call this method, the scrollview will always be at a position of
|
||||
+ // -130 or less. Scrolling back to -65 simulates the default behavior of RCTRefreshControl
|
||||
+ [scrollView setContentOffset:CGPointMake(0, -65)];
|
||||
+ }
|
||||
+ completion:^(__unused BOOL finished) {
|
||||
+ [super beginRefreshing];
|
||||
+ [self setCurrentRefreshingState:super.refreshing];
|
||||
+
|
||||
+ if (self->_onRefresh) {
|
||||
+ self->_onRefresh(nil);
|
||||
+ }
|
||||
+ }
|
||||
+ ];
|
||||
+}
|
||||
+
|
||||
@end
|
||||
diff --git a/React/Views/RefreshControl/RCTRefreshControlManager.m b/React/Views/RefreshControl/RCTRefreshControlManager.m
|
||||
@@ -150,6 +175,52 @@ index 8b6571698fc5dd091a0d8980a33bb40295faf305..27c97bfeb6f13907c89f1d85f2bb8b8a
|
||||
reactChoreographer.postFrameCallback(ReactChoreographer.CallbackType.IDLE_EVENT, this)
|
||||
}
|
||||
}
|
||||
diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
|
||||
index 89b666dcf0258df0702c812600b685463128294c..2b1c3971f0c31a0d7a592b90170e4cc53a8a69dd 100644
|
||||
--- a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
|
||||
+++ b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
|
||||
@@ -431,6 +431,13 @@ public open class ReactViewGroup public constructor(context: Context?) :
|
||||
inSubviewClippingLoop = true
|
||||
var clippedSoFar = 0
|
||||
for (i in 0..<allChildrenCount) {
|
||||
+ // Reentrant child removal during this loop can compact allChildren and leave a null at
|
||||
+ // an index below allChildrenCount. A null entry means the view is already detached, so
|
||||
+ // treat it as clipped instead of crashing.
|
||||
+ if (childArray[i] == null) {
|
||||
+ clippedSoFar++
|
||||
+ continue
|
||||
+ }
|
||||
try {
|
||||
updateSubviewClipStatus(clippingRect, i, clippedSoFar, excludedViewsSet)
|
||||
} catch (ex: IndexOutOfBoundsException) {
|
||||
@@ -466,7 +473,9 @@ public open class ReactViewGroup public constructor(context: Context?) :
|
||||
) {
|
||||
assertOnUiThread()
|
||||
|
||||
- val child = checkNotNull(allChildren?.get(idx))
|
||||
+ // allChildren can be mutated reentrantly while a clipping pass is running, so a stale
|
||||
+ // index can point at a null slot. Skip it instead of crashing.
|
||||
+ val child = allChildren?.get(idx) ?: return
|
||||
val intersects = clippingRect.intersects(child.left, child.top, child.right, child.bottom)
|
||||
var needUpdateClippingRecursive = false
|
||||
|
||||
diff --git a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
|
||||
index 216bb23beb023ef6c3ae814c17e05bccbda7fc91..6ad5cc1d9ed5b8cd2df08ad77adca56c6bb58ff4 100644
|
||||
--- a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
|
||||
+++ b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
|
||||
@@ -386,9 +386,10 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage
|
||||
size.height = enumeratedLinesHeight;
|
||||
}
|
||||
|
||||
+ CGFloat epsilon = 0.001;
|
||||
size = (CGSize){
|
||||
- ceil(size.width * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
|
||||
- ceil(size.height * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
|
||||
+ ceil((size.width + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
|
||||
+ ceil((size.height + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
|
||||
|
||||
__block auto attachments = TextMeasurement::Attachments{};
|
||||
|
||||
diff --git a/third-party-podspecs/fmt.podspec b/third-party-podspecs/fmt.podspec
|
||||
index 2f38990e226c13f483aaf1b986302d4094243814..9b02e481e290299be20a6f09c42056ff51695e9b 100644
|
||||
--- a/third-party-podspecs/fmt.podspec
|
||||
|
||||
@@ -6,8 +6,87 @@ Patching `RCTRefreshControl.mm` temporarily to play an impact haptic on refresh
|
||||
17.4, there has been a regression somewhere causing haptics to not play on iOS on refresh. Should monitor for an update
|
||||
in the RN repo: https://github.com/facebook/react-native/issues/43388
|
||||
|
||||
## RefreshControl Path - ScrollForwarder
|
||||
## RCTPullToRefreshViewComponentView.mm Patch - RefreshControl initial props dropped on New Arch
|
||||
|
||||
Patching `RCTRefreshControl.m` and `RCTRefreshControl.h` to add a new `forwarderBeginRefreshing` method to the class.
|
||||
This method is used by `ExpoScrollForwarder` to initiate a refresh of the underlying `UIScrollView` from inside that
|
||||
module.
|
||||
**TODO: Remove after bumping React Native to 0.82+** (fixed upstream by facebook/react-native#52615, #52584
|
||||
and #53231).
|
||||
|
||||
On Fabric, `updateProps` diffs against `_props`, but the initial-layout replay in `layoutSubviews` passes
|
||||
`_props` as the new props too, so the diff is a no-op and `tintColor`/`progressViewOffset`/`title` are never
|
||||
applied on mount. This hides the pull-to-refresh spinner behind the floating home header (it stays at offset
|
||||
0 instead of `headerOffset`). We diff against the `oldProps` argument instead, null-guarded with default
|
||||
props for the create-mutation path.
|
||||
|
||||
Issue: https://github.com/facebook/react-native/issues/56343
|
||||
|
||||
## RCTEnhancedScrollView.mm / RCTScrollViewComponentView.mm Patch - centerContent insets stale after content resize on New Arch
|
||||
|
||||
**TODO: Remove after bumping React Native to 0.87+** (fixed upstream by facebook/react-native#56832,
|
||||
commit d50c1b5207; first shipped in 0.87.0-rc.0).
|
||||
|
||||
On Fabric, `centerContent` centers by computing `contentInset` in `centerContentIfNeeded`, but that
|
||||
recompute only ran on `setFrame`/`didAddSubview`/`scrollViewDidZoom` - not when a state update assigns a
|
||||
new `contentSize` in `updateState`. Any content that resizes after mount inside a `centerContent`
|
||||
ScrollView (e.g. the lightbox image crop view getting its real aspect ratio from `onLoad` when the embed
|
||||
has no aspectRatio metadata) keeps the old insets: content rests off-center and the excess inset creates
|
||||
phantom scroll range, so the image can be dragged and parked off-center and the native scroll steals the
|
||||
swipe-down-to-dismiss pan. The old architecture paired every `contentSize` update with re-centering in
|
||||
`RCTScrollView.updateContentSizeIfNeeded`; Fabric dropped that link.
|
||||
|
||||
Backport of the upstream fix: `setContentSize:`/`setCenterContent:` overrides on `RCTEnhancedScrollView`
|
||||
that call `centerContentIfNeeded`, plus the `updateProps` guards so the `contentInset` prop does not
|
||||
fight the computed centering inset.
|
||||
|
||||
Issue: https://github.com/facebook/react-native/issues/55090
|
||||
|
||||
## RCTScrollViewComponentView.mm Patch - ScrollView pinch/pan ignored outside content area on New Arch
|
||||
|
||||
**TODO: Remove after bumping React Native to 0.87+** (fixed upstream by facebook/react-native#56747,
|
||||
commit efcab20908; first shipped in 0.87.0-rc.0).
|
||||
|
||||
On Fabric, `betterHitTest` in `RCTScrollViewComponentView` deliberately skips the `_containerView`
|
||||
and hit-tests its grandchildren, returning `self` (the wrapper component view) when the touch lands
|
||||
inside the scroll view bounds but outside any content. UIKit only delivers touches to a gesture
|
||||
recognizer when the hit view is the recognizer's view or a descendant of it, and the `UIScrollView`
|
||||
is a *child* of the wrapper - so its native pinch/pan recognizers never see those touches. In the
|
||||
lightbox this means pinch-to-zoom and pan-while-zoomed only respond when the fingers are over the
|
||||
image itself, not over the letterbox bars above/below it. On the old architecture, default UIKit
|
||||
hit-testing returns the `UIScrollView` itself for those touches, so everything works.
|
||||
|
||||
Backport of the upstream one-liner: return `_scrollView` instead of `self` so touches in the
|
||||
content-less area are attributed to the `UIScrollView`.
|
||||
|
||||
Issue: https://github.com/facebook/react-native/issues/54123
|
||||
PR: https://github.com/react/react-native/pull/56747
|
||||
|
||||
## ReactViewGroup.kt Patch - Fatal "Required value was null" during subview clipping on Android
|
||||
|
||||
Fixes Sentry issue APP-T20Q: `IllegalStateException: Required value was null` thrown by
|
||||
`checkNotNull(allChildren?.get(idx))` in `updateSubviewClipStatus`, reached from
|
||||
`ReactScrollView.onScrollChanged -> updateClippingRect` during an animated smooth scroll
|
||||
(New Architecture, `removeClippedSubviews`).
|
||||
|
||||
The clipping loop in `updateClippingToRect` captures its bound once, but clipping a view
|
||||
(`removeViewsInLayout`) can synchronously trigger reentrant child removal (layout-change
|
||||
listeners, animation-end callbacks, Fabric mounting on the UI thread), which compacts
|
||||
`allChildren` and nulls the tail mid-loop. Upstream already catches the
|
||||
`IndexOutOfBoundsException` variant of this corruption with diagnostics, but the null-child
|
||||
variant throws `IllegalStateException` and escapes as a fatal crash. A null entry means the
|
||||
view is already detached, so we skip it and count it as clipped to keep index math aligned.
|
||||
|
||||
Not fixed upstream as of July 2026 (identical `checkNotNull` on `main`); the sibling fix
|
||||
attempt facebook/react-native#57365 for the same bookkeeping corruption (different stack)
|
||||
was abandoned. Re-check when bumping React Native.
|
||||
|
||||
Note on build modes: production Android builds compile react-android from source
|
||||
(`buildReactNativeFromSource: IS_PRODUCTION` via expo-build-properties in app.config.js
|
||||
injects the includeBuild/dependency-substitution block at prebuild), so this hunk IS
|
||||
active in production releases. Local dev builds prebuilt in a non-production env consume
|
||||
the prebuilt AAR from Maven Central instead, where this hunk (like any ReactAndroid
|
||||
source change) has no effect - do not expect to see the fix in a local debug build unless
|
||||
you prebuild with EXPO_PUBLIC_ENV=production or add the substitution block manually.
|
||||
|
||||
## RCTTextLayoutManager.mm Patch - Text overflows instead of wrapping on the last line
|
||||
|
||||
Issue: https://github.com/react/react-native/issues/53450#issuecomment-3298157830
|
||||
Bandaid fix taken from: https://github.com/react/react-native/commit/581d643a9e59fd88f93757f80194e1efd11bd0e5
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
diff --git a/lib/module/toast.js b/lib/module/toast.js
|
||||
index be089f3ff23017a6844e2010cedc547729e198aa..c2dd0fa2df93f929c33bf2bcc3e6f921941fa006 100644
|
||||
--- a/lib/module/toast.js
|
||||
+++ b/lib/module/toast.js
|
||||
@@ -1,8 +1,8 @@
|
||||
"use strict";
|
||||
|
||||
import * as React from 'react';
|
||||
-import { ActivityIndicator, Pressable, Text, View } from 'react-native';
|
||||
-import Animated, { useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
|
||||
+import { ActivityIndicator, Pressable, Text, View, Platform } from 'react-native';
|
||||
+import Animated, { useAnimatedStyle, useSharedValue, withRepeat, withTiming, FadeOut } from 'react-native-reanimated';
|
||||
import { ANIMATION_DURATION, useToastLayoutAnimations } from "./animations.js";
|
||||
import { toastDefaultValues } from "./constants.js";
|
||||
import { useToastContext } from "./context.js";
|
||||
@@ -258,7 +258,10 @@ export const Toast = /*#__PURE__*/React.forwardRef(({
|
||||
...toastSwipeHandlerProps,
|
||||
children: /*#__PURE__*/_jsx(Animated.View, {
|
||||
entering: entering,
|
||||
- exiting: exiting,
|
||||
+ exiting: Platform.select({
|
||||
+ android: undefined,
|
||||
+ default: exiting
|
||||
+ }),
|
||||
children: jsx
|
||||
})
|
||||
});
|
||||
@@ -268,7 +271,10 @@ export const Toast = /*#__PURE__*/React.forwardRef(({
|
||||
children: /*#__PURE__*/_jsx(Animated.View, {
|
||||
style: [unstyled ? undefined : elevationStyle, defaultStyles.toast, toastStyleCtx, styles?.toast, style, wiggleAnimationStyle],
|
||||
entering: entering,
|
||||
- exiting: exiting,
|
||||
+ exiting: Platform.select({
|
||||
+ android: undefined,
|
||||
+ default: exiting
|
||||
+ }),
|
||||
children: /*#__PURE__*/_jsxs(View, {
|
||||
style: [defaultStyles.toastContent, toastContentStyleCtx, styles?.toastContent],
|
||||
children: [promiseOptions || variant === 'loading' ? 'loading' in icons ? icons.loading : /*#__PURE__*/_jsx(ActivityIndicator, {}) : icon ? /*#__PURE__*/_jsx(View, {
|
||||
@@ -1,3 +0,0 @@
|
||||
# sonner-native+0.21.0.patch
|
||||
|
||||
Removes Reanimated exit layout animations from the toasts. This was causing crashes if the toast was hidden while you were scrolling a flatlist.
|
||||
Generated
+1033
-496
File diff suppressed because it is too large
Load Diff
+13
-6
@@ -9,7 +9,8 @@ overrides:
|
||||
'@expo/image-utils': '0.8.12'
|
||||
'@types/estree': '1.0.6'
|
||||
'react-native-compressor': '1.13.0'
|
||||
'react-native-reanimated': '3.19.1'
|
||||
'react-native-reanimated': '4.3.2'
|
||||
'react-native-worklets': '0.8.3'
|
||||
'psl': '1.9.0'
|
||||
'@types/psl': '1.1.1'
|
||||
'react-native-screens': '4.24.0'
|
||||
@@ -20,8 +21,7 @@ allowBuilds:
|
||||
'unrs-resolver': true
|
||||
patchedDependencies:
|
||||
'@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-age-range@0.2.18': patches/expo-age-range@0.2.18.patch
|
||||
'expo-haptics@15.0.8': patches/expo-haptics@15.0.8.patch
|
||||
'expo-image-picker@17.0.11': patches/expo-image-picker@17.0.11.patch
|
||||
'expo-image@3.0.11': patches/expo-image@3.0.11.patch
|
||||
@@ -32,14 +32,21 @@ patchedDependencies:
|
||||
'react-native-compressor@1.13.0': patches/react-native-compressor@1.13.0.patch
|
||||
'react-native-date-picker@5.0.13': patches/react-native-date-picker@5.0.13.patch
|
||||
'react-native-drawer-layout@4.2.3': patches/react-native-drawer-layout@4.2.3.patch
|
||||
'react-native-gesture-handler': patches/react-native-gesture-handler.patch
|
||||
'react-native-keyboard-controller@1.21.8': patches/react-native-keyboard-controller@1.21.8.patch
|
||||
'react-native-pager-view@6.8.0': patches/react-native-pager-view@6.8.0.patch
|
||||
'react-native-reanimated@3.19.1': patches/react-native-reanimated@3.19.1.patch
|
||||
'react-native-reanimated@4.3.2': patches/react-native-reanimated@4.3.2.patch
|
||||
'react-native-svg@15.12.1': patches/react-native-svg@15.12.1.patch
|
||||
'react-native-uitextview@1.4.0': patches/react-native-uitextview@1.4.0.patch
|
||||
'react-native-view-shot@4.0.3': patches/react-native-view-shot@4.0.3.patch
|
||||
'react-native@0.81.5': patches/react-native@0.81.5.patch
|
||||
'sonner-native@0.21.0': patches/sonner-native@0.21.0.patch
|
||||
minimumReleaseAgeExclude:
|
||||
- '@atproto/*'
|
||||
- '@bsky.app/*'
|
||||
# todo: remove when old enough
|
||||
- '@oxlint-tsgolint/darwin-arm64@7.0.2001'
|
||||
- '@oxlint-tsgolint/darwin-x64@7.0.2001'
|
||||
- '@oxlint-tsgolint/linux-arm64@7.0.2001'
|
||||
- '@oxlint-tsgolint/linux-x64@7.0.2001'
|
||||
- '@oxlint-tsgolint/win32-arm64@7.0.2001'
|
||||
- '@oxlint-tsgolint/win32-x64@7.0.2001'
|
||||
- oxlint-tsgolint@7.0.2001
|
||||
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
set -o xtrace
|
||||
|
||||
# Resolve paths relative to the repo root, regardless of where this is run from.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
ANDROID_DIR="$REPO_ROOT/android"
|
||||
APK_OUTPUT_DIR="$ANDROID_DIR/app/build/outputs/apk/release"
|
||||
SETTINGS_GRADLE="$ANDROID_DIR/settings.gradle"
|
||||
|
||||
# Guard against building with the wrong app identity. The New Arch build must
|
||||
# use a distinct rootProject.name so it installs alongside the store app rather
|
||||
# than overwriting it.
|
||||
EXPECTED_APP_NAME="rootProject.name = 'Bluesky (New Arch)'"
|
||||
if ! grep -qF "$EXPECTED_APP_NAME" "$SETTINGS_GRADLE"; then
|
||||
echo "Error: expected \"$EXPECTED_APP_NAME\" in $SETTINGS_GRADLE" >&2
|
||||
echo "(Set the app name in settings.gradle before building the New Arch release.)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BRANCH_NAME="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD)"
|
||||
COMMIT_HASH="$(git -C "$REPO_ROOT" rev-parse --short=6 HEAD)"
|
||||
|
||||
# Sanitize the branch name so it is safe to use in a filename (e.g. ob/new-arch -> ob-new-arch).
|
||||
SAFE_BRANCH="$(echo "$BRANCH_NAME" | tr '/ ' '-')"
|
||||
|
||||
echo "Building Android release APK..."
|
||||
echo " branch: $BRANCH_NAME"
|
||||
echo " commit: $COMMIT_HASH"
|
||||
|
||||
# Marker used to detect which APK was produced by THIS build. Anything with an
|
||||
# older mtime (e.g. a stale app-release.apk from a prior or interrupted run) is
|
||||
# ignored, so we never mislabel it with the current commit.
|
||||
BUILD_MARKER="$(mktemp)"
|
||||
trap 'rm -f "$BUILD_MARKER"' EXIT
|
||||
|
||||
# Make sure the bundled JS ships with up-to-date compiled translations.
|
||||
pnpm intl:compile
|
||||
|
||||
cd "$ANDROID_DIR"
|
||||
# Build only arm64: Apple Silicon Macs run arm64 emulator images and all modern
|
||||
# devices are arm64, so the other three ABIs just quadruple the NDK compile.
|
||||
./gradlew assembleRelease --max-workers=2 --no-daemon -PreactNativeArchitectures=arm64-v8a
|
||||
|
||||
# Grab the freshly built APK: not an already-renamed bsky-* file, and newer than
|
||||
# the marker so it is guaranteed to be this run's output. There are no ABI splits
|
||||
# or flavors, so expect a single file.
|
||||
APK_PATH="$(find "$APK_OUTPUT_DIR" -maxdepth 1 -name '*.apk' -not -name 'bsky-*' -newer "$BUILD_MARKER" | head -n 1)"
|
||||
|
||||
if [ -z "$APK_PATH" ]; then
|
||||
echo "Error: no freshly built APK found in $APK_OUTPUT_DIR" >&2
|
||||
echo "(Gradle may have been up-to-date and produced no new APK - run a clean build.)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PREV_NAME="$(basename "$APK_PATH" .apk)"
|
||||
NEW_NAME="bsky-${PREV_NAME}-${SAFE_BRANCH}-${COMMIT_HASH}.apk"
|
||||
NEW_PATH="$APK_OUTPUT_DIR/$NEW_NAME"
|
||||
|
||||
mv "$APK_PATH" "$NEW_PATH"
|
||||
|
||||
echo "Renamed APK:"
|
||||
echo " $APK_PATH"
|
||||
echo " -> $NEW_PATH"
|
||||
+20
-2
@@ -14,8 +14,26 @@ if [ -z "$RUNTIME_VERSION" ]; then
|
||||
fi
|
||||
|
||||
cd bundleTempDir || exit
|
||||
BUNDLE_VERSION=$(date +%s)
|
||||
DEPLOYMENT_URL="https://updates.bsky.app/v1/upload?runtime-version=$RUNTIME_VERSION&bundle-version=$BUNDLE_VERSION&channel=$CHANNEL_NAME&ios-build-number=$BSKY_IOS_BUILD_NUMBER&android-build-number=$BSKY_ANDROID_VERSION_CODE"
|
||||
|
||||
# Shared with denisPublish.sh when both run in one job -- see the note there.
|
||||
# Both origins must receive the same bundle version for the same bytes, because
|
||||
# the version is part of the asset URL path.
|
||||
BUNDLE_VERSION="${BUNDLE_VERSION:-$(date +%s)}"
|
||||
|
||||
# This MUST address ota1's own origin hostname, never updates.bsky.app.
|
||||
#
|
||||
# Since the 2026-07-26 cutover updates.bsky.app resolves to denis on EKS, which
|
||||
# deliberately has no /v1/upload route -- publishing there is out-of-band via
|
||||
# `denis publish` (see denisPublish.sh). Posting to the CDN hostname therefore
|
||||
# returns 404, which is what broke this step the first time it ran after the
|
||||
# flip. The dual-write was never independent of the cutover precisely because it
|
||||
# addressed the hostname being cut over.
|
||||
#
|
||||
# This upload exists only to keep ota1 carrying current bundles so a rollback of
|
||||
# the Bunny origin remains useful. It goes away with this whole script when ota1
|
||||
# is decommissioned (Phase 5).
|
||||
OTA1_ORIGIN="${OTA1_ORIGIN:-https://ota1.us-east.updates.bsky.network}"
|
||||
DEPLOYMENT_URL="$OTA1_ORIGIN/v1/upload?runtime-version=$RUNTIME_VERSION&bundle-version=$BUNDLE_VERSION&channel=$CHANNEL_NAME&ios-build-number=$BSKY_IOS_BUILD_NUMBER&android-build-number=$BSKY_ANDROID_VERSION_CODE"
|
||||
|
||||
tar czvf bundle.tar.gz ./*
|
||||
|
||||
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
# Publishes the just-exported Expo bundle to the denis OTA service (S3) via the
|
||||
# `denis publish` CLI. Mirrors bundleUpdate.sh's inputs (runtime version, bundle
|
||||
# version, build numbers) but targets denis instead of the legacy ota1 upload.
|
||||
# Expects: the `denis` binary on PATH (setup-denis action), ambient AWS creds
|
||||
# (configure-aws-credentials OIDC), and BSKY_IOS_BUILD_NUMBER /
|
||||
# BSKY_ANDROID_VERSION_CODE from the use-build-number wrapper.
|
||||
|
||||
rm -rf bundleTempDir
|
||||
|
||||
echo "Assembling bundle directory..."
|
||||
node scripts/bundleUpdate.js
|
||||
|
||||
if [ -z "$RUNTIME_VERSION" ]; then
|
||||
RUNTIME_VERSION=$(cat package.json | jq '.version' -r)
|
||||
fi
|
||||
|
||||
# Accept a caller-supplied bundle version so that a dual-write publishes the SAME
|
||||
# version to every origin. When this script and bundleUpdate.sh each called
|
||||
# `date +%s` independently they produced versions seconds apart for identical
|
||||
# bytes -- observed 1785102575 (denis) vs 1785102614 (ota1) for one commit. Since
|
||||
# the version is part of the asset URL path, the two origins then served
|
||||
# manifests pointing at paths only one of them had, so the manifest and its
|
||||
# assets had to come from the same origin or the fetch 404s. Falling back to
|
||||
# `date +%s` keeps standalone callers (PR previews, `pnpm make-deploy-bundle`)
|
||||
# working unchanged.
|
||||
BUNDLE_VERSION="${BUNDLE_VERSION:-$(date +%s)}"
|
||||
DENIS_CDN_DOMAIN="${DENIS_CDN_DOMAIN:-updates.bsky.app}"
|
||||
DENIS_S3_BUCKET="${DENIS_S3_BUCKET:-bsky-denis-ota-prod}"
|
||||
|
||||
echo "Publishing to denis..."
|
||||
echo " runtime-version: $RUNTIME_VERSION"
|
||||
echo " bundle-version: $BUNDLE_VERSION"
|
||||
echo " channel: $CHANNEL_NAME"
|
||||
echo " ios-build-number: $BSKY_IOS_BUILD_NUMBER"
|
||||
echo " android-build-number: $BSKY_ANDROID_VERSION_CODE"
|
||||
echo " cdn-domain: $DENIS_CDN_DOMAIN"
|
||||
echo " s3-bucket: $DENIS_S3_BUCKET"
|
||||
|
||||
denis publish \
|
||||
--bundle-dir bundleTempDir \
|
||||
--runtime-version "$RUNTIME_VERSION" \
|
||||
--bundle-version "$BUNDLE_VERSION" \
|
||||
--channel "$CHANNEL_NAME" \
|
||||
--ios-build-number "$BSKY_IOS_BUILD_NUMBER" \
|
||||
--android-build-number "$BSKY_ANDROID_VERSION_CODE" \
|
||||
--cdn-domain "$DENIS_CDN_DOMAIN" \
|
||||
--s3-bucket "$DENIS_S3_BUCKET"
|
||||
|
||||
rm -rf bundleTempDir
|
||||
+22
-43
@@ -9,13 +9,13 @@ import {
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import Svg, {Path, type SvgProps} from 'react-native-svg'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
import {Image} from 'expo-image'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
|
||||
@@ -72,21 +72,26 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
const isDarkMode = colorScheme === 'dark'
|
||||
|
||||
const logoAnimation = useAnimatedStyle(() => {
|
||||
const introScale = interpolate(intro.get(), [0, 1], [0.8, 1], 'clamp')
|
||||
const outroScale =
|
||||
reduceMotion === true
|
||||
? 1
|
||||
: interpolate(outroLogo.get(), [0, 0.08, 1], [1, 0.8, 500], 'clamp')
|
||||
|
||||
const introOpacity = interpolate(intro.get(), [0, 1], [0, 1], 'clamp')
|
||||
const outroOpacity = interpolate(
|
||||
outroAppOpacity.get(),
|
||||
[0, 0.1, 0.2, 1],
|
||||
[1, 1, 0, 0],
|
||||
'clamp',
|
||||
)
|
||||
|
||||
return {
|
||||
opacity: introOpacity * outroOpacity,
|
||||
transform: [
|
||||
{
|
||||
scale: interpolate(intro.get(), [0, 1], [0.8, 1], 'clamp'),
|
||||
},
|
||||
{
|
||||
scale: interpolate(
|
||||
outroLogo.get(),
|
||||
[0, 0.08, 1],
|
||||
[1, 0.8, 500],
|
||||
'clamp',
|
||||
),
|
||||
},
|
||||
{translateY: -(insets.top / 2)},
|
||||
{scale: 0.1 * outroScale * introScale},
|
||||
],
|
||||
opacity: interpolate(intro.get(), [0, 1], [0, 1], 'clamp'),
|
||||
}
|
||||
})
|
||||
const bottomLogoAnimation = useAnimatedStyle(() => {
|
||||
@@ -94,27 +99,6 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
opacity: interpolate(intro.get(), [0, 1], [0, 1], 'clamp'),
|
||||
}
|
||||
})
|
||||
const reducedLogoAnimation = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
scale: interpolate(intro.get(), [0, 1], [0.8, 1], 'clamp'),
|
||||
},
|
||||
],
|
||||
opacity: interpolate(intro.get(), [0, 1], [0, 1], 'clamp'),
|
||||
}
|
||||
})
|
||||
|
||||
const logoWrapperAnimation = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: interpolate(
|
||||
outroAppOpacity.get(),
|
||||
[0, 0.1, 0.2, 1],
|
||||
[1, 1, 0, 0],
|
||||
'clamp',
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const appAnimation = useAnimatedStyle(() => {
|
||||
return {
|
||||
@@ -126,7 +110,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
opacity: interpolate(
|
||||
outroAppOpacity.get(),
|
||||
[0, 0.1, 0.2, 1],
|
||||
[0, 0, 1, 1],
|
||||
[0.02, 0.02, 1, 1], // first two values cant be 0 for the iOS blur/glass effects to work, the values obtained by trial and error
|
||||
'clamp',
|
||||
),
|
||||
}
|
||||
@@ -152,7 +136,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
1,
|
||||
{duration: 1200, easing: Easing.in(Easing.cubic)},
|
||||
() => {
|
||||
runOnJS(onFinish)()
|
||||
scheduleOnRN(onFinish)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -180,8 +164,6 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion)
|
||||
}, [])
|
||||
|
||||
const logoAnimations =
|
||||
reduceMotion === true ? reducedLogoAnimation : logoAnimation
|
||||
// special off-spec color for dark mode
|
||||
const logoBg = isDarkMode ? '#0F1824' : '#fff'
|
||||
|
||||
@@ -224,17 +206,14 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
<Animated.View
|
||||
style={[
|
||||
StyleSheet.absoluteFillObject,
|
||||
logoWrapperAnimation,
|
||||
logoAnimation,
|
||||
{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px
|
||||
},
|
||||
]}>
|
||||
<Animated.View style={[logoAnimations]}>
|
||||
<Logo fill={logoBg} />
|
||||
</Animated.View>
|
||||
<Logo fill={logoBg} />
|
||||
</Animated.View>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -4,13 +4,30 @@ import {
|
||||
} from '@atproto/api'
|
||||
|
||||
import {AgeAssuranceAccess} from '#/ageAssurance/types'
|
||||
import {ANDROID_API_LEVEL, IOS_MAJOR_VERSION, IS_ANDROID, IS_IOS} from '#/env'
|
||||
import {
|
||||
ANDROID_API_LEVEL,
|
||||
IOS_MAJOR_VERSION,
|
||||
IS_ANDROID,
|
||||
IS_IOS,
|
||||
IS_WEB,
|
||||
} from '#/env'
|
||||
|
||||
/**
|
||||
* Minimum age required to access the app at all.
|
||||
*/
|
||||
export const MIN_ACCESS_AGE = 13
|
||||
|
||||
/**
|
||||
* The identifier for the current platform, matching the `knownValues` of the
|
||||
* `platforms` property on `app.bsky.ageassurance.defs#configRegion`. Used to
|
||||
* filter out region configs that don't apply to this platform.
|
||||
*/
|
||||
export const AGE_ASSURANCE_PLATFORM: 'web' | 'ios' | 'android' = IS_WEB
|
||||
? 'web'
|
||||
: IS_IOS
|
||||
? 'ios'
|
||||
: 'android'
|
||||
|
||||
/**
|
||||
* Whether the current device can provide the native on-device age signals we
|
||||
* use for age assurance (via `expo-age-range`). We gate on OS version because
|
||||
|
||||
@@ -59,9 +59,10 @@ export const config: AppBskyAgeassuranceDefs.Config = {
|
||||
],
|
||||
},
|
||||
{
|
||||
// On-device verification region. KWS is included as a fallback for
|
||||
// platforms without the native age API (e.g. web) or when the device
|
||||
// On-device verification region, native-only (web users in TX are not
|
||||
// age assured). KWS is included as a fallback for when the device
|
||||
// result is insufficient.
|
||||
platforms: ['ios', 'android'],
|
||||
countryCode: 'US',
|
||||
regionCode: 'TX',
|
||||
minAccessAge: 18,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import {getAgeAssuranceRegionConfig} from '@atproto/api'
|
||||
|
||||
import {getAgeAssuranceRegionConfigForGeolocation} from '#/ageAssurance/util'
|
||||
|
||||
jest.mock('#/ageAssurance/data')
|
||||
jest.mock('@atproto/api', () => ({
|
||||
...jest.requireActual('@atproto/api'),
|
||||
getAgeAssuranceRegionConfig: jest.fn(),
|
||||
}))
|
||||
|
||||
/*
|
||||
* Platform-based region filtering itself is implemented and tested in
|
||||
* `@atproto/api` (see `getAgeAssuranceRegionConfig`). What we own - and test
|
||||
* here - is that region resolution passes the current platform through. The
|
||||
* jest preset is `jest-expo/ios`, so `AGE_ASSURANCE_PLATFORM` resolves to
|
||||
* `ios` in these tests.
|
||||
*/
|
||||
describe('getAgeAssuranceRegionConfigForGeolocation', () => {
|
||||
it('passes the current platform to the SDK region matcher', () => {
|
||||
const config = {regions: []}
|
||||
getAgeAssuranceRegionConfigForGeolocation(config, {
|
||||
countryCode: 'US',
|
||||
regionCode: 'TX',
|
||||
})
|
||||
expect(getAgeAssuranceRegionConfig).toHaveBeenCalledWith(config, {
|
||||
countryCode: 'US',
|
||||
regionCode: 'TX',
|
||||
platform: 'ios',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import {getAge} from '#/lib/strings/time'
|
||||
import {regionName} from '#/locale/helpers'
|
||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const'
|
||||
import {
|
||||
AGE_ASSURANCE_PLATFORM,
|
||||
DEVICE_SIGNALS_SUPPORTED,
|
||||
FALLBACK_REGION_CONFIG,
|
||||
MIN_ACCESS_AGE,
|
||||
@@ -29,6 +30,10 @@ import {USRegionNameToRegionCode} from '#/geolocation/util'
|
||||
* Resolves a geolocation to its matched age assurance region config, or
|
||||
* undefined when the geolocation matches no AA region.
|
||||
*
|
||||
* Regions scoped to other platforms via `platforms` are passed over entirely,
|
||||
* as if they weren't in the config - a later region matching the same
|
||||
* geolocation can still apply.
|
||||
*
|
||||
* This is the single source of truth for geolocation -> region resolution.
|
||||
* Device signals are written and read back under a key derived from the
|
||||
* matched region (see `createRegionKey`), so every site that resolves a region
|
||||
@@ -42,6 +47,7 @@ export function getAgeAssuranceRegionConfigForGeolocation(
|
||||
return getAgeAssuranceRegionConfig(config, {
|
||||
countryCode: geolocation.countryCode ?? '',
|
||||
regionCode: geolocation.regionCode,
|
||||
platform: AGE_ASSURANCE_PLATFORM,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type TextProps as RNTextProps,
|
||||
type TextStyle,
|
||||
} from 'react-native'
|
||||
import {UITextView} from 'react-native-uitextview'
|
||||
import {UITextView} from '@bsky.app/react-native-uitextview'
|
||||
import createEmojiRegex from 'emoji-regex'
|
||||
|
||||
import {type Alf, applyFonts, atoms, flatten} from '#/alf'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {MMKV} from '@bsky.app/react-native-mmkv'
|
||||
import {MMKV} from 'react-native-mmkv'
|
||||
import {setPolyfills} from '@growthbook/growthbook'
|
||||
import {GrowthBook} from '@growthbook/growthbook-react'
|
||||
import {type I18n} from '@lingui/core'
|
||||
|
||||
@@ -19,6 +19,8 @@ export enum Features {
|
||||
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
|
||||
PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable',
|
||||
CustomLogoJapanEnable = 'custom_logo:japan:enable',
|
||||
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
|
||||
FollowSortEnable = 'follow_sort:enable',
|
||||
|
||||
AATest = 'aa-test',
|
||||
}
|
||||
|
||||
@@ -103,6 +103,10 @@ export type Events = {
|
||||
}
|
||||
'signup:captchaSuccess': {}
|
||||
'signup:captchaFailure': {}
|
||||
'signup:captchaBackPress': {}
|
||||
'signup:createAccountFailure': {
|
||||
reason: string
|
||||
}
|
||||
'signup:fieldError': {
|
||||
field: string
|
||||
errorCount: number
|
||||
@@ -475,25 +479,30 @@ export type Events = {
|
||||
'profile:followers:view': {
|
||||
contextProfileDid: string
|
||||
isOwnProfile: boolean
|
||||
sort?: 'latest' | 'top'
|
||||
}
|
||||
'profile:followers:paginate': {
|
||||
contextProfileDid: string
|
||||
itemCount: number
|
||||
page: number
|
||||
sort?: 'latest' | 'top'
|
||||
}
|
||||
'profile:following:view': {
|
||||
contextProfileDid: string
|
||||
isOwnProfile: boolean
|
||||
sort?: 'latest' | 'top'
|
||||
}
|
||||
'profile:following:paginate': {
|
||||
contextProfileDid: string
|
||||
itemCount: number
|
||||
page: number
|
||||
sort?: 'latest' | 'top'
|
||||
}
|
||||
'profileCard:seen': {
|
||||
contextProfileDid?: string
|
||||
profileDid: string
|
||||
position?: number
|
||||
sort?: 'latest' | 'top'
|
||||
}
|
||||
'profile:mute': {}
|
||||
'profile:unmute': {}
|
||||
@@ -510,7 +519,7 @@ export type Events = {
|
||||
| 'ProgressGuide'
|
||||
location: 'Card' | 'Profile' | 'FollowAll'
|
||||
recSource?: 'Search'
|
||||
recId?: number | string
|
||||
recId?: string
|
||||
position: number
|
||||
suggestedDid: string
|
||||
category: string | null
|
||||
@@ -523,7 +532,7 @@ export type Events = {
|
||||
| 'ProfileHeader'
|
||||
| 'Onboarding'
|
||||
| 'SeeMoreSuggestedUsers'
|
||||
recId?: number | string
|
||||
recId?: string
|
||||
position: number
|
||||
suggestedDid: string
|
||||
category: string | null
|
||||
@@ -538,7 +547,7 @@ export type Events = {
|
||||
| 'SeeMoreSuggestedUsers'
|
||||
| 'ProgressGuide'
|
||||
recSource?: 'Search'
|
||||
recId?: number | string
|
||||
recId?: string
|
||||
position: number
|
||||
suggestedDid: string
|
||||
category: string | null
|
||||
@@ -550,11 +559,11 @@ export type Events = {
|
||||
| 'ProfileInterstitial'
|
||||
| 'ProfileHeader'
|
||||
| 'Onboarding'
|
||||
recId?: number | string
|
||||
recId?: string
|
||||
}
|
||||
'suggestedUser:dismiss': {
|
||||
logContext: 'DiscoverInterstitial' | 'ProfileInterstitial' | 'ProfileHeader'
|
||||
recId?: number | string
|
||||
recId?: string
|
||||
position: number
|
||||
suggestedDid: string
|
||||
}
|
||||
@@ -605,7 +614,7 @@ export type Events = {
|
||||
|
||||
// Group chat adoption
|
||||
'groupchat:create': {
|
||||
logContext: 'NewChatDialog'
|
||||
logContext: 'NewChatDialog' | 'SendViaChatDialog'
|
||||
}
|
||||
'groupchat:landingPage:view': {
|
||||
hasSession: boolean
|
||||
@@ -743,9 +752,7 @@ export type Events = {
|
||||
}
|
||||
'trendingTopic:click': {
|
||||
context: 'sidebar' | 'interstitial' | 'explore'
|
||||
}
|
||||
'recommendedTopic:click': {
|
||||
context: 'explore'
|
||||
recId?: string
|
||||
}
|
||||
'trendingVideos:show': {
|
||||
context: 'settings'
|
||||
@@ -779,13 +786,13 @@ export type Events = {
|
||||
}
|
||||
|
||||
'search:results:loaded': {
|
||||
tab: 'top' | 'latest' | 'people' | 'feeds'
|
||||
tab: 'top' | 'latest' | 'people' | 'feeds' | 'starterPacks'
|
||||
initialCount: number
|
||||
}
|
||||
|
||||
'search:result:press': {
|
||||
tab?: 'top' | 'latest' | 'people' | 'feeds'
|
||||
resultType: 'post' | 'profile' | 'feed'
|
||||
tab?: 'top' | 'latest' | 'people' | 'feeds' | 'starterPacks'
|
||||
resultType: 'post' | 'profile' | 'feed' | 'starterPack'
|
||||
position: number
|
||||
uri: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {useState} from 'react'
|
||||
import {type Insets, Pressable, View} from 'react-native'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
|
||||
import * as Tooltip from '#/components/Tooltip'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
/**
|
||||
* Whether to show the beta badge for a given profile. Only shown on the
|
||||
* viewer's own profile, and only when the viewer has opted in to beta features.
|
||||
*/
|
||||
export function useIsBetaBadgeVisible(
|
||||
profile: bsky.profile.AnyProfileView,
|
||||
): boolean {
|
||||
const {currentAccount} = useSession()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const isBetaUser = preferences?.bskyAppState?.isBetaUser ?? false
|
||||
const isSelf = currentAccount?.did === profile.did
|
||||
|
||||
return isSelf && isBetaUser
|
||||
}
|
||||
|
||||
export function BetaBadge({
|
||||
profile,
|
||||
width,
|
||||
padding,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
width: number
|
||||
padding: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const isVisible = useIsBetaBadgeVisible(profile)
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
{backgroundColor: t.palette.primary_50, padding},
|
||||
]}>
|
||||
<BeakerIcon width={width} fill={t.palette.primary_500} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function BetaBadgeButton({
|
||||
profile,
|
||||
width,
|
||||
padding,
|
||||
hitSlop,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
width: number
|
||||
padding: number
|
||||
hitSlop: Insets
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const isVisible = useIsBetaBadgeVisible(profile)
|
||||
|
||||
const [tooltipVisible, setTooltipVisible] = useState(false)
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<Tooltip.Outer
|
||||
color="primary"
|
||||
visible={tooltipVisible}
|
||||
onVisibleChange={setTooltipVisible}>
|
||||
<Tooltip.Target>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Beta features enabled`}
|
||||
accessibilityHint=""
|
||||
hitSlop={hitSlop}
|
||||
style={({hovered}) => [
|
||||
a.rounded_full,
|
||||
a.transition_transform,
|
||||
{
|
||||
backgroundColor: t.palette.primary_50,
|
||||
padding,
|
||||
transform: [
|
||||
{
|
||||
scale: hovered ? 1.1 : 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
onPress={() => setTooltipVisible(v => !v)}>
|
||||
<BeakerIcon width={width} fill={t.palette.primary_500} />
|
||||
</Pressable>
|
||||
</Tooltip.Target>
|
||||
<Tooltip.BubbleText label={l`Beta features enabled`}>
|
||||
<Trans>Beta features enabled</Trans>
|
||||
</Tooltip.BubbleText>
|
||||
</Tooltip.Outer>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {View} from 'react-native'
|
||||
import {type Insets, View} from 'react-native'
|
||||
import {type ComAtprotoLabelDefs} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
@@ -44,9 +44,11 @@ export function BotBadge({
|
||||
export function BotBadgeButton({
|
||||
profile,
|
||||
width,
|
||||
hitSlop,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
width: number
|
||||
hitSlop: Insets
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
@@ -61,7 +63,7 @@ export function BotBadgeButton({
|
||||
<>
|
||||
<Button
|
||||
label={l`Automated account`}
|
||||
hitSlop={20}
|
||||
hitSlop={hitSlop}
|
||||
onPress={evt => {
|
||||
evt.preventDefault()
|
||||
ax.metric('bot:badge:click', {})
|
||||
|
||||
@@ -30,7 +30,6 @@ import {KeyboardEvents} from 'react-native-keyboard-controller'
|
||||
import Animated, {
|
||||
clamp,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
type SharedValue,
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
@@ -44,6 +43,7 @@ import {
|
||||
useSafeAreaInsets,
|
||||
} from 'react-native-safe-area-context'
|
||||
import {captureRef} from 'react-native-view-shot'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
import {Image, type ImageErrorEventData} from 'expo-image'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -89,14 +89,12 @@ const SPRING_IN: WithSpringConfig = {
|
||||
mass: 0.75,
|
||||
damping: 300,
|
||||
stiffness: 1200,
|
||||
restDisplacementThreshold: 0.01,
|
||||
}
|
||||
|
||||
const SPRING_OUT: WithSpringConfig = {
|
||||
mass: IS_IOS ? 1.25 : 0.75,
|
||||
damping: 150,
|
||||
stiffness: 1000,
|
||||
restDisplacementThreshold: 0.01,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,7 +166,7 @@ export function Root({children}: {children: React.ReactNode}) {
|
||||
// note: return location has to be reset on open,
|
||||
// rather than on close, otherwise there's a flicker
|
||||
// where the reanimated update is faster than the react render
|
||||
runOnJS(onCompletedClose)()
|
||||
scheduleOnRN(onCompletedClose)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -333,7 +331,7 @@ export function Trigger({
|
||||
() => hoveredItemSV.get(),
|
||||
(hovered, prev) => {
|
||||
if (hovered !== prev) {
|
||||
runOnJS(setHoveredMenuItem)(hovered)
|
||||
scheduleOnRN(setHoveredMenuItem, hovered)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -345,7 +343,7 @@ export function Trigger({
|
||||
.averageTouches(true)
|
||||
.onStart(() => {
|
||||
'worklet'
|
||||
runOnJS(open)('full')
|
||||
scheduleOnRN(open, 'full')
|
||||
})
|
||||
.onUpdate(evt => {
|
||||
'worklet'
|
||||
@@ -359,7 +357,7 @@ export function Trigger({
|
||||
// as the menu may have slid into place beneath their finger
|
||||
const item = hoveredItemSV.get()
|
||||
if (item) {
|
||||
runOnJS(onTouchUpMenuItem)(item)
|
||||
scheduleOnRN(onTouchUpMenuItem, item)
|
||||
}
|
||||
})
|
||||
}, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV])
|
||||
|
||||
@@ -21,11 +21,11 @@ import {
|
||||
} from 'react-native'
|
||||
import {useReanimatedKeyboardAnimation} from 'react-native-keyboard-controller'
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
type ScrollEvent,
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -197,24 +197,8 @@ export function Outer({
|
||||
/**
|
||||
* @deprecated use `Dialog.ScrollableInner` instead
|
||||
*/
|
||||
export function Inner({children, style, header}: DialogInnerProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<View
|
||||
style={[
|
||||
a.pt_2xl,
|
||||
a.px_xl,
|
||||
IS_LIQUID_GLASS
|
||||
? a.pb_2xl
|
||||
: {paddingBottom: insets.bottom + insets.top},
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
export function Inner(props: DialogInnerProps) {
|
||||
return <ScrollableInner {...props} />
|
||||
}
|
||||
|
||||
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
|
||||
@@ -315,9 +299,9 @@ export const InnerFlatList = forwardRef<
|
||||
}
|
||||
const {contentOffset} = e
|
||||
if (contentOffset.y > 0 && !disableDrag) {
|
||||
runOnJS(setDisableDrag)(true)
|
||||
scheduleOnRN(setDisableDrag, true)
|
||||
} else if (contentOffset.y <= 1 && disableDrag) {
|
||||
runOnJS(setDisableDrag)(false)
|
||||
scheduleOnRN(setDisableDrag, false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +165,9 @@ export function Outer({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `Dialog.ScrollableInner` instead
|
||||
*/
|
||||
export function Inner({
|
||||
children,
|
||||
style,
|
||||
|
||||
@@ -3,7 +3,7 @@ import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
type AnimatedRef,
|
||||
measure,
|
||||
runOnJS,
|
||||
Reanimated3DefaultSpringConfig,
|
||||
scrollTo,
|
||||
type SharedValue,
|
||||
useAnimatedRef,
|
||||
@@ -13,6 +13,7 @@ import Animated, {
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
@@ -25,7 +26,7 @@ import {IS_IOS} from '#/env'
|
||||
*
|
||||
* All positioning is driven by a `slots` map (key → index) and translateY
|
||||
* (no discrete `top` changes). On drag end the new slot assignment is
|
||||
* computed on the UI thread first, then React state is updated via runOnJS.
|
||||
* computed on the UI thread first, then React state is updated via scheduleOnRN.
|
||||
*
|
||||
* See SortableList.web.tsx for the web implementation using pointer events.
|
||||
*/
|
||||
@@ -41,7 +42,7 @@ interface SortableListProps<T> {
|
||||
itemHeight: number
|
||||
/** Ref to the parent Animated.ScrollView for auto-scroll. */
|
||||
scrollRef?: AnimatedRef<Animated.ScrollView>
|
||||
/** Scroll offset shared value from useScrollViewOffset. */
|
||||
/** Scroll offset shared value from useScrollOffset. */
|
||||
scrollOffset?: SharedValue<number>
|
||||
}
|
||||
|
||||
@@ -237,8 +238,8 @@ function SortableItem<T>({
|
||||
itemKey: string
|
||||
itemCount: number
|
||||
itemHeight: number
|
||||
state: Animated.SharedValue<DragState>
|
||||
dragY: Animated.SharedValue<number>
|
||||
state: SharedValue<DragState>
|
||||
dragY: SharedValue<number>
|
||||
scrollCompensation: SharedValue<number>
|
||||
isGestureActive: SharedValue<boolean>
|
||||
measureDone: SharedValue<boolean>
|
||||
@@ -264,9 +265,9 @@ function SortableItem<T>({
|
||||
measureDone.set(false)
|
||||
lastHapticSlot.set(mySlot)
|
||||
if (onDragStart) {
|
||||
runOnJS(onDragStart)()
|
||||
scheduleOnRN(onDragStart)
|
||||
}
|
||||
runOnJS(playHaptic)()
|
||||
scheduleOnRN(playHaptic)
|
||||
})
|
||||
.onChange(e => {
|
||||
'worklet'
|
||||
@@ -284,7 +285,7 @@ function SortableItem<T>({
|
||||
const clampedSlot = Math.max(0, Math.min(currentSlot, itemCount - 1))
|
||||
if (IS_IOS && clampedSlot !== lastHapticSlot.get()) {
|
||||
lastHapticSlot.set(clampedSlot)
|
||||
runOnJS(playHaptic)('Light')
|
||||
scheduleOnRN(playHaptic, 'Light')
|
||||
}
|
||||
})
|
||||
.onEnd(() => {
|
||||
@@ -325,13 +326,13 @@ function SortableItem<T>({
|
||||
dragStartSlot: -1,
|
||||
})
|
||||
dragY.set(0)
|
||||
runOnJS(onCommitReorder)(sorted)
|
||||
scheduleOnRN(onCommitReorder, sorted)
|
||||
} else {
|
||||
const s = state.get()
|
||||
state.set({...s, activeKey: '', dragStartSlot: -1})
|
||||
dragY.set(0)
|
||||
if (onDragEnd) {
|
||||
runOnJS(onDragEnd)()
|
||||
scheduleOnRN(onDragEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,7 +347,7 @@ function SortableItem<T>({
|
||||
const s = state.get()
|
||||
state.set({...s, activeKey: '', dragStartSlot: -1})
|
||||
if (onDragEnd) {
|
||||
runOnJS(onDragEnd)()
|
||||
scheduleOnRN(onDragEnd)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -370,18 +371,18 @@ function SortableItem<T>({
|
||||
return {
|
||||
transform: [
|
||||
{translateY: s.dragStartSlot * itemHeight + dragY.get()},
|
||||
{scale: withSpring(1.03)},
|
||||
{scale: withSpring(1.03, Reanimated3DefaultSpringConfig)},
|
||||
],
|
||||
zIndex: 999,
|
||||
...(IS_IOS
|
||||
? {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {width: 0, height: 1},
|
||||
shadowOpacity: withSpring(0.08),
|
||||
shadowRadius: withSpring(4),
|
||||
shadowOpacity: withSpring(0.08, Reanimated3DefaultSpringConfig),
|
||||
shadowRadius: withSpring(4, Reanimated3DefaultSpringConfig),
|
||||
}
|
||||
: {
|
||||
elevation: withSpring(3),
|
||||
elevation: withSpring(3, Reanimated3DefaultSpringConfig),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -391,11 +392,11 @@ function SortableItem<T>({
|
||||
const inactive = {
|
||||
...(IS_IOS
|
||||
? {
|
||||
shadowOpacity: withSpring(0),
|
||||
shadowRadius: withSpring(0),
|
||||
shadowOpacity: withSpring(0, Reanimated3DefaultSpringConfig),
|
||||
shadowRadius: withSpring(0, Reanimated3DefaultSpringConfig),
|
||||
}
|
||||
: {
|
||||
elevation: withSpring(0),
|
||||
elevation: withSpring(0, Reanimated3DefaultSpringConfig),
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -425,7 +426,7 @@ function SortableItem<T>({
|
||||
return {
|
||||
transform: [
|
||||
{translateY: withTiming(baseY + offset, {duration: 200})},
|
||||
{scale: withSpring(1)},
|
||||
{scale: withSpring(1, Reanimated3DefaultSpringConfig)},
|
||||
],
|
||||
zIndex: 0,
|
||||
...inactive,
|
||||
|
||||
@@ -22,7 +22,7 @@ interface SortableListProps<T> {
|
||||
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. */
|
||||
/** Scroll offset shared value from useScrollOffset. Ignored on web. */
|
||||
scrollOffset?: SharedValue<number>
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export const IS_GLASS_AVAILABLE =
|
||||
* Liquid Glass View that uses `expo-glass-effect`
|
||||
*
|
||||
* If unavailable, falls back to a regular `View`. Use `fallbackStyle` to customize the fallback appearance.
|
||||
* Note: Setting opacity to 0 on Expo GlassView or any of its parent views causes the glass effect to not render at all. https://docs.expo.dev/versions/v56.0.0/sdk/glass-effect/#known-issues
|
||||
* If animating the opacity of a parent view, start from a non-zero opacity to avoid this issue.
|
||||
*/
|
||||
export const GlassView = IS_GLASS_AVAILABLE ? InnerGlassView : FallbackView
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
|
||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||
import {atoms as a, tokens, useTheme, web} from '#/alf'
|
||||
import {transparentifyColor} from '#/alf/util/colorGeneration'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
@@ -200,42 +201,44 @@ export function InterestTabs({
|
||||
|
||||
return (
|
||||
<View style={[a.relative, a.flex_row]}>
|
||||
<DraggableScrollView
|
||||
ref={listRef}
|
||||
contentContainerStyle={[
|
||||
a.gap_sm,
|
||||
{paddingHorizontal: gutterWidth},
|
||||
contentContainerStyle,
|
||||
]}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
decelerationRate="fast"
|
||||
snapToOffsets={
|
||||
tabOffsets.filter(o => !!o).length === interests.length
|
||||
? tabOffsets.map(o => o.x - tokens.space.xl)
|
||||
: undefined
|
||||
}
|
||||
onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
|
||||
onContentSizeChange={width => setContentWidth(width)}
|
||||
onScroll={evt => {
|
||||
const newScrollX = evt.nativeEvent.contentOffset.x
|
||||
setScrollX(newScrollX)
|
||||
}}
|
||||
scrollEventThrottle={16}>
|
||||
{interests.map((interest, i) => {
|
||||
const active = interest === selectedInterest && !disabled
|
||||
return (
|
||||
<TabComponent
|
||||
key={interest}
|
||||
onSelectTab={handleSelectTab}
|
||||
active={active}
|
||||
index={i}
|
||||
interest={interest}
|
||||
interestsDisplayName={interestsDisplayNames[interest]}
|
||||
onLayout={handleTabLayout}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</DraggableScrollView>
|
||||
<BlockDrawerGesture>
|
||||
<DraggableScrollView
|
||||
ref={listRef}
|
||||
contentContainerStyle={[
|
||||
a.gap_sm,
|
||||
{paddingHorizontal: gutterWidth},
|
||||
contentContainerStyle,
|
||||
]}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
decelerationRate="fast"
|
||||
snapToOffsets={
|
||||
tabOffsets.filter(o => !!o).length === interests.length
|
||||
? tabOffsets.map(o => o.x - tokens.space.xl)
|
||||
: undefined
|
||||
}
|
||||
onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
|
||||
onContentSizeChange={width => setContentWidth(width)}
|
||||
onScroll={evt => {
|
||||
const newScrollX = evt.nativeEvent.contentOffset.x
|
||||
setScrollX(newScrollX)
|
||||
}}
|
||||
scrollEventThrottle={16}>
|
||||
{interests.map((interest, i) => {
|
||||
const active = interest === selectedInterest && !disabled
|
||||
return (
|
||||
<TabComponent
|
||||
key={interest}
|
||||
onSelectTab={handleSelectTab}
|
||||
active={active}
|
||||
index={i}
|
||||
interest={interest}
|
||||
interestsDisplayName={interestsDisplayNames[interest]}
|
||||
onLayout={handleTabLayout}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</DraggableScrollView>
|
||||
</BlockDrawerGesture>
|
||||
{IS_WEB && canScrollLeft && (
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {forwardRef, memo, useContext, useMemo} from 'react'
|
||||
import {memo, useContext, useMemo} from 'react'
|
||||
import {
|
||||
type StyleProp,
|
||||
View,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
type AnimatedRef,
|
||||
type AnimatedScrollViewProps,
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated'
|
||||
@@ -73,67 +74,64 @@ export type ContentProps = AnimatedScrollViewProps & {
|
||||
style?: StyleProp<ViewStyle>
|
||||
contentContainerStyle?: StyleProp<ViewStyle>
|
||||
ignoreTabletLayoutOffset?: boolean
|
||||
ref?: AnimatedRef<Animated.ScrollView>
|
||||
}
|
||||
|
||||
/**
|
||||
* Default scroll view for simple pages
|
||||
*/
|
||||
export const Content = memo(
|
||||
forwardRef<Animated.ScrollView, ContentProps>(function Content(
|
||||
{
|
||||
children,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
ignoreTabletLayoutOffset,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const t = useTheme()
|
||||
const {footerHeight} = useShellLayout()
|
||||
const {isWithinSplitView} = useIsWithinSplitView()
|
||||
export const Content = memo(function Content({
|
||||
children,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
ignoreTabletLayoutOffset,
|
||||
ref,
|
||||
...props
|
||||
}: ContentProps) {
|
||||
const t = useTheme()
|
||||
const {footerHeight} = useShellLayout()
|
||||
const {isWithinSplitView} = useIsWithinSplitView()
|
||||
|
||||
// note - if we ever make the footer transparent in any way,
|
||||
// we'll need to change this to use contentInsets/scrollIndicatorInsets
|
||||
// on iOS and contentContainerStyle padding on Android -sfn
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
marginBottom: footerHeight.get(),
|
||||
}
|
||||
})
|
||||
// note - if we ever make the footer transparent in any way,
|
||||
// we'll need to change this to use contentInsets/scrollIndicatorInsets
|
||||
// on iOS and contentContainerStyle padding on Android -sfn
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
marginBottom: footerHeight.get(),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={ref}
|
||||
id="content"
|
||||
automaticallyAdjustsScrollIndicatorInsets={false}
|
||||
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
|
||||
style={[
|
||||
a.w_full,
|
||||
animatedStyle,
|
||||
isWithinSplitView &&
|
||||
web({
|
||||
flex: 1,
|
||||
overflowY: 'scroll',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
||||
}),
|
||||
style,
|
||||
]}
|
||||
contentContainerStyle={[contentContainerStyle]}
|
||||
{...props}>
|
||||
{IS_WEB ? (
|
||||
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
|
||||
{/* @ts-expect-error web only -esb */}
|
||||
{children}
|
||||
</Center>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Animated.ScrollView>
|
||||
)
|
||||
}),
|
||||
)
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={ref}
|
||||
id="content"
|
||||
automaticallyAdjustsScrollIndicatorInsets={false}
|
||||
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
|
||||
style={[
|
||||
a.w_full,
|
||||
animatedStyle,
|
||||
isWithinSplitView &&
|
||||
web({
|
||||
flex: 1,
|
||||
overflowY: 'scroll',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
||||
}),
|
||||
style,
|
||||
]}
|
||||
contentContainerStyle={[contentContainerStyle]}
|
||||
{...props}>
|
||||
{IS_WEB ? (
|
||||
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
|
||||
{/* @ts-expect-error web only -esb */}
|
||||
{children}
|
||||
</Center>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Animated.ScrollView>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Utility component to center content within the screen
|
||||
|
||||
@@ -2,12 +2,12 @@ import {useRef, useState} from 'react'
|
||||
import {Modal, Pressable, StyleSheet, View} from 'react-native'
|
||||
import Animated, {
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
@@ -52,7 +52,7 @@ export function ImageMenu({onPressShare, onPressSave}: Props) {
|
||||
progress.set(
|
||||
withTiming(0, TIMING_OUT, finished => {
|
||||
if (finished) {
|
||||
runOnJS(setIsMounted)(false)
|
||||
scheduleOnRN(setIsMounted, false)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
type AnimatableValue,
|
||||
runOnJS,
|
||||
Reanimated3DefaultSpringConfig,
|
||||
type SharedValue,
|
||||
useAnimatedReaction,
|
||||
useAnimatedRef,
|
||||
@@ -15,6 +15,7 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
import {Image} from 'expo-image'
|
||||
|
||||
import {
|
||||
@@ -94,7 +95,7 @@ const ImageItem = ({
|
||||
},
|
||||
(nextIsScaled, prevIsScaled) => {
|
||||
if (nextIsScaled !== prevIsScaled) {
|
||||
runOnJS(handleZoom)(nextIsScaled)
|
||||
scheduleOnRN(handleZoom, nextIsScaled)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -244,7 +245,7 @@ const ImageItem = ({
|
||||
|
||||
const singleTap = Gesture.Tap().onEnd(() => {
|
||||
'worklet'
|
||||
runOnJS(onTap)()
|
||||
scheduleOnRN(onTap)
|
||||
})
|
||||
|
||||
const doubleTap = Gesture.Tap()
|
||||
@@ -358,9 +359,9 @@ const ImageItem = ({
|
||||
},
|
||||
(show, prevShow) => {
|
||||
if (!prevShow && show) {
|
||||
runOnJS(setShowLoader)(true)
|
||||
scheduleOnRN(setShowLoader, true)
|
||||
} else if (prevShow && !show) {
|
||||
runOnJS(setShowLoader)(false)
|
||||
scheduleOnRN(setShowLoader, false)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -463,7 +464,10 @@ function clampTranslation(
|
||||
|
||||
function withClampedSpring<T extends AnimatableValue>(value: T): T {
|
||||
'worklet'
|
||||
return withSpring(value, {overshootClamping: true})
|
||||
return withSpring(value, {
|
||||
...Reanimated3DefaultSpringConfig,
|
||||
overshootClamping: true,
|
||||
})
|
||||
}
|
||||
|
||||
export default memo(ImageItem)
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
type PanGesture,
|
||||
} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
type SharedValue,
|
||||
useAnimatedProps,
|
||||
useAnimatedReaction,
|
||||
@@ -24,6 +23,7 @@ import Animated, {
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaFrame} from 'react-native-safe-area-context'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
import {Image} from 'expo-image'
|
||||
|
||||
import {
|
||||
@@ -84,7 +84,7 @@ const ImageItem = ({
|
||||
'worklet'
|
||||
const nextIsScaled = e.zoomScale > 1
|
||||
if (scaled !== nextIsScaled) {
|
||||
runOnJS(handleZoom)(nextIsScaled)
|
||||
scheduleOnRN(handleZoom, nextIsScaled)
|
||||
}
|
||||
},
|
||||
onBeginDrag() {
|
||||
@@ -118,7 +118,7 @@ const ImageItem = ({
|
||||
|
||||
const singleTap = Gesture.Tap().onEnd(() => {
|
||||
'worklet'
|
||||
runOnJS(onTap)()
|
||||
scheduleOnRN(onTap)
|
||||
})
|
||||
|
||||
const doubleTap = Gesture.Tap()
|
||||
@@ -142,7 +142,7 @@ const ImageItem = ({
|
||||
screenSize,
|
||||
)
|
||||
}
|
||||
runOnJS(zoomTo)(nextZoomRect)
|
||||
scheduleOnRN(zoomTo, nextZoomRect)
|
||||
})
|
||||
|
||||
const composedGesture = Gesture.Exclusive(
|
||||
@@ -170,8 +170,6 @@ const ImageItem = ({
|
||||
width: screenSize.width,
|
||||
maxHeight: screenSize.height,
|
||||
alignSelf: 'center',
|
||||
aspectRatio: imageAspect ?? 1 /* force onLoad */,
|
||||
opacity: imageAspect === undefined ? 0 : 1,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -180,11 +178,19 @@ const ImageItem = ({
|
||||
return {
|
||||
transform: cropContentTransform,
|
||||
width: '100%',
|
||||
aspectRatio: imageAspect ?? 1 /* force onLoad */,
|
||||
opacity: imageAspect === undefined ? 0 : 1,
|
||||
}
|
||||
})
|
||||
|
||||
/*
|
||||
* When the aspect ratio is unknown until onLoad fires, these layout props
|
||||
* change after mount. They must be applied via a React render rather than
|
||||
* useAnimatedStyle
|
||||
*/
|
||||
const imageLayoutStyle = {
|
||||
aspectRatio: imageAspect ?? 1 /* force onLoad */,
|
||||
opacity: imageAspect === undefined ? 0 : 1,
|
||||
}
|
||||
|
||||
const [showLoader, setShowLoader] = useState(false)
|
||||
const [hasLoaded, setHasLoaded] = useState(false)
|
||||
useAnimatedReaction(
|
||||
@@ -193,9 +199,9 @@ const ImageItem = ({
|
||||
},
|
||||
(show, prevShow) => {
|
||||
if (!prevShow && show) {
|
||||
runOnJS(setShowLoader)(true)
|
||||
scheduleOnRN(setShowLoader, true)
|
||||
} else if (prevShow && !show) {
|
||||
runOnJS(setShowLoader)(false)
|
||||
scheduleOnRN(setShowLoader, false)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -225,8 +231,8 @@ const ImageItem = ({
|
||||
{showLoader && (
|
||||
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
||||
)}
|
||||
<Animated.View style={imageCropStyle}>
|
||||
<Animated.View style={imageStyle}>
|
||||
<Animated.View style={[imageCropStyle, imageLayoutStyle]}>
|
||||
<Animated.View style={[imageStyle, imageLayoutStyle]}>
|
||||
<Image
|
||||
contentFit="contain"
|
||||
source={{uri: imageSrc.uri}}
|
||||
|
||||
@@ -20,8 +20,6 @@ import Animated, {
|
||||
measure,
|
||||
type MeasuredDimensions,
|
||||
ReduceMotion,
|
||||
runOnJS,
|
||||
runOnUI,
|
||||
type SharedValue,
|
||||
useAnimatedReaction,
|
||||
useAnimatedRef,
|
||||
@@ -32,6 +30,7 @@ import Animated, {
|
||||
withSpring,
|
||||
type WithSpringConfig,
|
||||
} from 'react-native-reanimated'
|
||||
import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets'
|
||||
import {Image} from 'expo-image'
|
||||
import * as ScreenOrientation from 'expo-screen-orientation'
|
||||
|
||||
@@ -60,13 +59,11 @@ const SLOW_SPRING: WithSpringConfig = {
|
||||
mass: IS_IOS ? 1.25 : 0.75,
|
||||
damping: 300,
|
||||
stiffness: 800,
|
||||
restDisplacementThreshold: 0.001,
|
||||
}
|
||||
const FAST_SPRING: WithSpringConfig = {
|
||||
mass: IS_IOS ? 1.25 : 0.75,
|
||||
damping: 150,
|
||||
stiffness: 900,
|
||||
restDisplacementThreshold: 0.001,
|
||||
}
|
||||
|
||||
function canAnimate(lightbox: Lightbox): boolean {
|
||||
@@ -138,10 +135,10 @@ export default function ImageViewRoot({
|
||||
|
||||
const onFullyClosed = useCallback(() => {
|
||||
setActiveLightbox(null)
|
||||
runOnUI(() => {
|
||||
scheduleOnUI(() => {
|
||||
'worklet'
|
||||
thumbRects.set({})
|
||||
})()
|
||||
})
|
||||
requestIdleCallback(() => {
|
||||
void Image.clearMemoryCache()
|
||||
})
|
||||
@@ -151,7 +148,7 @@ export default function ImageViewRoot({
|
||||
() => openProgress.get() === 0,
|
||||
(isGone, wasGone) => {
|
||||
if (isGone && !wasGone) {
|
||||
runOnJS(onFullyClosed)()
|
||||
scheduleOnRN(onFullyClosed)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -162,10 +159,10 @@ export default function ImageViewRoot({
|
||||
() => openProgress.get() === 1,
|
||||
(isOpen, wasOpen) => {
|
||||
if (isOpen && !wasOpen) {
|
||||
runOnJS(ScreenOrientation.unlockAsync)()
|
||||
scheduleOnRN(ScreenOrientation.unlockAsync)
|
||||
} else if (!isOpen && wasOpen) {
|
||||
// default is PORTRAIT_UP - set via config plugin in app.config.js -sfn
|
||||
runOnJS(ScreenOrientation.lockAsync)(PORTRAIT_UP)
|
||||
scheduleOnRN(ScreenOrientation.lockAsync, PORTRAIT_UP)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -173,7 +170,7 @@ export default function ImageViewRoot({
|
||||
const onFlyAway = useCallback(() => {
|
||||
'worklet'
|
||||
openProgress.set(0)
|
||||
runOnJS(onRequestClose)()
|
||||
scheduleOnRN(onRequestClose)
|
||||
}, [onRequestClose, openProgress])
|
||||
|
||||
return (
|
||||
@@ -322,7 +319,7 @@ function ImageView({
|
||||
const handleRequestClose = useCallback(() => {
|
||||
const activeRef = images[imageIndex]?.thumbRef
|
||||
if (isAnimated && activeRef) {
|
||||
runOnUI(() => {
|
||||
scheduleOnUI(() => {
|
||||
'worklet'
|
||||
const rect = measure(activeRef)
|
||||
thumbRects.modify(rects => {
|
||||
@@ -330,8 +327,8 @@ function ImageView({
|
||||
rects[imageIndex] = rect
|
||||
return rects
|
||||
})
|
||||
runOnJS(onRequestClose)()
|
||||
})()
|
||||
scheduleOnRN(onRequestClose)
|
||||
})
|
||||
} else {
|
||||
onRequestClose()
|
||||
}
|
||||
@@ -532,7 +529,7 @@ function LightboxImage({
|
||||
const dismissTranslateY =
|
||||
isActive && openProgressValue === 1 ? dismissSwipeTranslateY.get() : 0
|
||||
|
||||
if (openProgressValue === 0 && isFlyingAway.get()) {
|
||||
if (openProgressValue === 0) {
|
||||
return {
|
||||
isHidden: true,
|
||||
isResting: false,
|
||||
@@ -609,6 +606,7 @@ function LightboxImage({
|
||||
return withSpring(0, {
|
||||
stiffness: 700,
|
||||
damping: 50,
|
||||
mass: 1,
|
||||
reduceMotion: ReduceMotion.Never,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import {createContext, useContext, useEffect, useMemo, useState} from 'react'
|
||||
import {
|
||||
measure,
|
||||
type MeasuredDimensions,
|
||||
runOnJS,
|
||||
runOnUI,
|
||||
} from 'react-native-reanimated'
|
||||
import {measure, type MeasuredDimensions} from 'react-native-reanimated'
|
||||
import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
@@ -73,7 +69,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
if (thumbRef) {
|
||||
// Measure the tapped image on the UI thread, then open with
|
||||
// the rect baked in so it's available from the first render.
|
||||
// Only the rect (plain data) goes through runOnJS — AnimatedRef
|
||||
// Only the rect (plain data) goes through scheduleOnRN — AnimatedRef
|
||||
// objects can't survive serialization across threads.
|
||||
const openWithRect = (rect: MeasuredDimensions | null) => {
|
||||
doOpen({
|
||||
@@ -83,11 +79,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
),
|
||||
})
|
||||
}
|
||||
runOnUI(() => {
|
||||
scheduleOnUI(() => {
|
||||
'worklet'
|
||||
const rect = measure(thumbRef)
|
||||
runOnJS(openWithRect)(rect)
|
||||
})()
|
||||
scheduleOnRN(openWithRect, rect)
|
||||
})
|
||||
} else {
|
||||
doOpen(lightbox)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import {type Component} from 'react'
|
||||
import {type TransformsStyle} from 'react-native'
|
||||
import {
|
||||
type AnimatedRef,
|
||||
@@ -29,7 +28,7 @@ export type ImageSource = {
|
||||
thumbUri: string
|
||||
thumbDimensions: Dimensions | null
|
||||
thumbRect: MeasuredDimensions | null
|
||||
thumbRef?: AnimatedRef<Component> | null
|
||||
thumbRef?: AnimatedRef | null
|
||||
thumbBorderRadius?: number
|
||||
alt?: string
|
||||
type: 'image' | 'circle-avi' | 'rect-avi'
|
||||
|
||||
@@ -177,6 +177,7 @@ export function LabelBase({
|
||||
text,
|
||||
a.font_semi_bold,
|
||||
a.leading_tight,
|
||||
a.flex_shrink,
|
||||
t.atoms.text_contrast_medium,
|
||||
{paddingRight: 3},
|
||||
]}>
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
measure,
|
||||
runOnJS,
|
||||
useAnimatedRef,
|
||||
useFrameCallback,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {WebView} from 'react-native-webview'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
import {Image} from 'expo-image'
|
||||
import {type AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
@@ -164,7 +164,7 @@ export function ExternalPlayer({
|
||||
const isVisible = top <= realWinHeight - insets.bottom && bot >= insets.top
|
||||
|
||||
if (!isVisible) {
|
||||
runOnJS(setIsPlayerActive)(false)
|
||||
scheduleOnRN(setIsPlayerActive, false)
|
||||
}
|
||||
}, false) // False here disables autostarting the callback
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user