Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 574ea1c7a3 | |||
| ece5693362 | |||
| a197340bce | |||
| 48f96d6421 | |||
| f69ad16fed | |||
| edcd363fcb | |||
| 23d8fcf5ea | |||
| 3e8a48989a | |||
| 8af501de62 | |||
| 68e56eaea0 | |||
| bb747c5f26 | |||
| 58ca227922 | |||
| af3fbcc940 | |||
| 7b8e50aeb2 | |||
| 93e0d266fb | |||
| 457b2be680 | |||
| d6e5961adf | |||
| e08bcd0228 | |||
| 87329397a4 | |||
| 99715b9a67 | |||
| ae971db765 | |||
| 83cd5033b2 | |||
| e9378654d6 | |||
| 1add59f80c | |||
| 7d39aa3422 | |||
| 701d7c4659 | |||
| f288e18baa | |||
| 076cdd650d | |||
| 56efeee5e2 | |||
| 2ffa02c82a | |||
| 35705ff8bf | |||
| 2c60c45022 | |||
| 8b793d0843 | |||
| 96e6baa47b | |||
| 5be7d72011 | |||
| 4881224d2f | |||
| c634d9b1be | |||
| 2e08b90837 | |||
| c28b858030 | |||
| bfde8e60c2 | |||
| 0a00392b65 | |||
| 0f121427fb | |||
| f32ebdec79 | |||
| 4495462f84 | |||
| 89c8e1cb70 | |||
| a086a2ebe0 | |||
| f9cf58ea44 | |||
| 8bf5696d3c | |||
| 80d09a242d | |||
| 5fabad1b5d | |||
| 89509073f5 | |||
| 67375fe2cd | |||
| 4b4f5f9c9a | |||
| e1f75c2c01 | |||
| e39d308fb7 | |||
| e5a81402f3 | |||
| d9d19c7678 | |||
| 00dc9598fc | |||
| e91665d118 | |||
| 3a34bf1ad1 | |||
| 5eb5ac4800 | |||
| 9d76e42606 | |||
| 41f2d203c3 | |||
| cfd6d1e07e | |||
| f298a4ef5e | |||
| b9bff931a3 | |||
| 296b74d781 | |||
| c4c999ff4f | |||
| 206a932c5d | |||
| a0ed2da006 | |||
| ed4126f5a2 | |||
| 59082016e9 | |||
| c44f329b54 | |||
| 057f97ce11 | |||
| 8df64fcac1 | |||
| 5b88335bb1 | |||
| 196f41b528 | |||
| a9c502c8cf | |||
| 5118748cb1 | |||
| f968c9e721 | |||
| 7750882fd0 | |||
| f87fdd2ea2 | |||
| 5f3d24efab | |||
| 56bff45546 | |||
| f1190ce681 | |||
| cf2684b0d1 | |||
| 95f5360012 | |||
| 6b35d3de1c | |||
| 6f46927c28 | |||
| 6009e61ae9 | |||
| 97a85bb73d | |||
| 5e01a6c7ee | |||
| 9047df5413 | |||
| f48fab872e | |||
| 06cb80a4c3 | |||
| 44affaa6d7 | |||
| 63f0d4d311 | |||
| e0dd8eaed3 | |||
| 34f38c827d | |||
| 21a2c3d6f2 |
@@ -25,7 +25,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: 🔧 Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: 🔑 Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
- name: ⬇️ Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: 🔧 Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
- name: Build ${{ matrix.image }}
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: 🔧 Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: 🔑 Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: 🔧 Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: 🔑 Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: 🔧 Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: 🔑 Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: 🔧 Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: 🔑 Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
with:
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
|
||||
# bundletool needs a JRE. ubuntu-latest ships a default JDK, but pin it explicitly
|
||||
# like the build job so the toolchain is deterministic.
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
|
||||
@@ -225,31 +225,6 @@ jobs:
|
||||
SENTRY_DIST=${{ steps.env.outputs.bundle-identifier }}
|
||||
pnpm export
|
||||
|
||||
# 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 }}
|
||||
@@ -279,19 +254,6 @@ jobs:
|
||||
BSKY_IOS_BUILD_NUMBER: ${{ inputs.iosBuildNumber }}
|
||||
BSKY_ANDROID_VERSION_CODE: ${{ inputs.androidVersionCode }}
|
||||
|
||||
- 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
|
||||
env:
|
||||
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
|
||||
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
|
||||
CHANNEL_NAME: ${{ inputs.channel || 'testflight' }}
|
||||
# When set (required for production), these take precedence over the
|
||||
# global EAS counters inside the use-build-number wrapper
|
||||
BSKY_IOS_BUILD_NUMBER: ${{ inputs.iosBuildNumber }}
|
||||
BSKY_ANDROID_VERSION_CODE: ${{ inputs.androidVersionCode }}
|
||||
|
||||
buildIfNecessaryIOS:
|
||||
name: Build and Submit iOS
|
||||
needs: [bundleDeploy]
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: 🤖 Claude
|
||||
uses: anthropics/claude-code-action@5ef2e550a465a721f4f45e4a7d3c340c873e1dcc # v1.0.190
|
||||
uses: anthropics/claude-code-action@e5ad3c7725bc2459721893f88879fef9dbcf97b0 # v1.0.202
|
||||
env:
|
||||
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
|
||||
with:
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: 🤖 Claude review
|
||||
uses: anthropics/claude-code-action@5ef2e550a465a721f4f45e4a7d3c340c873e1dcc # v1.0.190
|
||||
uses: anthropics/claude-code-action@e5ad3c7725bc2459721893f88879fef9dbcf97b0 # v1.0.202
|
||||
env:
|
||||
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
|
||||
with:
|
||||
|
||||
@@ -15,7 +15,7 @@ concurrency:
|
||||
|
||||
env:
|
||||
CI: "1"
|
||||
MAESTRO_VERSION: "2.6.1"
|
||||
MAESTRO_VERSION: "2.10.0"
|
||||
MAESTRO_DRIVER_STARTUP_TIMEOUT: "180000"
|
||||
MAESTRO_CLI_NO_ANALYTICS: "1"
|
||||
MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED: "true"
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: ☕️ Set up Java 17
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
@@ -62,12 +62,12 @@ jobs:
|
||||
- name: 🔤 Compile translations
|
||||
uses: ./.github/actions/compile-i18n
|
||||
|
||||
- name: 📥 Install Maestro 2.6.1
|
||||
- name: 📥 Install Maestro 2.10.0
|
||||
run: |
|
||||
echo "Installing Maestro" > artifacts/ios/phase.txt
|
||||
curl -fsSL -o "$RUNNER_TEMP/maestro.zip" \
|
||||
"https://github.com/mobile-dev-inc/Maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip"
|
||||
echo "3440825f514f537c6a96bcf5de995780c2a4a7f83a43208fdc95d4f1fecfad3b $RUNNER_TEMP/maestro.zip" \
|
||||
echo "29b675e10cc12080e445e9bfb2e2b4e4dfb9c0f2e30d5884120d258b5e1cd991 $RUNNER_TEMP/maestro.zip" \
|
||||
| shasum -a 256 --check
|
||||
unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP"
|
||||
echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH"
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: ☕️ Set up Java 17
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
@@ -187,12 +187,12 @@ jobs:
|
||||
- name: 🔤 Compile translations
|
||||
uses: ./.github/actions/compile-i18n
|
||||
|
||||
- name: 📥 Install Maestro 2.6.1
|
||||
- name: 📥 Install Maestro 2.10.0
|
||||
run: |
|
||||
echo "Installing Maestro" > artifacts/android/phase.txt
|
||||
curl -fsSL -o "$RUNNER_TEMP/maestro.zip" \
|
||||
"https://github.com/mobile-dev-inc/Maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip"
|
||||
echo "3440825f514f537c6a96bcf5de995780c2a4a7f83a43208fdc95d4f1fecfad3b $RUNNER_TEMP/maestro.zip" \
|
||||
echo "29b675e10cc12080e445e9bfb2e2b4e4dfb9c0f2e30d5884120d258b5e1cd991 $RUNNER_TEMP/maestro.zip" \
|
||||
| shasum -a 256 --check
|
||||
unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP"
|
||||
echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH"
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
commit_message: Nightly source-language update
|
||||
file_pattern: ./src/locale/locales/en/messages.po
|
||||
- name: 🚀 Push source lang to Crowdin
|
||||
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
||||
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_sources_args: "-b main"
|
||||
|
||||
@@ -8,8 +8,8 @@ Bluesky Social is a cross-platform social media application built with React Nat
|
||||
|
||||
**Tech Stack:**
|
||||
|
||||
- React 19.1
|
||||
- React Native 0.81 with Expo 54
|
||||
- React 19.2
|
||||
- React Native 0.86 with Expo 57
|
||||
- TypeScript 7
|
||||
- React Navigation 7 for routing
|
||||
- TanStack Query (React Query) for data fetching
|
||||
@@ -561,7 +561,7 @@ Only use `useMemo`/`useCallback` when you have a specific reason, such as:
|
||||
|
||||
1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful
|
||||
|
||||
2. **Translations**: Wrap ALL user-facing strings with ` `l` `` or `<Trans>`
|
||||
2. **Translations**: Wrap ALL user-facing strings with the `` l`…` `` macro or the `<Trans>` component
|
||||
|
||||
3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ appId: xyz.blueskyweb.app
|
||||
id: "confirmBtn"
|
||||
- tapOn:
|
||||
id: "composerPublishBtn"
|
||||
- extendedWaitUntil:
|
||||
notVisible:
|
||||
id: "composePostView"
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
id: "e2eRefreshHome"
|
||||
- assertVisible: "Adult Content"
|
||||
|
||||
@@ -60,6 +60,7 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "onboardingContinue"
|
||||
- assertVisible: "What are your interests?"
|
||||
- tapOn: "Animals"
|
||||
- tapOn:
|
||||
id: "onboardingContinue"
|
||||
- assertVisible: "Suggested for you"
|
||||
|
||||
@@ -41,6 +41,7 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "onboardingContinue"
|
||||
- assertVisible: "What are your interests?"
|
||||
- tapOn: "Animals"
|
||||
- tapOn:
|
||||
id: "onboardingContinue"
|
||||
- assertVisible: "Suggested for you"
|
||||
|
||||
+15
-27
@@ -3,34 +3,22 @@ appId: xyz.blueskyweb.app
|
||||
- launchApp:
|
||||
appId: "xyz.blueskyweb.app"
|
||||
clearState: true
|
||||
arguments:
|
||||
"-EXDevMenuIsOnboardingFinished": true
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- extendedWaitUntil:
|
||||
visible: "http://localhost:8081"
|
||||
timeout: 60000
|
||||
- tapOn: "http://localhost:8081"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- extendedWaitUntil:
|
||||
visible: "http://10.0.2.2:8081"
|
||||
timeout: 60000
|
||||
- tapOn: "http://10.0.2.2:8081"
|
||||
- extendedWaitUntil:
|
||||
visible: "Continue"
|
||||
timeout: 180000
|
||||
- tapOn: "Continue"
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: e2eProxyHeaderInput
|
||||
timeout: 180000
|
||||
- tapOn:
|
||||
id: e2eProxyHeaderInput
|
||||
- inputText: ${output.result}
|
||||
- pressKey: Enter
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign in"
|
||||
timeout: 180000
|
||||
- retry:
|
||||
maxRetries: 3
|
||||
commands:
|
||||
- tapOn:
|
||||
id: e2eProxyHeaderInput
|
||||
- eraseText
|
||||
- inputText: ${output.result}
|
||||
- pressKey: Enter
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: e2eSignInAlice
|
||||
timeout: 10000
|
||||
|
||||
+163
-33
@@ -1,15 +1,23 @@
|
||||
import {createDownloadResumable, deleteAsync} from 'expo-file-system/legacy'
|
||||
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
|
||||
import {File, Paths} from 'expo-file-system'
|
||||
import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'
|
||||
import * as Sharing from 'expo-sharing'
|
||||
|
||||
import {IMAGE_SIZE_CONFIG_2K_1MB} from '../../src/lib/constants'
|
||||
import {
|
||||
downloadAndResize,
|
||||
type DownloadAndResizeOpts,
|
||||
saveToDevice,
|
||||
shareImageModal,
|
||||
} from '../../src/lib/media/manip'
|
||||
import {getResizedDimensions} from '../../src/lib/media/util'
|
||||
import {logger} from '../../src/logger'
|
||||
|
||||
jest.mock('expo-sharing', () => ({
|
||||
isAvailableAsync: jest.fn().mockResolvedValue(true),
|
||||
shareAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
const mockResizedImage = {
|
||||
path: 'file://resized-image.jpg',
|
||||
size: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
@@ -20,10 +28,29 @@ describe('downloadAndResize', () => {
|
||||
const errorSpy = jest.spyOn(global.console, 'error')
|
||||
|
||||
beforeEach(() => {
|
||||
const mockedCreateResizedImage = manipulateAsync as jest.Mock
|
||||
mockedCreateResizedImage.mockResolvedValue({
|
||||
uri: 'file://resized-image.jpg',
|
||||
...mockResizedImage,
|
||||
const mockedDownload = File.downloadFileAsync as jest.Mock
|
||||
mockedDownload.mockResolvedValue(new File('file://downloaded-image.jpg'))
|
||||
|
||||
let savedImageCount = 0
|
||||
const mockedManipulate = ImageManipulator.manipulate as jest.Mock
|
||||
mockedManipulate.mockImplementation(() => {
|
||||
const image = {
|
||||
...mockResizedImage,
|
||||
release: jest.fn(),
|
||||
uri: 'file://rendered-image.jpg',
|
||||
saveAsync: jest.fn().mockImplementation(() => {
|
||||
savedImageCount += 1
|
||||
return Promise.resolve({
|
||||
uri: `file://resized-image-${savedImageCount}.jpg`,
|
||||
...mockResizedImage,
|
||||
})
|
||||
}),
|
||||
}
|
||||
return {
|
||||
release: jest.fn(),
|
||||
renderAsync: jest.fn().mockResolvedValue(image),
|
||||
resize: jest.fn(),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,12 +59,91 @@ describe('downloadAndResize', () => {
|
||||
})
|
||||
|
||||
it('should return resized image for valid URI and options', async () => {
|
||||
const mockedFetch = createDownloadResumable as jest.Mock
|
||||
mockedFetch.mockReturnValue({
|
||||
cancelAsync: jest.fn(),
|
||||
downloadAsync: jest
|
||||
.fn()
|
||||
.mockResolvedValue({uri: 'file://resized-image.jpg'}),
|
||||
const opts: DownloadAndResizeOpts = {
|
||||
uri: 'https://example.com/image.jpg',
|
||||
maxDimension: 2000,
|
||||
maxSize: 500000,
|
||||
timeout: 10000,
|
||||
}
|
||||
|
||||
const result = await downloadAndResize(opts)
|
||||
expect(result).toEqual({
|
||||
...mockResizedImage,
|
||||
path: 'file://resized-image-7.jpg',
|
||||
})
|
||||
expect(File.downloadFileAsync).toHaveBeenCalledWith(
|
||||
opts.uri,
|
||||
expect.anything(),
|
||||
{
|
||||
idempotent: true,
|
||||
signal: expect.any(AbortSignal),
|
||||
},
|
||||
)
|
||||
|
||||
// First time it gets called is to get dimensions.
|
||||
expect(ImageManipulator.manipulate).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.any(String),
|
||||
)
|
||||
const firstContext = (ImageManipulator.manipulate as jest.Mock).mock
|
||||
.results[0].value
|
||||
expect(firstContext.resize).not.toHaveBeenCalled()
|
||||
|
||||
// The mocked source image is 100x100, below maxDimension, so it is not
|
||||
// downsized.
|
||||
const secondContext = (ImageManipulator.manipulate as jest.Mock).mock
|
||||
.results[1].value
|
||||
expect(secondContext.resize).toHaveBeenCalledWith({
|
||||
height: 100,
|
||||
width: 100,
|
||||
})
|
||||
const lastContext = (
|
||||
ImageManipulator.manipulate as jest.Mock
|
||||
).mock.results.at(-1)!.value
|
||||
const resizedImage = await lastContext.renderAsync.mock.results[0].value
|
||||
expect(resizedImage.saveAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({format: SaveFormat.JPEG, compress: 1.0}),
|
||||
)
|
||||
const deletedPaths = (Paths.info as jest.Mock).mock.calls.map(
|
||||
([path]) => path,
|
||||
)
|
||||
expect(deletedPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'file://resized-image-1.jpg',
|
||||
'file://resized-image-2.jpg',
|
||||
'file://resized-image-3.jpg',
|
||||
'file://resized-image-4.jpg',
|
||||
'file://resized-image-5.jpg',
|
||||
'file://resized-image-6.jpg',
|
||||
]),
|
||||
)
|
||||
expect(deletedPaths).not.toContain('file://resized-image-7.jpg')
|
||||
})
|
||||
|
||||
it('deletes a partial download when downloading fails', async () => {
|
||||
const mockedDownload = File.downloadFileAsync as jest.Mock
|
||||
mockedDownload.mockRejectedValue(new Error('download failed'))
|
||||
|
||||
const opts: DownloadAndResizeOpts = {
|
||||
uri: 'https://example.com/image.jpg',
|
||||
maxDimension: 2000,
|
||||
maxSize: 500000,
|
||||
timeout: 10000,
|
||||
}
|
||||
|
||||
await expect(downloadAndResize(opts)).rejects.toThrow('download failed')
|
||||
expect(Paths.info).toHaveBeenCalledWith(expect.stringMatching(/-download$/))
|
||||
})
|
||||
|
||||
it('deletes every intermediate image when resizing fails', async () => {
|
||||
const mockedManipulate = ImageManipulator.manipulate as jest.Mock
|
||||
const createContext = mockedManipulate.getMockImplementation()!
|
||||
mockedManipulate.mockImplementation((...args) => {
|
||||
const context = createContext(...args)
|
||||
if (mockedManipulate.mock.calls.length === 3) {
|
||||
context.renderAsync.mockRejectedValue(new Error('render failed'))
|
||||
}
|
||||
return context
|
||||
})
|
||||
|
||||
const opts: DownloadAndResizeOpts = {
|
||||
@@ -47,28 +153,16 @@ describe('downloadAndResize', () => {
|
||||
timeout: 10000,
|
||||
}
|
||||
|
||||
const result = await downloadAndResize(opts)
|
||||
expect(result).toEqual(mockResizedImage)
|
||||
expect(createDownloadResumable).toHaveBeenCalledWith(
|
||||
opts.uri,
|
||||
expect.anything(),
|
||||
{
|
||||
cache: true,
|
||||
},
|
||||
await expect(downloadAndResize(opts)).rejects.toThrow('render failed')
|
||||
const deletedPaths = (Paths.info as jest.Mock).mock.calls.map(
|
||||
([path]) => path,
|
||||
)
|
||||
|
||||
// First time it gets called is to get dimensions
|
||||
expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {})
|
||||
// The mocked source image is 100x100, below maxDimension, so it is not
|
||||
// downsized.
|
||||
expect(manipulateAsync).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
[{resize: {height: 100, width: 100}}],
|
||||
{format: SaveFormat.JPEG, compress: 1.0},
|
||||
expect(deletedPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'file://resized-image-1.jpg',
|
||||
'file://resized-image-2.jpg',
|
||||
]),
|
||||
)
|
||||
expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), {
|
||||
idempotent: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return undefined for invalid URI', async () => {
|
||||
@@ -136,3 +230,39 @@ describe('downloadAndResize', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('temporary file lifecycles', () => {
|
||||
beforeEach(() => {
|
||||
;(File.downloadFileAsync as jest.Mock).mockResolvedValue(
|
||||
new File('file://downloaded-image.jpg'),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('retains a successfully shared image for lazy consumers', async () => {
|
||||
await shareImageModal({uri: 'https://example.com/image.jpg'})
|
||||
|
||||
const sharedPath = (Sharing.shareAsync as jest.Mock).mock.calls[0][0]
|
||||
const deletedPaths = (Paths.info as jest.Mock).mock.calls.map(
|
||||
([path]) => path,
|
||||
)
|
||||
expect(sharedPath).toContain('/bsky-share/')
|
||||
expect(deletedPaths).not.toContain(sharedPath)
|
||||
})
|
||||
|
||||
it('does not report user cancellation as an error', async () => {
|
||||
const errorSpy = jest.spyOn(logger, 'error')
|
||||
;(Sharing.shareAsync as jest.Mock).mockRejectedValueOnce(
|
||||
new Error('Picker cancelled'),
|
||||
)
|
||||
|
||||
await expect(
|
||||
saveToDevice('test.txt', 'dGVzdA==', 'text/plain'),
|
||||
).resolves.toBe(false)
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,58 @@
|
||||
import {getLikelyType, LikelyType} from '../../src/lib/link-meta/link-meta'
|
||||
import {
|
||||
getLikelyType,
|
||||
getLinkMeta,
|
||||
LikelyType,
|
||||
} from '../../src/lib/link-meta/link-meta'
|
||||
|
||||
describe('getLikelyType', () => {
|
||||
it('correctly handles non-parsed url', async () => {
|
||||
const output = await getLikelyType('https://example.com')
|
||||
it('correctly handles non-parsed url', () => {
|
||||
const output = getLikelyType('https://example.com')
|
||||
expect(output).toEqual(LikelyType.HTML)
|
||||
})
|
||||
|
||||
it('handles non-string urls without crashing', async () => {
|
||||
const output = await getLikelyType('123')
|
||||
it('handles non-string urls without crashing', () => {
|
||||
const output = getLikelyType('123')
|
||||
expect(output).toEqual(LikelyType.Other)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLinkMeta', () => {
|
||||
const originalFetch = global.fetch
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
})
|
||||
|
||||
it('fetches metadata for stream.place routes that look like files', async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValue({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
error: '',
|
||||
description: 'AT Protocol livestreams',
|
||||
image: 'https://stream.place/thumbnail.jpg',
|
||||
title: 'atproto.com on stream.place',
|
||||
}),
|
||||
})
|
||||
global.fetch = fetchMock
|
||||
|
||||
const output = await getLinkMeta('https://stream.place/atproto.com')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(output).toMatchObject({
|
||||
description: 'AT Protocol livestreams',
|
||||
image: 'https://stream.place/thumbnail.jpg',
|
||||
likelyType: LikelyType.HTML,
|
||||
title: 'atproto.com on stream.place',
|
||||
})
|
||||
})
|
||||
|
||||
it('skips metadata fetching for direct image URLs', async () => {
|
||||
const fetchMock = jest.fn()
|
||||
global.fetch = fetchMock
|
||||
|
||||
const output = await getLinkMeta('https://example.com/image.JPEG')
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(output).toMatchObject({likelyType: LikelyType.Image})
|
||||
})
|
||||
})
|
||||
|
||||
+22
-3
@@ -20,6 +20,7 @@ module.exports = function (_config) {
|
||||
|
||||
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
||||
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
|
||||
const IS_E2E = process.env.EXPO_PUBLIC_ENV === 'e2e'
|
||||
const IS_DEV = !IS_TESTFLIGHT && !IS_PRODUCTION
|
||||
|
||||
const ASSOCIATED_DOMAINS = [
|
||||
@@ -234,6 +235,25 @@ module.exports = function (_config) {
|
||||
checkAutomatically: 'NEVER',
|
||||
},
|
||||
plugins: [
|
||||
[
|
||||
'expo-dev-client',
|
||||
{
|
||||
toolsButton: false,
|
||||
...(IS_E2E
|
||||
? {
|
||||
launchMode: 'most-recent',
|
||||
skipOnboarding: true,
|
||||
showMenuAtLaunch: false,
|
||||
ios: {
|
||||
defaultLaunchURL: 'http://localhost:8081',
|
||||
},
|
||||
android: {
|
||||
defaultLaunchURL: 'http://10.0.2.2:8081',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
'expo-video',
|
||||
'expo-localization',
|
||||
'expo-web-browser',
|
||||
@@ -271,8 +291,8 @@ module.exports = function (_config) {
|
||||
},
|
||||
android: {
|
||||
compileSdkVersion: 36,
|
||||
targetSdkVersion: 35,
|
||||
buildToolsVersion: '35.0.0',
|
||||
targetSdkVersion: 36,
|
||||
buildToolsVersion: '36.0.0',
|
||||
buildReactNativeFromSource: IS_PRODUCTION,
|
||||
},
|
||||
},
|
||||
@@ -285,7 +305,6 @@ module.exports = function (_config) {
|
||||
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
|
||||
},
|
||||
],
|
||||
'react-native-compressor',
|
||||
[
|
||||
'@bitdrift/react-native',
|
||||
{
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M20 12a8 8 0 1 0-16 0 8 8 0 0 0 16 0m2 0c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-10.843.256-.47-3.768a1.324 1.324 0 1 1 2.627 0l-.47 3.768a.85.85 0 0 1-1.687 0M12 17a1.2 1.2 0 1 0 0-2.4 1.2 1.2 0 0 0 0 2.4"/></svg>
|
||||
|
After Width: | Height: | Size: 334 B |
+30
-6
@@ -1,4 +1,11 @@
|
||||
import {Database, envToCfg, httpLogger, LinkService, readEnv} from './index.js'
|
||||
import {
|
||||
Database,
|
||||
envToCfg,
|
||||
FORCE_SHUTDOWN_TIMEOUT_MS,
|
||||
httpLogger,
|
||||
LinkService,
|
||||
readEnv,
|
||||
} from './index.js'
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
@@ -40,13 +47,30 @@ async function main() {
|
||||
await link.start()
|
||||
httpLogger.info('Link service is running')
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
const shutdown = (signal: NodeJS.Signals) => {
|
||||
const forceExitTimer = setTimeout(() => {
|
||||
httpLogger.error(
|
||||
{signal},
|
||||
'Link service exceeded its shutdown deadline; forcing exit',
|
||||
)
|
||||
process.exit(1)
|
||||
}, FORCE_SHUTDOWN_TIMEOUT_MS)
|
||||
forceExitTimer.unref()
|
||||
|
||||
void (async () => {
|
||||
httpLogger.info('Link service is stopping')
|
||||
await link.destroy()
|
||||
httpLogger.info('Link service is stopped')
|
||||
httpLogger.info({signal}, 'Link service is stopping')
|
||||
try {
|
||||
await link.destroy()
|
||||
httpLogger.info({signal}, 'Link service is stopped')
|
||||
} catch (err) {
|
||||
process.exitCode = 1
|
||||
httpLogger.error({err, signal}, 'Failed to stop link service cleanly')
|
||||
}
|
||||
})()
|
||||
})
|
||||
}
|
||||
|
||||
process.once('SIGTERM', shutdown)
|
||||
process.once('SIGINT', shutdown)
|
||||
} catch (error) {
|
||||
httpLogger.error(
|
||||
{
|
||||
|
||||
Vendored
+111
-25
@@ -26,6 +26,9 @@ export class SafelinkClient {
|
||||
private ozoneAgent: OzoneAgent
|
||||
|
||||
private cursor?: string
|
||||
private fetchEventsPromise?: Promise<void>
|
||||
private fetchEventsTimeout?: NodeJS.Timeout
|
||||
private stopped = false
|
||||
|
||||
constructor({cfg, db}: {cfg: ServiceConfig; db: Database}) {
|
||||
this.domainCache = new LRUCache<string, SafelinkRule | 'ok'>({
|
||||
@@ -124,7 +127,7 @@ export class SafelinkClient {
|
||||
return
|
||||
}
|
||||
|
||||
db.db
|
||||
await db.db
|
||||
.insertInto('safelink_rule')
|
||||
.values({
|
||||
id: rule.id,
|
||||
@@ -134,12 +137,14 @@ export class SafelinkClient {
|
||||
action: rule.action,
|
||||
createdAt: rule.createdAt,
|
||||
})
|
||||
.onConflict(oc => oc.column('id').doNothing())
|
||||
.execute()
|
||||
.catch(err => {
|
||||
redirectLogger.error(
|
||||
{error: err, rule},
|
||||
'failed to add rule to database',
|
||||
)
|
||||
throw err
|
||||
})
|
||||
|
||||
if (rule.pattern === 'domain') {
|
||||
@@ -174,6 +179,7 @@ export class SafelinkClient {
|
||||
{error: err, rule},
|
||||
'failed to remove rule from database',
|
||||
)
|
||||
throw err
|
||||
})
|
||||
|
||||
if (rule.pattern === 'domain') {
|
||||
@@ -183,13 +189,71 @@ export class SafelinkClient {
|
||||
}
|
||||
}
|
||||
|
||||
public async runFetchEvents() {
|
||||
public runFetchEvents(): Promise<void> {
|
||||
if (this.stopped) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
this.fetchEventsPromise ??= this.fetchEvents().finally(() => {
|
||||
this.fetchEventsPromise = undefined
|
||||
})
|
||||
return this.fetchEventsPromise
|
||||
}
|
||||
|
||||
public async stop(timeoutMs: number): Promise<void> {
|
||||
this.stopped = true
|
||||
if (this.fetchEventsTimeout) {
|
||||
clearTimeout(this.fetchEventsTimeout)
|
||||
this.fetchEventsTimeout = undefined
|
||||
}
|
||||
const activePoll = this.fetchEventsPromise
|
||||
if (!activePoll) {
|
||||
return
|
||||
}
|
||||
|
||||
let timeout: NodeJS.Timeout | undefined
|
||||
const stopWaiting = new Promise<void>(resolve => {
|
||||
timeout = setTimeout(() => {
|
||||
redirectLogger.warn(
|
||||
{timeoutMs},
|
||||
'Safelink poll exceeded its shutdown deadline',
|
||||
)
|
||||
resolve()
|
||||
}, timeoutMs)
|
||||
})
|
||||
try {
|
||||
await Promise.race([activePoll, stopWaiting])
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleFetchEvents(delay: number) {
|
||||
if (this.stopped) {
|
||||
return
|
||||
}
|
||||
this.fetchEventsTimeout = setTimeout(() => {
|
||||
this.fetchEventsTimeout = undefined
|
||||
void this.runFetchEvents()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private async fetchEvents() {
|
||||
let agent: AtpAgent
|
||||
try {
|
||||
agent = await this.ozoneAgent.getAgent()
|
||||
} catch (err) {
|
||||
if (this.stopped) {
|
||||
return
|
||||
}
|
||||
redirectLogger.error({error: err}, 'error getting Ozone agent')
|
||||
setTimeout(() => this.runFetchEvents(), SAFELINK_MAX_FETCH_INTERVAL)
|
||||
this.scheduleFetchEvents(SAFELINK_MAX_FETCH_INTERVAL)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.stopped) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -202,10 +266,13 @@ export class SafelinkClient {
|
||||
sortDirection: 'asc',
|
||||
})
|
||||
} catch (err) {
|
||||
if (this.stopped) {
|
||||
return
|
||||
}
|
||||
if (err instanceof ExpiredTokenError) {
|
||||
redirectLogger.info('ozone agent had expired session, refreshing...')
|
||||
await this.ozoneAgent.refreshSession()
|
||||
setTimeout(() => this.runFetchEvents(), SAFELINK_MIN_FETCH_INTERVAL)
|
||||
this.scheduleFetchEvents(SAFELINK_MIN_FETCH_INTERVAL)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -213,37 +280,56 @@ export class SafelinkClient {
|
||||
{error: err},
|
||||
'error fetching safelink events from Ozone',
|
||||
)
|
||||
setTimeout(() => this.runFetchEvents(), SAFELINK_MAX_FETCH_INTERVAL)
|
||||
this.scheduleFetchEvents(SAFELINK_MAX_FETCH_INTERVAL)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.stopped) {
|
||||
return
|
||||
}
|
||||
|
||||
if (res.data.events.length === 0) {
|
||||
redirectLogger.info('received no new safelink events from ozone')
|
||||
setTimeout(() => this.runFetchEvents(), SAFELINK_MAX_FETCH_INTERVAL)
|
||||
this.scheduleFetchEvents(SAFELINK_MAX_FETCH_INTERVAL)
|
||||
} else {
|
||||
await this.db.transaction(async db => {
|
||||
for (const rule of res.data.events) {
|
||||
switch (rule.eventType) {
|
||||
case 'removeRule':
|
||||
await this.removeRule(db, rule)
|
||||
break
|
||||
case 'addRule':
|
||||
case 'updateRule':
|
||||
await this.addRule(db, rule)
|
||||
break
|
||||
default:
|
||||
redirectLogger.warn({rule}, 'received unknown rule event type')
|
||||
try {
|
||||
await this.db.transaction(async db => {
|
||||
for (const rule of res.data.events) {
|
||||
switch (rule.eventType) {
|
||||
case 'removeRule':
|
||||
await this.removeRule(db, rule)
|
||||
break
|
||||
case 'addRule':
|
||||
case 'updateRule':
|
||||
await this.addRule(db, rule)
|
||||
break
|
||||
default:
|
||||
redirectLogger.warn({rule}, 'received unknown rule event type')
|
||||
}
|
||||
}
|
||||
})
|
||||
if (this.stopped) {
|
||||
return
|
||||
}
|
||||
})
|
||||
if (res.data.cursor) {
|
||||
redirectLogger.info(
|
||||
{cursor: res.data.cursor},
|
||||
'received new safelink events from Ozone',
|
||||
if (res.data.cursor) {
|
||||
redirectLogger.info(
|
||||
{cursor: res.data.cursor},
|
||||
'received new safelink events from Ozone',
|
||||
)
|
||||
await this.setCursor(res.data.cursor)
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.stopped) {
|
||||
return
|
||||
}
|
||||
redirectLogger.error(
|
||||
{error: err},
|
||||
'error applying safelink events from Ozone',
|
||||
)
|
||||
await this.setCursor(res.data.cursor)
|
||||
this.scheduleFetchEvents(SAFELINK_MAX_FETCH_INTERVAL)
|
||||
return
|
||||
}
|
||||
setTimeout(() => this.runFetchEvents(), SAFELINK_MIN_FETCH_INTERVAL)
|
||||
this.scheduleFetchEvents(SAFELINK_MIN_FETCH_INTERVAL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-8
@@ -11,10 +11,12 @@ import {AppContext} from './context.js'
|
||||
import i18n from './i18n.js'
|
||||
import {createPrometheusRegistry} from './prometheus.js'
|
||||
import {default as routes, errorHandler} from './routes/index.js'
|
||||
import {REQUEST_DRAIN_TIMEOUT_MS} from './shutdown.js'
|
||||
|
||||
export * from './config.js'
|
||||
export * from './db/index.js'
|
||||
export * from './logger.js'
|
||||
export * from './shutdown.js'
|
||||
|
||||
export class LinkService {
|
||||
public server?: http.Server
|
||||
@@ -22,6 +24,7 @@ export class LinkService {
|
||||
private terminator?: HttpTerminator
|
||||
private metricsTerminator?: HttpTerminator
|
||||
private metricsRegistry: Registry
|
||||
private destroyPromise?: Promise<void>
|
||||
|
||||
constructor(
|
||||
public app: express.Application,
|
||||
@@ -46,7 +49,10 @@ export class LinkService {
|
||||
this.ctx.metrics.start()
|
||||
this.server = this.app.listen(this.ctx.cfg.service.port)
|
||||
this.server.keepAliveTimeout = 90000
|
||||
this.terminator = createHttpTerminator({server: this.server})
|
||||
this.terminator = createHttpTerminator({
|
||||
server: this.server,
|
||||
gracefulTerminationTimeout: REQUEST_DRAIN_TIMEOUT_MS,
|
||||
})
|
||||
await events.once(this.server, 'listening')
|
||||
|
||||
const metricsApp = express()
|
||||
@@ -62,13 +68,26 @@ export class LinkService {
|
||||
await events.once(this.metricsServer, 'listening')
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
destroy(): Promise<void> {
|
||||
this.destroyPromise ??= this.destroyInternal()
|
||||
|
||||
return this.destroyPromise
|
||||
}
|
||||
|
||||
private async destroyInternal() {
|
||||
this.ctx.abortController.abort()
|
||||
await Promise.all([
|
||||
this.terminator?.terminate(),
|
||||
this.metricsTerminator?.terminate(),
|
||||
])
|
||||
await this.ctx.db.close()
|
||||
this.ctx.metrics.stop()
|
||||
try {
|
||||
await Promise.all([
|
||||
this.terminator?.terminate(),
|
||||
this.metricsTerminator?.terminate(),
|
||||
this.ctx.safelinkClient.stop(REQUEST_DRAIN_TIMEOUT_MS),
|
||||
])
|
||||
} finally {
|
||||
try {
|
||||
await this.ctx.db.close()
|
||||
} finally {
|
||||
this.ctx.metrics.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import assert from 'node:assert'
|
||||
import {describe, it} from 'node:test'
|
||||
|
||||
import {SafelinkClient} from './cache/safelinkClient.js'
|
||||
|
||||
const createClient = (getAgent: () => Promise<unknown>) => {
|
||||
const client: SafelinkClient = Object.create(SafelinkClient.prototype)
|
||||
Reflect.set(client, 'stopped', false)
|
||||
Reflect.set(client, 'ozoneAgent', {getAgent})
|
||||
Reflect.set(client, 'domainCache', {delete: () => {}})
|
||||
Reflect.set(client, 'urlCache', {delete: () => {}})
|
||||
return client
|
||||
}
|
||||
|
||||
void describe('Safelink shutdown', () => {
|
||||
void it('clears a scheduled retry and cannot restart after stop', async () => {
|
||||
const client = createClient(() =>
|
||||
Promise.reject(new Error('Ozone unavailable')),
|
||||
)
|
||||
|
||||
await client.runFetchEvents()
|
||||
assert.ok(Reflect.get(client, 'fetchEventsTimeout'))
|
||||
|
||||
await client.stop(1_000)
|
||||
assert.strictEqual(Reflect.get(client, 'fetchEventsTimeout'), undefined)
|
||||
assert.strictEqual(Reflect.get(client, 'stopped'), true)
|
||||
|
||||
await client.runFetchEvents()
|
||||
assert.strictEqual(Reflect.get(client, 'fetchEventsTimeout'), undefined)
|
||||
})
|
||||
|
||||
void it('waits for an active poll to finish before stopping', async () => {
|
||||
let pollStarted = () => {}
|
||||
const started = new Promise<void>(resolve => {
|
||||
pollStarted = () => resolve(undefined)
|
||||
})
|
||||
let finishPoll = () => {}
|
||||
const releasePoll = new Promise<void>(resolve => {
|
||||
finishPoll = () => resolve(undefined)
|
||||
})
|
||||
const client = createClient(async () => {
|
||||
pollStarted()
|
||||
await releasePoll
|
||||
throw new Error('poll released during shutdown')
|
||||
})
|
||||
|
||||
const polling = client.runFetchEvents()
|
||||
await started
|
||||
|
||||
let stopped = false
|
||||
const stopping = client.stop(1_000).then(() => {
|
||||
stopped = true
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 25))
|
||||
assert.strictEqual(stopped, false)
|
||||
|
||||
finishPoll()
|
||||
await Promise.all([polling, stopping])
|
||||
assert.strictEqual(stopped, true)
|
||||
})
|
||||
|
||||
void it(
|
||||
'bounds the wait for a poll that never finishes',
|
||||
{timeout: 1_000},
|
||||
async () => {
|
||||
const client = createClient(() => new Promise<never>(() => {}))
|
||||
void client.runFetchEvents()
|
||||
|
||||
const startedAt = Date.now()
|
||||
await client.stop(25)
|
||||
assert.ok(Date.now() - startedAt >= 20)
|
||||
},
|
||||
)
|
||||
|
||||
void it('retries a failed rule write without advancing the cursor', async () => {
|
||||
const client = createClient(() =>
|
||||
Promise.resolve({
|
||||
tools: {
|
||||
ozone: {
|
||||
safelink: {
|
||||
queryEvents: () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
cursor: 'next',
|
||||
events: [
|
||||
{
|
||||
action: 'block',
|
||||
createdAt: new Date().toISOString(),
|
||||
eventType: 'addRule',
|
||||
id: 1,
|
||||
pattern: 'domain',
|
||||
url: 'example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
Reflect.set(client, 'cursor', 'current')
|
||||
Reflect.set(client, 'db', {
|
||||
transaction: (run: (db: unknown) => Promise<void>) =>
|
||||
run({
|
||||
db: {
|
||||
insertInto: () => ({
|
||||
values: () => ({
|
||||
onConflict: () => ({
|
||||
execute: () =>
|
||||
Promise.reject(new Error('database unavailable')),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
await client.runFetchEvents()
|
||||
assert.ok(Reflect.get(client, 'fetchEventsTimeout'))
|
||||
assert.strictEqual(Reflect.get(client, 'cursor'), 'current')
|
||||
await client.stop(1_000)
|
||||
})
|
||||
|
||||
void it('advances the cursor after replaying an existing rule event', async () => {
|
||||
const client = createClient(() =>
|
||||
Promise.resolve({
|
||||
tools: {
|
||||
ozone: {
|
||||
safelink: {
|
||||
queryEvents: () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
cursor: 'next',
|
||||
events: [
|
||||
{
|
||||
action: 'block',
|
||||
createdAt: new Date().toISOString(),
|
||||
eventType: 'addRule',
|
||||
id: 1,
|
||||
pattern: 'domain',
|
||||
url: 'example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
Reflect.set(client, 'cursor', 'current')
|
||||
let storedCursor = 'current'
|
||||
Reflect.set(client, 'db', {
|
||||
transaction: (run: (db: unknown) => Promise<void>) =>
|
||||
run({
|
||||
db: {
|
||||
insertInto: () => ({
|
||||
values: () => ({
|
||||
onConflict: () => ({
|
||||
execute: () => Promise.resolve(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
db: {
|
||||
insertInto: () => ({
|
||||
values: ({cursor}: {cursor: string}) => ({
|
||||
onConflict: () => ({
|
||||
execute: () => {
|
||||
storedCursor = cursor
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
await client.runFetchEvents()
|
||||
try {
|
||||
assert.strictEqual(storedCursor, 'next')
|
||||
assert.strictEqual(Reflect.get(client, 'cursor'), 'next')
|
||||
} finally {
|
||||
await client.stop(1_000)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert'
|
||||
import events from 'node:events'
|
||||
import http from 'node:http'
|
||||
import {describe, it} from 'node:test'
|
||||
|
||||
import {createHttpTerminator} from 'http-terminator'
|
||||
|
||||
import {REQUEST_DRAIN_TIMEOUT_MS} from './shutdown.js'
|
||||
|
||||
describe('HTTP shutdown', () => {
|
||||
it('allows in-flight requests to finish during the drain window', async () => {
|
||||
let beginRequest = () => {}
|
||||
const requestStarted = new Promise<void>(resolve => {
|
||||
beginRequest = () => resolve(undefined)
|
||||
})
|
||||
let finishRequest = () => {}
|
||||
const releaseRequest = new Promise<void>(resolve => {
|
||||
finishRequest = () => resolve(undefined)
|
||||
})
|
||||
|
||||
const server = http.createServer(async (_req, res) => {
|
||||
beginRequest()
|
||||
await releaseRequest
|
||||
res.end('finished')
|
||||
})
|
||||
server.listen(0, '127.0.0.1')
|
||||
await events.once(server, 'listening')
|
||||
|
||||
const address = server.address()
|
||||
assert.ok(address && typeof address !== 'string')
|
||||
|
||||
const responsePromise = fetch(`http://127.0.0.1:${address.port}`)
|
||||
await requestStarted
|
||||
|
||||
const terminator = createHttpTerminator({
|
||||
server,
|
||||
gracefulTerminationTimeout: REQUEST_DRAIN_TIMEOUT_MS,
|
||||
})
|
||||
let termination: Promise<void> | undefined
|
||||
try {
|
||||
let terminated = false
|
||||
termination = terminator.terminate().then(() => {
|
||||
terminated = true
|
||||
})
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 25))
|
||||
assert.strictEqual(terminated, false)
|
||||
|
||||
finishRequest()
|
||||
const response = await responsePromise
|
||||
assert.strictEqual(await response.text(), 'finished')
|
||||
await termination
|
||||
assert.strictEqual(terminated, true)
|
||||
} finally {
|
||||
finishRequest()
|
||||
await (termination ?? terminator.terminate())
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the shared 60 second request drain budget', () => {
|
||||
assert.strictEqual(REQUEST_DRAIN_TIMEOUT_MS, 60_000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
export const REQUEST_DRAIN_TIMEOUT_MS = 60_000
|
||||
export const FORCE_SHUTDOWN_TIMEOUT_MS = REQUEST_DRAIN_TIMEOUT_MS + 4_000
|
||||
+91
-15
@@ -29,22 +29,88 @@ jest.mock('react-native-safe-area-context', () => {
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('expo-file-system/legacy', () => ({
|
||||
getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}),
|
||||
deleteAsync: jest.fn(),
|
||||
moveAsync: jest.fn().mockResolvedValue(undefined),
|
||||
createDownloadResumable: jest.fn(),
|
||||
}))
|
||||
jest.mock('expo-file-system', () => {
|
||||
const join = parts =>
|
||||
parts
|
||||
.map(part => (typeof part === 'string' ? part : part.uri))
|
||||
.join('/')
|
||||
.replace(/([^:]\/)\/+/g, '$1')
|
||||
|
||||
jest.mock('expo-image-manipulator', () => ({
|
||||
manipulateAsync: jest.fn().mockResolvedValue({
|
||||
uri: 'file://resized-image',
|
||||
}),
|
||||
SaveFormat: {
|
||||
JPEG: 'jpeg',
|
||||
WEBP: 'webp',
|
||||
},
|
||||
}))
|
||||
class File {
|
||||
static downloadFileAsync = jest.fn()
|
||||
|
||||
constructor(...parts) {
|
||||
this.uri = join(parts)
|
||||
this.exists = true
|
||||
this.size = 100
|
||||
this.type = 'image/jpeg'
|
||||
}
|
||||
|
||||
copy = jest.fn()
|
||||
delete = jest.fn()
|
||||
move = jest.fn(async destination => {
|
||||
this.uri = destination.uri
|
||||
})
|
||||
write = jest.fn()
|
||||
}
|
||||
|
||||
class Directory {
|
||||
static pickDirectoryAsync = jest.fn()
|
||||
|
||||
constructor(...parts) {
|
||||
this.uri = join(parts)
|
||||
this.exists = true
|
||||
}
|
||||
|
||||
create = jest.fn()
|
||||
createFile = jest.fn(name => new File(this, name))
|
||||
delete = jest.fn()
|
||||
list = jest.fn(() => [])
|
||||
}
|
||||
|
||||
return {
|
||||
Directory,
|
||||
EncodingType: {Base64: 'base64'},
|
||||
File,
|
||||
Paths: {
|
||||
availableDiskSpace: 100,
|
||||
cache: new Directory('file://cache'),
|
||||
document: new Directory('file://document'),
|
||||
info: jest.fn(() => ({exists: true, isDirectory: false})),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('expo-image-manipulator', () => {
|
||||
const createContext = () => {
|
||||
const image = {
|
||||
height: 100,
|
||||
release: jest.fn(),
|
||||
saveAsync: jest.fn().mockResolvedValue({
|
||||
height: 100,
|
||||
uri: 'file://resized-image',
|
||||
width: 100,
|
||||
}),
|
||||
width: 100,
|
||||
}
|
||||
return {
|
||||
crop: jest.fn(),
|
||||
release: jest.fn(),
|
||||
renderAsync: jest.fn().mockResolvedValue(image),
|
||||
resize: jest.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ImageManipulator: {
|
||||
manipulate: jest.fn(createContext),
|
||||
},
|
||||
SaveFormat: {
|
||||
JPEG: 'jpeg',
|
||||
WEBP: 'webp',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('expo-camera', () => ({
|
||||
Camera: {
|
||||
@@ -56,6 +122,16 @@ jest.mock('expo-media-library', () => ({
|
||||
__esModule: true, // this property makes it work
|
||||
default: jest.fn(),
|
||||
usePermissions: jest.fn(() => [true]),
|
||||
requestPermissionsAsync: jest.fn().mockResolvedValue({granted: true}),
|
||||
saveToLibraryAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
jest.mock('expo-media-library/legacy', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
usePermissions: jest.fn(() => [true]),
|
||||
requestPermissionsAsync: jest.fn().mockResolvedValue({granted: true}),
|
||||
saveToLibraryAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
jest.mock('@bsky.app/expo-guess-language', () => ({
|
||||
|
||||
+8
-3
@@ -92,6 +92,7 @@
|
||||
"app.bsky.graph.muteActor",
|
||||
"app.bsky.graph.muteActorList",
|
||||
"app.bsky.graph.muteThread",
|
||||
"app.bsky.graph.referencelistoptout",
|
||||
"app.bsky.graph.searchStarterPacks",
|
||||
"app.bsky.graph.searchStarterPacksV2",
|
||||
"app.bsky.graph.starterpack",
|
||||
@@ -426,11 +427,11 @@
|
||||
},
|
||||
"app.bsky.embed.video": {
|
||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.embed.video",
|
||||
"cid": "bafyreie3nug4ezpwodl6yrpgv5edkazzn22t7ea4yaeuun4rctyekkngai"
|
||||
"cid": "bafyreihoxb7lvczityqcv2s5od3rllmkee7tn3m4qg6wn34kd6m45p6v24"
|
||||
},
|
||||
"app.bsky.feed.defs": {
|
||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.feed.defs",
|
||||
"cid": "bafyreibg2zvz4n25vtd5yfqlodqra72s4h4lz7vwdexoutv3523wbw6jca"
|
||||
"cid": "bafyreihxsdr5eig2gntbwn5duiskdae6lb7xyrtvump6nzzuz7sspyqv3e"
|
||||
},
|
||||
"app.bsky.feed.describeFeedGenerator": {
|
||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.feed.describeFeedGenerator",
|
||||
@@ -538,7 +539,7 @@
|
||||
},
|
||||
"app.bsky.graph.defs": {
|
||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.defs",
|
||||
"cid": "bafyreifcipomli7yggtl46xufgxlnrw7se6xmsdxmzgfcz2tiu76ljatxm"
|
||||
"cid": "bafyreief2f7zpllyicjugbn7faohmnzwujeiytfzj76uckxmrqmtdvechy"
|
||||
},
|
||||
"app.bsky.graph.follow": {
|
||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.follow",
|
||||
@@ -632,6 +633,10 @@
|
||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.muteThread",
|
||||
"cid": "bafyreib6ppci3qzye6wktkm4byxtb5mnl2vg22fm7oawcdvof2tfogx4dy"
|
||||
},
|
||||
"app.bsky.graph.referencelistoptout": {
|
||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.referencelistoptout",
|
||||
"cid": "bafyreifode2cfu7x7yamiorzg66u46l4zdr2yxmtuoikrup7j2dhwzxf3q"
|
||||
},
|
||||
"app.bsky.graph.searchStarterPacks": {
|
||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.searchStarterPacks",
|
||||
"cid": "bafyreia446ip6mbnwpml6hlvxab7jtsud7zczde3u6zmnsa3op4bpxu7um"
|
||||
|
||||
@@ -9,17 +9,15 @@
|
||||
"properties": {
|
||||
"alt": {
|
||||
"type": "string",
|
||||
"maxLength": 10000,
|
||||
"description": "Alt text description of the video, for accessibility.",
|
||||
"maxGraphemes": 1000
|
||||
"description": "Alt text description of the video, for accessibility."
|
||||
},
|
||||
"video": {
|
||||
"type": "blob",
|
||||
"accept": [
|
||||
"video/mp4"
|
||||
],
|
||||
"maxSize": 100000000,
|
||||
"description": "The mp4 video file. May be up to 100mb, formerly limited to 50mb."
|
||||
"maxSize": 300000000,
|
||||
"description": "The mp4 video file. May be up to 300mb, formerly limited to 100mb."
|
||||
},
|
||||
"captions": {
|
||||
"type": "array",
|
||||
@@ -51,9 +49,7 @@
|
||||
],
|
||||
"properties": {
|
||||
"alt": {
|
||||
"type": "string",
|
||||
"maxLength": 10000,
|
||||
"maxGraphemes": 1000
|
||||
"type": "string"
|
||||
},
|
||||
"cid": {
|
||||
"type": "string",
|
||||
|
||||
@@ -169,6 +169,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"knownLikers": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"count",
|
||||
"actors"
|
||||
],
|
||||
"properties": {
|
||||
"count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"actors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"ref": "app.bsky.actor.defs#profileViewBasic",
|
||||
"type": "ref"
|
||||
},
|
||||
"maxLength": 5,
|
||||
"minLength": 0
|
||||
}
|
||||
},
|
||||
"description": "The post's likers whom you also follow"
|
||||
},
|
||||
"requestLess": {
|
||||
"type": "token",
|
||||
"description": "Request that less content like the given feed item be shown in the feed"
|
||||
@@ -194,6 +216,11 @@
|
||||
"bookmarked": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"knownLikers": {
|
||||
"ref": "#knownLikers",
|
||||
"type": "ref",
|
||||
"description": "This property is present only in selected cases, as an optimization."
|
||||
},
|
||||
"threadMuted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -100,6 +100,11 @@
|
||||
"subject": {
|
||||
"ref": "app.bsky.actor.defs#profileView",
|
||||
"type": "ref"
|
||||
},
|
||||
"subjectOptedOut": {
|
||||
"type": "boolean",
|
||||
"const": true,
|
||||
"description": "Set to true when the subject has opted out of appearing in the reference list. Only set when the viewer owns the list."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -228,6 +233,11 @@
|
||||
"blocked": {
|
||||
"type": "string",
|
||||
"format": "at-uri"
|
||||
},
|
||||
"referenceListOptOut": {
|
||||
"type": "string",
|
||||
"format": "at-uri",
|
||||
"description": "The authenticated viewer's app.bsky.graph.referencelistoptout record URI for this reference list. Only set for reference lists. A client can delete this record to undo the opt-out."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"id": "app.bsky.graph.referencelistoptout",
|
||||
"defs": {
|
||||
"main": {
|
||||
"key": "tid",
|
||||
"type": "record",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"subject",
|
||||
"createdAt"
|
||||
],
|
||||
"properties": {
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"format": "at-uri",
|
||||
"description": "Canonical, DID-based AT URI of the app.bsky.graph.list record from which the author requests omission."
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "datetime"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Record requesting that its author be omitted from the public presentation of a reference list. This record is only enforced when the subject list's current purpose is app.bsky.graph.defs#referencelist. AppView indexes at most one record per actor and list pair, and ignores duplicate records."
|
||||
}
|
||||
},
|
||||
"$type": "com.atproto.lexicon.schema",
|
||||
"lexicon": 1
|
||||
}
|
||||
@@ -61,7 +61,8 @@ The component uses a class-based approach to expose imperative methods (`present
|
||||
- Preserves status/nav bar appearance from host activity
|
||||
- **DialogRootViewGroup.kt**: Custom ViewGroup acting as RootView for the dialog
|
||||
- Forwards touch events to React Native event system
|
||||
- Updates shadow node size to match window dimensions
|
||||
- Reports its measured width to `BottomSheetView` so the content canvas can follow it
|
||||
- Also carries the legacy `UIManagerModule.updateNodeSize()` shadow node sizing, which only runs on the old architecture
|
||||
- Based on React Native's ReactModalHostView pattern
|
||||
- **SheetManager.kt**: Singleton for tracking sheets (same pattern as iOS)
|
||||
|
||||
@@ -74,6 +75,24 @@ Both platforms detect content height changes natively without JS bridge round-tr
|
||||
|
||||
This eliminates layout jank when content changes (e.g., keyboard appearance, dynamic content loading).
|
||||
|
||||
### Content Canvas Sizing
|
||||
|
||||
The "canvas" is the size the sheet content is laid out on by Yoga. **On Android the native side owns it**; on iOS it is still sized from JS.
|
||||
|
||||
- **Android**: JS renders unsized `flex: 1` content and `BottomSheetView` pushes the canvas size into the Fabric shadow tree through `ExpoView`'s `setViewSize` state channel (`shadowNodeProxy.setViewSize()`). Only native knows the real sheet frame - Material caps the frame at 640dp on tablets and centers it, and it changes on rotation.
|
||||
- **iOS**: `BottomSheetNativeComponent` sets `height: screenHeight - insets.top` and `width: '100%'` on the native view. Moving iOS onto the same state channel is deferred: it needs on-device iteration on iOS 26 sheet geometry (large-detent and floating-card metrics, where the visible sheet is shorter than the window minus the top inset).
|
||||
|
||||
How the Android path works:
|
||||
|
||||
- The JS style on the native view **must not set `width` or `height`** on Android. `ExpoViewComponentDescriptor::adopt()` only applies the state size on an axis where the style leaves that dimension undefined, so a style dimension would silently win.
|
||||
- The two axes come from different places, and the distinction is load-bearing:
|
||||
- **Width** is authoritatively the dialog container's measured width, reported through `DialogRootViewGroup`'s size-change listener - that is the real sheet width, with the horizontal window insets and Material's 640dp cap already applied. It is seeded from `min(window width, material_bottom_sheet_max_width)` on the first `onLayout` so content has something to lay out in before the dialog exists.
|
||||
- **Height** is always computed natively as `screenHeight - statusBarHeight` (matching the behavior's `expandedOffset`) - the whole expanded frame, **never** the dialog's measured height. The canvas has to be room for the content to grow *into*, because the content's height is what drives the snap points. Sizing it from the dialog's own height is circular: `BottomSheetBehavior` measures the container against the sheet, so the canvas collapses onto the content height and the content is then pinned - extra `ScrollView` padding (the Android keyboard path) or a longer list becomes scroll extent instead of a height change, `OnLayoutChangeListener` never fires, and the sheet stops responding to its content.
|
||||
- Seeding runs once per open cycle - re-seeding would fight the width the dialog reported and the two would push each other back and forth.
|
||||
- Because the content measures 0x0 until that first state commit lands, `present()` bails out early when the content height is still zero. The commit resizes the native view, which re-fires `onLayout`, which re-enters `present()` - so presentation self-retries rather than needing an explicit callback. Full-height sheets skip the check, since they don't need a content measurement.
|
||||
- Rotation is handled by the container push: the RN activity handles configuration changes itself, so the view is never recreated. `screenHeight` is read per access so the computed height follows the rotation, and the container reports the new width (plus a deferred `updateLayout()` to reposition the sheet).
|
||||
- On the **old architecture** there is no state channel (`stateWrapper` is null, so `setViewSize` no-ops) and Android falls back to `DialogRootViewGroup`'s legacy `UIManagerModule.updateNodeSize()` path. The `present()` gate is skipped there for the same reason - nothing would ever resize the view.
|
||||
|
||||
## Props
|
||||
|
||||
```typescript
|
||||
@@ -213,6 +232,10 @@ BottomSheetNativeComponent.dismissAll()
|
||||
|
||||
4. **Layout Updates During Gestures**: Content height changes are deferred during drag gestures to prevent fighting the user's input.
|
||||
|
||||
5. **Tablet Width**: Material caps the sheet frame at 640dp (`material_bottom_sheet_max_width`, the `android:maxWidth` on `Widget.MaterialComponents.BottomSheet`) and centers it horizontally, so on tablets the sheet is narrower than the screen. `BottomSheetView` reads that cap from resources when seeding the canvas width, and the dialog container's measured width then corrects it - see [Content Canvas Sizing](#content-canvas-sizing).
|
||||
|
||||
6. **Rotation**: The RN activity handles configuration changes itself, so a rotation resizes the display without recreating `BottomSheetView`. Screen height is therefore read per access rather than cached, and `maxHeight` is stored unclamped and clamped against the current screen at use time.
|
||||
|
||||
### Platform Differences
|
||||
|
||||
- **cornerRadius**: Applied to sheet on iOS, to content wrapper on Android (Android clips with `overflow: hidden`)
|
||||
|
||||
+158
-24
@@ -21,6 +21,12 @@ import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
|
||||
/**
|
||||
* Fallback for Material's `material_bottom_sheet_max_width` dimen (in dp), used only
|
||||
* if the resource lookup fails. 640dp is the value Material ships.
|
||||
*/
|
||||
private const val FALLBACK_MAX_SHEET_WIDTH_DP = 640f
|
||||
|
||||
class BottomSheetView(
|
||||
context: Context,
|
||||
appContext: AppContext,
|
||||
@@ -38,26 +44,30 @@ class BottomSheetView(
|
||||
private var lastObservedContentHeight: Float = 0f
|
||||
private var pendingLayoutUpdate: Boolean = false
|
||||
|
||||
private val screenHeight: Float =
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
|
||||
context.resources.displayMetrics.heightPixels
|
||||
.toFloat()
|
||||
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
||||
wm.currentWindowMetrics.bounds
|
||||
.height()
|
||||
.toFloat()
|
||||
} else {
|
||||
// API < 30: currentWindowMetrics not available, use getRealSize
|
||||
// which includes system bars (heightPixels may exclude them)
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
||||
val size = android.graphics.Point()
|
||||
@Suppress("DEPRECATION")
|
||||
wm.defaultDisplay.getRealSize(size)
|
||||
size.y.toFloat()
|
||||
}
|
||||
// Computed per read rather than cached at construction: the RN activity handles
|
||||
// configuration changes itself, so a rotation resizes the display without
|
||||
// recreating this view and a cached value would stay stale for the sheet's life.
|
||||
private val screenHeight: Float
|
||||
get() =
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
|
||||
context.resources.displayMetrics.heightPixels
|
||||
.toFloat()
|
||||
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
||||
wm.currentWindowMetrics.bounds
|
||||
.height()
|
||||
.toFloat()
|
||||
} else {
|
||||
// API < 30: currentWindowMetrics not available, use getRealSize
|
||||
// which includes system bars (heightPixels may exclude them)
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
||||
val size = android.graphics.Point()
|
||||
@Suppress("DEPRECATION")
|
||||
wm.defaultDisplay.getRealSize(size)
|
||||
size.y.toFloat()
|
||||
}
|
||||
|
||||
private fun getNavigationBarHeight(): Int {
|
||||
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
|
||||
@@ -73,6 +83,11 @@ class BottomSheetView(
|
||||
private val onSnapPointChange by EventDispatcher()
|
||||
private val onStateChange by EventDispatcher()
|
||||
|
||||
// Last canvas size (in dp) pushed into the shadow tree, so repeated layout
|
||||
// passes don't spam state updates
|
||||
private var lastPushedCanvasWidth: Float = -1f
|
||||
private var lastPushedCanvasHeight: Float = -1f
|
||||
|
||||
var disableDrag = false
|
||||
set(value) {
|
||||
field = value
|
||||
@@ -99,10 +114,11 @@ class BottomSheetView(
|
||||
field = if (value < 0) 0f else dpToPx(value)
|
||||
}
|
||||
|
||||
var maxHeight = this.screenHeight
|
||||
// Stored unclamped (in px) because screenHeight can change under us on rotation.
|
||||
// The clamp against the screen happens at use time, in getTargetHeight().
|
||||
var maxHeight = Float.MAX_VALUE
|
||||
set(value) {
|
||||
val px = dpToPx(value)
|
||||
field = if (px > this.screenHeight) this.screenHeight else px
|
||||
field = dpToPx(value)
|
||||
}
|
||||
|
||||
private var isOpen: Boolean = false
|
||||
@@ -140,6 +156,38 @@ class BottomSheetView(
|
||||
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
|
||||
this.dialogRootViewGroup = DialogRootViewGroup(context)
|
||||
this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
|
||||
|
||||
// The dialog container's measured WIDTH is the authoritative canvas width: it
|
||||
// already accounts for the window's horizontal insets, Material's max-width cap on
|
||||
// tablets and the current rotation. DialogRootViewGroup's own updateNodeSize() path
|
||||
// is a no-op on the new architecture (getNativeModule(UIManagerModule) returns null
|
||||
// under Fabric), so this state channel is what actually gets the width across there.
|
||||
//
|
||||
// Its measured HEIGHT is deliberately ignored - see canvasHeight.
|
||||
this.dialogRootViewGroup.setOnSizeChangeListener(
|
||||
object : DialogRootViewGroup.OnSizeChangeListener {
|
||||
override fun onSizeChange(
|
||||
width: Int,
|
||||
height: Int,
|
||||
) {
|
||||
val density = context.resources.displayMetrics.density
|
||||
pushCanvasSize(width / density, canvasHeight / density)
|
||||
|
||||
// onSizeChanged fires from inside a layout pass, so defer the reposition:
|
||||
// updateLayout() reads child heights that aren't final yet. This is what
|
||||
// makes the sheet settle back into place after a rotation. It no-ops for
|
||||
// fullHeight sheets, which is correct - those are pinned to the expanded
|
||||
// offset either way.
|
||||
if ((isOpen || isOpening) && !isClosing) {
|
||||
post {
|
||||
if ((isOpen || isOpening) && !isClosing) {
|
||||
updateLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
SheetManager.add(this)
|
||||
}
|
||||
@@ -151,9 +199,84 @@ class BottomSheetView(
|
||||
r: Int,
|
||||
b: Int,
|
||||
) {
|
||||
this.seedCanvasSize()
|
||||
this.present()
|
||||
}
|
||||
|
||||
/**
|
||||
* The height, in px, of the canvas the sheet content is laid out on. This is the whole
|
||||
* expanded frame (the behavior's expandedOffset is the status bar height), NOT the
|
||||
* sheet's current height.
|
||||
*
|
||||
* That distinction is the whole ballgame. The content's height is what drives the snap
|
||||
* points, so the canvas has to be room to grow *into*. Sizing the canvas from the
|
||||
* dialog's own measured height is circular - BottomSheetBehavior measures the dialog
|
||||
* container against the sheet, so the canvas collapses onto the content height, and from
|
||||
* then on the content is pinned: extra ScrollView padding (the Android keyboard path) or
|
||||
* a longer list just becomes scroll extent instead of a height change, the
|
||||
* OnLayoutChangeListener never fires, and the sheet stops responding to its content.
|
||||
*/
|
||||
private val canvasHeight: Float
|
||||
get() = screenHeight - getStatusBarHeight()
|
||||
|
||||
/**
|
||||
* JS renders the sheet content unsized, so before the first state commit it measures
|
||||
* 0x0 and present() has no content height to derive snap points from. Seed the canvas
|
||||
* here to kick that off - the dialog container reports the authoritative width later,
|
||||
* via its OnSizeChangeListener.
|
||||
*
|
||||
* Runs at most once per open cycle. It has to: each state commit re-fires onLayout, so
|
||||
* re-seeding would fight the width the dialog reported and the two would push each other
|
||||
* back and forth forever.
|
||||
*
|
||||
* stateWrapper is assigned while Fabric mounts the view, before the first layout pass,
|
||||
* so setViewSize() should already reach the shadow tree from here. On the old
|
||||
* architecture it is null and this no-ops, which is fine: DialogRootViewGroup's legacy
|
||||
* updateNodeSize() path still sizes the content there.
|
||||
*/
|
||||
private fun seedCanvasSize() {
|
||||
if (lastPushedCanvasWidth > 0f) return
|
||||
val density = context.resources.displayMetrics.density
|
||||
val widthPx =
|
||||
minOf(
|
||||
context.resources.displayMetrics.widthPixels
|
||||
.toFloat(),
|
||||
getMaxSheetWidth(),
|
||||
)
|
||||
this.pushCanvasSize(widthPx / density, canvasHeight / density)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the size of this view's shadow node, which is the canvas the sheet content is
|
||||
* laid out on. Deduped because both onLayout and the dialog container's size changes
|
||||
* can re-report an unchanged size.
|
||||
*/
|
||||
private fun pushCanvasSize(
|
||||
widthDp: Float,
|
||||
heightDp: Float,
|
||||
) {
|
||||
if (widthDp <= 0f || heightDp <= 0f) return
|
||||
if (widthDp == lastPushedCanvasWidth && heightDp == lastPushedCanvasHeight) return
|
||||
lastPushedCanvasWidth = widthDp
|
||||
lastPushedCanvasHeight = heightDp
|
||||
this.shadowNodeProxy.setViewSize(widthDp.toDouble(), heightDp.toDouble())
|
||||
}
|
||||
|
||||
/**
|
||||
* Material caps the sheet frame at `material_bottom_sheet_max_width` (the
|
||||
* `android:maxWidth` on `Widget.MaterialComponents.BottomSheet`, which our dialog theme
|
||||
* inherits from) and centers it horizontally, so on tablets the sheet is narrower than
|
||||
* the display. Returns the cap in px.
|
||||
*/
|
||||
private fun getMaxSheetWidth(): Float =
|
||||
try {
|
||||
resources
|
||||
.getDimensionPixelSize(com.google.android.material.R.dimen.material_bottom_sheet_max_width)
|
||||
.toFloat()
|
||||
} catch (e: android.content.res.Resources.NotFoundException) {
|
||||
FALLBACK_MAX_SHEET_WIDTH_DP * context.resources.displayMetrics.density
|
||||
}
|
||||
|
||||
private fun destroy() {
|
||||
this.stopObservingContentHeight()
|
||||
this.isClosing = false
|
||||
@@ -178,6 +301,15 @@ class BottomSheetView(
|
||||
|
||||
val contentHeight = this.getContentHeight()
|
||||
|
||||
// The content is unsized until the canvas size we pushed lands in the shadow tree,
|
||||
// so bail and let this retry itself: the state commit resizes this view, that
|
||||
// re-fires onLayout, and onLayout re-enters present(). Full-height sheets don't
|
||||
// need a content measurement, so they can go ahead immediately.
|
||||
//
|
||||
// Only gate when there is a state channel to wait on. Without one (old architecture)
|
||||
// nothing would ever resize this view, and the sheet would never present.
|
||||
if (stateWrapper != null && !fullHeight && contentHeight <= 0f) return
|
||||
|
||||
var activityWindow: Window? = null
|
||||
var currentContext = context
|
||||
while (currentContext != null) {
|
||||
@@ -425,8 +557,10 @@ class BottomSheetView(
|
||||
|
||||
private fun getTargetHeight(): Float {
|
||||
val contentHeight = this.getContentHeight()
|
||||
// maxHeight is stored unclamped, so clamp it against the current screen here
|
||||
val effectiveMaxHeight = minOf(this.maxHeight, this.screenHeight)
|
||||
return when {
|
||||
contentHeight > maxHeight -> maxHeight
|
||||
contentHeight > effectiveMaxHeight -> effectiveMaxHeight
|
||||
contentHeight < minHeight -> minHeight
|
||||
else -> contentHeight
|
||||
}
|
||||
|
||||
@@ -34,10 +34,6 @@ const IS_IOS15 =
|
||||
Platform.OS === 'ios' &&
|
||||
// semvar - can be 3 segments, so can't use Number(Platform.Version)
|
||||
Number(Platform.Version.split('.').at(0)) < 16
|
||||
// older android versions (15 and below) aren't naturally edge-to-edge
|
||||
// and behave a little differently
|
||||
const IS_NON_E2E_ANDROID =
|
||||
Platform.OS === 'android' && Number(Platform.Version) < 35
|
||||
|
||||
export class BottomSheetNativeComponent extends Component<
|
||||
BottomSheetViewProps,
|
||||
@@ -148,24 +144,35 @@ function BottomSheetNativeComponentInner({
|
||||
const {height: screenHeight} = useWindowDimensions()
|
||||
const isHeightConstrained = maxHeight != null || rest.fullHeight === true
|
||||
|
||||
// sigh... on older Android versions, screenHeight does not include safe area insets
|
||||
// on newer Androids + iOS, it does. we need to find the inner bit + the bottom inset
|
||||
// for the sheet content
|
||||
const sheetHeight = IS_NON_E2E_ANDROID
|
||||
? screenHeight + insets.bottom
|
||||
: screenHeight - insets.top
|
||||
|
||||
return (
|
||||
<NativeView
|
||||
{...rest}
|
||||
maxHeight={maxHeight}
|
||||
onStateChange={onStateChange}
|
||||
ref={nativeViewRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
height: sheetHeight,
|
||||
width: '100%',
|
||||
}}
|
||||
/*
|
||||
* On Android the native side owns this view's size - the canvas the sheet
|
||||
* content is laid out on - and pushes it into the Fabric shadow tree through
|
||||
* ExpoView's `setViewSize` state channel. It knows the real sheet frame
|
||||
* (window insets, Material's max-width cap on tablets, rotation), which JS
|
||||
* can only guess at. `width` and `height` must stay unset there:
|
||||
* `ExpoViewComponentDescriptor::adopt()` only applies the state size on an
|
||||
* axis where the style leaves that dimension undefined, so a style dimension
|
||||
* would silently win and clip the content again.
|
||||
*
|
||||
* iOS still sizes the canvas from JS. Moving it onto the same state channel
|
||||
* needs on-device iteration on iOS 26 sheet geometry (large-detent and
|
||||
* floating-card metrics), so it is deferred.
|
||||
*/
|
||||
style={
|
||||
Platform.OS === 'ios'
|
||||
? {
|
||||
position: 'absolute',
|
||||
height: screenHeight - insets.top,
|
||||
width: '100%',
|
||||
}
|
||||
: {position: 'absolute'}
|
||||
}
|
||||
containerBackgroundColor={backgroundColor}>
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'expo-module-gradle-plugin'
|
||||
}
|
||||
|
||||
group = 'expo.modules.blueskyvideocompress'
|
||||
version = '1.0.0'
|
||||
|
||||
android {
|
||||
namespace "expo.modules.blueskyvideocompress"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
}
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
package expo.modules.blueskyvideocompress
|
||||
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaCodecList
|
||||
import android.media.MediaFormat
|
||||
import android.os.Build
|
||||
|
||||
object CodecSelector {
|
||||
// Source: https://github.com/numandev1/react-native-compressor/blob/f949b0868055178e7c8753e05202f784b1bcd589/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt#L500
|
||||
private val AVC_DENYLIST = setOf(
|
||||
"c2.qti.avc.encoder"
|
||||
)
|
||||
|
||||
private val SOFTWARE_PREFIXES = listOf(
|
||||
"OMX.google.",
|
||||
"c2.android.",
|
||||
"c2.google."
|
||||
)
|
||||
|
||||
data class EncoderInfo(
|
||||
val name: String,
|
||||
val mime: String,
|
||||
val isHardware: Boolean
|
||||
)
|
||||
|
||||
fun findEncoder(mime: String, requireHardware: Boolean): EncoderInfo? {
|
||||
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
|
||||
val candidates = codecList.codecInfos
|
||||
.filter { it.isEncoder }
|
||||
.filter { it.supportedTypes.any { t -> t.equals(mime, ignoreCase = true) } }
|
||||
.filter { !it.name.contains("secure", ignoreCase = true) }
|
||||
.filter { !(mime == MediaFormat.MIMETYPE_VIDEO_AVC && AVC_DENYLIST.contains(it.name)) }
|
||||
|
||||
val hardware = candidates.filter { isHardware(it) }
|
||||
val selected = if (requireHardware) {
|
||||
hardware.firstOrNull()
|
||||
} else {
|
||||
hardware.firstOrNull() ?: candidates.firstOrNull()
|
||||
}
|
||||
selected ?: return null
|
||||
return EncoderInfo(
|
||||
name = selected.name,
|
||||
mime = mime,
|
||||
isHardware = isHardware(selected)
|
||||
)
|
||||
}
|
||||
|
||||
private fun isHardware(info: MediaCodecInfo): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
info.isHardwareAccelerated
|
||||
} else {
|
||||
SOFTWARE_PREFIXES.none { info.name.startsWith(it, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
package expo.modules.blueskyvideocompress
|
||||
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class ExpoBlueskyVideoCompressModule : Module() {
|
||||
private val activeCompressors = ConcurrentHashMap<Int, VideoCompressor>()
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("ExpoBlueskyVideoCompress")
|
||||
|
||||
Events("onProgress")
|
||||
|
||||
AsyncFunction("probe") { uri: String ->
|
||||
val context = appContext.reactContext
|
||||
?: throw Error("React context is null")
|
||||
return@AsyncFunction VideoProber.probe(context, uri)
|
||||
}
|
||||
|
||||
AsyncFunction("compress") { uri: String, options: Map<String, Any?> ->
|
||||
val context = appContext.reactContext
|
||||
?: throw Error("React context is null")
|
||||
val targetBitrate = (options["targetBitrate"] as? Number)?.toInt() ?: 0
|
||||
val maxSize = (options["maxSize"] as? Number)?.toInt() ?: 1920
|
||||
val codecPref = (options["codec"] as? String) ?: "auto"
|
||||
val frameRateCap = ((options["frameRateCap"] as? Number)?.toInt() ?: 30).coerceAtLeast(1)
|
||||
val jobId = (options["jobId"] as? Number)?.toInt() ?: 0
|
||||
|
||||
val compressor = VideoCompressor(
|
||||
context = context,
|
||||
uriString = uri,
|
||||
targetBitrate = targetBitrate,
|
||||
maxSize = maxSize,
|
||||
codecPref = codecPref,
|
||||
frameRateCap = frameRateCap,
|
||||
jobId = jobId,
|
||||
onProgress = { id, progress ->
|
||||
sendEvent("onProgress", mapOf(
|
||||
"id" to id,
|
||||
"progress" to progress
|
||||
))
|
||||
}
|
||||
)
|
||||
|
||||
activeCompressors[jobId] = compressor
|
||||
|
||||
try {
|
||||
val result = compressor.compress()
|
||||
activeCompressors.remove(jobId)
|
||||
return@AsyncFunction result
|
||||
} catch (e: Exception) {
|
||||
activeCompressors.remove(jobId)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
Function("cancel") { jobId: Int ->
|
||||
activeCompressors.remove(jobId)?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
package expo.modules.blueskyvideocompress
|
||||
|
||||
import android.opengl.EGL14
|
||||
import android.opengl.EGLConfig
|
||||
import android.opengl.EGLContext
|
||||
import android.opengl.EGLDisplay
|
||||
import android.opengl.EGLExt
|
||||
import android.opengl.EGLSurface
|
||||
import android.view.Surface
|
||||
|
||||
class InputSurface(private val surface: Surface) {
|
||||
private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY
|
||||
private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT
|
||||
private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
|
||||
|
||||
init {
|
||||
eglSetup()
|
||||
}
|
||||
|
||||
private fun eglSetup() {
|
||||
eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
|
||||
if (eglDisplay === EGL14.EGL_NO_DISPLAY) {
|
||||
throw RuntimeException("unable to get EGL14 display")
|
||||
}
|
||||
|
||||
val version = IntArray(2)
|
||||
if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
|
||||
throw RuntimeException("unable to initialize EGL14")
|
||||
}
|
||||
|
||||
val attribList = intArrayOf(
|
||||
EGL14.EGL_RED_SIZE, 8,
|
||||
EGL14.EGL_GREEN_SIZE, 8,
|
||||
EGL14.EGL_BLUE_SIZE, 8,
|
||||
EGL14.EGL_ALPHA_SIZE, 8,
|
||||
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
|
||||
EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT,
|
||||
EGL14.EGL_NONE
|
||||
)
|
||||
val configs = arrayOfNulls<EGLConfig>(1)
|
||||
val numConfigs = IntArray(1)
|
||||
EGL14.eglChooseConfig(eglDisplay, attribList, 0, configs, 0, 1, numConfigs, 0)
|
||||
checkEglError("eglChooseConfig")
|
||||
|
||||
val contextAttribs = intArrayOf(
|
||||
EGL14.EGL_CONTEXT_CLIENT_VERSION, 2,
|
||||
EGL14.EGL_NONE
|
||||
)
|
||||
eglContext = EGL14.eglCreateContext(
|
||||
eglDisplay, configs[0], EGL14.EGL_NO_CONTEXT, contextAttribs, 0
|
||||
)
|
||||
checkEglError("eglCreateContext")
|
||||
|
||||
val surfaceAttribs = intArrayOf(EGL14.EGL_NONE)
|
||||
eglSurface = EGL14.eglCreateWindowSurface(
|
||||
eglDisplay, configs[0], surface, surfaceAttribs, 0
|
||||
)
|
||||
checkEglError("eglCreateWindowSurface")
|
||||
}
|
||||
|
||||
fun makeCurrent() {
|
||||
EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)
|
||||
checkEglError("eglMakeCurrent")
|
||||
}
|
||||
|
||||
fun swapBuffers(): Boolean {
|
||||
return EGL14.eglSwapBuffers(eglDisplay, eglSurface)
|
||||
}
|
||||
|
||||
fun setPresentationTime(nsecs: Long) {
|
||||
EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, nsecs)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
if (eglDisplay !== EGL14.EGL_NO_DISPLAY) {
|
||||
EGL14.eglMakeCurrent(
|
||||
eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT
|
||||
)
|
||||
EGL14.eglDestroySurface(eglDisplay, eglSurface)
|
||||
EGL14.eglDestroyContext(eglDisplay, eglContext)
|
||||
EGL14.eglReleaseThread()
|
||||
EGL14.eglTerminate(eglDisplay)
|
||||
}
|
||||
surface.release()
|
||||
eglDisplay = EGL14.EGL_NO_DISPLAY
|
||||
eglContext = EGL14.EGL_NO_CONTEXT
|
||||
eglSurface = EGL14.EGL_NO_SURFACE
|
||||
}
|
||||
|
||||
private fun checkEglError(msg: String) {
|
||||
val error = EGL14.eglGetError()
|
||||
if (error != EGL14.EGL_SUCCESS) {
|
||||
throw RuntimeException("$msg: EGL error: 0x${Integer.toHexString(error)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
package expo.modules.blueskyvideocompress
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.view.Surface
|
||||
|
||||
class OutputSurface : SurfaceTexture.OnFrameAvailableListener {
|
||||
private val renderer = TextureRenderer()
|
||||
private var surfaceTexture: SurfaceTexture? = null
|
||||
private val stMatrix = FloatArray(16)
|
||||
private val callbackThread = HandlerThread("OutputSurfaceCallbacks")
|
||||
val surface: Surface
|
||||
|
||||
@Volatile
|
||||
private var frameAvailable = false
|
||||
private val frameSyncObject = Object()
|
||||
|
||||
init {
|
||||
renderer.surfaceCreated()
|
||||
callbackThread.start()
|
||||
val handler = Handler(callbackThread.looper)
|
||||
surfaceTexture = SurfaceTexture(renderer.getTextureId()).also {
|
||||
it.setOnFrameAvailableListener(this, handler)
|
||||
}
|
||||
surface = Surface(surfaceTexture)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
surface.release()
|
||||
surfaceTexture?.release()
|
||||
surfaceTexture = null
|
||||
callbackThread.quitSafely()
|
||||
}
|
||||
|
||||
fun awaitNewImage() {
|
||||
val timeoutMs = 2500L
|
||||
synchronized(frameSyncObject) {
|
||||
while (!frameAvailable) {
|
||||
frameSyncObject.wait(timeoutMs)
|
||||
if (!frameAvailable) {
|
||||
throw RuntimeException("Surface frame wait timed out")
|
||||
}
|
||||
}
|
||||
frameAvailable = false
|
||||
}
|
||||
surfaceTexture!!.updateTexImage()
|
||||
}
|
||||
|
||||
fun drawImage() {
|
||||
surfaceTexture!!.getTransformMatrix(stMatrix)
|
||||
renderer.drawFrame(stMatrix)
|
||||
}
|
||||
|
||||
override fun onFrameAvailable(st: SurfaceTexture) {
|
||||
synchronized(frameSyncObject) {
|
||||
frameAvailable = true
|
||||
frameSyncObject.notifyAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
package expo.modules.blueskyvideocompress
|
||||
|
||||
import android.opengl.GLES11Ext
|
||||
import android.opengl.GLES20
|
||||
import android.opengl.Matrix
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.FloatBuffer
|
||||
|
||||
class TextureRenderer {
|
||||
companion object {
|
||||
private const val FLOAT_SIZE_BYTES = 4
|
||||
private const val STRIDE_BYTES = 5 * FLOAT_SIZE_BYTES
|
||||
private const val POS_OFFSET = 0
|
||||
private const val UV_OFFSET = 3
|
||||
|
||||
private val VERTICES = floatArrayOf(
|
||||
-1.0f, -1.0f, 0f, 0f, 0f,
|
||||
1.0f, -1.0f, 0f, 1f, 0f,
|
||||
-1.0f, 1.0f, 0f, 0f, 1f,
|
||||
1.0f, 1.0f, 0f, 1f, 1f,
|
||||
)
|
||||
|
||||
private const val VERTEX_SHADER = """
|
||||
uniform mat4 uMVPMatrix;
|
||||
uniform mat4 uSTMatrix;
|
||||
attribute vec4 aPosition;
|
||||
attribute vec4 aTextureCoord;
|
||||
varying vec2 vTextureCoord;
|
||||
void main() {
|
||||
gl_Position = uMVPMatrix * aPosition;
|
||||
vTextureCoord = (uSTMatrix * aTextureCoord).xy;
|
||||
}
|
||||
"""
|
||||
|
||||
private const val FRAGMENT_SHADER = """
|
||||
#extension GL_OES_EGL_image_external : require
|
||||
precision mediump float;
|
||||
varying vec2 vTextureCoord;
|
||||
uniform samplerExternalOES sTexture;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(sTexture, vTextureCoord);
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
private val vertices: FloatBuffer =
|
||||
ByteBuffer.allocateDirect(VERTICES.size * FLOAT_SIZE_BYTES)
|
||||
.order(ByteOrder.nativeOrder())
|
||||
.asFloatBuffer()
|
||||
.apply { put(VERTICES); position(0) }
|
||||
|
||||
private val mvpMatrix = FloatArray(16)
|
||||
private var program = 0
|
||||
private var textureId = -1
|
||||
private var uMVPMatrixHandle = 0
|
||||
private var uSTMatrixHandle = 0
|
||||
private var aPositionHandle = 0
|
||||
private var aTextureCoordHandle = 0
|
||||
|
||||
init {
|
||||
Matrix.setIdentityM(mvpMatrix, 0)
|
||||
}
|
||||
|
||||
fun getTextureId(): Int = textureId
|
||||
|
||||
fun surfaceCreated() {
|
||||
program = createProgram(VERTEX_SHADER, FRAGMENT_SHADER)
|
||||
|
||||
aPositionHandle = GLES20.glGetAttribLocation(program, "aPosition")
|
||||
aTextureCoordHandle = GLES20.glGetAttribLocation(program, "aTextureCoord")
|
||||
uMVPMatrixHandle = GLES20.glGetUniformLocation(program, "uMVPMatrix")
|
||||
uSTMatrixHandle = GLES20.glGetUniformLocation(program, "uSTMatrix")
|
||||
|
||||
val textures = IntArray(1)
|
||||
GLES20.glGenTextures(1, textures, 0)
|
||||
textureId = textures[0]
|
||||
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
|
||||
GLES20.glTexParameterf(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||
GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR.toFloat()
|
||||
)
|
||||
GLES20.glTexParameterf(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||
GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat()
|
||||
)
|
||||
GLES20.glTexParameteri(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||
GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE
|
||||
)
|
||||
GLES20.glTexParameteri(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||
GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE
|
||||
)
|
||||
}
|
||||
|
||||
fun drawFrame(stMatrix: FloatArray) {
|
||||
GLES20.glClearColor(0f, 0f, 0f, 1f)
|
||||
GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT or GLES20.GL_COLOR_BUFFER_BIT)
|
||||
|
||||
GLES20.glUseProgram(program)
|
||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
|
||||
|
||||
vertices.position(POS_OFFSET)
|
||||
GLES20.glVertexAttribPointer(
|
||||
aPositionHandle, 3, GLES20.GL_FLOAT, false, STRIDE_BYTES, vertices
|
||||
)
|
||||
GLES20.glEnableVertexAttribArray(aPositionHandle)
|
||||
|
||||
vertices.position(UV_OFFSET)
|
||||
GLES20.glVertexAttribPointer(
|
||||
aTextureCoordHandle, 2, GLES20.GL_FLOAT, false, STRIDE_BYTES, vertices
|
||||
)
|
||||
GLES20.glEnableVertexAttribArray(aTextureCoordHandle)
|
||||
|
||||
GLES20.glUniformMatrix4fv(uMVPMatrixHandle, 1, false, mvpMatrix, 0)
|
||||
GLES20.glUniformMatrix4fv(uSTMatrixHandle, 1, false, stMatrix, 0)
|
||||
|
||||
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
||||
GLES20.glDisableVertexAttribArray(aPositionHandle)
|
||||
GLES20.glDisableVertexAttribArray(aTextureCoordHandle)
|
||||
}
|
||||
|
||||
private fun createProgram(vertexSource: String, fragmentSource: String): Int {
|
||||
val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource)
|
||||
val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
|
||||
val program = GLES20.glCreateProgram()
|
||||
GLES20.glAttachShader(program, vertexShader)
|
||||
GLES20.glAttachShader(program, fragmentShader)
|
||||
GLES20.glLinkProgram(program)
|
||||
val linkStatus = IntArray(1)
|
||||
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0)
|
||||
if (linkStatus[0] != GLES20.GL_TRUE) {
|
||||
val log = GLES20.glGetProgramInfoLog(program)
|
||||
GLES20.glDeleteProgram(program)
|
||||
throw RuntimeException("Could not link program: $log")
|
||||
}
|
||||
return program
|
||||
}
|
||||
|
||||
private fun loadShader(type: Int, source: String): Int {
|
||||
val shader = GLES20.glCreateShader(type)
|
||||
GLES20.glShaderSource(shader, source)
|
||||
GLES20.glCompileShader(shader)
|
||||
val compiled = IntArray(1)
|
||||
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0)
|
||||
if (compiled[0] == 0) {
|
||||
val log = GLES20.glGetShaderInfoLog(shader)
|
||||
GLES20.glDeleteShader(shader)
|
||||
throw RuntimeException("Could not compile shader $type: $log")
|
||||
}
|
||||
return shader
|
||||
}
|
||||
}
|
||||
-582
@@ -1,582 +0,0 @@
|
||||
package expo.modules.blueskyvideocompress
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMuxer
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.UUID
|
||||
|
||||
class VideoCompressor(
|
||||
private val context: Context,
|
||||
private val uriString: String,
|
||||
private val targetBitrate: Int,
|
||||
private val maxSize: Int,
|
||||
private val codecPref: String,
|
||||
private val frameRateCap: Int,
|
||||
private val jobId: Int,
|
||||
private val onProgress: (Int, Double) -> Unit
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "BskyVideoCompress"
|
||||
private const val TIMEOUT_DEQUEUE = 100L
|
||||
private const val I_FRAME_INTERVAL = 3
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var isCancelled = false
|
||||
|
||||
fun cancel() {
|
||||
isCancelled = true
|
||||
}
|
||||
|
||||
fun compress(): Map<String, Any> {
|
||||
// 'auto' targets h264 — server pipeline is HLS, which favors h264 (HEVC needs
|
||||
// fMP4 segments + commercial licensing). HEVC remains opt-in via codec: 'hevc'.
|
||||
val tryHevc = codecPref == "hevc"
|
||||
|
||||
if (tryHevc) {
|
||||
try {
|
||||
return doCompress(useHevc = true, allowSoftwareFallback = false)
|
||||
} catch (e: Exception) {
|
||||
if (codecPref == "hevc" || isCancelled) throw e
|
||||
Log.w(TAG, "HEVC encode failed, falling back to h264", e)
|
||||
}
|
||||
}
|
||||
return doCompress(useHevc = false, allowSoftwareFallback = true)
|
||||
}
|
||||
|
||||
private fun doCompress(useHevc: Boolean, allowSoftwareFallback: Boolean): Map<String, Any> {
|
||||
val mime = if (useHevc) MediaFormat.MIMETYPE_VIDEO_HEVC else MediaFormat.MIMETYPE_VIDEO_AVC
|
||||
val encoderInfo = CodecSelector.findEncoder(mime, requireHardware = !allowSoftwareFallback)
|
||||
?: throw RuntimeException("No encoder for $mime")
|
||||
|
||||
try {
|
||||
return runPipeline(encoderInfo, useHevc)
|
||||
} catch (e: Exception) {
|
||||
if (!allowSoftwareFallback || isCancelled || !encoderInfo.isHardware) throw e
|
||||
Log.w(TAG, "Hardware encoder ${encoderInfo.name} failed, trying software", e)
|
||||
val sw = CodecSelector.findEncoder(mime, requireHardware = false)
|
||||
?.takeIf { !it.isHardware }
|
||||
?: throw e
|
||||
return runPipeline(sw, useHevc)
|
||||
}
|
||||
}
|
||||
|
||||
private fun runPipeline(
|
||||
encoderInfo: CodecSelector.EncoderInfo,
|
||||
useHevc: Boolean
|
||||
): Map<String, Any> {
|
||||
val outputFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4")
|
||||
|
||||
var extractor: MediaExtractor? = null
|
||||
var muxer: MediaMuxer? = null
|
||||
var encoder: MediaCodec? = null
|
||||
var decoder: MediaCodec? = null
|
||||
var inputSurface: InputSurface? = null
|
||||
var outputSurface: OutputSurface? = null
|
||||
var muxerStarted = false
|
||||
var outputDims = Pair(0, 0)
|
||||
var durationUs = 0L
|
||||
|
||||
try {
|
||||
val uri = Uri.parse(uriString)
|
||||
extractor = MediaExtractor()
|
||||
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
|
||||
extractor.setDataSource(context, uri, null)
|
||||
} else {
|
||||
extractor.setDataSource(uriString)
|
||||
}
|
||||
|
||||
var videoTrackIndex = -1
|
||||
var audioTrackIndex = -1
|
||||
var videoFormat: MediaFormat? = null
|
||||
var audioFormat: MediaFormat? = null
|
||||
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val trackMime = format.getString(MediaFormat.KEY_MIME) ?: continue
|
||||
if (trackMime.startsWith("video/") && videoTrackIndex == -1) {
|
||||
videoTrackIndex = i
|
||||
videoFormat = format
|
||||
} else if (trackMime.startsWith("audio/") && audioTrackIndex == -1) {
|
||||
audioTrackIndex = i
|
||||
audioFormat = format
|
||||
}
|
||||
}
|
||||
|
||||
if (videoTrackIndex == -1 || videoFormat == null) {
|
||||
throw RuntimeException("No video track found")
|
||||
}
|
||||
|
||||
val sourceWidth = videoFormat.getInteger(MediaFormat.KEY_WIDTH)
|
||||
val sourceHeight = videoFormat.getInteger(MediaFormat.KEY_HEIGHT)
|
||||
val rotation = if (videoFormat.containsKey(MediaFormat.KEY_ROTATION)) {
|
||||
videoFormat.getInteger(MediaFormat.KEY_ROTATION)
|
||||
} else 0
|
||||
durationUs = if (videoFormat.containsKey(MediaFormat.KEY_DURATION)) {
|
||||
videoFormat.getLong(MediaFormat.KEY_DURATION)
|
||||
} else 0L
|
||||
val sourceFps = if (videoFormat.containsKey(MediaFormat.KEY_FRAME_RATE)) {
|
||||
videoFormat.getInteger(MediaFormat.KEY_FRAME_RATE)
|
||||
} else 30
|
||||
|
||||
outputDims = calculateOutputDims(sourceWidth, sourceHeight, rotation, maxSize)
|
||||
val shouldPassthroughAudio = audioFormat != null && canPassthroughAudio(audioFormat)
|
||||
val transcodedAudio: TranscodedAudio? = if (
|
||||
audioTrackIndex >= 0 && audioFormat != null && !shouldPassthroughAudio
|
||||
) {
|
||||
transcodeAudioToAAC(audioTrackIndex, audioFormat)
|
||||
} else null
|
||||
muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
|
||||
val effectiveBitrate = if (targetBitrate > 0) {
|
||||
targetBitrate
|
||||
} else if (useHevc) 2_500_000 else 3_000_000
|
||||
|
||||
val encoderFormat = MediaFormat.createVideoFormat(
|
||||
encoderInfo.mime, outputDims.first, outputDims.second
|
||||
).apply {
|
||||
setInteger(
|
||||
MediaFormat.KEY_COLOR_FORMAT,
|
||||
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
|
||||
)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, effectiveBitrate)
|
||||
setInteger(
|
||||
MediaFormat.KEY_BITRATE_MODE,
|
||||
MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR
|
||||
)
|
||||
setInteger(MediaFormat.KEY_FRAME_RATE, frameRateCap)
|
||||
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT709)
|
||||
setInteger(MediaFormat.KEY_COLOR_TRANSFER, MediaFormat.COLOR_TRANSFER_SDR_VIDEO)
|
||||
setInteger(MediaFormat.KEY_COLOR_RANGE, MediaFormat.COLOR_RANGE_LIMITED)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
setInteger(MediaFormat.KEY_PRIORITY, 0)
|
||||
setInteger(MediaFormat.KEY_OPERATING_RATE, frameRateCap)
|
||||
if (useHevc) {
|
||||
setInteger(
|
||||
MediaFormat.KEY_PROFILE,
|
||||
MediaCodecInfo.CodecProfileLevel.HEVCProfileMain
|
||||
)
|
||||
} else {
|
||||
setInteger(
|
||||
MediaFormat.KEY_PROFILE,
|
||||
MediaCodecInfo.CodecProfileLevel.AVCProfileHigh
|
||||
)
|
||||
setInteger(
|
||||
MediaFormat.KEY_LEVEL,
|
||||
MediaCodecInfo.CodecProfileLevel.AVCLevel41
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
encoder = MediaCodec.createByCodecName(encoderInfo.name)
|
||||
encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
val encoderInputSurface = encoder.createInputSurface()
|
||||
inputSurface = InputSurface(encoderInputSurface)
|
||||
inputSurface.makeCurrent()
|
||||
outputSurface = OutputSurface()
|
||||
encoder.start()
|
||||
|
||||
decoder = MediaCodec.createDecoderByType(
|
||||
videoFormat.getString(MediaFormat.KEY_MIME) ?: "video/avc"
|
||||
)
|
||||
// Ask the decoder to tone-map HDR (HLG/PQ) sources to SDR. Vendors may
|
||||
// ignore the hint, but where supported it produces correct BT.709 pixels
|
||||
// for the encoder rather than HDR pixels mislabeled as SDR.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
videoFormat.setInteger(
|
||||
MediaFormat.KEY_COLOR_TRANSFER_REQUEST,
|
||||
MediaFormat.COLOR_TRANSFER_SDR_VIDEO
|
||||
)
|
||||
}
|
||||
decoder.configure(videoFormat, outputSurface.surface, null, 0)
|
||||
decoder.start()
|
||||
extractor.selectTrack(videoTrackIndex)
|
||||
|
||||
val frameDropEnabled = sourceFps > frameRateCap
|
||||
val targetFrameIntervalUs = if (frameDropEnabled) 1_000_000L / frameRateCap else 0L
|
||||
var nextTargetPtsUs = 0L
|
||||
|
||||
var muxerVideoTrack = -1
|
||||
var muxerAudioTrack = -1
|
||||
|
||||
val bufferInfo = MediaCodec.BufferInfo()
|
||||
var inputDone = false
|
||||
var decoderDone = false
|
||||
var outputDone = false
|
||||
var lastProgressMs = 0L
|
||||
|
||||
while (!outputDone && !isCancelled) {
|
||||
if (!inputDone) {
|
||||
val idx = decoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
|
||||
if (idx >= 0) {
|
||||
val buf = decoder.getInputBuffer(idx)
|
||||
if (buf != null) {
|
||||
val sz = extractor.readSampleData(buf, 0)
|
||||
if (sz < 0) {
|
||||
decoder.queueInputBuffer(
|
||||
idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||
)
|
||||
inputDone = true
|
||||
} else {
|
||||
decoder.queueInputBuffer(idx, 0, sz, extractor.sampleTime, 0)
|
||||
extractor.advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!decoderDone) {
|
||||
val status = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_DEQUEUE)
|
||||
if (status >= 0) {
|
||||
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
val shouldRender = if (isEos) {
|
||||
false
|
||||
} else if (frameDropEnabled) {
|
||||
if (bufferInfo.presentationTimeUs >= nextTargetPtsUs) {
|
||||
nextTargetPtsUs = bufferInfo.presentationTimeUs + targetFrameIntervalUs
|
||||
true
|
||||
} else false
|
||||
} else true
|
||||
decoder.releaseOutputBuffer(status, shouldRender)
|
||||
if (shouldRender) {
|
||||
outputSurface.awaitNewImage()
|
||||
outputSurface.drawImage()
|
||||
inputSurface.setPresentationTime(bufferInfo.presentationTimeUs * 1000)
|
||||
inputSurface.swapBuffers()
|
||||
}
|
||||
if (isEos) {
|
||||
encoder.signalEndOfInputStream()
|
||||
decoderDone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var encoderDrained = false
|
||||
while (!outputDone && !isCancelled && !encoderDrained) {
|
||||
val encIdx = encoder.dequeueOutputBuffer(bufferInfo, 0)
|
||||
when {
|
||||
encIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> encoderDrained = true
|
||||
encIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||
if (!muxerStarted) {
|
||||
muxerVideoTrack = muxer.addTrack(encoder.outputFormat)
|
||||
if (audioTrackIndex >= 0 && audioFormat != null) {
|
||||
if (shouldPassthroughAudio) {
|
||||
muxerAudioTrack = muxer.addTrack(audioFormat)
|
||||
} else if (transcodedAudio != null) {
|
||||
muxerAudioTrack = muxer.addTrack(transcodedAudio.outputFormat)
|
||||
}
|
||||
}
|
||||
muxer.start()
|
||||
muxerStarted = true
|
||||
}
|
||||
}
|
||||
encIdx >= 0 -> {
|
||||
val data = encoder.getOutputBuffer(encIdx)
|
||||
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
|
||||
bufferInfo.size = 0
|
||||
}
|
||||
if (data != null && bufferInfo.size > 0 && muxerStarted) {
|
||||
muxer.writeSampleData(muxerVideoTrack, data, bufferInfo)
|
||||
}
|
||||
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
encoder.releaseOutputBuffer(encIdx, false)
|
||||
if (isEos) {
|
||||
outputDone = true
|
||||
} else if (durationUs > 0) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastProgressMs >= 100) {
|
||||
lastProgressMs = now
|
||||
val p = (bufferInfo.presentationTimeUs.toDouble() / durationUs)
|
||||
.coerceIn(0.0, 1.0)
|
||||
onProgress(jobId, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTrackIndex >= 0 && muxerAudioTrack >= 0 && muxerStarted && !isCancelled) {
|
||||
if (shouldPassthroughAudio) {
|
||||
passthroughAudio(audioTrackIndex, muxer, muxerAudioTrack)
|
||||
} else if (transcodedAudio != null) {
|
||||
writeTranscodedAudio(transcodedAudio.samples, muxer, muxerAudioTrack)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { decoder?.stop() } catch (_: Exception) {}
|
||||
try { decoder?.release() } catch (_: Exception) {}
|
||||
try { encoder?.stop() } catch (_: Exception) {}
|
||||
try { encoder?.release() } catch (_: Exception) {}
|
||||
try { outputSurface?.release() } catch (_: Exception) {}
|
||||
try { inputSurface?.release() } catch (_: Exception) {}
|
||||
try { extractor?.release() } catch (_: Exception) {}
|
||||
try {
|
||||
if (muxerStarted) muxer?.stop()
|
||||
muxer?.release()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
if (isCancelled) {
|
||||
outputFile.delete()
|
||||
throw RuntimeException("Compression cancelled")
|
||||
}
|
||||
|
||||
onProgress(jobId, 1.0)
|
||||
val durationSeconds = durationUs / 1_000_000.0
|
||||
|
||||
return mapOf(
|
||||
"uri" to "file://${outputFile.absolutePath}",
|
||||
"size" to outputFile.length(),
|
||||
"mimeType" to "video/mp4",
|
||||
"width" to outputDims.first,
|
||||
"height" to outputDims.second,
|
||||
"duration" to durationSeconds,
|
||||
"codec" to if (useHevc) "hevc" else "h264"
|
||||
)
|
||||
}
|
||||
|
||||
private fun passthroughAudio(audioTrackIndex: Int, muxer: MediaMuxer, muxerAudioTrack: Int) {
|
||||
val audioExtractor = MediaExtractor()
|
||||
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
|
||||
audioExtractor.setDataSource(context, Uri.parse(uriString), null)
|
||||
} else {
|
||||
audioExtractor.setDataSource(uriString)
|
||||
}
|
||||
audioExtractor.selectTrack(audioTrackIndex)
|
||||
audioExtractor.seekTo(0, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
|
||||
|
||||
val buffer = ByteBuffer.allocate(256 * 1024)
|
||||
val info = MediaCodec.BufferInfo()
|
||||
try {
|
||||
while (!isCancelled) {
|
||||
val sz = audioExtractor.readSampleData(buffer, 0)
|
||||
if (sz < 0) break
|
||||
info.offset = 0
|
||||
info.size = sz
|
||||
info.presentationTimeUs = audioExtractor.sampleTime
|
||||
info.flags = audioExtractor.sampleFlags
|
||||
muxer.writeSampleData(muxerAudioTrack, buffer, info)
|
||||
audioExtractor.advance()
|
||||
}
|
||||
} finally {
|
||||
audioExtractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun canPassthroughAudio(format: MediaFormat): Boolean {
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: return false
|
||||
return mime == MediaFormat.MIMETYPE_AUDIO_AAC
|
||||
}
|
||||
|
||||
private data class TranscodedAudio(
|
||||
val outputFormat: MediaFormat,
|
||||
val samples: List<Sample>
|
||||
) {
|
||||
data class Sample(
|
||||
val bytes: ByteArray,
|
||||
val presentationTimeUs: Long,
|
||||
val flags: Int
|
||||
)
|
||||
}
|
||||
|
||||
// Re-encode non-AAC source audio (Opus, Vorbis, etc.) to AAC so the mp4 muxer
|
||||
// can take it. iOS always re-encodes to AAC; without this Android would drop
|
||||
// the audio track entirely.
|
||||
private fun transcodeAudioToAAC(
|
||||
audioTrackIndex: Int,
|
||||
sourceFormat: MediaFormat
|
||||
): TranscodedAudio? {
|
||||
val sourceMime = sourceFormat.getString(MediaFormat.KEY_MIME) ?: return null
|
||||
val sampleRate = if (sourceFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE))
|
||||
sourceFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) else 44100
|
||||
val channelCount = if (sourceFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT))
|
||||
sourceFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT).coerceIn(1, 2) else 2
|
||||
|
||||
val audioExtractor = MediaExtractor()
|
||||
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
|
||||
audioExtractor.setDataSource(context, Uri.parse(uriString), null)
|
||||
} else {
|
||||
audioExtractor.setDataSource(uriString)
|
||||
}
|
||||
audioExtractor.selectTrack(audioTrackIndex)
|
||||
|
||||
var decoder: MediaCodec? = null
|
||||
var encoder: MediaCodec? = null
|
||||
try {
|
||||
decoder = MediaCodec.createDecoderByType(sourceMime)
|
||||
decoder.configure(sourceFormat, null, null, 0)
|
||||
decoder.start()
|
||||
|
||||
val encoderFormat = MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channelCount
|
||||
).apply {
|
||||
setInteger(
|
||||
MediaFormat.KEY_AAC_PROFILE,
|
||||
MediaCodecInfo.CodecProfileLevel.AACObjectLC
|
||||
)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, 128_000)
|
||||
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 256 * 1024)
|
||||
}
|
||||
encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
|
||||
encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
encoder.start()
|
||||
|
||||
val samples = mutableListOf<TranscodedAudio.Sample>()
|
||||
var outputFormat: MediaFormat? = null
|
||||
var inputDone = false
|
||||
var decoderDone = false
|
||||
var encoderInputSignalled = false
|
||||
var encoderDone = false
|
||||
val info = MediaCodec.BufferInfo()
|
||||
|
||||
while (!encoderDone && !isCancelled) {
|
||||
if (!inputDone) {
|
||||
val idx = decoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
|
||||
if (idx >= 0) {
|
||||
val buf = decoder.getInputBuffer(idx)
|
||||
if (buf != null) {
|
||||
val sz = audioExtractor.readSampleData(buf, 0)
|
||||
if (sz < 0) {
|
||||
decoder.queueInputBuffer(
|
||||
idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||
)
|
||||
inputDone = true
|
||||
} else {
|
||||
decoder.queueInputBuffer(
|
||||
idx, 0, sz, audioExtractor.sampleTime, audioExtractor.sampleFlags
|
||||
)
|
||||
audioExtractor.advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!decoderDone) {
|
||||
val status = decoder.dequeueOutputBuffer(info, TIMEOUT_DEQUEUE)
|
||||
when {
|
||||
status == MediaCodec.INFO_TRY_AGAIN_LATER -> {}
|
||||
status == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {}
|
||||
status >= 0 -> {
|
||||
val isEos = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
val data = decoder.getOutputBuffer(status)
|
||||
if (data != null && info.size > 0) {
|
||||
val encInIdx = encoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
|
||||
if (encInIdx >= 0) {
|
||||
val encInBuf = encoder.getInputBuffer(encInIdx)
|
||||
if (encInBuf != null) {
|
||||
encInBuf.clear()
|
||||
data.position(info.offset)
|
||||
data.limit(info.offset + info.size)
|
||||
encInBuf.put(data)
|
||||
encoder.queueInputBuffer(
|
||||
encInIdx, 0, info.size, info.presentationTimeUs, 0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
decoder.releaseOutputBuffer(status, false)
|
||||
if (isEos) {
|
||||
if (!encoderInputSignalled) {
|
||||
val encInIdx = encoder.dequeueInputBuffer(TIMEOUT_DEQUEUE * 10)
|
||||
if (encInIdx >= 0) {
|
||||
encoder.queueInputBuffer(
|
||||
encInIdx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||
)
|
||||
encoderInputSignalled = true
|
||||
}
|
||||
}
|
||||
decoderDone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val encOutIdx = encoder.dequeueOutputBuffer(info, TIMEOUT_DEQUEUE)
|
||||
when {
|
||||
encOutIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> {}
|
||||
encOutIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||
outputFormat = encoder.outputFormat
|
||||
}
|
||||
encOutIdx >= 0 -> {
|
||||
val data = encoder.getOutputBuffer(encOutIdx)
|
||||
val isEos = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
val isConfig = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0
|
||||
if (data != null && info.size > 0 && !isConfig) {
|
||||
val bytes = ByteArray(info.size)
|
||||
data.position(info.offset)
|
||||
data.get(bytes, 0, info.size)
|
||||
samples.add(
|
||||
TranscodedAudio.Sample(
|
||||
bytes = bytes,
|
||||
presentationTimeUs = info.presentationTimeUs,
|
||||
flags = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG.inv()
|
||||
)
|
||||
)
|
||||
}
|
||||
encoder.releaseOutputBuffer(encOutIdx, false)
|
||||
if (isEos) encoderDone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val fmt = outputFormat ?: return null
|
||||
return TranscodedAudio(fmt, samples)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Audio transcode failed; dropping audio", e)
|
||||
return null
|
||||
} finally {
|
||||
try { decoder?.stop() } catch (_: Exception) {}
|
||||
try { decoder?.release() } catch (_: Exception) {}
|
||||
try { encoder?.stop() } catch (_: Exception) {}
|
||||
try { encoder?.release() } catch (_: Exception) {}
|
||||
audioExtractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeTranscodedAudio(
|
||||
samples: List<TranscodedAudio.Sample>,
|
||||
muxer: MediaMuxer,
|
||||
muxerAudioTrack: Int
|
||||
) {
|
||||
val info = MediaCodec.BufferInfo()
|
||||
for (sample in samples) {
|
||||
if (isCancelled) break
|
||||
val buffer = ByteBuffer.wrap(sample.bytes)
|
||||
info.offset = 0
|
||||
info.size = sample.bytes.size
|
||||
info.presentationTimeUs = sample.presentationTimeUs
|
||||
info.flags = sample.flags
|
||||
muxer.writeSampleData(muxerAudioTrack, buffer, info)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateOutputDims(srcW: Int, srcH: Int, rotation: Int, maxSize: Int): Pair<Int, Int> {
|
||||
val isRotated = rotation == 90 || rotation == 270
|
||||
val displayW = if (isRotated) srcH else srcW
|
||||
val displayH = if (isRotated) srcW else srcH
|
||||
val scale: Float = when {
|
||||
displayW <= maxSize && displayH <= maxSize -> 1.0f
|
||||
displayW > displayH -> maxSize.toFloat() / displayW
|
||||
else -> maxSize.toFloat() / displayH
|
||||
}
|
||||
return Pair(
|
||||
roundToEven((displayW * scale).toInt()),
|
||||
roundToEven((displayH * scale).toInt())
|
||||
)
|
||||
}
|
||||
|
||||
private fun roundToEven(v: Int): Int = if (v % 2 == 0) v else v - 1
|
||||
}
|
||||
-125
@@ -1,125 +0,0 @@
|
||||
package expo.modules.blueskyvideocompress
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
|
||||
object VideoProber {
|
||||
fun probe(context: Context, uriString: String): Map<String, Any> {
|
||||
val uri = Uri.parse(uriString)
|
||||
val retriever = MediaMetadataRetriever()
|
||||
|
||||
try {
|
||||
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
|
||||
retriever.setDataSource(context, uri)
|
||||
} else {
|
||||
retriever.setDataSource(uriString)
|
||||
}
|
||||
|
||||
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
|
||||
?.toIntOrNull() ?: 0
|
||||
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
|
||||
?.toIntOrNull() ?: 0
|
||||
val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||
?.toLongOrNull() ?: 0L
|
||||
val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
|
||||
?.toIntOrNull() ?: 0
|
||||
val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
|
||||
?.toIntOrNull() ?: 0
|
||||
val hasAudio = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO)
|
||||
?.equals("yes") ?: false
|
||||
val frameRate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)
|
||||
?.toFloatOrNull() ?: 0f
|
||||
|
||||
val fileSize = getFileSize(context, uriString)
|
||||
|
||||
val extractor = MediaExtractor()
|
||||
var codec = "unknown"
|
||||
var mimeType = "video/mp4"
|
||||
var extractedFrameRate = frameRate
|
||||
var isHDR = false
|
||||
|
||||
try {
|
||||
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
|
||||
extractor.setDataSource(context, uri, null)
|
||||
} else {
|
||||
extractor.setDataSource(uriString)
|
||||
}
|
||||
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME)
|
||||
if (mime?.startsWith("video/") == true) {
|
||||
mimeType = mime
|
||||
codec = mime.removePrefix("video/")
|
||||
if (format.containsKey(MediaFormat.KEY_FRAME_RATE)) {
|
||||
extractedFrameRate = format.getInteger(MediaFormat.KEY_FRAME_RATE).toFloat()
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||
format.containsKey(MediaFormat.KEY_COLOR_TRANSFER)
|
||||
) {
|
||||
val transfer = format.getInteger(MediaFormat.KEY_COLOR_TRANSFER)
|
||||
isHDR = transfer == MediaFormat.COLOR_TRANSFER_HLG ||
|
||||
transfer == MediaFormat.COLOR_TRANSFER_ST2084
|
||||
}
|
||||
// Dolby Vision tracks use codec-specific mimes that aren't covered by
|
||||
// KEY_COLOR_TRANSFER on every device.
|
||||
if (mime.contains("dolby-vision", ignoreCase = true)) {
|
||||
isHDR = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
extractor.release()
|
||||
}
|
||||
|
||||
val durationSeconds = durationMs / 1000.0
|
||||
val effectiveBitrate = if (bitrate > 0) {
|
||||
bitrate
|
||||
} else if (durationSeconds > 0 && fileSize > 0) {
|
||||
(fileSize * 8 / durationSeconds).toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
return mapOf(
|
||||
"width" to width,
|
||||
"height" to height,
|
||||
"duration" to durationSeconds,
|
||||
"bitrate" to effectiveBitrate,
|
||||
"fileSize" to fileSize,
|
||||
"mimeType" to mimeType,
|
||||
"codec" to codec,
|
||||
"hasAudio" to hasAudio,
|
||||
"frameRate" to extractedFrameRate.toDouble(),
|
||||
"rotation" to rotation,
|
||||
"isHDR" to isHDR
|
||||
)
|
||||
} finally {
|
||||
retriever.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFileSize(context: Context, uriString: String): Long {
|
||||
return try {
|
||||
if (uriString.startsWith("content://")) {
|
||||
context.contentResolver.openFileDescriptor(Uri.parse(uriString), "r")?.use {
|
||||
it.statSize
|
||||
} ?: 0L
|
||||
} else {
|
||||
val path = if (uriString.startsWith("file://")) {
|
||||
uriString.removePrefix("file://")
|
||||
} else {
|
||||
uriString
|
||||
}
|
||||
java.io.File(path).length()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"platforms": ["ios", "android"],
|
||||
"ios": {
|
||||
"modules": ["ExpoBlueskyVideoCompressModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["expo.modules.blueskyvideocompress.ExpoBlueskyVideoCompressModule"]
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import {type EventSubscription} from 'expo-modules-core'
|
||||
|
||||
import NativeModule from './src/ExpoBlueskyVideoCompressModule'
|
||||
import {
|
||||
type CodecPreference,
|
||||
type CompressCallbacks,
|
||||
type CompressOptions,
|
||||
type CompressResult,
|
||||
type VideoMetadata,
|
||||
} from './src/types'
|
||||
|
||||
export type {
|
||||
CodecPreference,
|
||||
CompressCallbacks,
|
||||
CompressOptions,
|
||||
CompressResult,
|
||||
VideoMetadata,
|
||||
}
|
||||
|
||||
class AbortError extends Error {
|
||||
name = 'AbortError'
|
||||
constructor() {
|
||||
super('Aborted')
|
||||
}
|
||||
}
|
||||
|
||||
let jobIdCounter = 0
|
||||
|
||||
export function probe(uri: string): Promise<VideoMetadata> {
|
||||
return NativeModule.probe(uri)
|
||||
}
|
||||
|
||||
export function compress(
|
||||
uri: string,
|
||||
options: CompressOptions = {},
|
||||
callbacks?: CompressCallbacks,
|
||||
): Promise<CompressResult> {
|
||||
const jobId = ++jobIdCounter
|
||||
let subscription: EventSubscription | undefined
|
||||
|
||||
if (callbacks?.signal?.aborted) {
|
||||
return Promise.reject(new AbortError())
|
||||
}
|
||||
|
||||
const nativeOptions = {
|
||||
targetBitrate: options.targetBitrate ?? 0,
|
||||
maxSize: options.maxSize ?? 1920,
|
||||
codec: options.codec ?? 'auto',
|
||||
frameRateCap: options.frameRateCap ?? 30,
|
||||
jobId,
|
||||
}
|
||||
|
||||
return new Promise<CompressResult>((resolve, reject) => {
|
||||
if (callbacks?.onProgress) {
|
||||
subscription = NativeModule.addListener(
|
||||
'onProgress',
|
||||
(event: {id: number; progress: number}) => {
|
||||
if (event.id === jobId) {
|
||||
callbacks.onProgress!(event.progress)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const abortHandler = () => {
|
||||
NativeModule.cancel(jobId)
|
||||
subscription?.remove()
|
||||
reject(new AbortError())
|
||||
}
|
||||
|
||||
if (callbacks?.signal) {
|
||||
callbacks.signal.addEventListener('abort', abortHandler, {once: true})
|
||||
}
|
||||
|
||||
NativeModule.compress(uri, nativeOptions)
|
||||
.then(result => {
|
||||
callbacks?.signal?.removeEventListener('abort', abortHandler)
|
||||
subscription?.remove()
|
||||
resolve(result)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
callbacks?.signal?.removeEventListener('abort', abortHandler)
|
||||
subscription?.remove()
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import VideoToolbox
|
||||
|
||||
enum CodecCapability {
|
||||
static let isHardwareHEVCEncodeAvailable: Bool = {
|
||||
var encoderListCF: CFArray?
|
||||
let status = VTCopyVideoEncoderList(nil, &encoderListCF)
|
||||
guard status == noErr, let encoderList = encoderListCF as? [[String: Any]] else {
|
||||
return false
|
||||
}
|
||||
return encoderList.contains { encoder in
|
||||
guard let codecTypeValue = encoder[kVTVideoEncoderList_CodecType as String] as? Int,
|
||||
codecTypeValue == Int(kCMVideoCodecType_HEVC) else {
|
||||
return false
|
||||
}
|
||||
if let isHardware = encoder[kVTVideoEncoderList_IsHardwareAccelerated as String] as? Bool {
|
||||
return isHardware
|
||||
}
|
||||
return true
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ExpoBlueskyVideoCompress'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Hardware-accelerated video compression for Bluesky'
|
||||
s.description = 'Hardware-accelerated h264/HEVC video compression using AVAssetReader/Writer and VideoToolbox on iOS'
|
||||
s.author = ''
|
||||
s.homepage = 'https://github.com/bluesky-social/social-app'
|
||||
s.platforms = { :ios => '15.1' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
@@ -1,74 +0,0 @@
|
||||
import AVFoundation
|
||||
import ExpoModulesCore
|
||||
|
||||
public class ExpoBlueskyVideoCompressModule: Module {
|
||||
private var activeCompressors: [Int: VideoCompressor] = [:]
|
||||
private let activeCompressorsLock = NSLock()
|
||||
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoBlueskyVideoCompress")
|
||||
|
||||
Events("onProgress")
|
||||
|
||||
AsyncFunction("probe") { (uri: String) -> [String: Any] in
|
||||
let url = URL(string: uri) ?? URL(fileURLWithPath: uri)
|
||||
return try await VideoProber.probe(url: url)
|
||||
}
|
||||
|
||||
AsyncFunction("compress") { (uri: String, options: [String: Any]) -> [String: Any] in
|
||||
let url = URL(string: uri) ?? URL(fileURLWithPath: uri)
|
||||
let targetBitrate = options["targetBitrate"] as? Int ?? 0
|
||||
let maxSize = options["maxSize"] as? Int ?? 1920
|
||||
let codecPref = options["codec"] as? String ?? "auto"
|
||||
let frameRateCap = max(1, options["frameRateCap"] as? Int ?? 30)
|
||||
let jobId = options["jobId"] as? Int ?? 0
|
||||
|
||||
let compressor = VideoCompressor(
|
||||
url: url,
|
||||
targetBitrate: targetBitrate,
|
||||
maxSize: maxSize,
|
||||
codecPref: codecPref,
|
||||
frameRateCap: frameRateCap,
|
||||
jobId: jobId,
|
||||
onProgress: { [weak self] id, progress in
|
||||
self?.sendEvent("onProgress", [
|
||||
"id": id,
|
||||
"progress": progress,
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
self.setCompressor(jobId, compressor)
|
||||
|
||||
do {
|
||||
let result = try await compressor.compress()
|
||||
self.setCompressor(jobId, nil)
|
||||
return result
|
||||
} catch {
|
||||
self.setCompressor(jobId, nil)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
Function("cancel") { (jobId: Int) in
|
||||
self.cancelCompressor(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
private func setCompressor(_ jobId: Int, _ compressor: VideoCompressor?) {
|
||||
activeCompressorsLock.lock()
|
||||
defer { activeCompressorsLock.unlock() }
|
||||
if let compressor = compressor {
|
||||
activeCompressors[jobId] = compressor
|
||||
} else {
|
||||
activeCompressors.removeValue(forKey: jobId)
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelCompressor(_ jobId: Int) {
|
||||
activeCompressorsLock.lock()
|
||||
let compressor = activeCompressors.removeValue(forKey: jobId)
|
||||
activeCompressorsLock.unlock()
|
||||
compressor?.cancel()
|
||||
}
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
import AVFoundation
|
||||
import VideoToolbox
|
||||
|
||||
class VideoCompressor {
|
||||
private let url: URL
|
||||
private let targetBitrate: Int
|
||||
private let maxSize: Int
|
||||
private let codecPref: String
|
||||
private let frameRateCap: Int
|
||||
private let jobId: Int
|
||||
private let onProgress: (Int, Double) -> Void
|
||||
private var isCancelled = false
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
targetBitrate: Int,
|
||||
maxSize: Int,
|
||||
codecPref: String,
|
||||
frameRateCap: Int,
|
||||
jobId: Int,
|
||||
onProgress: @escaping (Int, Double) -> Void
|
||||
) {
|
||||
self.url = url
|
||||
self.targetBitrate = targetBitrate
|
||||
self.maxSize = maxSize
|
||||
self.codecPref = codecPref
|
||||
self.frameRateCap = frameRateCap
|
||||
self.jobId = jobId
|
||||
self.onProgress = onProgress
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
isCancelled = true
|
||||
}
|
||||
|
||||
func compress() async throws -> [String: Any] {
|
||||
let asset = AVURLAsset(
|
||||
url: url,
|
||||
options: [AVURLAssetPreferPreciseDurationAndTimingKey: true]
|
||||
)
|
||||
let duration = try await asset.load(.duration)
|
||||
let totalSeconds = CMTimeGetSeconds(duration)
|
||||
guard totalSeconds > 0 else { throw err("Invalid video duration", code: 2) }
|
||||
|
||||
let videoTracks = try await asset.loadTracks(withMediaType: .video)
|
||||
guard let videoTrack = videoTracks.first else {
|
||||
throw err("No video track found", code: 1)
|
||||
}
|
||||
|
||||
let naturalSize = try await videoTrack.load(.naturalSize)
|
||||
let preferredTransform = try await videoTrack.load(.preferredTransform)
|
||||
|
||||
let rotatedRect = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform)
|
||||
let displaySize = CGSize(
|
||||
width: abs(rotatedRect.width),
|
||||
height: abs(rotatedRect.height)
|
||||
)
|
||||
let outputSize = scaleEvenly(displaySize: displaySize, maxSize: maxSize)
|
||||
|
||||
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
|
||||
|
||||
// 'auto' targets h264 — server pipeline is HLS, which favors h264 (HEVC needs
|
||||
// fMP4 segments + commercial licensing). HEVC remains opt-in via codec: 'hevc'.
|
||||
let useHEVC: Bool
|
||||
switch codecPref {
|
||||
case "hevc": useHEVC = true
|
||||
default: useHEVC = false
|
||||
}
|
||||
let codecType: AVVideoCodecType = useHEVC ? .hevc : .h264
|
||||
let profileLevel: String = useHEVC
|
||||
? kVTProfileLevel_HEVC_Main_AutoLevel as String
|
||||
: kVTProfileLevel_H264_High_AutoLevel as String
|
||||
|
||||
let effectiveBitrate = targetBitrate > 0
|
||||
? targetBitrate
|
||||
: (useHEVC ? 2_500_000 : 3_000_000)
|
||||
|
||||
let outputURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension("mp4")
|
||||
|
||||
let videoComposition = makeRotatingComposition(
|
||||
videoTrack: videoTrack,
|
||||
preferredTransform: preferredTransform,
|
||||
naturalSize: naturalSize,
|
||||
outputSize: outputSize,
|
||||
duration: duration
|
||||
)
|
||||
|
||||
let reader = try AVAssetReader(asset: asset)
|
||||
|
||||
let videoReaderSettings: [String: Any] = [
|
||||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
|
||||
]
|
||||
let videoReaderOutput = AVAssetReaderVideoCompositionOutput(
|
||||
videoTracks: [videoTrack],
|
||||
videoSettings: videoReaderSettings
|
||||
)
|
||||
videoReaderOutput.videoComposition = videoComposition
|
||||
videoReaderOutput.alwaysCopiesSampleData = false
|
||||
guard reader.canAdd(videoReaderOutput) else {
|
||||
throw err("Cannot read video track", code: 3)
|
||||
}
|
||||
reader.add(videoReaderOutput)
|
||||
|
||||
var compressionProps: [String: Any] = [
|
||||
AVVideoAverageBitRateKey: effectiveBitrate,
|
||||
AVVideoProfileLevelKey: profileLevel,
|
||||
AVVideoMaxKeyFrameIntervalKey: max(frameRateCap * 3, 30),
|
||||
AVVideoExpectedSourceFrameRateKey: frameRateCap,
|
||||
AVVideoAllowFrameReorderingKey: false,
|
||||
kVTCompressionPropertyKey_RealTime as String: true,
|
||||
]
|
||||
let peakBytesPerSecond = Int(Double(effectiveBitrate) / 8.0 * 1.5)
|
||||
compressionProps[kVTCompressionPropertyKey_DataRateLimits as String] = [
|
||||
peakBytesPerSecond, 1.0
|
||||
] as CFArray
|
||||
|
||||
let videoColorProps: [String: Any] = [
|
||||
AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2,
|
||||
AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2,
|
||||
AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2,
|
||||
]
|
||||
|
||||
let videoWriterSettings: [String: Any] = [
|
||||
AVVideoCodecKey: codecType,
|
||||
AVVideoWidthKey: outputSize.width,
|
||||
AVVideoHeightKey: outputSize.height,
|
||||
AVVideoColorPropertiesKey: videoColorProps,
|
||||
AVVideoCompressionPropertiesKey: compressionProps,
|
||||
]
|
||||
let videoWriterInput = AVAssetWriterInput(
|
||||
mediaType: .video,
|
||||
outputSettings: videoWriterSettings
|
||||
)
|
||||
videoWriterInput.expectsMediaDataInRealTime = false
|
||||
|
||||
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
|
||||
writer.shouldOptimizeForNetworkUse = true
|
||||
writer.metadata = []
|
||||
guard writer.canAdd(videoWriterInput) else {
|
||||
throw err("Cannot write video track", code: 4)
|
||||
}
|
||||
writer.add(videoWriterInput)
|
||||
|
||||
var audioReaderOutput: AVAssetReaderTrackOutput?
|
||||
var audioWriterInput: AVAssetWriterInput?
|
||||
if let audioTrack = audioTracks.first {
|
||||
let audioDecoderSettings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatLinearPCM,
|
||||
AVLinearPCMBitDepthKey: 16,
|
||||
AVLinearPCMIsFloatKey: false,
|
||||
AVLinearPCMIsBigEndianKey: false,
|
||||
AVLinearPCMIsNonInterleaved: false,
|
||||
]
|
||||
let audioOutput = AVAssetReaderTrackOutput(
|
||||
track: audioTrack,
|
||||
outputSettings: audioDecoderSettings
|
||||
)
|
||||
audioOutput.alwaysCopiesSampleData = false
|
||||
if reader.canAdd(audioOutput) {
|
||||
reader.add(audioOutput)
|
||||
audioReaderOutput = audioOutput
|
||||
|
||||
let audioEncoderSettings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 44100,
|
||||
AVNumberOfChannelsKey: 2,
|
||||
AVEncoderBitRateKey: 128_000,
|
||||
]
|
||||
let audioInput = AVAssetWriterInput(
|
||||
mediaType: .audio,
|
||||
outputSettings: audioEncoderSettings
|
||||
)
|
||||
audioInput.expectsMediaDataInRealTime = false
|
||||
if writer.canAdd(audioInput) {
|
||||
writer.add(audioInput)
|
||||
audioWriterInput = audioInput
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard reader.startReading() else {
|
||||
throw reader.error ?? err("Reader failed to start", code: 8)
|
||||
}
|
||||
guard writer.startWriting() else {
|
||||
throw writer.error ?? err("Writer failed to start", code: 9)
|
||||
}
|
||||
writer.startSession(atSourceTime: .zero)
|
||||
|
||||
let minFrameIntervalSeconds = 1.0 / Double(frameRateCap)
|
||||
let minFrameInterval = CMTime(
|
||||
seconds: minFrameIntervalSeconds,
|
||||
preferredTimescale: 600
|
||||
)
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
group.addTask { [self] in
|
||||
await processVideoTrack(
|
||||
readerOutput: videoReaderOutput,
|
||||
writerInput: videoWriterInput,
|
||||
totalDuration: totalSeconds,
|
||||
minFrameInterval: minFrameInterval
|
||||
)
|
||||
}
|
||||
if let audioOutput = audioReaderOutput, let audioInput = audioWriterInput {
|
||||
group.addTask { [self] in
|
||||
await processAudioTrack(
|
||||
readerOutput: audioOutput,
|
||||
writerInput: audioInput
|
||||
)
|
||||
}
|
||||
}
|
||||
await group.waitForAll()
|
||||
}
|
||||
|
||||
if isCancelled {
|
||||
writer.cancelWriting()
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
throw err("Compression cancelled", code: 5)
|
||||
}
|
||||
|
||||
if reader.status == .failed {
|
||||
let error = reader.error ?? err("Reader failed", code: 6)
|
||||
writer.cancelWriting()
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
throw error
|
||||
}
|
||||
|
||||
await writer.finishWriting()
|
||||
|
||||
if writer.status == .failed {
|
||||
let error = writer.error ?? err("Writer failed", code: 7)
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
throw error
|
||||
}
|
||||
|
||||
onProgress(jobId, 1.0)
|
||||
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: outputURL.path)
|
||||
let fileSize = attributes[.size] as? Int ?? 0
|
||||
|
||||
return [
|
||||
"uri": outputURL.absoluteString,
|
||||
"size": fileSize,
|
||||
"mimeType": "video/mp4",
|
||||
"width": outputSize.width,
|
||||
"height": outputSize.height,
|
||||
"duration": totalSeconds,
|
||||
"codec": useHEVC ? "hevc" : "h264",
|
||||
]
|
||||
}
|
||||
|
||||
private func processVideoTrack(
|
||||
readerOutput: AVAssetReaderOutput,
|
||||
writerInput: AVAssetWriterInput,
|
||||
totalDuration: Double,
|
||||
minFrameInterval: CMTime
|
||||
) async {
|
||||
var lastProgressTime: CFAbsoluteTime = 0
|
||||
var lastAppendedPTS: CMTime?
|
||||
var finished = false
|
||||
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
writerInput.requestMediaDataWhenReady(
|
||||
on: DispatchQueue(label: "com.bsky.videocompress.video")
|
||||
) {
|
||||
let finish = {
|
||||
if !finished {
|
||||
finished = true
|
||||
writerInput.markAsFinished()
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
while writerInput.isReadyForMoreMediaData {
|
||||
if finished { return }
|
||||
if self.isCancelled {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
||||
if let last = lastAppendedPTS {
|
||||
let delta = CMTimeSubtract(pts, last)
|
||||
if CMTimeCompare(delta, minFrameInterval) < 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
lastAppendedPTS = pts
|
||||
|
||||
if !writerInput.append(sampleBuffer) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
if now - lastProgressTime >= 0.1 {
|
||||
lastProgressTime = now
|
||||
let progress = min(CMTimeGetSeconds(pts) / totalDuration, 1.0)
|
||||
self.onProgress(self.jobId, progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processAudioTrack(
|
||||
readerOutput: AVAssetReaderOutput,
|
||||
writerInput: AVAssetWriterInput
|
||||
) async {
|
||||
var finished = false
|
||||
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
writerInput.requestMediaDataWhenReady(
|
||||
on: DispatchQueue(label: "com.bsky.videocompress.audio")
|
||||
) {
|
||||
let finish = {
|
||||
if !finished {
|
||||
finished = true
|
||||
writerInput.markAsFinished()
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
while writerInput.isReadyForMoreMediaData {
|
||||
if finished { return }
|
||||
if self.isCancelled {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if !writerInput.append(sampleBuffer) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeRotatingComposition(
|
||||
videoTrack: AVAssetTrack,
|
||||
preferredTransform: CGAffineTransform,
|
||||
naturalSize: CGSize,
|
||||
outputSize: (width: Int, height: Int),
|
||||
duration: CMTime
|
||||
) -> AVMutableVideoComposition {
|
||||
let composition = AVMutableVideoComposition()
|
||||
composition.renderSize = CGSize(width: outputSize.width, height: outputSize.height)
|
||||
composition.frameDuration = CMTime(value: 1, timescale: Int32(frameRateCap))
|
||||
|
||||
let rotatedRect = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform)
|
||||
let translate = CGAffineTransform(
|
||||
translationX: -rotatedRect.minX,
|
||||
y: -rotatedRect.minY
|
||||
)
|
||||
let displaySize = CGSize(
|
||||
width: abs(rotatedRect.width),
|
||||
height: abs(rotatedRect.height)
|
||||
)
|
||||
let scaleX = CGFloat(outputSize.width) / displaySize.width
|
||||
let scaleY = CGFloat(outputSize.height) / displaySize.height
|
||||
let scale = CGAffineTransform(scaleX: scaleX, y: scaleY)
|
||||
let combined = preferredTransform.concatenating(translate).concatenating(scale)
|
||||
|
||||
let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack)
|
||||
layerInstruction.setTransform(combined, at: .zero)
|
||||
|
||||
let instruction = AVMutableVideoCompositionInstruction()
|
||||
instruction.timeRange = CMTimeRange(start: .zero, duration: duration)
|
||||
instruction.layerInstructions = [layerInstruction]
|
||||
|
||||
composition.instructions = [instruction]
|
||||
return composition
|
||||
}
|
||||
|
||||
private func scaleEvenly(displaySize: CGSize, maxSize: Int) -> (width: Int, height: Int) {
|
||||
let cap = CGFloat(maxSize)
|
||||
let scale: CGFloat
|
||||
if displaySize.width <= cap && displaySize.height <= cap {
|
||||
scale = 1.0
|
||||
} else if displaySize.width > displaySize.height {
|
||||
scale = cap / displaySize.width
|
||||
} else {
|
||||
scale = cap / displaySize.height
|
||||
}
|
||||
return (
|
||||
roundToEven(Int(displaySize.width * scale)),
|
||||
roundToEven(Int(displaySize.height * scale))
|
||||
)
|
||||
}
|
||||
|
||||
private func roundToEven(_ value: Int) -> Int {
|
||||
return value % 2 == 0 ? value : value - 1
|
||||
}
|
||||
|
||||
private func err(_ message: String, code: Int) -> NSError {
|
||||
return NSError(
|
||||
domain: "ExpoBlueskyVideoCompress",
|
||||
code: code,
|
||||
userInfo: [NSLocalizedDescriptionKey: message]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import AVFoundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct VideoProber {
|
||||
static func probe(url: URL) async throws -> [String: Any] {
|
||||
let asset = AVURLAsset(url: url)
|
||||
|
||||
let duration = try await asset.load(.duration)
|
||||
let tracks = try await asset.loadTracks(withMediaType: .video)
|
||||
|
||||
guard let videoTrack = tracks.first else {
|
||||
throw NSError(
|
||||
domain: "ExpoBlueskyVideoCompress",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No video track found"]
|
||||
)
|
||||
}
|
||||
|
||||
let naturalSize = try await videoTrack.load(.naturalSize)
|
||||
let preferredTransform = try await videoTrack.load(.preferredTransform)
|
||||
let estimatedDataRate = try await videoTrack.load(.estimatedDataRate)
|
||||
let nominalFrameRate = try await videoTrack.load(.nominalFrameRate)
|
||||
let formatDescriptions = try await videoTrack.load(.formatDescriptions)
|
||||
|
||||
var codec = "unknown"
|
||||
var isHDR = false
|
||||
if let formatDescription = formatDescriptions.first {
|
||||
let subType = CMFormatDescriptionGetMediaSubType(formatDescription)
|
||||
codec = fourCCToString(subType)
|
||||
// Dolby Vision codecs are HDR by definition.
|
||||
let dolbyVisionSubtypes: Set<String> = ["dvhe", "dvh1", "dvav", "dva1"]
|
||||
if dolbyVisionSubtypes.contains(codec) {
|
||||
isHDR = true
|
||||
} else if let extensions = CMFormatDescriptionGetExtensions(formatDescription)
|
||||
as? [String: Any]
|
||||
{
|
||||
let transferKey = kCMFormatDescriptionExtension_TransferFunction as String
|
||||
if let transfer = extensions[transferKey] as? String {
|
||||
let hlg = kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG as String
|
||||
let pq = kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ as String
|
||||
isHDR = transfer == hlg || transfer == pq
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let rotation = rotationFromTransform(preferredTransform)
|
||||
let isRotated = rotation == 90 || rotation == 270
|
||||
let width = isRotated ? Int(naturalSize.height) : Int(naturalSize.width)
|
||||
let height = isRotated ? Int(naturalSize.width) : Int(naturalSize.height)
|
||||
|
||||
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
|
||||
let hasAudio = !audioTracks.isEmpty
|
||||
|
||||
let fileSize: Int
|
||||
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let size = attributes[.size] as? Int {
|
||||
fileSize = size
|
||||
} else {
|
||||
fileSize = 0
|
||||
}
|
||||
|
||||
let mimeType: String
|
||||
if let utType = UTType(filenameExtension: url.pathExtension) {
|
||||
mimeType = utType.preferredMIMEType ?? "video/mp4"
|
||||
} else {
|
||||
mimeType = "video/mp4"
|
||||
}
|
||||
|
||||
let durationSeconds = CMTimeGetSeconds(duration)
|
||||
var bitrate = Int(estimatedDataRate)
|
||||
if bitrate == 0 && durationSeconds > 0 {
|
||||
bitrate = Int(Double(fileSize * 8) / durationSeconds)
|
||||
}
|
||||
|
||||
return [
|
||||
"width": width,
|
||||
"height": height,
|
||||
"duration": durationSeconds,
|
||||
"bitrate": bitrate,
|
||||
"fileSize": fileSize,
|
||||
"mimeType": mimeType,
|
||||
"codec": codec,
|
||||
"hasAudio": hasAudio,
|
||||
"frameRate": nominalFrameRate,
|
||||
"rotation": rotation,
|
||||
"isHDR": isHDR
|
||||
]
|
||||
}
|
||||
|
||||
private static func rotationFromTransform(_ transform: CGAffineTransform) -> Int {
|
||||
let angle = atan2(transform.b, transform.a)
|
||||
let degrees = Int(round(angle * 180.0 / .pi))
|
||||
return ((degrees % 360) + 360) % 360
|
||||
}
|
||||
|
||||
private static func fourCCToString(_ code: FourCharCode) -> String {
|
||||
let chars: [Character] = [
|
||||
Character(UnicodeScalar((code >> 24) & 0xFF)!),
|
||||
Character(UnicodeScalar((code >> 16) & 0xFF)!),
|
||||
Character(UnicodeScalar((code >> 8) & 0xFF)!),
|
||||
Character(UnicodeScalar(code & 0xFF)!)
|
||||
]
|
||||
return String(chars).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import {type EventSubscription, requireNativeModule} from 'expo-modules-core'
|
||||
|
||||
import {
|
||||
type CompressResult,
|
||||
type NativeCompressOptions,
|
||||
type VideoMetadata,
|
||||
} from './types'
|
||||
|
||||
type ProgressEvent = {id: number; progress: number}
|
||||
|
||||
interface ExpoBlueskyVideoCompressModule {
|
||||
probe(uri: string): Promise<VideoMetadata>
|
||||
compress(uri: string, options: NativeCompressOptions): Promise<CompressResult>
|
||||
cancel(jobId: number): void
|
||||
addListener(
|
||||
eventName: 'onProgress',
|
||||
listener: (event: ProgressEvent) => void,
|
||||
): EventSubscription
|
||||
}
|
||||
|
||||
export default requireNativeModule<ExpoBlueskyVideoCompressModule>(
|
||||
'ExpoBlueskyVideoCompress',
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
export type CodecPreference = 'auto' | 'hevc' | 'h264'
|
||||
|
||||
export type VideoMetadata = {
|
||||
width: number
|
||||
height: number
|
||||
duration: number
|
||||
bitrate: number
|
||||
fileSize: number
|
||||
mimeType: string
|
||||
codec: string
|
||||
hasAudio: boolean
|
||||
frameRate: number
|
||||
rotation: number
|
||||
isHDR: boolean
|
||||
}
|
||||
|
||||
export type CompressOptions = {
|
||||
targetBitrate?: number
|
||||
maxSize?: number
|
||||
codec?: CodecPreference
|
||||
frameRateCap?: number
|
||||
}
|
||||
|
||||
export type CompressCallbacks = {
|
||||
onProgress?: (progress: number) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type CompressResult = {
|
||||
uri: string
|
||||
size: number
|
||||
mimeType: string
|
||||
width: number
|
||||
height: number
|
||||
duration: number
|
||||
codec: 'h264' | 'hevc'
|
||||
}
|
||||
|
||||
export type NativeCompressOptions = {
|
||||
targetBitrate: number
|
||||
maxSize: number
|
||||
codec: CodecPreference
|
||||
frameRateCap: number
|
||||
jobId: number
|
||||
}
|
||||
+18
-276
@@ -60,11 +60,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/Navigation.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/Splash.android.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
@@ -80,14 +75,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/alf/util/flatten.ts": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-unsafe-member-access": {
|
||||
"count": 9
|
||||
}
|
||||
},
|
||||
"src/alf/util/systemUI.ts": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 2
|
||||
@@ -167,9 +154,6 @@
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 4
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-unsafe-call": {
|
||||
"count": 2
|
||||
},
|
||||
@@ -224,9 +208,6 @@
|
||||
"src/components/Lists.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Menu/types.ts": {
|
||||
@@ -261,9 +242,6 @@
|
||||
"src/components/PostControls/BookmarkButton.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/PostControls/DiscoverDebug.tsx": {
|
||||
@@ -318,41 +296,14 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/StarterPack/Main/ProfilesList.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/StarterPack/ProfileStarterPacks.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/StarterPack/QrCodeDialog.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 5
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 2
|
||||
},
|
||||
"typescript/require-await": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/components/StarterPack/ShareDialog.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 2
|
||||
},
|
||||
"typescript/require-await": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/StarterPack/Wizard/WizardEditListDialog.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -363,16 +314,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/ageAssurance/AgeAssuranceErrors.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/ageAssurance/AgeAssuranceInitDialog.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx": {
|
||||
"typescript/require-await": {
|
||||
"count": 1
|
||||
@@ -388,18 +329,10 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/contacts/screens/ViewMatches.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialogs/DeviceLocationRequestDialog.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-unsafe-member-access": {
|
||||
"count": 2
|
||||
}
|
||||
@@ -409,26 +342,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialogs/EmailDialog/screens/Update.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/dialogs/EmailDialog/screens/Verify.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/dialogs/LanguageSelectDialog.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -447,7 +360,7 @@
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 3
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-unsafe-member-access": {
|
||||
"count": 2
|
||||
@@ -472,27 +385,14 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialogs/StarterPackDialog.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialogs/SwitchAccount.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/dialogs/lists/CreateOrEditListDialog.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialogs/nuxs/FindContactsAnnouncement.tsx": {
|
||||
@@ -580,16 +480,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/intents/VerifyEmailIntentDialog.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/verification/VerificationCreatePrompt.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/verification/VerificationRemovePrompt.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
@@ -636,11 +526,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/lib/async/until.ts": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/lib/broadcast/stub.ts": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -696,13 +581,10 @@
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/lib/hooks/usePermissions.ts": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-unsafe-enum-comparison": {
|
||||
"count": 5
|
||||
}
|
||||
@@ -754,9 +636,6 @@
|
||||
"src/lib/media/manip.ts": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/lib/media/manip.web.ts": {
|
||||
@@ -845,15 +724,9 @@
|
||||
},
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/E2E/SharedPreferencesTesterScreen.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 6
|
||||
},
|
||||
"typescript/require-await": {
|
||||
"count": 6
|
||||
}
|
||||
@@ -863,22 +736,9 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/Feeds/NoSavedFeedsOfAnyType.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/Home/NoFeedsPinned.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/List/ListHiddenScreen.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 3
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/Log.tsx": {
|
||||
@@ -886,20 +746,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/screens/Moderation/index.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-unsafe-member-access": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/screens/ModerationInteractionSettings/index.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -922,19 +768,6 @@
|
||||
"src/screens/Onboarding/StepProfile/index.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/Onboarding/StepSuggestedStarterpacks/StarterPackCard.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/Onboarding/StepSuggestedStarterpacks/index.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx": {
|
||||
@@ -962,18 +795,12 @@
|
||||
"src/screens/Profile/Header/EditProfileDialog.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 3
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/Profile/Header/ProfileHeaderLabeler.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-unsafe-member-access": {
|
||||
"count": 3
|
||||
}
|
||||
@@ -1004,19 +831,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/ProfileList/components/Header.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/screens/ProfileList/components/MoreOptionsMenu.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/screens/ProfileList/components/SubscribeMenu.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 2
|
||||
@@ -1027,11 +841,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/SavedFeeds.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/screens/Search/modules/ExploreSuggestedAccounts.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
@@ -1093,9 +902,6 @@
|
||||
"src/screens/Settings/components/ChangePasswordDialog.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/screens/Settings/components/CopyButton.tsx": {
|
||||
@@ -1107,9 +913,6 @@
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-unsafe-member-access": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -1119,11 +922,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/screens/Settings/components/DisableEmail2FADialog.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/screens/Settings/components/OTAInfo.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
@@ -1147,7 +945,7 @@
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-unsafe-call": {
|
||||
"count": 1
|
||||
@@ -1164,25 +962,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/StarterPack/StarterPackScreen.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 2
|
||||
},
|
||||
"typescript/require-await": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/StarterPack/Wizard/index.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 2
|
||||
},
|
||||
"typescript/require-await": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/state/a11y.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
@@ -1349,7 +1128,7 @@
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-unsafe-member-access": {
|
||||
"count": 2
|
||||
@@ -1384,7 +1163,7 @@
|
||||
"count": 3
|
||||
},
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-unsafe-member-access": {
|
||||
"count": 3
|
||||
@@ -1404,11 +1183,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/state/queries/starter-packs.ts": {
|
||||
"typescript/require-await": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/state/queries/suggested-follows.ts": {
|
||||
"no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -1435,11 +1209,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/state/shell/composer/index.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/state/shell/onboarding.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 5
|
||||
@@ -1474,14 +1243,6 @@
|
||||
"src/view/com/composer/SelectMediaButton.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/com/composer/drafts/DraftsButton.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/com/composer/drafts/state/queries.ts": {
|
||||
@@ -1500,11 +1261,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/com/composer/photos/EditImageDialog.web.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/com/composer/photos/Gallery.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
@@ -1513,9 +1269,6 @@
|
||||
"src/view/com/composer/photos/OpenCameraBtn.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/com/composer/text-input/TextInput.web.tsx": {
|
||||
@@ -1560,7 +1313,7 @@
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-unsafe-member-access": {
|
||||
"count": 6
|
||||
@@ -1598,12 +1351,6 @@
|
||||
"src/view/com/pager/TabBar.web.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-unsafe-call": {
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-unsafe-member-access": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/view/com/post-thread/PostLikedBy.tsx": {
|
||||
@@ -1627,9 +1374,6 @@
|
||||
"src/view/com/posts/FeedShutdownMsg.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/com/posts/PostFeedErrorMessage.tsx": {
|
||||
@@ -1693,9 +1437,6 @@
|
||||
"src/view/com/util/Views.tsx": {
|
||||
"react/display-name": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/com/util/Views.web.tsx": {
|
||||
@@ -1703,11 +1444,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/com/util/forms/Button.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/screens/Feeds.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 3
|
||||
@@ -1719,11 +1455,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/screens/Home.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/screens/ModerationBlockedAccounts.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 3
|
||||
@@ -1770,5 +1501,16 @@
|
||||
"typescript/no-unsafe-member-access": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"webpack.config.js": {
|
||||
"import/no-nodejs-modules": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-unsafe-call": {
|
||||
"count": 12
|
||||
},
|
||||
"typescript/no-unsafe-member-access": {
|
||||
"count": 33
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.131.1",
|
||||
"version": "1.132.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24.19.0"
|
||||
@@ -93,17 +93,16 @@
|
||||
"update-extensions": "bash scripts/updateExtensions.sh",
|
||||
"export": "expo export --dump-sourcemap && pnpm upload-native-sourcemaps",
|
||||
"upload-native-sourcemaps": "pnpm exec sentry-expo-upload-sourcemaps dist",
|
||||
"make-deploy-bundle": "bash scripts/bundleUpdate.sh",
|
||||
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 pnpm build-web",
|
||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 pnpm build-web",
|
||||
"icons:optimize": "svgo -f ./assets/icons",
|
||||
"prettier": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/common-web": "0.5.9",
|
||||
"@atproto/lex": "0.3.6",
|
||||
"@atproto/lex-password-session": "0.2.0",
|
||||
"@atproto/syntax": "0.7.4",
|
||||
"@atproto/common-web": "0.5.10",
|
||||
"@atproto/lex": "0.3.8",
|
||||
"@atproto/lex-password-session": "0.2.2",
|
||||
"@atproto/syntax": "0.7.5",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.15",
|
||||
@@ -113,12 +112,12 @@
|
||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.9",
|
||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
||||
"@bsky.app/peek-menu": "^0.3.2",
|
||||
"@bsky.app/react-native-uitextview": "^2.7.0",
|
||||
"@bsky.app/react-native-uitextview": "^2.7.1",
|
||||
"@bsky.app/sift": "^0.3.9",
|
||||
"@bsky.app/tapper": "^0.6.1",
|
||||
"@bsky.app/video": "0.3.6",
|
||||
"@bsky.app/video-compressor": "0.2.0",
|
||||
"@bsky/sdk": "1.0.1",
|
||||
"@bsky/sdk": "1.1.0",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@expo/html-elements": "^0.12.5",
|
||||
@@ -160,6 +159,7 @@
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"bcp-47": "^2.1.0",
|
||||
"bcp-47-match": "^2.0.3",
|
||||
"bidi-js": "^1.0.3",
|
||||
"date-fns": "^4.4.0",
|
||||
"email-validator": "^2.0.4",
|
||||
"emoji-mart": "^5.6.0",
|
||||
@@ -223,14 +223,13 @@
|
||||
"psl": "1.9.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-compiler-runtime": "19.1.0-rc.3",
|
||||
"react-compiler-runtime": "1.0.0",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hotkeys-hook": "5.2.4",
|
||||
"react-image-crop": "^11.0.7",
|
||||
"react-is": "19",
|
||||
"react-keyed-flatten-children": "^5.0.0",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-compressor": "1.13.0",
|
||||
"react-native-date-picker": "^5.0.13",
|
||||
"react-native-device-attest": "^0.1.6",
|
||||
"react-native-drawer-layout": "^4.2.3",
|
||||
@@ -241,7 +240,7 @@
|
||||
"react-native-pager-view": "6.8.0",
|
||||
"react-native-progress": "^5.0.1",
|
||||
"react-native-qrcode-styled": "^0.3.3",
|
||||
"react-native-reanimated": "~4.5.3",
|
||||
"react-native-reanimated": "~4.6.0",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.26.2",
|
||||
"react-native-scroll-forwarder": "link:./modules/react-native-scroll-forwarder",
|
||||
@@ -251,7 +250,7 @@
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-web-webview": "^1.0.2",
|
||||
"react-native-webview": "^13.16.1",
|
||||
"react-native-worklets": "0.11.3",
|
||||
"react-native-worklets": "0.12.1",
|
||||
"react-remove-scroll-bar": "^2.3.8",
|
||||
"react-responsive": "^10.0.1",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
@@ -288,7 +287,7 @@
|
||||
"@typescript/native": "npm:typescript@^7.0.2",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"babel-preset-expo": "~57.0.4",
|
||||
"eslint-plugin-bsky-internal": "link:lint-rules",
|
||||
"eslint-plugin-react-native-a11y": "^3.5.1",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
diff --git a/index.js b/index.js
|
||||
index 12101e1b954e1a8c7bf7887bef4f4e08fb06a8be..51d7af352a8a222c5320426f376d51c5a173300b 100644
|
||||
--- a/index.js
|
||||
+++ b/index.js
|
||||
@@ -170,6 +170,13 @@ var BigDecimal = class BigDecimal {
|
||||
times(y) {
|
||||
const other = BigDecimal._coerce(y);
|
||||
if (this._special || other._special) return this._specialArith(other, "times");
|
||||
+ // Fast path: multiplying by exactly 1 is an identity operation. ECMA-402's
|
||||
+ // ToRawFixed multiplies by roundingIncrement, which defaults to 1, on every
|
||||
+ // format call - without this check that is a full BigInt multiply followed
|
||||
+ // by a trailing-zero strip. BigDecimal is immutable, so returning the
|
||||
+ // existing instance is safe.
|
||||
+ if (other._mantissa === 1n && other._exponent === 0) return this;
|
||||
+ if (this._mantissa === 1n && this._exponent === 0) return other;
|
||||
if (this._mantissa === 0n || other._mantissa === 0n) {
|
||||
const negZero = this._isSignNegative() ? !other._isSignNegative() : other._isSignNegative();
|
||||
return BigDecimal._create(0n, 0, SpecialValue.NONE, negZero);
|
||||
@@ -189,6 +196,11 @@ var BigDecimal = class BigDecimal {
|
||||
const negZero = this._isSignNegative() !== other._isSignNegative();
|
||||
return BigDecimal._create(0n, 0, SpecialValue.NONE, negZero);
|
||||
}
|
||||
+ // Fast path: dividing by exactly 1 is an identity operation. ECMA-402's
|
||||
+ // ToRawFixed divides by roundingIncrement, which defaults to 1, on every
|
||||
+ // format call - without this check that is a 10^DIV_PRECISION BigInt
|
||||
+ // scale-up, a division, and a trailing-zero strip that undoes the scaling.
|
||||
+ if (other._mantissa === 1n && other._exponent === 0) return this;
|
||||
const [nm, ne] = removeTrailingZeros(this._mantissa * bigintPow10(DIV_PRECISION) / other._mantissa, this._exponent - other._exponent - DIV_PRECISION);
|
||||
return BigDecimal._create(nm, ne, SpecialValue.NONE, false);
|
||||
}
|
||||
@@ -1,3 +1,19 @@
|
||||
diff --git a/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp b/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp
|
||||
index a2d707ce76ceb35456eb075d06473ac98ad8b5eb..be2e3d66031d2bbd6208a7cdc1700a9daefb1267 100644
|
||||
--- a/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp
|
||||
+++ b/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp
|
||||
@@ -24,10 +24,7 @@ namespace expo {
|
||||
return;
|
||||
}
|
||||
|
||||
- workletRuntime->executeSync([func = std::move(func)](jsi::Runtime &rt) -> jsi::Value {
|
||||
- func(rt);
|
||||
- return jsi::Value::undefined();
|
||||
- });
|
||||
+ workletRuntime->runSync(func);
|
||||
}
|
||||
} // namespace expo
|
||||
|
||||
diff --git a/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt b/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
|
||||
index 47c4d15f6b10bbd77858cfff425cda9a618735b9..afe138d22d566244482498a7be0e14b8454eab96 100644
|
||||
--- a/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
|
||||
@@ -13,3 +29,16 @@ index 47c4d15f6b10bbd77858cfff425cda9a618735b9..afe138d22d566244482498a7be0e14b8
|
||||
// Check for Content-Type
|
||||
val skipContentTypes = listOf(
|
||||
"text/event-stream", // Server Sent Events
|
||||
diff --git a/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm b/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm
|
||||
index 126545fae12dc3af71e0ea382b976ef430e62d17..d97163a2e9a9db09f43786bc919f2fa229f21901 100644
|
||||
--- a/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm
|
||||
+++ b/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm
|
||||
@@ -233,7 +233,7 @@ - (void)executeWorkletWithRuntimeHandle:(id)runtimeHandle
|
||||
return;
|
||||
}
|
||||
|
||||
- workletRuntime->executeSync([worklet, arguments](jsi::Runtime &rt) -> jsi::Value {
|
||||
+ workletRuntime->runSync([worklet, arguments](jsi::Runtime &rt) -> jsi::Value {
|
||||
return callWorklet(rt, worklet, arguments);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,3 +3,15 @@
|
||||
### Android: bitdrift interceptor
|
||||
|
||||
Fixes an issue where bitdrift's API stream gets blocked by the Expo interceptor used to power the devtools.
|
||||
|
||||
### iOS + Android: worklets `runSync` migration
|
||||
|
||||
Backport of https://github.com/expo/expo/pull/49366 ("[sdk-57] Backport
|
||||
WorkletRuntime runSync migration"). react-native-worklets 0.12 removed the
|
||||
deprecated `WorkletRuntime::executeSync`, so the worklets adapters in
|
||||
`ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm` and
|
||||
`android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp` fail to compile
|
||||
against it. The patch swaps both call sites to `runSync` (available since
|
||||
worklets 0.7.0). Required for the react-native-reanimated 4.6.0 /
|
||||
react-native-worklets 0.12.1 upgrade; drop once expo-modules-core ships a
|
||||
version containing that PR.
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
diff --git a/android/build.gradle b/android/build.gradle
|
||||
index 5071139f8ee5fbba085d2afe3b2093de8eda915c..84bee34a238c6510169f6b6bdb0fda0594c77136 100644
|
||||
--- a/android/build.gradle
|
||||
+++ b/android/build.gradle
|
||||
@@ -115,7 +115,6 @@ dependencies {
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
|
||||
implementation 'org.mp4parser:isoparser:1.9.56'
|
||||
- implementation 'com.github.banketree:AndroidLame-kotlin:v0.0.1'
|
||||
implementation 'javazoom:jlayer:1.0.1'
|
||||
}
|
||||
|
||||
diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt
|
||||
deleted file mode 100644
|
||||
index 9292d3ee50776bd9d7760b8dcf6d123d44b4e31b..0000000000000000000000000000000000000000
|
||||
diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt
|
||||
deleted file mode 100644
|
||||
index c6551828014437a14dc8f2f19488b647dba1bbe1..0000000000000000000000000000000000000000
|
||||
diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt
|
||||
deleted file mode 100644
|
||||
index 42040b4916573463415ef2f57789b3c4fa25d135..0000000000000000000000000000000000000000
|
||||
diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt
|
||||
index 446d4fb8b69e7cfdb51b29603aa2d52aac1ab8c8..f02190992dac823b6bbf2d77880a25adc48f16c7 100644
|
||||
--- a/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt
|
||||
+++ b/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt
|
||||
@@ -11,7 +11,9 @@ class AudioMain(private val reactContext: ReactApplicationContext) {
|
||||
promise: Promise) {
|
||||
try {
|
||||
|
||||
- AudioCompressor.CompressAudio(fileUrl,optionMap,reactContext,promise)
|
||||
+ // Skip compression on Android to avoid libandroidlame dependency
|
||||
+ // Return the original file URL without compression
|
||||
+ promise.resolve(fileUrl)
|
||||
} catch (ex: Exception) {
|
||||
promise.reject(ex)
|
||||
}
|
||||
diff --git a/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt b/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt
|
||||
index c14b727e930f4114765bfbe15b742ddcdeaa392f..1198908fcc66eeeea5e537085d7632a0d4b04545 100644
|
||||
--- a/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt
|
||||
+++ b/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt
|
||||
@@ -7,7 +7,6 @@ import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
-import com.reactnativecompressor.Audio.AudioCompressor
|
||||
import com.reactnativecompressor.Video.VideoCompressor.CompressionListener
|
||||
import com.reactnativecompressor.Video.VideoCompressor.VideoCompressorClass
|
||||
import java.io.FileNotFoundException
|
||||
@@ -152,10 +151,6 @@ object Utils {
|
||||
}
|
||||
}
|
||||
|
||||
- fun addLog(log: String) {
|
||||
- Log.d(AudioCompressor.TAG, log)
|
||||
- }
|
||||
-
|
||||
val exifAttributes = arrayOf(
|
||||
"FNumber",
|
||||
"ApertureValue",
|
||||
@@ -1,5 +0,0 @@
|
||||
# react-native-compressor
|
||||
|
||||
Patch file taken from https://github.com/numandev1/react-native-compressor/pull/355#issuecomment-3180870738
|
||||
|
||||
This patch removes the audio compression feature on Android from the library. This is because `libandroidlame.so`, the native dependency, does not support 16kb page sizes, and the Play Store has made this mandatory as of 1st Nov 2025.
|
||||
@@ -1,283 +0,0 @@
|
||||
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
|
||||
index 8603591..20d042b 100644
|
||||
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
|
||||
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
|
||||
@@ -62,11 +62,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
|
||||
)
|
||||
@@ -74,11 +74,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
|
||||
{
|
||||
@@ -98,10 +98,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 fcc677f..115971a 100644
|
||||
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
|
||||
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
|
||||
@@ -67,11 +67,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
|
||||
)
|
||||
@@ -80,11 +80,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 df53d8d..735f138 100644
|
||||
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
|
||||
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h>
|
||||
|
||||
#include <react/debug/react_native_assert.h>
|
||||
+#include <react/renderer/mounting/ShadowTree.h>
|
||||
#include <react/renderer/mounting/ShadowViewMutation.h>
|
||||
|
||||
#include <memory>
|
||||
@@ -60,14 +61,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};
|
||||
}
|
||||
|
||||
@@ -998,23 +1022,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 57cc134..1a2966c 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;
|
||||
@@ -206,19 +211,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 2b68ff7..d08b1ae 100644
|
||||
--- a/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
|
||||
+++ b/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
|
||||
@@ -1235,22 +1235,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
# react-native-reanimated@4.5.3.patch
|
||||
|
||||
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 hunks
|
||||
were rebased onto the 4.5.3 release sources.
|
||||
|
||||
Note that upstream's own `pullTransaction` rework in 4.5.3 (the new
|
||||
`reconcileContradictedRemovals`) covers a different case - a `Create`/`Insert`
|
||||
contradicting a *withheld* exit removal - and does not subsume the deleted-tag
|
||||
`Update` filter here, which guards against the `LayoutAnimationDriver` final
|
||||
keyframe. That driver only runs at all once this patch frees the delegate slot.
|
||||
@@ -1,88 +0,0 @@
|
||||
diff --git a/lib/module/threads.js b/lib/module/threads.js
|
||||
index c17e314..71f3cf7 100644
|
||||
--- a/lib/module/threads.js
|
||||
+++ b/lib/module/threads.js
|
||||
@@ -1,7 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
import { IS_JEST } from './platformChecker';
|
||||
-import { mockedRequestAnimationFrame } from "./runLoop/uiRuntime/mockedRequestAnimationFrame.js";
|
||||
export function scheduleOnUI(worklet, ...args) {
|
||||
enqueueUI(worklet, args);
|
||||
}
|
||||
@@ -23,38 +22,50 @@ export function scheduleOnRN(fun, ...args) {
|
||||
queueMicrotask(args.length ? () => fun(...args) : fun);
|
||||
}
|
||||
export function runOnUIAsync(worklet, ...args) {
|
||||
- return new Promise(resolve => {
|
||||
- enqueueUI(worklet, args, resolve);
|
||||
+ return new Promise((resolve, reject) => {
|
||||
+ enqueueUI(worklet, args, resolve, reject);
|
||||
});
|
||||
}
|
||||
let runOnUIQueue = [];
|
||||
-function enqueueUI(worklet, args, resolve) {
|
||||
- if (IS_JEST) {
|
||||
- mockedRequestAnimationFrame(() => {
|
||||
- const result = worklet(...args);
|
||||
- resolve?.(result);
|
||||
- });
|
||||
- } else {
|
||||
- const job = [worklet, args, resolve];
|
||||
- runOnUIQueue.push(job);
|
||||
- if (runOnUIQueue.length === 1) {
|
||||
+function enqueueUI(worklet, args, resolve, reject) {
|
||||
+ const job = [worklet, args, resolve, reject];
|
||||
+ runOnUIQueue.push(job);
|
||||
+ if (runOnUIQueue.length === 1) {
|
||||
+ if (IS_JEST) {
|
||||
flushUIQueue();
|
||||
+ } else {
|
||||
+ queueMicrotask(flushUIQueue);
|
||||
}
|
||||
}
|
||||
}
|
||||
+let offset = 0;
|
||||
function flushUIQueue() {
|
||||
- queueMicrotask(() => {
|
||||
- const queue = runOnUIQueue;
|
||||
- runOnUIQueue = [];
|
||||
- requestAnimationFrameImpl(() => {
|
||||
- queue.forEach(([workletFunction, workletArgs, jobResolve]) => {
|
||||
- const result = workletFunction(...workletArgs);
|
||||
- if (jobResolve) {
|
||||
- jobResolve(result);
|
||||
+ const queue = runOnUIQueue;
|
||||
+ runOnUIQueue = [];
|
||||
+ requestAnimationFrame(() => {
|
||||
+ offset = 0;
|
||||
+ while (queue.length > offset) {
|
||||
+ try {
|
||||
+ drainUIQueue(queue);
|
||||
+ } catch (e) {
|
||||
+ const [, , , jobReject] = queue[offset - 1];
|
||||
+ if (jobReject) {
|
||||
+ jobReject(e);
|
||||
+ } else {
|
||||
+ console.error(e);
|
||||
}
|
||||
- });
|
||||
- });
|
||||
+ }
|
||||
+ }
|
||||
});
|
||||
}
|
||||
-const requestAnimationFrameImpl = !globalThis.requestAnimationFrame ? mockedRequestAnimationFrame : globalThis.requestAnimationFrame;
|
||||
-//# sourceMappingURL=threads.js.map
|
||||
\ No newline at end of file
|
||||
+function drainUIQueue(queue) {
|
||||
+ while (queue.length > offset) {
|
||||
+ const [workletFunction, workletArgs, jobResolve] = queue[offset];
|
||||
+ offset++;
|
||||
+ const result = workletFunction(...workletArgs);
|
||||
+ if (jobResolve) {
|
||||
+ jobResolve(result);
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+//# sourceMappingURL=threads.js.map
|
||||
@@ -1,37 +0,0 @@
|
||||
# react-native-worklets@0.11.3.patch
|
||||
|
||||
Backport of https://github.com/software-mansion/react-native-reanimated/pull/10167
|
||||
("fix(Worklets): web scheduleOnUI implementation on errors").
|
||||
|
||||
## The bug
|
||||
|
||||
On web, `scheduleOnUI`/`runOnUI` batch their callbacks per animation frame and
|
||||
run them with `queue.forEach(...)`. If any callback in the batch throws,
|
||||
`forEach` aborts immediately and every callback still queued after it is
|
||||
silently dropped - it never runs, and any `runOnUIAsync` promise for it never
|
||||
resolves or rejects.
|
||||
|
||||
Reanimated's own internals rely on those callbacks running in order (e.g. to
|
||||
populate `frameCallbackRegistry`), so a single throwing worklet in a batch can
|
||||
leave later, unrelated frame callbacks referencing state that was never set
|
||||
up, surfacing as:
|
||||
|
||||
```
|
||||
TypeError: can't access property "startTime", this.frameCallbackRegistry.get(...) is undefined
|
||||
```
|
||||
|
||||
## The fix
|
||||
|
||||
Replace the `forEach` batch runner with a `while` loop (`drainUIQueue`) that
|
||||
tracks its position via an `offset`, wrapped in a `try`/`catch`. A throw now
|
||||
only aborts the *current* callback: the loop resumes at the next queued job
|
||||
instead of abandoning the rest of the batch. Errors are routed to the
|
||||
matching `runOnUIAsync` promise's `reject` (a new second argument threaded
|
||||
through `enqueueUI`) if there is one, or `console.error`-ed otherwise, rather
|
||||
than crashing the whole frame.
|
||||
|
||||
Only `lib/module/threads.js` (the compiled web entry point actually loaded by
|
||||
the app's webpack build) is patched - `src/threads.ts` is unused here since
|
||||
this repo's web build resolves the package's `module` field, and native
|
||||
platforms use the separate `threads.native.ts` implementation untouched by
|
||||
this PR.
|
||||
+249
-174
@@ -23,180 +23,6 @@ index 1b02e8b2d39672063551411d5c403a69b671a869..b3481c1b98b45dea769035140dc2fd8d
|
||||
- (void)setFrame:(CGRect)frame
|
||||
{
|
||||
[super setFrame:frame];
|
||||
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
|
||||
index a087536f3af0d33b13fe38d8abd1bc6d7935def2..01f5c884ea4772350c0ebe6263723d97632f2b74 100644
|
||||
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
|
||||
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
|
||||
@@ -396,7 +396,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);
|
||||
}
|
||||
|
||||
@@ -523,7 +531,7 @@ - (UIView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event
|
||||
}
|
||||
}
|
||||
|
||||
- return isPointInside ? self : nil;
|
||||
+ return isPointInside ? _scrollView : nil;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -1133,6 +1141,11 @@ - (RCTVirtualViewContainerState *)virtualViewContainerState
|
||||
return _virtualViewContainerState;
|
||||
}
|
||||
|
||||
++ (BOOL)shouldBeRecycled
|
||||
+{
|
||||
+ return NO;
|
||||
+}
|
||||
+
|
||||
@end
|
||||
|
||||
Class<RCTComponentViewProtocol> RCTScrollViewCls(void)
|
||||
diff --git a/React/Views/RefreshControl/RCTRefreshControl.h b/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
index ed306d7cadbf36a2fed79be8bd9d68b5dca135bd..d447dad534fefa9fcbdbbde6dcbbdcddadd5a824 100644
|
||||
--- a/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
+++ b/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
@@ -18,6 +18,7 @@ __attribute__((deprecated("This API will be removed along with the legacy archit
|
||||
@property (nonatomic, copy) NSString *title;
|
||||
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
|
||||
@property (nonatomic, weak) UIScrollView *scrollView;
|
||||
+@property (nonatomic, copy) UIColor *customTintColor;
|
||||
|
||||
@end
|
||||
|
||||
diff --git a/React/Views/RefreshControl/RCTRefreshControl.m b/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
index 2dc86e464264c9450eef18d7b153d35bf6a5cc55..6661dc69a04766afa0284d6e83839b219e98cf57 100644
|
||||
--- a/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
+++ b/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
@@ -25,6 +25,7 @@ @implementation RCTRefreshControl {
|
||||
UIColor *_titleColor;
|
||||
CGFloat _progressViewOffset;
|
||||
BOOL _hasMovedToWindow;
|
||||
+ UIColor *_customTintColor;
|
||||
}
|
||||
|
||||
- (instancetype)init
|
||||
@@ -60,6 +61,12 @@ - (void)layoutSubviews
|
||||
_isInitialRender = false;
|
||||
}
|
||||
|
||||
+- (void)didMoveToSuperview
|
||||
+{
|
||||
+ [super didMoveToSuperview];
|
||||
+ [self setTintColor:_customTintColor];
|
||||
+}
|
||||
+
|
||||
- (void)didMoveToWindow
|
||||
{
|
||||
[super didMoveToWindow];
|
||||
@@ -225,6 +232,18 @@ - (void)refreshControlValueChanged
|
||||
}
|
||||
}
|
||||
|
||||
+// Fix for https://github.com/facebook/react-native/issues/43388
|
||||
+// A bug in iOS 17.4 causes the haptic to not play when refreshing if the tintColor
|
||||
+// is set before the refresh control gets added to the scrollview. We'll call this
|
||||
+// function whenever the superview changes. We'll also call it if the value of customTintColor
|
||||
+// changes.
|
||||
+- (void)setTintColor:(UIColor *)tintColor
|
||||
+{
|
||||
+ if ([self.superview isKindOfClass:[UIScrollView class]] && self.tintColor != tintColor) {
|
||||
+ [super setTintColor:tintColor];
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
@end
|
||||
|
||||
#endif // RCT_REMOVE_LEGACY_ARCH
|
||||
diff --git a/React/Views/RefreshControl/RCTRefreshControlManager.m b/React/Views/RefreshControl/RCTRefreshControlManager.m
|
||||
index 1e9ff527f4e6691d716da624031113a397876981..44329c5422c6f24d8a437fa35c6f2bad6bf8622b 100644
|
||||
--- a/React/Views/RefreshControl/RCTRefreshControlManager.m
|
||||
+++ b/React/Views/RefreshControl/RCTRefreshControlManager.m
|
||||
@@ -24,11 +24,12 @@ - (UIView *)view
|
||||
|
||||
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
|
||||
-RCT_EXPORT_VIEW_PROPERTY(tintColor, UIColor)
|
||||
RCT_EXPORT_VIEW_PROPERTY(title, NSString)
|
||||
RCT_EXPORT_VIEW_PROPERTY(titleColor, UIColor)
|
||||
RCT_EXPORT_VIEW_PROPERTY(progressViewOffset, CGFloat)
|
||||
|
||||
+RCT_REMAP_VIEW_PROPERTY(tintColor, customTintColor, UIColor)
|
||||
+
|
||||
RCT_EXPORT_METHOD(setNativeRefreshing : (nonnull NSNumber *)viewTag toRefreshing : (BOOL)refreshing)
|
||||
{
|
||||
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
|
||||
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 59775241c80bec99ad3ec080f2425aacc8900c24..426de3aa77cda2032d7b0991e2ca3f8482a438d3 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
|
||||
@@ -459,6 +459,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) {
|
||||
@@ -496,7 +503,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/RCTFontUtils.mm b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
|
||||
index 9b04cadc22f5ae7b105f9f9875a242b53188cf03..b2b27626edc46625ac2372a13977d700948835b6 100644
|
||||
--- a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
|
||||
+++ b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
|
||||
@@ -361,7 +361,7 @@ static UIFontDescriptorSystemDesign RCTGetFontDescriptorSystemDesign(NSString *f
|
||||
font = [UIFont fontWithName:fontProperties.family size:effectiveFontSize];
|
||||
if (font != nullptr) {
|
||||
fontNames = [UIFont fontNamesForFamilyName:font.familyName];
|
||||
- fontWeight = (fontWeight != 0.0) ?: RCTGetFontWeight(font);
|
||||
+ fontWeight = (fontWeight != 0.0) ? fontWeight : RCTGetFontWeight(font);
|
||||
} else {
|
||||
// Failback to system font.
|
||||
font = RCTDefaultFontWithFontProperties(fontProperties);
|
||||
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 ac553045a9c0ce77e288277912538d9e131ebc01..d99c8f4db5a07f1e4ffe7e03ff23adce9c63137b 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
|
||||
@@ -389,8 +389,9 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage
|
||||
size.height = enumeratedLinesHeight;
|
||||
}
|
||||
|
||||
- size = (CGSize){ceil(size.width * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
|
||||
- ceil(size.height * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
|
||||
+ CGFloat epsilon = 0.001;
|
||||
+ size = (CGSize){ceil((size.width + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
|
||||
+ ceil((size.height + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
|
||||
|
||||
NSRange visibleGlyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
|
||||
|
||||
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
|
||||
index 60160efb163d91813fa2ca7ca758b51afcf261e1..fb646fe945ffe4aa4a386f80a1e42a90180691f1 100644
|
||||
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
|
||||
@@ -371,3 +197,252 @@ index 60160efb163d91813fa2ca7ca758b51afcf261e1..fb646fe945ffe4aa4a386f80a1e42a90
|
||||
}
|
||||
}
|
||||
|
||||
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
|
||||
index a087536f3af0d33b13fe38d8abd1bc6d7935def2..01f5c884ea4772350c0ebe6263723d97632f2b74 100644
|
||||
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
|
||||
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
|
||||
@@ -396,7 +396,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);
|
||||
}
|
||||
|
||||
@@ -523,7 +531,7 @@ - (UIView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event
|
||||
}
|
||||
}
|
||||
|
||||
- return isPointInside ? self : nil;
|
||||
+ return isPointInside ? _scrollView : nil;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -1133,6 +1141,11 @@ - (RCTVirtualViewContainerState *)virtualViewContainerState
|
||||
return _virtualViewContainerState;
|
||||
}
|
||||
|
||||
++ (BOOL)shouldBeRecycled
|
||||
+{
|
||||
+ return NO;
|
||||
+}
|
||||
+
|
||||
@end
|
||||
|
||||
Class<RCTComponentViewProtocol> RCTScrollViewCls(void)
|
||||
diff --git a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
|
||||
index b033b7c71914d287470b7b86bd6bf39d311294ba..7e10dc929147fa4474ca9f955f5cd85d27aea935 100644
|
||||
--- a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
|
||||
+++ b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
|
||||
@@ -827,9 +827,17 @@ static void RCTAddContourEffectToLayer(
|
||||
} else {
|
||||
CGSize imageSize = image.size;
|
||||
UIEdgeInsets imageCapInsets = image.capInsets;
|
||||
+ // The stretchable middle is whatever lies between the cap insets. The image
|
||||
+ // may be larger than capInsets + 1 (its size is ceil'd to whole points), so
|
||||
+ // deriving the middle from the caps rather than assuming a 1pt band keeps
|
||||
+ // the bottom/right caps at their true size. A phantom cap here makes the
|
||||
+ // caps overflow sub-pixel-sized layers (e.g. hairline borders), and the
|
||||
+ // squeezed mesh + nearest-neighbor filtering drops the stroke entirely.
|
||||
CGRect contentsCenter = CGRect{
|
||||
CGPoint{imageCapInsets.left / imageSize.width, imageCapInsets.top / imageSize.height},
|
||||
- CGSize{(CGFloat)1.0 / imageSize.width, (CGFloat)1.0 / imageSize.height}};
|
||||
+ CGSize{
|
||||
+ (imageSize.width - imageCapInsets.left - imageCapInsets.right) / imageSize.width,
|
||||
+ (imageSize.height - imageCapInsets.top - imageCapInsets.bottom) / imageSize.height}};
|
||||
layer.contents = (id)image.CGImage;
|
||||
layer.contentsScale = image.scale;
|
||||
|
||||
diff --git a/React/Views/RefreshControl/RCTRefreshControl.h b/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
index ed306d7cadbf36a2fed79be8bd9d68b5dca135bd..d447dad534fefa9fcbdbbde6dcbbdcddadd5a824 100644
|
||||
--- a/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
+++ b/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
@@ -18,6 +18,7 @@ __attribute__((deprecated("This API will be removed along with the legacy archit
|
||||
@property (nonatomic, copy) NSString *title;
|
||||
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
|
||||
@property (nonatomic, weak) UIScrollView *scrollView;
|
||||
+@property (nonatomic, copy) UIColor *customTintColor;
|
||||
|
||||
@end
|
||||
|
||||
diff --git a/React/Views/RefreshControl/RCTRefreshControl.m b/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
index 2dc86e464264c9450eef18d7b153d35bf6a5cc55..6661dc69a04766afa0284d6e83839b219e98cf57 100644
|
||||
--- a/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
+++ b/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
@@ -25,6 +25,7 @@ @implementation RCTRefreshControl {
|
||||
UIColor *_titleColor;
|
||||
CGFloat _progressViewOffset;
|
||||
BOOL _hasMovedToWindow;
|
||||
+ UIColor *_customTintColor;
|
||||
}
|
||||
|
||||
- (instancetype)init
|
||||
@@ -60,6 +61,12 @@ - (void)layoutSubviews
|
||||
_isInitialRender = false;
|
||||
}
|
||||
|
||||
+- (void)didMoveToSuperview
|
||||
+{
|
||||
+ [super didMoveToSuperview];
|
||||
+ [self setTintColor:_customTintColor];
|
||||
+}
|
||||
+
|
||||
- (void)didMoveToWindow
|
||||
{
|
||||
[super didMoveToWindow];
|
||||
@@ -225,6 +232,18 @@ - (void)refreshControlValueChanged
|
||||
}
|
||||
}
|
||||
|
||||
+// Fix for https://github.com/facebook/react-native/issues/43388
|
||||
+// A bug in iOS 17.4 causes the haptic to not play when refreshing if the tintColor
|
||||
+// is set before the refresh control gets added to the scrollview. We'll call this
|
||||
+// function whenever the superview changes. We'll also call it if the value of customTintColor
|
||||
+// changes.
|
||||
+- (void)setTintColor:(UIColor *)tintColor
|
||||
+{
|
||||
+ if ([self.superview isKindOfClass:[UIScrollView class]] && self.tintColor != tintColor) {
|
||||
+ [super setTintColor:tintColor];
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
@end
|
||||
|
||||
#endif // RCT_REMOVE_LEGACY_ARCH
|
||||
diff --git a/React/Views/RefreshControl/RCTRefreshControlManager.m b/React/Views/RefreshControl/RCTRefreshControlManager.m
|
||||
index 1e9ff527f4e6691d716da624031113a397876981..44329c5422c6f24d8a437fa35c6f2bad6bf8622b 100644
|
||||
--- a/React/Views/RefreshControl/RCTRefreshControlManager.m
|
||||
+++ b/React/Views/RefreshControl/RCTRefreshControlManager.m
|
||||
@@ -24,11 +24,12 @@ - (UIView *)view
|
||||
|
||||
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
|
||||
-RCT_EXPORT_VIEW_PROPERTY(tintColor, UIColor)
|
||||
RCT_EXPORT_VIEW_PROPERTY(title, NSString)
|
||||
RCT_EXPORT_VIEW_PROPERTY(titleColor, UIColor)
|
||||
RCT_EXPORT_VIEW_PROPERTY(progressViewOffset, CGFloat)
|
||||
|
||||
+RCT_REMAP_VIEW_PROPERTY(tintColor, customTintColor, UIColor)
|
||||
+
|
||||
RCT_EXPORT_METHOD(setNativeRefreshing : (nonnull NSNumber *)viewTag toRefreshing : (BOOL)refreshing)
|
||||
{
|
||||
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
|
||||
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 59775241c80bec99ad3ec080f2425aacc8900c24..426de3aa77cda2032d7b0991e2ca3f8482a438d3 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
|
||||
@@ -459,6 +459,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) {
|
||||
@@ -496,7 +503,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/featureflags/ReactNativeFeatureFlagsOverridesOSSStable.h b/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSStable.h
|
||||
index fdabd7bab1f03966dc04ba9a462465daeccf8ae3..ef70011ee5c270fb2cac52f3a7408d1e87334145 100644
|
||||
--- a/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSStable.h
|
||||
+++ b/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSStable.h
|
||||
@@ -21,6 +21,10 @@ class ReactNativeFeatureFlagsOverridesOSSStable : public ReactNativeFeatureFlags
|
||||
{
|
||||
return true;
|
||||
}
|
||||
+ bool enableSchedulerDelegateInvalidation() override
|
||||
+ {
|
||||
+ return true;
|
||||
+ }
|
||||
bool useTurboModules() override
|
||||
{
|
||||
return true;
|
||||
diff --git a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
|
||||
index 9b04cadc22f5ae7b105f9f9875a242b53188cf03..b2b27626edc46625ac2372a13977d700948835b6 100644
|
||||
--- a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
|
||||
+++ b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
|
||||
@@ -361,7 +361,7 @@ static UIFontDescriptorSystemDesign RCTGetFontDescriptorSystemDesign(NSString *f
|
||||
font = [UIFont fontWithName:fontProperties.family size:effectiveFontSize];
|
||||
if (font != nullptr) {
|
||||
fontNames = [UIFont fontNamesForFamilyName:font.familyName];
|
||||
- fontWeight = (fontWeight != 0.0) ?: RCTGetFontWeight(font);
|
||||
+ fontWeight = (fontWeight != 0.0) ? fontWeight : RCTGetFontWeight(font);
|
||||
} else {
|
||||
// Failback to system font.
|
||||
font = RCTDefaultFontWithFontProperties(fontProperties);
|
||||
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 ac553045a9c0ce77e288277912538d9e131ebc01..d99c8f4db5a07f1e4ffe7e03ff23adce9c63137b 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
|
||||
@@ -389,8 +389,9 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage
|
||||
size.height = enumeratedLinesHeight;
|
||||
}
|
||||
|
||||
- size = (CGSize){ceil(size.width * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
|
||||
- ceil(size.height * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
|
||||
+ CGFloat epsilon = 0.001;
|
||||
+ size = (CGSize){ceil((size.width + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
|
||||
+ ceil((size.height + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
|
||||
|
||||
NSRange visibleGlyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
|
||||
|
||||
diff --git a/ReactCommon/react/renderer/uimanager/UIManager.cpp b/ReactCommon/react/renderer/uimanager/UIManager.cpp
|
||||
index 3e48dabc6fffc246fd0517ef5f3f2b7721115511..ea4ba5fdf359c513a5ca4f0e94492facf4ead221 100644
|
||||
--- a/ReactCommon/react/renderer/uimanager/UIManager.cpp
|
||||
+++ b/ReactCommon/react/renderer/uimanager/UIManager.cpp
|
||||
@@ -530,30 +530,8 @@ std::shared_ptr<const ShadowNode> UIManager::findShadowNodeByTag_DEPRECATED(
|
||||
auto shadowNode = std::shared_ptr<const ShadowNode>{};
|
||||
|
||||
shadowTreeRegistry_.enumerate([&](const ShadowTree& shadowTree, bool& stop) {
|
||||
- // Obtain a pointer to the root node. The flag-gated path uses
|
||||
- // getCurrentRevision() which keeps the root alive via shared_ptr for
|
||||
- // the entire traversal, fixing a use-after-free race condition.
|
||||
- RootShadowNode::Shared rootShadowNodeHolder;
|
||||
- const RootShadowNode* rootShadowNode = nullptr;
|
||||
- if (ReactNativeFeatureFlags::fixFindShadowNodeByTagRaceCondition()) {
|
||||
- rootShadowNodeHolder = shadowTree.getCurrentRevision().rootShadowNode;
|
||||
- rootShadowNode = rootShadowNodeHolder.get();
|
||||
- } else {
|
||||
- // TODO(T257154369): Remove after flag rollout.
|
||||
- // The public interface of `ShadowTree` discourages accessing a stored
|
||||
- // pointer to a root node because of the possible data race.
|
||||
- // To work around this, we ask for a commit and immediately cancel it
|
||||
- // returning `nullptr` instead of a new shadow tree.
|
||||
- // We don't want to add a way to access a stored pointer to a root
|
||||
- // node because this `findShadowNodeByTag` is deprecated. It is only
|
||||
- // added to make migration to the new architecture easier.
|
||||
- shadowTree.tryCommit(
|
||||
- [&](const RootShadowNode& oldRootShadowNode) {
|
||||
- rootShadowNode = &oldRootShadowNode;
|
||||
- return nullptr;
|
||||
- },
|
||||
- {/* default commit options */});
|
||||
- }
|
||||
+ auto rootShadowNodeHolder = shadowTree.getCurrentRevision().rootShadowNode;
|
||||
+ const auto* rootShadowNode = rootShadowNodeHolder.get();
|
||||
|
||||
if (rootShadowNode != nullptr) {
|
||||
const auto& children = rootShadowNode->getChildren();
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# ***This second part of this patch is load bearing, do not remove.***
|
||||
|
||||
## Scheduler delegate invalidation - iOS use-after-free
|
||||
|
||||
Fixes Sentry issue APP-T28X: an `EXC_BAD_ACCESS` in
|
||||
`Scheduler::uiManagerDidFinishTransaction` or
|
||||
`Scheduler::uiManagerDidDispatchCommand` after a queued rendering update
|
||||
outlives its captured raw `SchedulerDelegate` pointer.
|
||||
|
||||
React Native 0.86 contains the invalidation-token guard from
|
||||
facebook/react-native#56680, but `enableSchedulerDelegateInvalidation` is false
|
||||
for the stable release level used by Expo. Override only this flag in
|
||||
`ReactNativeFeatureFlagsOverridesOSSStable` instead of opting the app into all
|
||||
experimental React Native flags.
|
||||
|
||||
**TODO: Remove after upgrading to a React Native release that closes the
|
||||
queued Scheduler delegate lifetime race by default.**
|
||||
|
||||
## UIManager.cpp Patch - Fabric focus navigation use-after-free
|
||||
|
||||
Fixes Sentry issue APP-T4H9: a SIGSEGV in
|
||||
`FabricUIManagerBinding::findNextFocusableElement` during focus navigation.
|
||||
|
||||
React Native 0.86 contains the safe implementation behind
|
||||
`fixFindShadowNodeByTagRaceCondition`, but the public default is false. The
|
||||
fallback captures a raw root shadow-node pointer in `tryCommit` and dereferences
|
||||
it after the lock is released, allowing a concurrent commit or surface stop to
|
||||
free the node first. This backports the final upstream implementation, which
|
||||
holds the current revision's `shared_ptr` for the entire traversal.
|
||||
|
||||
**TODO: Remove after bumping React Native to a release containing
|
||||
facebook/react-native#56850.**
|
||||
|
||||
## RefreshControl Patch - iOS 17.4 Haptic Regression
|
||||
|
||||
Patching `RCTRefreshControl.mm` temporarily to play an impact haptic on refresh when using iOS 17.4 or higher. Since
|
||||
@@ -144,3 +175,22 @@ PR: https://github.com/facebook/react-native/pull/57483
|
||||
|
||||
Issue: https://github.com/react/react-native/issues/53450#issuecomment-3298157830
|
||||
Bandaid fix taken from: https://github.com/react/react-native/commit/581d643a9e59fd88f93757f80194e1efd11bd0e5
|
||||
|
||||
## RCTViewComponentView.mm Patch - Hairline border strokes dropped at certain subpixel Y offsets on New Arch
|
||||
|
||||
Symptom: dividers built as `borderTopWidth: hairlineWidth` vanish on some screens and not
|
||||
others, deterministically by the view's absolute subpixel Y. A background fill of the same
|
||||
geometry always renders.
|
||||
|
||||
Cause: Fabric draws borders as a stretched 9-slice image. The consumer
|
||||
(`RCTAddContourEffectToLayer`) hardcodes the stretchable middle as a 1pt band, which matched
|
||||
the image the producer built until RN 0.81. facebook/react-native#54237 changed the image
|
||||
size to `ceil(insets) + 1 + ceil(insets)` without updating that formula, so for fractional
|
||||
(hairline) insets the labels no longer match the image: transparent filler is treated as a
|
||||
rigid cap, and when squeezed into a one-pixel-tall layer the sampling can land on it instead
|
||||
of the stroke - no line.
|
||||
|
||||
Fix: compute the middle from the cap insets (`size - caps`) instead of assuming 1pt.
|
||||
|
||||
Upstream issue: https://github.com/react/react-native/issues/58054 (repro:
|
||||
https://github.com/abulenok/HairlineBorderRepro, fails identically on 0.86.0 and 0.87.0).
|
||||
|
||||
Generated
+445
-460
File diff suppressed because it is too large
Load Diff
+3
-6
@@ -17,9 +17,8 @@ overrides:
|
||||
'@react-native/normalize-colors': '0.86.0'
|
||||
'@expo/image-utils': '0.8.12'
|
||||
'@types/estree': '1.0.6'
|
||||
'react-native-compressor': '1.13.0'
|
||||
'react-native-reanimated': '4.5.3'
|
||||
'react-native-worklets': '0.11.3'
|
||||
'react-native-reanimated': '4.6.0'
|
||||
'react-native-worklets': '0.12.1'
|
||||
'psl': '1.9.0'
|
||||
'@types/psl': '1.1.1'
|
||||
'react-native-screens': '4.26.2'
|
||||
@@ -30,6 +29,7 @@ allowBuilds:
|
||||
'esbuild': false
|
||||
'unrs-resolver': true
|
||||
patchedDependencies:
|
||||
'@formatjs/bigdecimal@0.2.5': patches/@formatjs__bigdecimal@0.2.5.patch
|
||||
'@sentry/expo-upload-sourcemaps@8.18.0': patches/@sentry__expo-upload-sourcemaps@8.18.0.patch
|
||||
'expo-age-range@57.0.2': patches/expo-age-range@57.0.2.patch
|
||||
'expo-haptics@57.0.1': patches/expo-haptics@57.0.1.patch
|
||||
@@ -38,16 +38,13 @@ patchedDependencies:
|
||||
'expo-notifications@57.0.7': patches/expo-notifications@57.0.7.patch
|
||||
'expo-updates@57.0.10': patches/expo-updates@57.0.10.patch
|
||||
expo@57.0.8: patches/expo@57.0.8.patch
|
||||
'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-dotenv@3.4.11': patches/react-native-dotenv@3.4.11.patch
|
||||
'react-native-drawer-layout@4.2.3': patches/react-native-drawer-layout@4.2.3.patch
|
||||
'react-native-keyboard-controller@1.21.9': patches/react-native-keyboard-controller@1.21.9.patch
|
||||
'react-native-pager-view@6.8.0': patches/react-native-pager-view@6.8.0.patch
|
||||
'react-native-reanimated@4.5.3': patches/react-native-reanimated@4.5.3.patch
|
||||
'react-native-screens@4.26.2': patches/react-native-screens@4.26.2.patch
|
||||
'react-native-svg@15.15.4': patches/react-native-svg@15.15.4.patch
|
||||
react-native-worklets@0.11.3: patches/react-native-worklets@0.11.3.patch
|
||||
'react-native@0.86.0': patches/react-native@0.86.0.patch
|
||||
minimumReleaseAgeExclude:
|
||||
- '@atproto/*'
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
rm -rf bundleTempDir
|
||||
rm -rf bundle.tar.gz
|
||||
|
||||
echo "Creating tarball..."
|
||||
node scripts/bundleUpdate.js
|
||||
|
||||
if [ -z "$RUNTIME_VERSION" ]; then
|
||||
RUNTIME_VERSION=$(cat package.json | jq '.version' -r)
|
||||
fi
|
||||
|
||||
cd bundleTempDir || exit
|
||||
|
||||
# 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 ./*
|
||||
|
||||
echo "Deploying to $DEPLOYMENT_URL..."
|
||||
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"
|
||||
|
||||
curl --fail-with-body -o - --form "bundle=@./bundle.tar.gz" --user "bsky:$DENIS_API_KEY" --basic "$DEPLOYMENT_URL"
|
||||
|
||||
cd ..
|
||||
|
||||
rm -rf bundleTempDir
|
||||
rm -rf bundle.tar.gz
|
||||
+2
-12
@@ -4,8 +4,7 @@ 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.
|
||||
# `denis publish` CLI.
|
||||
# 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.
|
||||
@@ -19,16 +18,7 @@ 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)}"
|
||||
BUNDLE_VERSION=$(date +%s)
|
||||
DENIS_CDN_DOMAIN="${DENIS_CDN_DOMAIN:-updates.bsky.app}"
|
||||
DENIS_S3_BUCKET="${DENIS_S3_BUCKET:-bsky-denis-ota-prod}"
|
||||
|
||||
|
||||
+1
-2
@@ -126,9 +126,8 @@ function InnerApp() {
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`session: resume failed`, {message: e})
|
||||
} finally {
|
||||
setIsReady(true)
|
||||
}
|
||||
setIsReady(true)
|
||||
}
|
||||
const account = readLastActiveAccount()
|
||||
void onLaunch(account)
|
||||
|
||||
+1
-2
@@ -105,9 +105,8 @@ function InnerApp() {
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('session: resumeSession failed', {message: e})
|
||||
} finally {
|
||||
setIsReady(true)
|
||||
}
|
||||
setIsReady(true)
|
||||
}
|
||||
const account = readLastActiveAccount()
|
||||
void onLaunch(account)
|
||||
|
||||
+24
-6
@@ -80,6 +80,7 @@ import {FindContactsFlowScreen} from '#/screens/FindContactsFlowScreen'
|
||||
import HashtagScreen from '#/screens/Hashtag'
|
||||
import {LogScreen} from '#/screens/Log'
|
||||
import {MessagesScreen} from '#/screens/Messages/ChatList'
|
||||
import {renderMessagesSplitViewLayout} from '#/screens/Messages/components/splitView/MessagesSplitViewLayout'
|
||||
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
|
||||
import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings'
|
||||
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
|
||||
@@ -87,6 +88,9 @@ import {MessagesJoinRequestsScreen} from '#/screens/Messages/JoinRequests'
|
||||
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
|
||||
import {ModerationInboxScreen} from '#/screens/ModerationInbox'
|
||||
import {ModerationInboxReportDetailsScreen} from '#/screens/ModerationInbox/Details'
|
||||
import {ModerationInboxSettingsScreen} from '#/screens/ModerationInbox/Settings'
|
||||
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
|
||||
import {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList'
|
||||
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
|
||||
@@ -139,7 +143,6 @@ import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {InviteScannerScreen} from '#/features/inviteFriends'
|
||||
import {router} from '#/routes'
|
||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||
import {renderMessagesSplitViewLayout} from './screens/Messages/components/splitView/MessagesSplitViewLayout'
|
||||
|
||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||
|
||||
@@ -178,6 +181,21 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
getComponent={() => ModerationScreen}
|
||||
options={{title: title(msg`Moderation`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationInbox"
|
||||
getComponent={() => ModerationInboxScreen}
|
||||
options={{title: title(msg`Moderation inbox`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationInboxSettings"
|
||||
getComponent={() => ModerationInboxSettingsScreen}
|
||||
options={{title: title(msg`Mod inbox settings`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationInboxReportDetails"
|
||||
getComponent={() => ModerationInboxReportDetailsScreen}
|
||||
options={{title: title(msg`Your report`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationModlists"
|
||||
getComponent={() => ModerationModlistsScreen}
|
||||
@@ -557,12 +575,12 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
<Stack.Screen
|
||||
name="StarterPackWizard"
|
||||
getComponent={() => Wizard}
|
||||
options={{title: title(msg`Create a starter pack`), requireAuth: true}}
|
||||
options={{title: title(msg`Create a Starter Pack`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="StarterPackEdit"
|
||||
getComponent={() => Wizard}
|
||||
options={{title: title(msg`Edit your starter pack`), requireAuth: true}}
|
||||
options={{title: title(msg`Edit your Starter Pack`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="VideoFeed"
|
||||
@@ -919,7 +937,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
// chat-removed-from-group, chat-join-request-rejected: the convo is
|
||||
// no longer accessible to the recipient, so just open the list.
|
||||
// @ts-expect-error nested navigators aren't typed -sfn
|
||||
navigate('MessagesTab', {screen: 'Messages'})
|
||||
void navigate('MessagesTab', {screen: 'Messages'})
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1006,7 +1024,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
|
||||
ax.metric('init', {
|
||||
initMs: Math.round(
|
||||
// @ts-ignore Emitted by Metro in the bundle prelude
|
||||
// @ts-expect-error Emitted by Metro in the bundle prelude
|
||||
performance.now() - global.__BUNDLE_START_TIME__,
|
||||
),
|
||||
})
|
||||
@@ -1081,7 +1099,7 @@ function navigate<K extends keyof AllNavigatorParams>(
|
||||
}
|
||||
navigationRef.addListener('state', handler)
|
||||
|
||||
// @ts-ignore I don't know what would make typescript happy but I have a life -prf
|
||||
// @ts-expect-error I don't know what would make typescript happy but I have a life -prf
|
||||
navigationRef.navigate(name, params)
|
||||
}),
|
||||
timeout(1e3),
|
||||
|
||||
+6
-5
@@ -20,14 +20,15 @@ import * as SplashScreen from 'expo-splash-screen'
|
||||
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
import {atoms as a} from '#/alf'
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
import splashImagePointer from '../assets/splash/splash.png'
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
import darkSplashImagePointer from '../assets/splash/splash-dark.png'
|
||||
const splashImageUri = RNImage.resolveAssetSource(splashImagePointer).uri
|
||||
|
||||
const splashImageUri = RNImage.resolveAssetSource(splashImagePointer)!.uri
|
||||
const darkSplashImageUri = RNImage.resolveAssetSource(
|
||||
darkSplashImagePointer,
|
||||
).uri
|
||||
)!.uri
|
||||
|
||||
export const Logo = forwardRef(function LogoImpl(props: SvgProps, ref) {
|
||||
const width = 1000
|
||||
@@ -35,7 +36,7 @@ export const Logo = forwardRef(function LogoImpl(props: SvgProps, ref) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
// @ts-ignore it's fiiiiine
|
||||
// @ts-expect-error it's fiiiiine
|
||||
ref={ref}
|
||||
viewBox="0 0 64 66"
|
||||
style={[{width, height}, props.style]}>
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import Svg, {Path} from 'react-native-svg'
|
||||
|
||||
import {atoms as a, flatten} from '#/alf'
|
||||
import {atoms as a, flattenToCSS} from '#/alf'
|
||||
|
||||
const size = 100
|
||||
const ratio = 57 / 64
|
||||
@@ -72,7 +72,7 @@ export function Splash({
|
||||
{!isAnimationComplete && (
|
||||
<div
|
||||
ref={splashRef}
|
||||
style={flatten([
|
||||
style={flattenToCSS([
|
||||
a.fixed,
|
||||
a.inset_0,
|
||||
a.flex,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useSessionApi} from '#/state/session'
|
||||
import {Error} from '#/components/Error'
|
||||
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSadIcon} from '#/components/icons/Emoji'
|
||||
import {useOtherRequiredDataQuery} from '#/ageAssurance/data'
|
||||
import {IS_WEB} from '#/env'
|
||||
|
||||
export function DataUnavailableScreen() {
|
||||
const {t: l} = useLingui()
|
||||
const {logoutCurrentAccount} = useSessionApi()
|
||||
const {isFetching, refetch} = useOtherRequiredDataQuery()
|
||||
|
||||
return (
|
||||
<Error
|
||||
icon={EmojiSadIcon}
|
||||
title={l`Unable to load your account`}
|
||||
message={l`We couldn't load your account settings. Check your internet connection and try again.`}
|
||||
onRetry={() => void refetch()}
|
||||
isRetrying={isFetching}
|
||||
secondaryAction={{
|
||||
label: l`Sign out`,
|
||||
onPress: () => {
|
||||
if (IS_WEB) history.pushState(null, '', '/')
|
||||
logoutCurrentAccount('AgeAssuranceDataUnavailableScreen')
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback, useEffect} from 'react'
|
||||
import {useCallback, useEffect, useEffectEvent} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
@@ -76,7 +76,7 @@ export function NoAccessScreen() {
|
||||
const geolocationString = createGeolocationString(geolocation, i18n.locale)
|
||||
const isUsingGPS = !!geolocation.deviceGeolocation?.countryCode && IS_NATIVE
|
||||
|
||||
useEffect(() => {
|
||||
const onShown = useEffectEvent(() => {
|
||||
// just counting overall hits here
|
||||
ax.metric(`blockedGeoOverlay:shown`, {})
|
||||
ax.metric(`ageAssurance:noAccessScreen:shown`, {
|
||||
@@ -85,8 +85,10 @@ export function NoAccessScreen() {
|
||||
hasDeclaredAge,
|
||||
canUpdateBirthday,
|
||||
})
|
||||
// TODO This can be cleaned up with useEffectEvent once we're on 19.2
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onShown()
|
||||
}, [])
|
||||
|
||||
const onPressLogout = useCallback(() => {
|
||||
|
||||
@@ -149,7 +149,6 @@ export function RedirectOverlay() {
|
||||
t.atoms.bg,
|
||||
gtMobile ? a.p_2xl : a.p_xl,
|
||||
a.align_center,
|
||||
// @ts-ignore
|
||||
platform({
|
||||
web: {
|
||||
paddingTop: '35vh',
|
||||
|
||||
@@ -7,7 +7,7 @@ import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
|
||||
import {persistQueryClient} from '@tanstack/react-query-persist-client'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {isRetryableRequestError, networkRetry} from '#/lib/async/retry'
|
||||
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {
|
||||
@@ -57,6 +57,13 @@ const persister = createAsyncStoragePersister({
|
||||
const [, cacheHydrationPromise] = persistQueryClient({
|
||||
queryClient: qc,
|
||||
persister,
|
||||
/*
|
||||
* Device signals are local-only and cannot be recovered without prompting
|
||||
* the user again, so they must survive the persister's default 24-hour
|
||||
* expiration. Server-backed queries still use their own stale times and
|
||||
* refetch normally after hydration.
|
||||
*/
|
||||
maxAge: Infinity,
|
||||
})
|
||||
|
||||
/*
|
||||
@@ -340,9 +347,15 @@ export type OtherRequiredData = {
|
||||
birthdate: string | undefined
|
||||
actorDeclaration?: chat.bsky.actor.declaration.Main
|
||||
}
|
||||
export type OtherRequiredDataStatus = 'pending' | 'error' | 'success'
|
||||
const otherRequiredDataRetryOptions = {
|
||||
retry: (failureCount: number, error: unknown) =>
|
||||
failureCount < 2 && isRetryableRequestError(error),
|
||||
}
|
||||
export function createOtherRequiredDataQueryKey({did}: {did: string}) {
|
||||
return ['otherRequiredData', did]
|
||||
}
|
||||
|
||||
async function getOtherRequiredData({
|
||||
accountClient,
|
||||
}: {
|
||||
@@ -448,10 +461,11 @@ export async function prefetchOtherRequiredData({
|
||||
|
||||
try {
|
||||
logger.debug(`prefetchOtherRequiredData: resolving...`)
|
||||
const res = await networkRetry(3, () =>
|
||||
getOtherRequiredData({accountClient}),
|
||||
)
|
||||
qc.setQueryData<OtherRequiredData>(qk, res)
|
||||
await qc.fetchQuery({
|
||||
...otherRequiredDataRetryOptions,
|
||||
queryKey: qk,
|
||||
queryFn: () => getOtherRequiredData({accountClient}),
|
||||
})
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
logger.warn(`prefetchOtherRequiredData: failed`, {
|
||||
@@ -483,12 +497,14 @@ export function useOtherRequiredDataQuery() {
|
||||
const did = accountClient.did
|
||||
return useQuery(
|
||||
{
|
||||
...otherRequiredDataRetryOptions,
|
||||
enabled: !!did,
|
||||
initialData: () => {
|
||||
if (!did) return
|
||||
return getOtherRequiredDataFromCache({did})
|
||||
},
|
||||
queryKey: createOtherRequiredDataQueryKey({did: did!}),
|
||||
retryOnMount: false,
|
||||
async queryFn() {
|
||||
return getOtherRequiredData({accountClient})
|
||||
},
|
||||
@@ -715,6 +731,11 @@ export type AgeAssuranceServerData = {
|
||||
*/
|
||||
state: app.bsky.ageassurance.defs.State | undefined
|
||||
metadata: AgeAssuranceMetadata | undefined
|
||||
/**
|
||||
* Whether the account data needed to compute age assurance is available.
|
||||
* A successful response without a birthdate is still `success`.
|
||||
*/
|
||||
otherRequiredDataStatus: OtherRequiredDataStatus
|
||||
/**
|
||||
* The native on-device age signals for the region the user is currently in,
|
||||
* if they've granted access there. Already resolved from the region-keyed
|
||||
@@ -732,6 +753,7 @@ const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
|
||||
declaredAge: undefined,
|
||||
birthdate: undefined,
|
||||
},
|
||||
otherRequiredDataStatus: 'pending',
|
||||
deviceSignals: undefined,
|
||||
})
|
||||
export function useAgeAssuranceServerDataContext() {
|
||||
@@ -745,7 +767,18 @@ export function AgeAssuranceServerDataProvider({
|
||||
const {data: config} = useConfigQuery()
|
||||
const serverState = useServerStateQuery()
|
||||
const {state, metadata} = serverState.data || {}
|
||||
const {data} = useOtherRequiredDataQuery()
|
||||
const {data, errorUpdatedAt, status} = useOtherRequiredDataQuery()
|
||||
/*
|
||||
* A data-less query returns to `pending` and clears `error` while refetching,
|
||||
* but retains `errorUpdatedAt`. Keep the error screen mounted until data
|
||||
* loads successfully.
|
||||
*/
|
||||
const otherRequiredDataStatus: OtherRequiredDataStatus =
|
||||
data !== undefined
|
||||
? 'success'
|
||||
: status === 'error' || errorUpdatedAt > 0
|
||||
? 'error'
|
||||
: 'pending'
|
||||
// `select` resolves the cached region-keyed map to the current region.
|
||||
const {data: deviceSignals} = useDeviceSignalsQuery()
|
||||
const ctx = useMemo(
|
||||
@@ -760,9 +793,10 @@ export function AgeAssuranceServerDataProvider({
|
||||
: undefined,
|
||||
birthdate: data?.birthdate,
|
||||
},
|
||||
otherRequiredDataStatus,
|
||||
deviceSignals,
|
||||
}),
|
||||
[config, state, data, metadata, deviceSignals],
|
||||
[config, state, data, metadata, otherRequiredDataStatus, deviceSignals],
|
||||
)
|
||||
return (
|
||||
<AgeAssuranceServerDataContext.Provider value={ctx}>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {computeAgeAssuranceState} from '#/ageAssurance/state'
|
||||
import {AgeAssuranceAccess, AgeAssuranceStatus} from '#/ageAssurance/types'
|
||||
|
||||
jest.mock('#/ageAssurance/data', () => ({}))
|
||||
jest.mock('#/ageAssurance/logger', () => ({
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}))
|
||||
jest.mock('#/state/session', () => ({}))
|
||||
|
||||
const geolocation = {
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
}
|
||||
|
||||
describe('computeAgeAssuranceState', () => {
|
||||
it('computes access while required account data is pending', () => {
|
||||
expect(
|
||||
computeAgeAssuranceState({
|
||||
hasSession: true,
|
||||
geolocation,
|
||||
config: {regions: []},
|
||||
otherRequiredDataStatus: 'pending',
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.None,
|
||||
})
|
||||
})
|
||||
|
||||
it('denies access when required account data fails', () => {
|
||||
expect(
|
||||
computeAgeAssuranceState({
|
||||
hasSession: true,
|
||||
geolocation,
|
||||
config: {regions: []},
|
||||
otherRequiredDataStatus: 'error',
|
||||
}),
|
||||
).toEqual({
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.None,
|
||||
error: 'account-data',
|
||||
})
|
||||
})
|
||||
|
||||
it('computes access after a successful response without a birthdate', () => {
|
||||
expect(
|
||||
computeAgeAssuranceState({
|
||||
hasSession: true,
|
||||
geolocation,
|
||||
config: {regions: []},
|
||||
metadata: {birthdate: undefined},
|
||||
otherRequiredDataStatus: 'success',
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.None,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves authoritative terminal server state without account data', () => {
|
||||
expect(
|
||||
computeAgeAssuranceState({
|
||||
hasSession: true,
|
||||
geolocation: {countryCode: 'AA', regionCode: undefined},
|
||||
config: {
|
||||
regions: [
|
||||
{
|
||||
countryCode: 'AA',
|
||||
minAccessAge: 13,
|
||||
rules: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
state: {status: 'blocked', access: 'none'},
|
||||
otherRequiredDataStatus: 'error',
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: AgeAssuranceStatus.Blocked,
|
||||
access: AgeAssuranceAccess.None,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getDeviceSignalsFromCacheForRegion,
|
||||
getOtherRequiredDataFromCache,
|
||||
getServerStateFromCache,
|
||||
type OtherRequiredDataStatus,
|
||||
useAgeAssuranceServerDataContext,
|
||||
} from '#/ageAssurance/data'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
@@ -35,12 +36,13 @@ import {device} from '#/storage'
|
||||
* server state before computing access based on AA config from the server +
|
||||
* geolocation and other data.
|
||||
*/
|
||||
function computeAgeAssuranceState({
|
||||
export function computeAgeAssuranceState({
|
||||
hasSession,
|
||||
geolocation,
|
||||
config,
|
||||
state,
|
||||
metadata,
|
||||
otherRequiredDataStatus,
|
||||
deviceSignals,
|
||||
}: {
|
||||
hasSession: boolean
|
||||
@@ -48,6 +50,7 @@ function computeAgeAssuranceState({
|
||||
config?: app.bsky.ageassurance.defs.Config
|
||||
state?: app.bsky.ageassurance.defs.State
|
||||
metadata?: AgeAssuranceMetadata
|
||||
otherRequiredDataStatus: OtherRequiredDataStatus
|
||||
deviceSignals?: AgeRange.AgeRangeResponse
|
||||
}) {
|
||||
/**
|
||||
@@ -93,6 +96,14 @@ function computeAgeAssuranceState({
|
||||
}
|
||||
}
|
||||
|
||||
if (otherRequiredDataStatus === 'error') {
|
||||
return {
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.None,
|
||||
error: 'account-data' as const,
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Otherwise, we need to compute the access based on the latest data. For
|
||||
* accounts with an accurate birthdate, our default fallback rules should
|
||||
@@ -177,6 +188,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
||||
geolocation,
|
||||
state: state.state,
|
||||
metadata,
|
||||
otherRequiredDataStatus: 'success',
|
||||
deviceSignals,
|
||||
})
|
||||
|
||||
@@ -194,7 +206,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
||||
export function useAgeAssuranceState(): AgeAssuranceState {
|
||||
const {hasSession} = useSession()
|
||||
const geolocation = useGeolocation()
|
||||
const {config, state, metadata, deviceSignals} =
|
||||
const {config, state, metadata, otherRequiredDataStatus, deviceSignals} =
|
||||
useAgeAssuranceServerDataContext()
|
||||
|
||||
return useMemo(
|
||||
@@ -205,9 +217,18 @@ export function useAgeAssuranceState(): AgeAssuranceState {
|
||||
geolocation,
|
||||
state,
|
||||
metadata,
|
||||
otherRequiredDataStatus,
|
||||
deviceSignals,
|
||||
}),
|
||||
[hasSession, geolocation, config, state, metadata, deviceSignals],
|
||||
[
|
||||
hasSession,
|
||||
geolocation,
|
||||
config,
|
||||
state,
|
||||
metadata,
|
||||
otherRequiredDataStatus,
|
||||
deviceSignals,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ export type AgeAssuranceState = {
|
||||
lastInitiatedAt?: string
|
||||
status: AgeAssuranceStatus
|
||||
access: AgeAssuranceAccess
|
||||
error?: 'config' // maybe other specific cases in the future
|
||||
isLoading?: boolean
|
||||
error?: 'config' | 'account-data'
|
||||
}
|
||||
|
||||
export type AgeAssuranceFlags = {
|
||||
|
||||
+6
-1
@@ -3,6 +3,8 @@ import {type FontVariant, type TextStyle} from 'react-native'
|
||||
import {IS_ANDROID, IS_WEB} from '#/env'
|
||||
import {type Device, device} from '#/storage'
|
||||
|
||||
export type MutableTextStyle = {-readonly [K in keyof TextStyle]: TextStyle[K]}
|
||||
|
||||
const WEB_FONT_FAMILIES = `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"`
|
||||
|
||||
const factor = 0.0625 // 1 - (15/16)
|
||||
@@ -37,7 +39,10 @@ export function setFontFamily(fontFamily: Device['fontFamily']) {
|
||||
/*
|
||||
* Unused fonts are commented out, but the files are there if we need them.
|
||||
*/
|
||||
export function applyFonts(style: TextStyle, fontFamily: 'system' | 'theme') {
|
||||
export function applyFonts(
|
||||
style: MutableTextStyle,
|
||||
fontFamily: 'system' | 'theme',
|
||||
) {
|
||||
if (fontFamily === 'theme') {
|
||||
if (IS_ANDROID) {
|
||||
style.fontFamily =
|
||||
|
||||
@@ -7,7 +7,13 @@ import {
|
||||
import {UITextView} from '@bsky.app/react-native-uitextview'
|
||||
import createEmojiRegex from 'emoji-regex'
|
||||
|
||||
import {type Alf, applyFonts, atoms, flatten} from '#/alf'
|
||||
import {
|
||||
type Alf,
|
||||
applyFonts,
|
||||
atoms,
|
||||
flatten,
|
||||
type MutableTextStyle,
|
||||
} from '#/alf'
|
||||
import {IS_IOS, IS_NATIVE} from '#/env'
|
||||
|
||||
/**
|
||||
@@ -27,7 +33,7 @@ export function normalizeTextStyles(
|
||||
fontFamily: Alf['fonts']['family']
|
||||
} & Pick<Alf, 'flags'>,
|
||||
) {
|
||||
const s = flatten(styles) ?? {}
|
||||
const s: MutableTextStyle = {...flatten(styles)}
|
||||
|
||||
// should always be defined on these components
|
||||
s.fontSize = (s.fontSize || atoms.text_md.fontSize) * fontScale
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Dimensions} from 'react-native'
|
||||
import {Dimensions, type DimensionsPayload} from 'react-native'
|
||||
|
||||
/**
|
||||
* Same as `useWindowDimensions().fontScale`, but avoids rerendering
|
||||
@@ -9,9 +9,12 @@ export function useNativeFontScale() {
|
||||
const [fontScale, setFontScale] = useState(Dimensions.get('window').fontScale)
|
||||
|
||||
useEffect(() => {
|
||||
const sub = Dimensions.addEventListener('change', evt => {
|
||||
setFontScale(evt.window.fontScale)
|
||||
})
|
||||
const sub = Dimensions.addEventListener(
|
||||
'change',
|
||||
(evt: DimensionsPayload) => {
|
||||
if (evt.window) setFontScale(evt.window.fontScale)
|
||||
},
|
||||
)
|
||||
return () => sub.remove()
|
||||
}, [])
|
||||
|
||||
|
||||
+16
-3
@@ -1,6 +1,19 @@
|
||||
import {type DimensionValue, StyleSheet} from 'react-native'
|
||||
import {type DimensionValue, type StyleProp, StyleSheet} from 'react-native'
|
||||
|
||||
export const flatten = StyleSheet.flatten
|
||||
export function flatten<T>(
|
||||
style?: StyleProp<T>,
|
||||
): T extends (infer U)[] ? U : T {
|
||||
return (StyleSheet.flatten(
|
||||
style as unknown as Parameters<typeof StyleSheet.flatten>[0],
|
||||
) ?? {}) as T extends (infer U)[] ? U : T
|
||||
}
|
||||
|
||||
/** Flatten React Native styles passed directly to a web-only DOM component. */
|
||||
export function flattenToCSS(style: unknown): React.CSSProperties {
|
||||
return (StyleSheet.flatten(
|
||||
style as Parameters<typeof StyleSheet.flatten>[0],
|
||||
) ?? {}) as React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a style value to a number. Padding values are typed as
|
||||
@@ -28,7 +41,7 @@ interface PaddingStyle {
|
||||
* non-numeric `DimensionValue` (e.g. percentages) is treated as 0.
|
||||
*/
|
||||
export function extractPadding(style: PaddingStyle | PaddingStyle[]) {
|
||||
const s = flatten(style as any) ?? {}
|
||||
const s = flatten(style)
|
||||
const base = num(s.padding)
|
||||
return {
|
||||
paddingTop: num(s.paddingTop) || num(s.paddingVertical) || base,
|
||||
|
||||
@@ -24,7 +24,7 @@ export function useThemeName(): ThemeName {
|
||||
}
|
||||
|
||||
function getThemeName(
|
||||
colorScheme: ColorSchemeName,
|
||||
colorScheme: ColorSchemeName | null | undefined,
|
||||
colorMode: 'system' | 'light' | 'dark',
|
||||
darkTheme?: ThemeName,
|
||||
) {
|
||||
@@ -39,11 +39,8 @@ function getThemeName(
|
||||
}
|
||||
|
||||
function updateDocument(theme: ThemeName) {
|
||||
// @ts-ignore web only
|
||||
if (IS_WEB && typeof window !== 'undefined') {
|
||||
// @ts-ignore web only
|
||||
const html = window.document.documentElement
|
||||
// @ts-ignore web only
|
||||
const meta = window.document.querySelector('meta[name="theme-color"]')
|
||||
|
||||
// remove any other color mode classes
|
||||
|
||||
+15
-14
@@ -1,5 +1,3 @@
|
||||
import {useMemo} from 'react'
|
||||
|
||||
import {type Breakpoint, useBreakpoints} from '#/alf/breakpoints'
|
||||
import * as tokens from '#/alf/tokens'
|
||||
|
||||
@@ -44,23 +42,26 @@ export function useGutters([top, right, bottom, left]: [
|
||||
Gutter,
|
||||
Gutter,
|
||||
]): Gutters
|
||||
export function useGutters([top, right, bottom, left]: Gutter[]) {
|
||||
export function useGutters(gutter: Gutter[]) {
|
||||
const {activeBreakpoint} = useBreakpoints()
|
||||
/*
|
||||
* Destructured in the body rather than the parameter list: these are
|
||||
* reassigned below to fill in the CSS-shorthand forms, and React Compiler
|
||||
* cannot lower a reassigned destructured parameter.
|
||||
*/
|
||||
let [top, right, bottom, left] = gutter
|
||||
if (right === undefined) {
|
||||
right = bottom = left = top
|
||||
} else if (bottom === undefined) {
|
||||
bottom = top
|
||||
left = right
|
||||
}
|
||||
return useMemo(() => {
|
||||
return {
|
||||
paddingTop: top === 0 ? 0 : gutters[top][activeBreakpoint || 'default'],
|
||||
paddingRight:
|
||||
right === 0 ? 0 : gutters[right][activeBreakpoint || 'default'],
|
||||
paddingBottom:
|
||||
bottom === 0 ? 0 : gutters[bottom][activeBreakpoint || 'default'],
|
||||
paddingLeft:
|
||||
left === 0 ? 0 : gutters[left][activeBreakpoint || 'default'],
|
||||
}
|
||||
}, [activeBreakpoint, top, right, bottom, left])
|
||||
return {
|
||||
paddingTop: top === 0 ? 0 : gutters[top][activeBreakpoint || 'default'],
|
||||
paddingRight:
|
||||
right === 0 ? 0 : gutters[right][activeBreakpoint || 'default'],
|
||||
paddingBottom:
|
||||
bottom === 0 ? 0 : gutters[bottom][activeBreakpoint || 'default'],
|
||||
paddingLeft: left === 0 ? 0 : gutters[left][activeBreakpoint || 'default'],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,37 +77,19 @@ export function getFeatures() {
|
||||
|
||||
export function getFeatureDescription(feature: Features, i18n: I18n) {
|
||||
switch (feature) {
|
||||
case Features.VideoAllow10MinuteEnable:
|
||||
case Features.PostFeedKnownLikersEnable:
|
||||
return {
|
||||
key: feature,
|
||||
name: i18n._(
|
||||
msg({
|
||||
message: 'Longer videos',
|
||||
comment: 'Name for a feature flag (longer videos)',
|
||||
message: 'Social proofing on posts',
|
||||
comment: 'Name for a feature flag',
|
||||
}),
|
||||
),
|
||||
description: i18n._(
|
||||
msg({
|
||||
message: 'Enable 10-minute video uploads.',
|
||||
comment: 'Description of a feature flag (10-minute video uploads)',
|
||||
}),
|
||||
),
|
||||
}
|
||||
case Features.CanonicalPostNumberingEnable:
|
||||
return {
|
||||
key: feature,
|
||||
name: i18n._(
|
||||
msg({
|
||||
message: 'Thread numbering',
|
||||
comment:
|
||||
'Name for a feature flag (See numbered badges (1/3, 2/3, etc.) on posts in a thread by the same author.)',
|
||||
}),
|
||||
),
|
||||
description: i18n._(
|
||||
msg({
|
||||
message:
|
||||
'See numbered badges (1/3, 2/3, etc.) on posts in a thread by the same author.',
|
||||
comment: 'Description of a feature flag (Thread numbering)',
|
||||
message: 'Spot posts your friends and follows have liked.',
|
||||
comment: 'Description of a feature flag (Social proofing on posts)',
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -16,16 +16,14 @@ export enum Features {
|
||||
GroupChatsDisable = 'group_chats:disable',
|
||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||
PostFeedKnownLikersEnable = 'post_feed:known_likers:enable',
|
||||
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
|
||||
PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable',
|
||||
CustomLogoJapanEnable = 'custom_logo:japan:enable',
|
||||
VideoAllow10MinuteEnable = 'video:allow-10-minute:enable',
|
||||
VideoMultipartUploadEnable = 'video:multipart_upload:enable',
|
||||
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
|
||||
FollowSortEnable = 'follow_sort:enable',
|
||||
OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable',
|
||||
CanonicalPostNumberingEnable = 'canonical_post_numbering:enable',
|
||||
ContentVisibilitySettingsEnable = 'content_visibility_settings:enable',
|
||||
ModerationInboxEnable = 'moderation_inbox:enable',
|
||||
|
||||
// values
|
||||
TrendingDiscoverValues = 'trending_discover:values',
|
||||
|
||||
+10
-8
@@ -9,10 +9,6 @@ import {Platform} from 'react-native'
|
||||
import {type Result, type WidenPrimitives} from '@growthbook/growthbook-react'
|
||||
|
||||
import {Logger} from '#/logger'
|
||||
import {
|
||||
getCachedIsBetaUser,
|
||||
subscribeToCachedIsBetaUser,
|
||||
} from '#/state/preferences/beta-user-cache'
|
||||
import {
|
||||
Features,
|
||||
features as feats,
|
||||
@@ -37,7 +33,7 @@ import {type Metrics, metrics} from '#/analytics/metrics'
|
||||
import * as refParams from '#/analytics/misc/refParams'
|
||||
import * as env from '#/env'
|
||||
import {useGeolocationServiceResponse} from '#/geolocation/service'
|
||||
import {device} from '#/storage'
|
||||
import {account, device} from '#/storage'
|
||||
|
||||
export * as utils from '#/analytics/utils'
|
||||
export const features = {init, refresh}
|
||||
@@ -121,6 +117,7 @@ const Context = createContext<AnalyticsBaseContextType>({
|
||||
geolocation: device.get(['geolocationServiceResponse']) || {
|
||||
countryCode: '',
|
||||
regionCode: '',
|
||||
city: '',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -135,7 +132,7 @@ export const setupDeviceId = getAndMigrateDeviceId()
|
||||
|
||||
/**
|
||||
* Reads the per-account cached `isBetaUser` flag for `did`, kept in sync with
|
||||
* PDS preference query results and the beta settings toggle.
|
||||
* writes from `BetaUserStorageSync` and the beta settings toggle.
|
||||
*
|
||||
* This deliberately does not use `useStorage`, whose `useState` seeds once and
|
||||
* only updates via the change listener. The consuming `AnalyticsContext` lives
|
||||
@@ -150,13 +147,17 @@ function useAccountIsBetaUser(did: string | undefined): boolean | undefined {
|
||||
const subscribe = useCallback(
|
||||
(onChange: () => void) => {
|
||||
if (!did) return () => {}
|
||||
return subscribeToCachedIsBetaUser(did, onChange)
|
||||
const sub = account.addOnValueChangedListener(
|
||||
[did, 'isBetaUser'],
|
||||
onChange,
|
||||
)
|
||||
return () => sub.remove()
|
||||
},
|
||||
[did],
|
||||
)
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (!did) return undefined
|
||||
return getCachedIsBetaUser(did)
|
||||
return account.get([did, 'isBetaUser'])
|
||||
}, [did])
|
||||
return useSyncExternalStore(subscribe, getSnapshot)
|
||||
}
|
||||
@@ -181,6 +182,7 @@ export function AnalyticsContext({
|
||||
}
|
||||
const deviceId = useDeviceId() ?? 'unknown'
|
||||
const sessionId = useSessionId()
|
||||
// only IP based, never GPS
|
||||
const geolocation = useGeolocationServiceResponse()
|
||||
const parentContext = useContext(Context)
|
||||
/*
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
import {type Platform} from 'react-native'
|
||||
|
||||
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
|
||||
import {
|
||||
type VideoCompressSkipReason,
|
||||
type VideoUploadTransport,
|
||||
} from '#/lib/media/video/types'
|
||||
import {type VideoCompressSkipReason} from '#/lib/media/video/types'
|
||||
import {type NotificationType} from '#/state/queries/notifications/types'
|
||||
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
|
||||
@@ -47,6 +44,7 @@ export type Events = {
|
||||
| 'SignupQueued'
|
||||
| 'Deactivated'
|
||||
| 'Takendown'
|
||||
| 'AgeAssuranceDataUnavailableScreen'
|
||||
| 'AgeAssuranceNoAccessScreen'
|
||||
scope: 'current' | 'every'
|
||||
}
|
||||
@@ -453,6 +451,7 @@ export type Events = {
|
||||
'post:view': {
|
||||
uri: string
|
||||
authorDid: string
|
||||
isReply: boolean
|
||||
logContext:
|
||||
| 'FeedItem'
|
||||
| 'PostThreadItem'
|
||||
@@ -706,6 +705,7 @@ export type Events = {
|
||||
}
|
||||
'starterPack:removeUser': {
|
||||
starterPack?: string
|
||||
context?: 'opt-out'
|
||||
}
|
||||
'starterPack:share': {
|
||||
starterPack: string
|
||||
@@ -718,6 +718,10 @@ export type Events = {
|
||||
count: number
|
||||
}
|
||||
'starterPack:delete': {}
|
||||
'starterPack:optOut': {
|
||||
starterPack: string
|
||||
action: 'optOut' | 'undo'
|
||||
}
|
||||
'starterPack:create': {
|
||||
setName: boolean
|
||||
setDescription: boolean
|
||||
@@ -1399,6 +1403,41 @@ export type Events = {
|
||||
playlist: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The playable video was meaningfully visible. This is an exposure event,
|
||||
* not proof that playback started. Fires once per mounted video item.
|
||||
*/
|
||||
'video:impression': {
|
||||
postUri?: string
|
||||
postAuthorDid?: string
|
||||
context: 'embed' | 'immersiveFeed'
|
||||
presentation: 'video' | 'gif'
|
||||
}
|
||||
/**
|
||||
* Playback advanced far enough to render the first frame. Preloading and
|
||||
* merely becoming active do not count. Fires once per mounted video item;
|
||||
* automatic loops do not produce another event.
|
||||
*/
|
||||
'video:playback:start': {
|
||||
postUri?: string
|
||||
postAuthorDid?: string
|
||||
context: 'embed' | 'immersiveFeed'
|
||||
presentation: 'video' | 'gif'
|
||||
autoplay: boolean
|
||||
}
|
||||
/**
|
||||
* The user activated a third-party media player. Cross-origin players do
|
||||
* not expose confirmed playback consistently, so this must not be treated
|
||||
* as equivalent to video:playback:start without an explicit methodology.
|
||||
*/
|
||||
'externalEmbed:playerActivated': {
|
||||
postUri?: string
|
||||
postAuthorDid?: string
|
||||
source: string
|
||||
playerType: string
|
||||
mediaType: 'video' | 'audio' | 'gif' | 'other'
|
||||
}
|
||||
|
||||
// === Video upload funnel (Frontend Spec section D) ===
|
||||
// Every event carries uploadId (client-generated UUID, ties one upload
|
||||
// session end-to-end) + engine (compression engine id, e.g.
|
||||
@@ -1473,7 +1512,6 @@ export type Events = {
|
||||
bytes: number
|
||||
elapsedMs: number
|
||||
throughputBytesPerSec: number
|
||||
transport: VideoUploadTransport
|
||||
}
|
||||
'video:upload:uploadFailed': {
|
||||
uploadId: string
|
||||
@@ -1481,7 +1519,6 @@ export type Events = {
|
||||
bytes: number
|
||||
errorClass: string
|
||||
elapsedMs: number
|
||||
transport: VideoUploadTransport
|
||||
}
|
||||
'video:upload:processingStarted': {
|
||||
uploadId: string
|
||||
|
||||
@@ -11,12 +11,16 @@ import {
|
||||
* Thin `useMemo` wrapper that marks the metadata as memoized and provides a
|
||||
* type guard.
|
||||
*/
|
||||
function markMemoized<T extends MergeableMetadata>(m: T): T {
|
||||
// @ts-expect-error
|
||||
m.__meta = true
|
||||
return m
|
||||
}
|
||||
|
||||
export function useMeta(metadata?: MergeableMetadata) {
|
||||
const m = useMemo(() => metadata, [metadata])
|
||||
if (!m) return
|
||||
// @ts-ignore
|
||||
m.__meta = true
|
||||
return m
|
||||
return markMemoized(m)
|
||||
}
|
||||
|
||||
export function accountToSessionMetadata(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Pressable} from 'react-native'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Pressable} from '#/components/Pressable'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -49,7 +49,7 @@ export function AltBadgeWithDialog({
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_20}
|
||||
onPress={control.open}
|
||||
style={s => [
|
||||
style={({pressed, hovered}) => [
|
||||
a.justify_center,
|
||||
a.rounded_sm,
|
||||
a.p_xs,
|
||||
@@ -62,7 +62,7 @@ export function AltBadgeWithDialog({
|
||||
opacity: 0.8,
|
||||
},
|
||||
pos,
|
||||
s.hovered || s.pressed
|
||||
hovered || pressed
|
||||
? [
|
||||
{
|
||||
opacity: 1,
|
||||
|
||||
@@ -13,13 +13,17 @@ export function AvatarStack({
|
||||
size = 26,
|
||||
numPending,
|
||||
backgroundColor,
|
||||
borderWidth = 1,
|
||||
overlap,
|
||||
}: {
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
size?: number
|
||||
numPending?: number
|
||||
backgroundColor?: string
|
||||
borderWidth?: number
|
||||
overlap?: number
|
||||
}) {
|
||||
const translation = size / 3 // overlap by 1/3
|
||||
const translation = overlap ?? size / 3
|
||||
const t = useTheme()
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
@@ -55,7 +59,7 @@ export function AvatarStack({
|
||||
width: size,
|
||||
height: size,
|
||||
left: i * -translation,
|
||||
borderWidth: 1,
|
||||
borderWidth,
|
||||
borderColor: backgroundColor ?? t.atoms.bg.backgroundColor,
|
||||
borderRadius: 999,
|
||||
zIndex: 3 - i,
|
||||
@@ -63,7 +67,7 @@ export function AvatarStack({
|
||||
]}>
|
||||
{item.profile && (
|
||||
<UserAvatar
|
||||
size={size - 2}
|
||||
size={size - borderWidth * 2}
|
||||
avatar={item.profile.avatar}
|
||||
type={item.profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
moderation={item.moderation.ui('avatar')}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {useState} from 'react'
|
||||
import {type Insets, Pressable, View} from 'react-native'
|
||||
import {type Insets, 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 {Pressable} from '#/components/Pressable'
|
||||
import * as Tooltip from '#/components/Tooltip'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
|
||||
@@ -101,6 +101,8 @@ export type ButtonProps = Pick<
|
||||
| 'onPressOut'
|
||||
| 'onFocus'
|
||||
| 'onBlur'
|
||||
| 'onAccessibilityAction'
|
||||
| 'onAccessibilityEscape'
|
||||
> &
|
||||
AccessibilityProps &
|
||||
VariantProps & {
|
||||
@@ -131,7 +133,7 @@ export function useButtonContext() {
|
||||
return useContext(Context)
|
||||
}
|
||||
|
||||
export const Button = forwardRef<View, ButtonProps>(
|
||||
export const Button = forwardRef<React.ComponentRef<typeof View>, ButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
@@ -575,7 +577,6 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
role="button"
|
||||
accessibilityHint={undefined} // optional
|
||||
{...rest}
|
||||
// @ts-ignore - this will always be a pressable
|
||||
ref={ref}
|
||||
aria-label={label}
|
||||
aria-pressed={state.pressed}
|
||||
|
||||
@@ -39,6 +39,9 @@ import {
|
||||
import {Span, Text} from '#/components/Typography'
|
||||
import {IS_IOS, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env'
|
||||
|
||||
type TextInputInstance = React.ComponentRef<typeof TextInput>
|
||||
type ViewInstance = React.ComponentRef<typeof View>
|
||||
|
||||
export type SubmitRequest =
|
||||
| {
|
||||
platform: 'web'
|
||||
@@ -60,7 +63,7 @@ export type ComposerInternalApi = {
|
||||
input?: ReturnType<typeof useTapper>['input']
|
||||
clear: () => void
|
||||
insert(text: string): void
|
||||
setAutocompleteAnchor: (node: View | null) => void
|
||||
setAutocompleteAnchor: (node: ViewInstance | null) => void
|
||||
}
|
||||
|
||||
export function useComposerInternalApiRef() {
|
||||
@@ -82,7 +85,7 @@ export type ComposerProps = Omit<
|
||||
| 'onSubmitEditing'
|
||||
> & {
|
||||
label: string
|
||||
ref?: React.RefObject<TextInput>
|
||||
ref?: React.RefObject<TextInputInstance>
|
||||
internalApiRef?: React.Ref<ComposerInternalApi>
|
||||
outerStyle?: ViewStyleProp['style']
|
||||
contentTextStyle?: TextStyleProp['style']
|
||||
@@ -139,6 +142,11 @@ export function Composer({
|
||||
placement: autocompletePlacement,
|
||||
dynamicWidth: IS_WEB,
|
||||
})
|
||||
const inputRef = mergeRefs<TextInputInstance>([
|
||||
ref,
|
||||
tapper.inputProps.ref as React.Ref<TextInputInstance>,
|
||||
sift.targetProps.ref as React.Ref<TextInputInstance>,
|
||||
])
|
||||
|
||||
/*
|
||||
* Active facet state for controlling the visibility of the Autocomplete.
|
||||
@@ -306,7 +314,7 @@ export function Composer({
|
||||
style={[a.absolute, a.inset_0, a.z_10, {overflow: 'hidden'}]}
|
||||
ref={node => {
|
||||
if (IS_WEB && node) {
|
||||
// @ts-ignore web only a11y
|
||||
// @ts-expect-error web only a11y
|
||||
node.setAttribute('inert', '')
|
||||
}
|
||||
}}>
|
||||
@@ -345,7 +353,7 @@ export function Composer({
|
||||
{...rest}
|
||||
{...tapper.inputProps}
|
||||
{...sift.targetProps}
|
||||
ref={mergeRefs([ref, tapper.inputProps.ref, sift.targetProps.ref])}
|
||||
ref={inputRef}
|
||||
rawValue={tapper.state.text}
|
||||
onBlur={e => {
|
||||
rest.onBlur?.(e)
|
||||
@@ -354,16 +362,15 @@ export function Composer({
|
||||
onKeyPress={IS_WEB ? onKeyPressWeb : undefined}
|
||||
onScroll={e => {
|
||||
if (IS_WEB) {
|
||||
inputScrollSharedValue.value = (e.target as any).scrollTop
|
||||
inputScrollSharedValue.set((e.target as any).scrollTop)
|
||||
} else {
|
||||
inputScrollSharedValue.value = e.nativeEvent.contentOffset.y
|
||||
inputScrollSharedValue.set(e.nativeEvent.contentOffset.y)
|
||||
}
|
||||
}}
|
||||
// @ts-ignore web only
|
||||
// @ts-expect-error web only
|
||||
onCompositionStart={() => {
|
||||
isComposing.current = true
|
||||
}}
|
||||
// @ts-ignore web only
|
||||
onCompositionEnd={() => {
|
||||
isComposing.current = false
|
||||
}}
|
||||
|
||||
@@ -244,7 +244,7 @@ export function Trigger({
|
||||
const context = useContextMenuContext()
|
||||
const playHaptic = useHaptics()
|
||||
const insets = useSafeAreaInsets()
|
||||
const ref = useRef<View>(null)
|
||||
const ref = useRef<React.ComponentRef<typeof View>>(null)
|
||||
const isFocused = useIsFocused()
|
||||
const [image, setImage] = useState<string | null>(null)
|
||||
const [pendingMeasurement, setPendingMeasurement] = useState<{
|
||||
@@ -530,7 +530,7 @@ export function AuxiliaryView({
|
||||
transform: [
|
||||
{
|
||||
translateY:
|
||||
(ensureOnScreenTranslationSV.get() || translationSV.get()) *
|
||||
Math.max(ensureOnScreenTranslationSV.get(), translationSV.get()) *
|
||||
animationSV.get(),
|
||||
},
|
||||
{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])},
|
||||
@@ -544,21 +544,17 @@ export function AuxiliaryView({
|
||||
const onLayout = useCallback(() => {
|
||||
if (!measurement) return
|
||||
|
||||
let translation = 0
|
||||
|
||||
// vibes based, just assuming it'll fit within this space. revisit if we use
|
||||
// AuxiliaryView for something tall
|
||||
const TOP_INSET = topInset + 80
|
||||
|
||||
const distanceMessageFromTop = measurement.y - TOP_INSET
|
||||
if (distanceMessageFromTop < 0) {
|
||||
translation = -distanceMessageFromTop
|
||||
}
|
||||
const minimumTranslation = -distanceMessageFromTop
|
||||
|
||||
// normally, the context menu is responsible for measuring itself and moving everything into the right place
|
||||
// however, in auxiliary-only mode, that doesn't happen, so we need to do it ourselves here
|
||||
if (mode === 'auxiliary-only') {
|
||||
translationSV.set(translation)
|
||||
translationSV.set(Math.max(minimumTranslation, 0))
|
||||
ensureOnScreenTranslationSV.set(0)
|
||||
}
|
||||
// however, we also need to make sure that for super tall triggers, we don't go off the screen
|
||||
@@ -567,7 +563,7 @@ export function AuxiliaryView({
|
||||
// we'll just have to live with it for now, fixing it would be possible but be a large complexity
|
||||
// increase for an edge case
|
||||
else {
|
||||
ensureOnScreenTranslationSV.set(translation)
|
||||
ensureOnScreenTranslationSV.set(minimumTranslation)
|
||||
}
|
||||
}, [mode, measurement, translationSV, topInset, ensureOnScreenTranslationSV])
|
||||
|
||||
@@ -971,7 +967,10 @@ export function Divider() {
|
||||
)
|
||||
}
|
||||
|
||||
function measureView(view: View | null, insets: EdgeInsets) {
|
||||
function measureView(
|
||||
view: React.ComponentRef<typeof View> | null,
|
||||
insets: EdgeInsets,
|
||||
) {
|
||||
if (!view) return Promise.resolve(null)
|
||||
return new Promise<Measurement>(resolve => {
|
||||
view?.measureInWindow((x, y, width, height) =>
|
||||
|
||||
@@ -42,7 +42,6 @@ export function useDialogControl(): DialogOuterProps['control'] {
|
||||
useEffect(() => {
|
||||
activeDialogs.current.set(id, control)
|
||||
return () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
activeDialogs.current.delete(id)
|
||||
}
|
||||
}, [id, activeDialogs])
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from 'react'
|
||||
import {
|
||||
Keyboard,
|
||||
type KeyboardEventListener,
|
||||
type KeyboardEvent,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
@@ -203,80 +203,85 @@ export function Inner(props: DialogInnerProps) {
|
||||
return <ScrollableInner {...props} />
|
||||
}
|
||||
|
||||
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
|
||||
function ScrollableInner(
|
||||
{children, contentContainerStyle, header, footer, style, ...props},
|
||||
ref,
|
||||
) {
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} =
|
||||
useDialogContext()
|
||||
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
|
||||
const insets = useSafeAreaInsets()
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(() =>
|
||||
IS_ANDROID ? (Keyboard.metrics()?.height ?? 0) : 0,
|
||||
)
|
||||
export function ScrollableInner({
|
||||
ref,
|
||||
children,
|
||||
contentContainerStyle,
|
||||
header,
|
||||
footer,
|
||||
style,
|
||||
...props
|
||||
}: DialogInnerProps & {
|
||||
ref?: React.Ref<React.ComponentRef<typeof ScrollView>>
|
||||
}) {
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} =
|
||||
useDialogContext()
|
||||
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
|
||||
const insets = useSafeAreaInsets()
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(() =>
|
||||
IS_ANDROID ? (Keyboard.metrics()?.height ?? 0) : 0,
|
||||
)
|
||||
|
||||
const keyboardEventHandler = useCallback<KeyboardEventListener>(e => {
|
||||
setKeyboardHeight(e.endCoordinates.height)
|
||||
}, [])
|
||||
useOnKeyboard('keyboardDidShow', keyboardEventHandler)
|
||||
useOnKeyboard('keyboardDidHide', keyboardEventHandler)
|
||||
const keyboardEventHandler = useCallback((e: KeyboardEvent) => {
|
||||
setKeyboardHeight(e.endCoordinates.height)
|
||||
}, [])
|
||||
useOnKeyboard('keyboardDidShow', keyboardEventHandler)
|
||||
useOnKeyboard('keyboardDidHide', keyboardEventHandler)
|
||||
|
||||
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (!IS_ANDROID) {
|
||||
return
|
||||
}
|
||||
const {contentOffset} = e.nativeEvent
|
||||
if (contentOffset.y > 0 && !disableDrag) {
|
||||
setDisableDrag(true)
|
||||
} else if (contentOffset.y <= 1 && disableDrag) {
|
||||
setDisableDrag(false)
|
||||
}
|
||||
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (!IS_ANDROID) {
|
||||
return
|
||||
}
|
||||
const {contentOffset} = e.nativeEvent
|
||||
if (contentOffset.y > 0 && !disableDrag) {
|
||||
setDisableDrag(true)
|
||||
} else if (contentOffset.y <= 1 && disableDrag) {
|
||||
setDisableDrag(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
style={[isHeightConstrained && a.flex_1, style]}
|
||||
contentContainerStyle={[
|
||||
a.pt_2xl,
|
||||
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
||||
platform({
|
||||
ios: a.pb_2xl,
|
||||
android: {
|
||||
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
|
||||
},
|
||||
}),
|
||||
contentContainerStyle,
|
||||
]}
|
||||
ref={ref}
|
||||
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
||||
contentInsetAdjustmentBehavior={
|
||||
isAtMaxSnapPoint ? 'automatic' : 'never'
|
||||
}
|
||||
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
|
||||
{...props}
|
||||
bounces={isAtMaxSnapPoint}
|
||||
scrollEventThrottle={50}
|
||||
// set drag state based on scroll on android.
|
||||
// we want to detect if it's at the top or not, so watch
|
||||
// scrollEndDrag and momentumScrollEnd as well
|
||||
onScroll={android(onScroll)}
|
||||
onScrollEndDrag={android(onScroll)}
|
||||
onMomentumScrollEnd={android(onScroll)}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
// TODO: figure out why this positions the header absolutely (rather than stickily)
|
||||
// on Android. fine to disable for now, because we don't have any
|
||||
// dialogs that use this that actually scroll -sfn
|
||||
stickyHeaderIndices={ios(header ? [0] : undefined)}>
|
||||
{header}
|
||||
{children}
|
||||
</ScrollView>
|
||||
{footer}
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
style={[isHeightConstrained && a.flex_1, style]}
|
||||
contentContainerStyle={[
|
||||
a.pt_2xl,
|
||||
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
||||
platform({
|
||||
ios: a.pb_2xl,
|
||||
android: {
|
||||
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
|
||||
},
|
||||
}),
|
||||
contentContainerStyle,
|
||||
]}
|
||||
ref={ref}
|
||||
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
||||
contentInsetAdjustmentBehavior={
|
||||
isAtMaxSnapPoint ? 'automatic' : 'never'
|
||||
}
|
||||
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
|
||||
{...props}
|
||||
bounces={isAtMaxSnapPoint}
|
||||
scrollEventThrottle={50}
|
||||
// set drag state based on scroll on android.
|
||||
// we want to detect if it's at the top or not, so watch
|
||||
// scrollEndDrag and momentumScrollEnd as well
|
||||
onScroll={android(onScroll)}
|
||||
onScrollEndDrag={android(onScroll)}
|
||||
onMomentumScrollEnd={android(onScroll)}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
// TODO: figure out why this positions the header absolutely (rather than stickily)
|
||||
// on Android. fine to disable for now, because we don't have any
|
||||
// dialogs that use this that actually scroll -sfn
|
||||
stickyHeaderIndices={ios(header ? [0] : undefined)}>
|
||||
{header}
|
||||
{children}
|
||||
</ScrollView>
|
||||
{footer}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const InnerFlatList = forwardRef<
|
||||
ListMethods,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user