Compare commits

...

58 Commits

Author SHA1 Message Date
Samuel Newman a9143920f0 use debugOptimized variant 2026-07-27 21:34:38 +03:00
Samuel Newman 0e63434bb4 Reenable wake-from-background Android OTA (#11298) 2026-07-27 09:20:10 -07:00
Samuel Newman eb8f5fc4bc Update sonner-native, remove patch (#11300) 2026-07-27 09:19:33 -07:00
Samuel Newman 2ad84e540f Update react-native-uitextview (#11299) 2026-07-27 09:09:48 -07:00
Samuel Newman 97c2c516bd Downbump version to v1.129.1 (#11297) 2026-07-27 07:52:10 -07:00
Samuel Newman 34827ef644 ci: only publish the per-PR OTA when the fingerprint is clean (#11287)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 07:51:55 -07:00
Samuel Newman 691619f297 fix: stop grouping all signup failures into one sentry issue (#11278)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 07:50:40 -07:00
Samuel Newman c2e5a9093c Update play store assetlinks (#11294) 2026-07-27 07:28:28 -07:00
Samuel Newman 9b89a9adba Remove unused ScrollForwarder React Native patches (#11295) 2026-07-27 13:57:46 +03:00
Tomasz Zawadzki 22d657986d Fix IllegalStateException: Required value was null. (#11293)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 03:38:03 -07:00
Samuel Newman abde10b7bd Fix tooltip layout measurement loop (#11284) 2026-07-27 03:16:31 -07:00
Austin McKinley 51bcacb20b Publish one bundle version to both OTA origins, not two (#11291)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 16:58:20 -07:00
Austin McKinley b662cd43b5 Fix legacy ota1 OTA upload broken by the denis cutover (#11290) 2026-07-27 00:42:38 +03:00
Samuel Newman 66ec855efc Add emoji to GitHub Actions step names for consistency (#11286)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 00:03:45 +03:00
Samuel Newman d9e76c3954 Restore PR OTA QR comment (#11285) 2026-07-26 23:07:41 +03:00
Samuel Newman be70a31f4c Fix silently-frozen OTA fingerprint baseline with artifacts (#11231)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 23:07:20 +03:00
Tomasz Zawadzki 2b9deeeb6a Migrate runOnUI/runOnJS to scheduleOnUI/scheduleOnRN (#11249) 2026-07-26 12:00:35 -07:00
Samuel Newman d5e335e575 Update version to v1.130.0 (#11274) 2026-07-25 10:58:03 +03:00
Austin McKinley 76a928a7af ci: skip the per-PR OTA job for bot authors (#11273)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 19:07:57 -07:00
Austin McKinley 9eb78c0f6c ci: publish OTA to denis (S3) + automatic per-PR previews (#11235)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:18:00 -07:00
Eric Bailey fb8bda861b Add new config to lingui too (#11270) 2026-07-24 16:19:11 -05:00
estrattonbailey afd267bd2b Nightly source-language update 2026-07-24 17:57:36 +00:00
DS Boyce 93cb42b9f2 Tweak advanced search UI for better localization (#11266) 2026-07-24 10:40:37 -07:00
DS Boyce 549e3d7a7d Fix issue where form controls appear above sticky headers (#11254) 2026-07-24 10:40:16 -07:00
Samuel Newman 74a89832a3 v1.129.0 release prep (#11265) 2026-07-24 09:43:38 -07:00
Samuel Newman 19b0d89313 Update react-native-uitextview (#11264) 2026-07-24 07:40:46 -07:00
Spence Pope 98e135f489 Fix native signup captcha interactions (#11263) 2026-07-24 08:46:59 -04:00
Samuel Newman fbb0ee38c2 Add hitslop to switch account ... menu (#11262) 2026-07-24 05:44:45 -07:00
Eric Bailey 7bd27e4180 Add intl note on OTA flow (#11241) 2026-07-24 03:48:55 -07:00
dependabot[bot] 07a810945d Bump github.com/hashicorp/go-retryablehttp from 0.7.5 to 0.7.7 in /bskyweb (#11257)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 03:48:39 -07:00
Samuel Newman 63b284030a Fix Maestro E2E regressions on iOS and Android (#11258) 2026-07-24 03:48:03 -07:00
Samuel Newman 3b6fbf315b Fix Maestro E2E runtime environment (#11247) 2026-07-24 03:30:10 -07:00
DS Boyce 97169dbc35 Tweak appearance of trending topics (#11253) 2026-07-24 03:29:55 -07:00
Tomasz Zawadzki 22a4958321 Migrate useScrollViewOffset to useScrollOffset (#11250) 2026-07-24 07:09:01 +03:00
pfrazee a9df815bd0 Nightly source-language update 2026-07-24 03:10:54 +00:00
Eric Bailey a53df16e82 Update API package and filter AA configs by platform (#11244) 2026-07-23 14:15:09 -05:00
Samuel Newman 4e3d1704a6 Fix scroll forwarder web entrypoint (#11245) 2026-07-23 11:17:36 -07:00
DS Boyce 723a60e979 Display post counts greater than 1,000 (#11242) 2026-07-23 10:26:47 -07:00
Spence Pope 325246ec6c Stub RN codegen native component on web (#11240) 2026-07-23 12:52:47 -04:00
DS Boyce 10b027c35b Sort follows and following behind gate (#11237) 2026-07-23 09:02:35 -07:00
Oleksii Bulenok 8d64ab9d4b React Native New Arch (#10980) 2026-07-23 18:25:40 +03:00
pfrazee b80d09000f Nightly source-language update 2026-07-23 03:11:00 +00:00
DS Boyce f38f84e1a3 Enable sharing a post to a new group chat (#11191) 2026-07-22 15:54:46 -07:00
DS Boyce 8d7a7369aa Display beta badge when isBetaUser is true (#11160) 2026-07-22 15:06:08 -07:00
DS Boyce 77bbe8d8a0 Add From: Me to advanced search filters (#11126) 2026-07-22 15:01:24 -07:00
DS Boyce 850765bc8d Tweak loading state for block dialog (#11236) 2026-07-22 14:56:25 -07:00
DS Boyce 8ca8176eb1 Update presentation of trending feeds (#11206) 2026-07-22 14:35:45 -07:00
DS Boyce c71a4f8360 Create new trending feeds interstitial (#11207) 2026-07-22 14:23:01 -07:00
DS Boyce b2de086c3b Update presentation of trending topics in Explore (#11227) 2026-07-22 13:58:26 -07:00
DS Boyce 933ba1c373 Add feature gate for starter pack search (#11234) 2026-07-22 13:22:56 -07:00
DS Boyce 0bd6a99613 Pass recId with trending topic click metrics (#11232) 2026-07-22 12:51:49 -07:00
Samuel Newman f62e54ec82 Use stable tsgolint (#11229) 2026-07-22 07:39:11 -07:00
pfrazee 676f3f9e1a Nightly source-language update 2026-07-22 03:10:59 +00:00
Spence Pope 26ce5f0934 Hide video pillarboxing on web when the card fits (#11118) 2026-07-21 16:39:51 -05:00
Alex Benzer 44b1ab08b5 Move known likers below stats row; restore original order in stats row (#11192) 2026-07-21 15:40:30 -05:00
RetroSunstar c70baff709 Squish separate imports from the same source in ActivityNotificationSettings (#11225) 2026-07-21 23:25:15 +03:00
DS Boyce cbf0d89128 Remove console.log, address lint (#11228) 2026-07-21 23:25:01 +03:00
DS Boyce 86f0bedfbe Add new search results tab for starter packs (#11226) 2026-07-21 13:18:12 -07:00
251 changed files with 94235 additions and 45598 deletions
+50
View File
@@ -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"
+1 -1
View File
@@ -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
-16
View File
@@ -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
+159 -20
View File
@@ -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
+3 -3
View File
@@ -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'
+3 -3
View File
@@ -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'
+10 -10
View File
@@ -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
View File
@@ -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: |
+38 -38
View File
@@ -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
-221
View File
@@ -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) 🤖*
+150 -5
View File
@@ -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 }}`
+3 -3
View File
@@ -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: |
+6 -6
View File
@@ -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`.'
+2 -2
View File
@@ -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
+3
View File
@@ -134,3 +134,6 @@ bskyweb/static/media/*.svg
# superpowers plugin plans/specs — local-only workspace
docs/superpowers/
.claude/worktrees
# pnpm
.pnpm-store/
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+8 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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" | ![workflow](./img/web_action.png) |
### 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.
+1 -1
View File
@@ -28,7 +28,7 @@
},
"env": {
"EXPO_PUBLIC_ENV": "e2e",
"NODE_ENV": "test",
"NODE_ENV": "development",
"RN_SRC_EXT": "e2e.ts,e2e.tsx"
}
},
+2
View File
@@ -13,11 +13,13 @@ export default defineConfig({
'de',
'el',
'en-GB',
'en-CA',
'eo',
'es',
'eu',
'fi',
'fr',
'fr-CA',
'fy',
'ga',
'gd',
+25 -19
View File
@@ -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
-116
View File
@@ -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
View File
@@ -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'
-46
View File
@@ -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
View File
@@ -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",
-60
View File
@@ -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
+126
View File
@@ -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',
- },
-});
+500
View File
@@ -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))
}
+123 -52
View File
@@ -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
+83 -4
View File
@@ -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
-39
View File
@@ -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, {
-3
View File
@@ -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.
+1033 -496
View File
File diff suppressed because it is too large Load Diff
+13 -6
View File
@@ -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
+67
View File
@@ -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
View File
@@ -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 ./*
+54
View File
@@ -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
View File
@@ -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>
)}
</>
+18 -1
View File
@@ -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
+3 -2
View File
@@ -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,
+31
View File
@@ -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',
})
})
})
+6
View File
@@ -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,
})
}
+1 -1
View File
@@ -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 -1
View File
@@ -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'
+2
View File
@@ -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',
}
+19 -12
View File
@@ -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
}
+104
View File
@@ -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>
)
}
+4 -2
View File
@@ -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', {})
+5 -7
View File
@@ -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])
+5 -21
View File
@@ -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)
}
}
+3
View File
@@ -165,6 +165,9 @@ export function Outer({
)
}
/**
* @deprecated use `Dialog.ScrollableInner` instead
*/
export function Inner({
children,
style,
+20 -19
View File
@@ -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,
+1 -1
View File
@@ -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>
}
+2
View File
@@ -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
+39 -36
View File
@@ -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={[
+53 -55
View File
@@ -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 -2
View File
@@ -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}}
+12 -14
View File
@@ -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,
})
})
+6 -10
View File
@@ -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)
}
+1 -2
View File
@@ -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'
+1
View File
@@ -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