diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml
new file mode 100644
index 0000000000..e71792358f
--- /dev/null
+++ b/.github/workflows/e2e-test.yml
@@ -0,0 +1,339 @@
+name: E2E Tests
+
+on:
+ pull_request:
+
+ workflow_dispatch:
+ inputs:
+ ios:
+ description: "Run iOS e2e tests"
+ type: boolean
+ default: false
+ android:
+ description: "Run Android e2e tests"
+ type: boolean
+ default: true
+ mock_server_branch:
+ description: "stream-chat-test-mock-server branch"
+ type: string
+ default: "main"
+
+permissions:
+ contents: read
+ pull-requests: read
+ checks: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ MOCK_SERVER_BRANCH: ${{ inputs.mock_server_branch || 'main' }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_PR_NUM: ${{ github.event.pull_request.number }}
+ GITHUB_EVENT_ACTION: ${{ github.event.action }}
+ GITHUB_EVENT_BEFORE: ${{ github.event.before }}
+ GITHUB_EVENT_AFTER: ${{ github.event.after }}
+ # node_modules of the e2e app and the workspaces it depends on (nmHoistingLimits: workspaces
+ # keeps them apart); `yarn workspaces focus` installs nothing else.
+ NODE_MODULES_PATHS: |
+ node_modules
+ configs/*/node_modules
+ package/node_modules
+ package/native-package/node_modules
+ examples/E2ETestApp/node_modules
+ .yarn/install-state.gz
+
+jobs:
+ check:
+ name: Check E2E Required
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ outputs:
+ required: ${{ steps.check.outputs.required }}
+ steps:
+ - uses: actions/checkout@v6
+ - uses: ./.github/actions/ruby-cache
+ - name: Check if e2e tests are required
+ id: check
+ working-directory: examples/SampleApp
+ run: bundle exec fastlane e2e_check_required
+
+ ios:
+ needs: check
+ if: needs.check.outputs.required == 'true' && github.event_name == 'workflow_dispatch' && inputs.ios
+ runs-on: macos-15
+ timeout-minutes: 60
+ env:
+ DETOX_IOS_DEVICE: "iPhone 17 Pro"
+ IOS_VERSION: "26.2"
+ XCODE_VERSION: "26.2"
+ APP_PATH: examples/E2ETestApp/ios/build/detox/Build/Products/Release-iphonesimulator/E2ETestApp.app
+ MOCK_SERVER_BUNDLE_PATH: ${{ github.workspace }}/../mock-server-gems
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 24.x
+ cache: "yarn"
+ cache-dependency-path: "yarn.lock"
+ - uses: maxim-lobanov/setup-xcode@v1
+ with:
+ xcode-version: ${{ env.XCODE_VERSION }}
+ # A fresh simulator takes minutes to boot on CI; boot it now so that overlaps with setup.
+ - name: Boot simulator
+ run: |
+ runtime="com.apple.CoreSimulator.SimRuntime.iOS-${IOS_VERSION//./-}"
+ udid=$(xcrun simctl list devices available -j |
+ jq -r --arg rt "$runtime" --arg name "$DETOX_IOS_DEVICE" '.devices[$rt][]? | select(.name == $name) | .udid' | head -1)
+ if [ -z "$udid" ]; then
+ echo "::warning::No $DETOX_IOS_DEVICE on iOS $IOS_VERSION; Detox will pick and boot a simulator itself"
+ exit 0
+ fi
+ echo "DETOX_IOS_UDID=$udid" >> "$GITHUB_ENV"
+ nohup xcrun simctl boot "$udid" > /dev/null 2>&1 &
+ - uses: ./.github/actions/ruby-cache
+
+ # --- JS dependencies ---
+ - name: Restore node_modules
+ id: node-modules
+ uses: actions/cache/restore@v5
+ with:
+ path: ${{ env.NODE_MODULES_PATHS }}
+ key: ${{ runner.os }}-e2e-node-modules-${{ hashFiles('yarn.lock') }}
+ # Only the e2e app's dependency tree, not every example app.
+ - name: Install dependencies
+ run: yarn workspaces focus e2etestapp
+ - name: Save node_modules
+ if: steps.node-modules.outputs.cache-hit != 'true'
+ uses: actions/cache/save@v5
+ with:
+ path: ${{ env.NODE_MODULES_PATHS }}
+ key: ${{ steps.node-modules.outputs.cache-primary-key }}
+
+ # --- App binary: rebuilt only when the app or SDK sources change, not for test-only changes ---
+ - name: Restore app
+ id: app
+ uses: actions/cache/restore@v5
+ with:
+ path: ${{ env.APP_PATH }}
+ key: e2e-ios-app-xcode${{ env.XCODE_VERSION }}-${{ hashFiles('yarn.lock', 'package/package.json', 'package/src/**', 'package/shared-native/**', 'package/native-package/package.json', 'package/native-package/src/**', 'package/native-package/ios/**', 'package/native-package/*.podspec', 'examples/SampleApp/Gemfile.lock', 'examples/E2ETestApp/*.js', '!examples/E2ETestApp/.detoxrc.js', 'examples/E2ETestApp/*.json', 'examples/E2ETestApp/*.tsx', 'examples/E2ETestApp/src/**', 'examples/E2ETestApp/ios/**', '!examples/E2ETestApp/ios/build/**', '!examples/E2ETestApp/ios/Pods/**') }}
+ - name: Set up ccache
+ if: steps.app.outputs.cache-hit != 'true'
+ uses: hendrikmuhs/ccache-action@v1.2
+ with:
+ key: e2e-ios-xcode${{ env.XCODE_VERSION }}
+ max-size: 1500M
+ - name: Point the RN ccache wrappers at ccache
+ if: steps.app.outputs.cache-hit != 'true'
+ # RN's ccache-clang.sh runs `$CCACHE_BINARY clang`, but Xcode doesn't export that build
+ # setting to the compiler, so without this every compile silently skips ccache.
+ run: echo "CCACHE_BINARY=$(which ccache)" >> "$GITHUB_ENV"
+ - name: Cache pods
+ if: steps.app.outputs.cache-hit != 'true'
+ uses: actions/cache@v5
+ with:
+ path: examples/E2ETestApp/ios/Pods
+ key: ${{ runner.os }}-e2e-pods-${{ hashFiles('examples/E2ETestApp/ios/Podfile.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-e2e-pods-
+ - name: Install pods
+ if: steps.app.outputs.cache-hit != 'true'
+ working-directory: examples/SampleApp
+ run: USE_CCACHE=1 bundle exec fastlane e2e_pod_install
+ - name: Build app
+ if: steps.app.outputs.cache-hit != 'true'
+ working-directory: examples/E2ETestApp
+ run: yarn e2e:build -c ios.sim.release
+ - name: Save app
+ if: steps.app.outputs.cache-hit != 'true'
+ uses: actions/cache/save@v5
+ with:
+ path: ${{ env.APP_PATH }}
+ key: ${{ steps.app.outputs.cache-primary-key }}
+
+ # --- Test tooling ---
+ - name: Install applesimutils
+ # Homebrew refuses formulae from non-official taps until they are trusted.
+ run: brew tap wix/brew && brew trust --formula wix/brew/applesimutils && brew install wix/brew/applesimutils
+ - name: Restore Detox framework
+ id: detox-framework
+ uses: actions/cache/restore@v5
+ with:
+ path: ~/Library/Detox
+ key: ${{ runner.os }}-detox-framework-xcode${{ env.XCODE_VERSION }}-${{ hashFiles('yarn.lock') }}
+ # Yarn's `enableScripts: false` skips Detox's postinstall, which would build this.
+ - name: Build Detox framework
+ if: steps.detox-framework.outputs.cache-hit != 'true'
+ working-directory: examples/E2ETestApp
+ run: yarn detox build-framework-cache
+ - name: Save Detox framework
+ if: steps.detox-framework.outputs.cache-hit != 'true'
+ uses: actions/cache/save@v5
+ with:
+ path: ~/Library/Detox
+ key: ${{ steps.detox-framework.outputs.cache-primary-key }}
+ - name: Resolve mock server revision
+ id: mock-server
+ run: echo "sha=$(git ls-remote https://github.com/GetStream/stream-chat-test-mock-server "refs/heads/$MOCK_SERVER_BRANCH" | cut -f1)" >> "$GITHUB_OUTPUT"
+ - name: Cache mock server gems
+ uses: actions/cache@v5
+ with:
+ path: ${{ env.MOCK_SERVER_BUNDLE_PATH }}
+ key: ${{ runner.os }}-mock-server-gems-${{ steps.mock-server.outputs.sha }}
+ restore-keys: |
+ ${{ runner.os }}-mock-server-gems-
+
+ - name: Wait for the simulator
+ if: env.DETOX_IOS_UDID != ''
+ timeout-minutes: 15
+ # `-b` boots it if the background boot never started; it errors (405) when the device is
+ # already booted, and then the plain call returns at once.
+ run: xcrun simctl bootstatus "$DETOX_IOS_UDID" -b || xcrun simctl bootstatus "$DETOX_IOS_UDID"
+ - name: Run e2e tests
+ working-directory: examples/SampleApp
+ run: bundle exec fastlane test_e2e platform:ios skip_build:true mock_server_branch:"$MOCK_SERVER_BRANCH"
+ - uses: actions/upload-artifact@v6
+ if: failure()
+ with:
+ name: e2e-ios-logs
+ path: |
+ examples/E2ETestApp/stream-chat-test-mock-server/logs
+ examples/E2ETestApp/artifacts
+
+ android:
+ needs: check
+ if: needs.check.outputs.required == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android)
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ env:
+ # Same as the native Android SDK e2e jobs, which run stably on these runners
+ ANDROID_API_LEVEL: "36"
+ ANDROID_EMULATOR_OPTIONS: -no-snapshot-save -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect -camera-back none -camera-front none
+ DETOX_ADB_NAME: "emulator-5554"
+ APK_PATHS: |
+ examples/E2ETestApp/android/app/build/outputs/apk/release/app-release.apk
+ examples/E2ETestApp/android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk
+ MOCK_SERVER_BUNDLE_PATH: ${{ github.workspace }}/../mock-server-gems
+ # The emulator is x86_64: skip compiling native code for the other ABIs.
+ ORG_GRADLE_PROJECT_reactNativeArchitectures: x86_64
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 24.x
+ cache: "yarn"
+ cache-dependency-path: "yarn.lock"
+ - uses: actions/setup-java@v5
+ with:
+ distribution: "zulu"
+ java-version: "21"
+ # Caches Gradle's dependencies, wrapper and build cache. It only writes on the default branch by
+ # default, and this workflow doesn't run there, so let PR runs write: their reruns reuse it.
+ - uses: gradle/actions/setup-gradle@v4
+ with:
+ cache-read-only: false
+ - uses: ./.github/actions/ruby-cache
+
+ # --- JS dependencies ---
+ - name: Restore node_modules
+ id: node-modules
+ uses: actions/cache/restore@v5
+ with:
+ path: ${{ env.NODE_MODULES_PATHS }}
+ key: ${{ runner.os }}-e2e-node-modules-${{ hashFiles('yarn.lock') }}
+ # Only the e2e app's dependency tree, not every example app.
+ - name: Install dependencies
+ run: yarn workspaces focus e2etestapp
+ - name: Save node_modules
+ if: steps.node-modules.outputs.cache-hit != 'true'
+ uses: actions/cache/save@v5
+ with:
+ path: ${{ env.NODE_MODULES_PATHS }}
+ key: ${{ steps.node-modules.outputs.cache-primary-key }}
+
+ # --- App binaries: rebuilt only when the app or SDK sources change, not for test-only changes ---
+ - name: Restore APKs
+ id: apk
+ uses: actions/cache/restore@v5
+ with:
+ path: ${{ env.APK_PATHS }}
+ key: e2e-android-apk-${{ hashFiles('yarn.lock', 'package/package.json', 'package/src/**', 'package/shared-native/**', 'package/native-package/package.json', 'package/native-package/src/**', 'package/native-package/android/**', '!package/native-package/android/build/**', 'examples/E2ETestApp/*.js', '!examples/E2ETestApp/.detoxrc.js', 'examples/E2ETestApp/*.json', 'examples/E2ETestApp/*.tsx', 'examples/E2ETestApp/src/**', 'examples/E2ETestApp/android/**', '!examples/E2ETestApp/android/**/build/**', '!examples/E2ETestApp/android/.gradle/**') }}
+ # Built before the emulator boots so the emulator doesn't idle through the Gradle build.
+ - name: Build app
+ if: steps.apk.outputs.cache-hit != 'true'
+ working-directory: examples/E2ETestApp
+ run: yarn e2e:build -c android.att.release
+ - name: Save APKs
+ if: steps.apk.outputs.cache-hit != 'true'
+ uses: actions/cache/save@v5
+ with:
+ path: ${{ env.APK_PATHS }}
+ key: ${{ steps.apk.outputs.cache-primary-key }}
+ # Frees the daemons' memory for the emulator, which otherwise can get killed mid-run.
+ - name: Stop Gradle daemons
+ if: steps.apk.outputs.cache-hit != 'true'
+ working-directory: examples/E2ETestApp/android
+ run: ./gradlew --stop
+
+ # --- Emulator: same setup as the native Android SDK's e2e jobs (AOSP image, no Play services) ---
+ - name: Enable KVM
+ run: |
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm
+
+ # --- Test tooling ---
+ - name: Resolve mock server revision
+ id: mock-server
+ run: echo "sha=$(git ls-remote https://github.com/GetStream/stream-chat-test-mock-server "refs/heads/$MOCK_SERVER_BRANCH" | cut -f1)" >> "$GITHUB_OUTPUT"
+ - name: Cache mock server gems
+ uses: actions/cache@v5
+ with:
+ path: ${{ env.MOCK_SERVER_BUNDLE_PATH }}
+ key: ${{ runner.os }}-mock-server-gems-${{ steps.mock-server.outputs.sha }}
+ restore-keys: |
+ ${{ runner.os }}-mock-server-gems-
+
+ - name: Run e2e tests (Android emulator)
+ uses: reactivecircus/android-emulator-runner@v2
+ with:
+ api-level: ${{ env.ANDROID_API_LEVEL }}
+ arch: x86_64
+ profile: pixel
+ emulator-options: ${{ env.ANDROID_EMULATOR_OPTIONS }}
+ disable-animations: true
+ working-directory: examples/SampleApp
+ script: bundle exec fastlane test_e2e platform:android device:att skip_build:true mock_server_branch:"$MOCK_SERVER_BRANCH"
+ # The emulator has vanished mid-run without any output; this shows whether the kernel killed it.
+ - name: Diagnose emulator
+ if: failure()
+ run: |
+ sudo dmesg -T | grep -i -E 'out of memory|oom-kill|killed process' || echo "No OOM kills in dmesg"
+ free -m
+ df -h
+ ls -la /tmp/android-runner 2>/dev/null || true
+ - uses: actions/upload-artifact@v6
+ if: failure()
+ with:
+ name: e2e-android-logs
+ path: |
+ examples/E2ETestApp/stream-chat-test-mock-server/logs
+ examples/E2ETestApp/artifacts
+ /tmp/android-runner
+
+ result:
+ name: Test E2E Result
+ runs-on: ubuntu-latest
+ if: always()
+ needs:
+ - check
+ - android
+ steps:
+ - name: Check E2E results
+ if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
+ run: |
+ echo "$NEEDS"
+ exit 1
+ env:
+ NEEDS: ${{ toJson(needs) }}
diff --git a/.prettierignore b/.prettierignore
index c4f36f8ee4..55d7ebb190 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -24,6 +24,10 @@ examples/SampleApp/patches/
.yarn/
yarn.lock
+# Detox e2e: mock server checkout cloned by `fastlane start_mock_server`, test artifacts
+examples/E2ETestApp/stream-chat-test-mock-server/
+examples/E2ETestApp/artifacts/
+
# Generated SDK assets
package/src/components/docs/
package/src/theme/generated/
diff --git a/AGENTS.md b/AGENTS.md
index e295c458bc..9d725756c5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,7 +20,7 @@ Targets iOS and Android.
- **Languages:** TypeScript + React Native
- **Runtime:** Node 24 (`.nvmrc` is `v24`; root `engines.node` is `>=20.19.4`; CI runs `24.x`)
- **Package manager:** Yarn 4.15.0 (Berry). The binary lives at `.yarn/releases/yarn-4.15.0.cjs` and is activated via `yarnPath` in `.yarnrc.yml`. Any globally installed `yarn` (even the Homebrew classic 1.x) acts only as a launcher — no Corepack required.
-- **Workspaces:** single root `yarn.lock`; workspaces are `configs/typescript-config`, `package`, `package/native-package`, `package/expo-package`, `examples/SampleApp`, `examples/ExpoMessaging`. **No Lerna.**
+- **Workspaces:** single root `yarn.lock`; workspaces are `configs/typescript-config`, `package`, `package/native-package`, `package/expo-package`, `examples/SampleApp`, `examples/E2ETestApp`, `examples/ExpoMessaging`. **No Lerna.**
- **Testing:** Jest with the `@react-native/jest-preset` + `@testing-library/react-native`.
- **Build:** `react-native-builder-bob` → CommonJS (`lib/commonjs`), ESM (`lib/module`), types (`lib/typescript`)
- **Lint/format:** ESLint 9 flat config + Prettier, strict (`--max-warnings 0`)
@@ -41,7 +41,7 @@ Respect repo-specific rules. Do not suppress lint rules broadly; justify and sco
- `native-package/` — bare RN wrapper (`stream-chat-react-native`)
- `expo-package/` — Expo wrapper (`stream-chat-expo`)
- `shared-native/{ios,android}` — native source shared by both wrappers; synced into them, never edited in place
-- `examples/` — `SampleApp` (full-featured), `ExpoMessaging`
+- `examples/` — `SampleApp` (full-featured), `E2ETestApp` (minimal app for the Detox e2e suite), `ExpoMessaging`
- `configs/typescript-config/` — shared `tsconfig` presets
- `ai-docs/` — agent-facing deep dives (see [References](#references))
- `perf/` — on-device performance toolkit (see [Accessibility, RTL & performance](#accessibility-rtl--performance))
@@ -244,6 +244,8 @@ Native pieces live in `package/shared-native/`: the Android-only `PerfText` Fabr
To run one test file, prefer `cd package && TZ=UTC npx jest path/to/file.test.tsx`. The `testRegex` array in `jest.config.js` also accepts a temporary path — revert it before committing.
+**E2E:** Detox suite in `examples/E2ETestApp/e2e/` against the shared `stream-chat-test-mock-server`, with robots and page objects mirroring the native iOS/Android suites. Run it with `bundle exec fastlane test_e2e platform:ios|android` from `examples/SampleApp`; see `examples/E2ETestApp/e2e/README.md`. Elements it needs get a `testID` in the SDK (kebab-case, additive).
+
**Mock builders** (`package/src/mock-builders/`):
- `api/initiateClientWithChannels` — creates a test client + channels in one call (fastest path)
@@ -371,7 +373,7 @@ Follow `PULL_REQUEST_TEMPLATE.md`. Keep PRs small and focused.
### CI expectations
-`.github/workflows/check-pr.yml` (Node 24): `yarn install --immutable` → `yarn build` → `yarn lint` → `yarn typecheck` → `yarn test:coverage`. Other workflows: `changelog-preview`, `lint-pr-title`, `release`, `sample-distribution`, `sdk-size-metrics`.
+`.github/workflows/check-pr.yml` (Node 24): `yarn install --immutable` → `yarn build` → `yarn lint` → `yarn typecheck` → `yarn test:coverage`. Other workflows: `changelog-preview`, `e2e-test` (Detox on `examples/E2ETestApp`, gated by `is_check_required`; require its `Test E2E Result` check), `lint-pr-title`, `release`, `sample-distribution`, `sdk-size-metrics`.
Failing or flaky tests: fix them, or quarantine with a justification comment and a follow-up.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 97425a89b6..468d782eab 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -44,6 +44,10 @@ export default tsEslint.config(
'**/android/',
'**/fastlane/',
+ // Detox e2e: mock server checkout cloned by `fastlane start_mock_server`, test artifacts
+ 'examples/E2ETestApp/stream-chat-test-mock-server/',
+ 'examples/E2ETestApp/artifacts/',
+
// Generated SDK assets
'package/src/components/docs/',
'package/src/theme/generated/',
diff --git a/examples/E2ETestApp/.detoxrc.js b/examples/E2ETestApp/.detoxrc.js
new file mode 100644
index 0000000000..2e090e80c7
--- /dev/null
+++ b/examples/E2ETestApp/.detoxrc.js
@@ -0,0 +1,67 @@
+/** @type {Detox.DetoxConfig} */
+module.exports = {
+ testRunner: {
+ args: {
+ $0: 'jest',
+ config: 'e2e/jest.config.js',
+ },
+ jest: {
+ setupTimeout: 300000,
+ },
+ },
+ apps: {
+ 'ios.debug': {
+ type: 'ios.app',
+ binaryPath: 'ios/build/detox/Build/Products/Debug-iphonesimulator/E2ETestApp.app',
+ build:
+ 'xcodebuild -workspace ios/E2ETestApp.xcworkspace -scheme E2ETestApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build/detox',
+ },
+ 'ios.release': {
+ type: 'ios.app',
+ binaryPath: 'ios/build/detox/Build/Products/Release-iphonesimulator/E2ETestApp.app',
+ build:
+ 'xcodebuild -workspace ios/E2ETestApp.xcworkspace -scheme E2ETestApp -configuration Release -sdk iphonesimulator -derivedDataPath ios/build/detox',
+ },
+ 'android.debug': {
+ type: 'android.apk',
+ binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
+ testBinaryPath: 'android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk',
+ build:
+ 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..',
+ reversePorts: [8081],
+ },
+ 'android.release': {
+ type: 'android.apk',
+ binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
+ testBinaryPath:
+ 'android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk',
+ build:
+ 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release && cd ..',
+ },
+ },
+ devices: {
+ simulator: {
+ type: 'ios.simulator',
+ // CI pins the simulator it boots early (DETOX_IOS_UDID), so Detox reuses it.
+ device: process.env.DETOX_IOS_UDID
+ ? { id: process.env.DETOX_IOS_UDID }
+ : { type: process.env.DETOX_IOS_DEVICE || 'iPhone 16 Pro' },
+ },
+ emulator: {
+ type: 'android.emulator',
+ device: { avdName: process.env.DETOX_AVD_NAME || 'Pixel_API_37' },
+ },
+ attached: {
+ type: 'android.attached',
+ device: { adbName: process.env.DETOX_ADB_NAME || '.*' },
+ },
+ },
+ configurations: {
+ 'ios.sim.debug': { app: 'ios.debug', device: 'simulator' },
+ 'ios.sim.release': { app: 'ios.release', device: 'simulator' },
+ 'android.emu.debug': { app: 'android.debug', device: 'emulator' },
+ 'android.emu.release': { app: 'android.release', device: 'emulator' },
+ 'android.att.debug': { app: 'android.debug', device: 'attached' },
+ 'android.att.release': { app: 'android.release', device: 'attached' },
+ },
+};
diff --git a/examples/E2ETestApp/.gitignore b/examples/E2ETestApp/.gitignore
new file mode 100644
index 0000000000..9be6361de6
--- /dev/null
+++ b/examples/E2ETestApp/.gitignore
@@ -0,0 +1,79 @@
+# OSX
+#
+.DS_Store
+
+# Xcode
+#
+build/
+*.pbxuser
+!default.pbxuser
+*.mode1v3
+!default.mode1v3
+*.mode2v3
+!default.mode2v3
+*.perspectivev3
+!default.perspectivev3
+xcuserdata
+*.xccheckout
+*.moved-aside
+DerivedData
+*.hmap
+*.ipa
+*.xcuserstate
+**/.xcode.env.local
+
+# Android/IntelliJ
+#
+build/
+.idea
+.gradle
+local.properties
+*.iml
+*.hprof
+.cxx/
+*.keystore
+!debug.keystore
+.kotlin/
+
+# node.js
+#
+node_modules/
+npm-debug.log
+yarn-error.log
+
+# fastlane
+#
+# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
+# screenshots whenever they are needed.
+# For more information about the recommended setup visit:
+# https://docs.fastlane.tools/best-practices/source-control/
+
+**/fastlane/report.xml
+**/fastlane/Preview.html
+**/fastlane/screenshots
+**/fastlane/test_output
+
+# Bundle artifact
+*.jsbundle
+
+# Ruby / CocoaPods
+**/Pods/
+/vendor/bundle/
+
+# Temporary files created by Metro to check the health of the file watcher
+.metro-health-check*
+
+# testing
+/coverage
+
+# Yarn
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/sdks
+!.yarn/versions
+
+# Detox e2e: mock server checkout cloned by `fastlane start_mock_server`, test artifacts
+/stream-chat-test-mock-server/
+/artifacts/
diff --git a/examples/E2ETestApp/.watchmanconfig b/examples/E2ETestApp/.watchmanconfig
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/examples/E2ETestApp/.watchmanconfig
@@ -0,0 +1 @@
+{}
diff --git a/examples/E2ETestApp/App.tsx b/examples/E2ETestApp/App.tsx
new file mode 100644
index 0000000000..653961dc5f
--- /dev/null
+++ b/examples/E2ETestApp/App.tsx
@@ -0,0 +1,133 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { BackHandler, Pressable, StyleSheet, Text, View } from 'react-native';
+import { GestureHandlerRootView } from 'react-native-gesture-handler';
+import { SafeAreaProvider, SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { Channel as ChannelType, ChannelFilters, ChannelSort, StreamChat } from 'stream-chat';
+import {
+ Channel,
+ ChannelList,
+ Chat,
+ MessageComposer,
+ MessageList,
+ OverlayProvider,
+} from 'stream-chat-react-native';
+
+import { apiKey, appUser, appUserToken, mockServerUrl } from './src/config';
+
+// Same opt-in as the SampleApp, so the e2e suite sees the same accessibility tree.
+const accessibility = { enabled: true };
+const sort: ChannelSort = { last_message_at: -1 };
+
+/** Lists the mock server's app user, like the SampleApp's user selector. */
+const LoginScreen = ({ onLogin }: { onLogin: () => void }) => (
+
+ E2E Test App
+ {mockServerUrl ? (
+
+ {appUser.name}
+
+ ) : (
+ Launch with the MOCK_SERVER_URL argument (see e2e/README.md).
+ )}
+
+);
+
+const ChannelScreen = ({ channel, onBack }: { channel: ChannelType; onBack: () => void }) => {
+ const { top } = useSafeAreaInsets();
+
+ useEffect(() => {
+ const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
+ onBack();
+ return true;
+ });
+ return () => subscription.remove();
+ }, [onBack]);
+
+ // Laid out like the SampleApp's channel screen: the header lives inside , which sizes
+ // itself to the screen and handles the bottom inset and keyboard for the composer.
+ return (
+
+
+
+
+ ‹ Back
+
+
+ {channel.id}
+
+
+
+
+
+
+ );
+};
+
+const ChatScreens = ({ client }: { client: StreamChat }) => {
+ const [channel, setChannel] = useState();
+ const filters = useMemo(
+ () => ({ members: { $in: [client.userID ?? appUser.id] } }),
+ [client.userID],
+ );
+ const closeChannel = useCallback(() => setChannel(undefined), []);
+
+ return (
+
+ {/* The list stays mounted under the channel, as in a navigation stack. */}
+
+
+
+ {channel ? : null}
+
+ );
+};
+
+const App = () => {
+ const [client, setClient] = useState();
+
+ const login = useCallback(async () => {
+ const chatClient = StreamChat.getInstance(apiKey, { baseURL: mockServerUrl, timeout: 6000 });
+ await chatClient.connectUser(appUser, appUserToken);
+ setClient(chatClient);
+ }, []);
+
+ return (
+
+
+
+ {client ? : }
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ back: { fontSize: 17, paddingRight: 12 },
+ container: { backgroundColor: '#FFFFFF', flex: 1 },
+ flex: { flex: 1 },
+ header: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ },
+ headerTitle: { flex: 1, fontSize: 17, fontWeight: '600' },
+ hint: { padding: 16 },
+ title: { fontSize: 22, fontWeight: '700', padding: 16 },
+ userName: { fontSize: 17 },
+ userRow: { borderBottomColor: '#E5E5E5', borderBottomWidth: 1, padding: 16 },
+});
+
+export default App;
diff --git a/examples/E2ETestApp/android/app/build.gradle b/examples/E2ETestApp/android/app/build.gradle
new file mode 100644
index 0000000000..879f64489c
--- /dev/null
+++ b/examples/E2ETestApp/android/app/build.gradle
@@ -0,0 +1,124 @@
+apply plugin: "com.android.application"
+apply plugin: "org.jetbrains.kotlin.android"
+apply plugin: "com.facebook.react"
+
+/**
+ * This is the configuration block to customize your React Native Android app.
+ * By default you don't need to apply any configuration, just uncomment the lines you need.
+ */
+react {
+ /* Folders */
+ // The root of your project, i.e. where "package.json" lives. Default is '../..'
+ // root = file("../../")
+ // The folder where the react-native NPM package is. Default is ../../node_modules/react-native
+ // reactNativeDir = file("../../node_modules/react-native")
+ // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
+ // codegenDir = file("../../node_modules/@react-native/codegen")
+ // The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js
+ // cliFile = file("../../node_modules/react-native/cli.js")
+
+ /* Variants */
+ // The list of variants to that are debuggable. For those we're going to
+ // skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized".
+ // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
+ // debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"]
+
+ /* Bundling */
+ // A list containing the node command and its flags. Default is just 'node'.
+ // nodeExecutableAndArgs = ["node"]
+ //
+ // The command to run when bundling. By default is 'bundle'
+ // bundleCommand = "ram-bundle"
+ //
+ // The path to the CLI configuration file. Default is empty.
+ // bundleConfig = file(../rn-cli.config.js)
+ //
+ // The name of the generated asset file containing your JS bundle
+ // bundleAssetName = "MyApplication.android.bundle"
+ //
+ // The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
+ // entryFile = file("../js/MyApplication.android.js")
+ //
+ // A list of extra flags to pass to the 'bundle' commands.
+ // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
+ // extraPackagerArgs = []
+
+ /* Hermes Commands */
+ // The hermes compiler command to run. By default it is 'hermesc'
+ // hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
+ //
+ // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
+ // hermesFlags = ["-O", "-output-source-map"]
+
+ /* Autolinking */
+ autolinkLibrariesWithApp()
+}
+
+/**
+ * Set this to true to Run Proguard on Release builds to minify the Java bytecode.
+ */
+def enableProguardInReleaseBuilds = false
+
+/**
+ * The preferred build flavor of JavaScriptCore (JSC)
+ *
+ * For example, to use the international variant, you can use:
+ * `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+`
+ *
+ * The international variant includes ICU i18n library and necessary data
+ * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
+ * give correct results when using with locales other than en-US. Note that
+ * this variant is about 6MiB larger per architecture than default.
+ */
+def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
+
+android {
+ ndkVersion rootProject.ext.ndkVersion
+ buildToolsVersion rootProject.ext.buildToolsVersion
+ compileSdk rootProject.ext.compileSdkVersion
+
+ namespace "io.getstream.reactnative.e2etestapp"
+ defaultConfig {
+ applicationId "io.getstream.reactnative.e2etestapp"
+ minSdkVersion rootProject.ext.minSdkVersion
+ targetSdkVersion rootProject.ext.targetSdkVersion
+ versionCode 1
+ versionName "1.0"
+ // Detox: builds the instrumentation APK against the variant under test
+ testBuildType System.getProperty('testBuildType', 'debug')
+ testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
+ }
+ signingConfigs {
+ debug {
+ storeFile file('debug.keystore')
+ storePassword 'android'
+ keyAlias 'androiddebugkey'
+ keyPassword 'android'
+ }
+ }
+ buildTypes {
+ debug {
+ signingConfig signingConfigs.debug
+ }
+ release {
+ // Caution! In production, you need to generate your own keystore file.
+ // see https://reactnative.dev/docs/signed-apk-android.
+ signingConfig signingConfigs.debug
+ minifyEnabled enableProguardInReleaseBuilds
+ proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
+ }
+ }
+}
+
+dependencies {
+ // The version of react-native is set by the React Native Gradle Plugin
+ implementation("com.facebook.react:react-android")
+
+ if (hermesEnabled.toBoolean()) {
+ implementation("com.facebook.react:hermes-android")
+ } else {
+ implementation jscFlavor
+ }
+
+ androidTestImplementation('com.wix:detox:+')
+}
diff --git a/examples/E2ETestApp/android/app/debug.keystore b/examples/E2ETestApp/android/app/debug.keystore
new file mode 100644
index 0000000000..364e105ed3
Binary files /dev/null and b/examples/E2ETestApp/android/app/debug.keystore differ
diff --git a/examples/E2ETestApp/android/app/proguard-rules.pro b/examples/E2ETestApp/android/app/proguard-rules.pro
new file mode 100644
index 0000000000..11b025724a
--- /dev/null
+++ b/examples/E2ETestApp/android/app/proguard-rules.pro
@@ -0,0 +1,10 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
+# You can edit the include path and order by changing the proguardFiles
+# directive in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# Add any project specific keep options here:
diff --git a/examples/E2ETestApp/android/app/src/androidTest/java/io/getstream/reactnative/e2etestapp/DetoxTest.kt b/examples/E2ETestApp/android/app/src/androidTest/java/io/getstream/reactnative/e2etestapp/DetoxTest.kt
new file mode 100644
index 0000000000..18e8533735
--- /dev/null
+++ b/examples/E2ETestApp/android/app/src/androidTest/java/io/getstream/reactnative/e2etestapp/DetoxTest.kt
@@ -0,0 +1,27 @@
+package io.getstream.reactnative.e2etestapp
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.rule.ActivityTestRule
+import com.wix.detox.Detox
+import com.wix.detox.config.DetoxConfig
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+class DetoxTest {
+ @get:Rule
+ val activityRule = ActivityTestRule(MainActivity::class.java, false, false)
+
+ @Test
+ fun runDetoxTests() {
+ val detoxConfig = DetoxConfig().apply {
+ idlePolicyConfig.masterTimeoutSec = 90
+ idlePolicyConfig.idleResourceTimeoutSec = 60
+ rnContextLoadTimeoutSec = if (BuildConfig.DEBUG) 180 else 60
+ }
+ Detox.runTests(activityRule, detoxConfig)
+ }
+}
diff --git a/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml b/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..d5ac9c6e7c
--- /dev/null
+++ b/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/E2ETestApp/android/app/src/main/java/io/getstream/reactnative/e2etestapp/MainActivity.kt b/examples/E2ETestApp/android/app/src/main/java/io/getstream/reactnative/e2etestapp/MainActivity.kt
new file mode 100644
index 0000000000..b59b475ece
--- /dev/null
+++ b/examples/E2ETestApp/android/app/src/main/java/io/getstream/reactnative/e2etestapp/MainActivity.kt
@@ -0,0 +1,22 @@
+package io.getstream.reactnative.e2etestapp
+
+import com.facebook.react.ReactActivity
+import com.facebook.react.ReactActivityDelegate
+import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
+import com.facebook.react.defaults.DefaultReactActivityDelegate
+
+class MainActivity : ReactActivity() {
+
+ /**
+ * Returns the name of the main component registered from JavaScript. This is used to schedule
+ * rendering of the component.
+ */
+ override fun getMainComponentName(): String = "E2ETestApp"
+
+ /**
+ * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
+ * which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
+ */
+ override fun createReactActivityDelegate(): ReactActivityDelegate =
+ DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
+}
diff --git a/examples/E2ETestApp/android/app/src/main/java/io/getstream/reactnative/e2etestapp/MainApplication.kt b/examples/E2ETestApp/android/app/src/main/java/io/getstream/reactnative/e2etestapp/MainApplication.kt
new file mode 100644
index 0000000000..dd573a42aa
--- /dev/null
+++ b/examples/E2ETestApp/android/app/src/main/java/io/getstream/reactnative/e2etestapp/MainApplication.kt
@@ -0,0 +1,27 @@
+package io.getstream.reactnative.e2etestapp
+
+import android.app.Application
+import com.facebook.react.PackageList
+import com.facebook.react.ReactApplication
+import com.facebook.react.ReactHost
+import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
+import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
+
+class MainApplication : Application(), ReactApplication {
+
+ override val reactHost: ReactHost by lazy {
+ getDefaultReactHost(
+ context = applicationContext,
+ packageList =
+ PackageList(this).packages.apply {
+ // Packages that cannot be autolinked yet can be added manually here, for example:
+ // add(MyReactNativePackage())
+ },
+ )
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ loadReactNative(this)
+ }
+}
diff --git a/examples/E2ETestApp/android/app/src/main/res/drawable/rn_edit_text_material.xml b/examples/E2ETestApp/android/app/src/main/res/drawable/rn_edit_text_material.xml
new file mode 100644
index 0000000000..5c25e728ea
--- /dev/null
+++ b/examples/E2ETestApp/android/app/src/main/res/drawable/rn_edit_text_material.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/E2ETestApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/examples/E2ETestApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000..a2f5908281
Binary files /dev/null and b/examples/E2ETestApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/examples/E2ETestApp/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/examples/E2ETestApp/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..1b52399808
Binary files /dev/null and b/examples/E2ETestApp/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/examples/E2ETestApp/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/examples/E2ETestApp/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000..ff10afd6e1
Binary files /dev/null and b/examples/E2ETestApp/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/examples/E2ETestApp/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/examples/E2ETestApp/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..115a4c768a
Binary files /dev/null and b/examples/E2ETestApp/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/examples/E2ETestApp/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/examples/E2ETestApp/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000..dcd3cd8083
Binary files /dev/null and b/examples/E2ETestApp/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/examples/E2ETestApp/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/examples/E2ETestApp/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..459ca609d3
Binary files /dev/null and b/examples/E2ETestApp/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/examples/E2ETestApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/examples/E2ETestApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..8ca12fe024
Binary files /dev/null and b/examples/E2ETestApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/examples/E2ETestApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/examples/E2ETestApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..8e19b410a1
Binary files /dev/null and b/examples/E2ETestApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/examples/E2ETestApp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/examples/E2ETestApp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..b824ebdd48
Binary files /dev/null and b/examples/E2ETestApp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/examples/E2ETestApp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/examples/E2ETestApp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..4c19a13c23
Binary files /dev/null and b/examples/E2ETestApp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/examples/E2ETestApp/android/app/src/main/res/values/strings.xml b/examples/E2ETestApp/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..a0ea08d876
--- /dev/null
+++ b/examples/E2ETestApp/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ E2ETestApp
+
diff --git a/examples/E2ETestApp/android/app/src/main/res/values/styles.xml b/examples/E2ETestApp/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000000..7ba83a2ad5
--- /dev/null
+++ b/examples/E2ETestApp/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/examples/E2ETestApp/android/app/src/main/res/xml/network_security_config.xml b/examples/E2ETestApp/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000000..a667072fee
--- /dev/null
+++ b/examples/E2ETestApp/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,8 @@
+
+
+
+
+ 10.0.2.2
+ localhost
+
+
diff --git a/examples/E2ETestApp/android/build.gradle b/examples/E2ETestApp/android/build.gradle
new file mode 100644
index 0000000000..49baeef939
--- /dev/null
+++ b/examples/E2ETestApp/android/build.gradle
@@ -0,0 +1,34 @@
+buildscript {
+ ext {
+ buildToolsVersion = "37.0.0"
+ minSdkVersion = 24
+ compileSdkVersion = 37
+ targetSdkVersion = 36
+ ndkVersion = "27.1.12297006"
+ kotlinVersion = "2.2.0"
+ }
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath("com.android.tools.build:gradle")
+ classpath("com.facebook.react:react-native-gradle-plugin")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
+ }
+}
+
+ext.REACT_NATIVE_NODE_MODULES_DIR = file("$rootDir/../node_modules/react-native").absolutePath
+ext.REACT_NATIVE_WORKLETS_NODE_MODULES_DIR =
+ file("$rootDir/../node_modules/react-native-worklets").absolutePath
+
+allprojects {
+ repositories {
+ // Detox's Android library ships inside its npm package
+ maven {
+ url "$rootDir/../node_modules/detox/Detox-android"
+ }
+ }
+}
+
+apply plugin: "com.facebook.react.rootproject"
diff --git a/examples/E2ETestApp/android/gradle.properties b/examples/E2ETestApp/android/gradle.properties
new file mode 100644
index 0000000000..ab47cc1f7b
--- /dev/null
+++ b/examples/E2ETestApp/android/gradle.properties
@@ -0,0 +1,54 @@
+# Project-wide Gradle settings.
+
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
+org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
+
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+
+# Use this property to specify which architecture you want to build.
+# You can also override it from the CLI using
+# ./gradlew -PreactNativeArchitectures=x86_64
+# Emulator ABIs only (arm64 for Apple Silicon, x86_64 for CI); CI narrows it to x86_64 with
+# ORG_GRADLE_PROJECT_reactNativeArchitectures.
+reactNativeArchitectures=arm64-v8a,x86_64
+
+# Use this property to enable support to the new architecture.
+# This will allow you to use TurboModules and the Fabric render in
+# your application. You should enable this flag either if you want
+# to write custom TurboModules/Fabric components OR use libraries that
+# are providing them.
+newArchEnabled=true
+
+# Use this property to enable or disable the Hermes JS engine.
+# If set to false, you will be using JSC instead.
+hermesEnabled=true
+
+# Use this property to enable edge-to-edge display support.
+# This allows your app to draw behind system bars for an immersive UI.
+# Note: Only works with ReactActivity and should not be used with custom Activity.
+edgeToEdgeEnabled=true
+
+# Opt out of built-in kotlin and new DSL behavior that ships with AGP 9.
+# Starting from AGP 10.x these opt outs will be removed.
+android.builtInKotlin=false
+android.newDsl=false
+# Reuse task outputs across builds (and CI runs, via the Gradle cache)
+org.gradle.caching=true
+org.gradle.parallel=true
diff --git a/examples/E2ETestApp/android/gradle/wrapper/gradle-wrapper.jar b/examples/E2ETestApp/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000000..d997cfc60f
Binary files /dev/null and b/examples/E2ETestApp/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/examples/E2ETestApp/android/gradle/wrapper/gradle-wrapper.properties b/examples/E2ETestApp/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000000..c61a118f7d
--- /dev/null
+++ b/examples/E2ETestApp/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/examples/E2ETestApp/android/gradlew b/examples/E2ETestApp/android/gradlew
new file mode 100755
index 0000000000..739907dfd1
--- /dev/null
+++ b/examples/E2ETestApp/android/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/examples/E2ETestApp/android/gradlew.bat b/examples/E2ETestApp/android/gradlew.bat
new file mode 100644
index 0000000000..39baf4d684
--- /dev/null
+++ b/examples/E2ETestApp/android/gradlew.bat
@@ -0,0 +1,98 @@
+@REM Copyright (c) Meta Platforms, Inc. and affiliates.
+@REM
+@REM This source code is licensed under the MIT license found in the
+@REM LICENSE file in the root directory of this source tree.
+
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/examples/E2ETestApp/android/settings.gradle b/examples/E2ETestApp/android/settings.gradle
new file mode 100644
index 0000000000..96b171f560
--- /dev/null
+++ b/examples/E2ETestApp/android/settings.gradle
@@ -0,0 +1,6 @@
+pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
+plugins { id("com.facebook.react.settings") }
+extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() }
+rootProject.name = 'io.getstream.reactnative.e2etestapp'
+include ':app'
+includeBuild('../node_modules/@react-native/gradle-plugin')
diff --git a/examples/E2ETestApp/app.json b/examples/E2ETestApp/app.json
new file mode 100644
index 0000000000..73849fcf35
--- /dev/null
+++ b/examples/E2ETestApp/app.json
@@ -0,0 +1,4 @@
+{
+ "name": "E2ETestApp",
+ "displayName": "E2ETestApp"
+}
diff --git a/examples/E2ETestApp/babel.config.js b/examples/E2ETestApp/babel.config.js
new file mode 100644
index 0000000000..8ba8eb658c
--- /dev/null
+++ b/examples/E2ETestApp/babel.config.js
@@ -0,0 +1,4 @@
+module.exports = {
+ presets: ['module:@react-native/babel-preset'],
+ plugins: ['react-native-worklets/plugin'],
+};
diff --git a/examples/E2ETestApp/e2e/README.md b/examples/E2ETestApp/e2e/README.md
new file mode 100644
index 0000000000..7088447cf3
--- /dev/null
+++ b/examples/E2ETestApp/e2e/README.md
@@ -0,0 +1,119 @@
+# E2E tests (Detox)
+
+End-to-end tests for the SDK, driving `examples/E2ETestApp` with [Detox](https://wix.github.io/Detox/) against the shared
+[`stream-chat-test-mock-server`](https://github.com/GetStream/stream-chat-test-mock-server). The layout mirrors the
+native iOS/Android (and Flutter) suites, so tests can be ported almost line by line:
+
+| Path | What | Native counterpart |
+| ------------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------- |
+| `mock-server/` | `MockServer` (driver `/start/:test` → per-test server), `DataTypes` | `MockServer.kt`, `StreamMockServer.swift` |
+| `robots/BackendRobot.ts` | server-side state via test-control endpoints (`/mock`, `/add_member`, `/jwt/*`, …) | `BackendRobot` |
+| `robots/ParticipantRobot.ts` | the other user, `count_dooku` (`/participant/*`) | `ParticipantRobot` |
+| `robots/UserRobot.ts` + `UserRobot*Asserts` | UI actions as the app user + assert extensions | `UserRobot` + `UserRobot*Asserts.kt` |
+| `pages/` | page objects: `LoginPage`, `ChannelListPage`, `MessageListPage` | `pages/*Page.kt`, `Pages/*Page.swift` |
+| `support/` | `setUpStreamTestCase()`, `step()`, uiautomator-like `Element` helpers | `StreamTestCase`, `step {}`, `uiautomator/*` |
+| `tests/*.e2e.ts` | the tests | `*Tests.kt`, `*_Tests.swift` |
+
+Robots are fluent like the native ones; every call is queued and the chain runs when awaited:
+
+```ts
+await step('GIVEN user opens a channel', () => userRobot.login().openChannel());
+await step('WHEN participant sends a message', () => participantRobot.sendMessage('Hi'));
+await step('THEN user receives it', () => userRobot.assertMessage('Hi').assertMessageAuthor(false));
+```
+
+## The app
+
+`examples/E2ETestApp` exists only for this suite, like the native SDKs' `StreamChatUITestsApp`. It has the SDK and its
+required peer dependencies, and nothing else: no Firebase, maps, video or navigation library. That keeps native builds
+short (a clean Release build is ~2 min on an M-series Mac, versus ~15 min for the SampleApp on Android). It has three
+screens: a user selector, the channel list, and a channel stacked on top of the list (`App.tsx`), laid out like the
+SampleApp's, so the same SDK paths run.
+
+`setUpStreamTestCase()` starts a mock server per test and relaunches the app with the launch argument
+`MOCK_SERVER_URL` (`localhost` on iOS, `10.0.2.2` on the Android emulator), read in `src/config.ts` via
+`react-native-launch-arguments`. The app only lists the mock user (`luke_skywalker`), never persists a login and runs
+without offline storage, so every test starts clean.
+
+## Running
+
+The fastlane lanes live with the repo's other lanes in `examples/SampleApp/fastlane` (run them from
+`examples/SampleApp`). The e2e app has no Gemfile of its own; `e2e_pod_install` installs its pods with that bundle.
+
+```bash
+bundle exec fastlane e2e_pod_install # once, and after native dependency changes
+# Everything: clones + starts the mock server, builds, tests, stops the mock server
+bundle exec fastlane test_e2e platform:ios
+bundle exec fastlane test_e2e platform:android # AVD from DETOX_AVD_NAME (default Pixel_API_37)
+# Options: configuration:debug, skip_build:true, local_server:,
+# mock_server_branch:, test:e2e/tests/MessageListTests.e2e.ts
+
+# Or by hand (yarn commands in examples/E2ETestApp)
+bundle exec fastlane start_mock_server # driver on MOCK_DRIVER_PORT (default 4569)
+yarn e2e:build -c ios.sim.release # or android.emu.release
+MOCK_DRIVER_PORT=4569 yarn e2e:test -c ios.sim.release
+bundle exec fastlane stop_mock_server
+```
+
+Android builds only the `arm64-v8a` and `x86_64` ABIs (emulators); set `ORG_GRADLE_PROJECT_reactNativeArchitectures` to
+narrow it further.
+
+Release configurations bundle the JS, so no Metro is needed. Debug configurations need `yarn start`. Android builds need
+JDK 17 or 21 (`JAVA_HOME`).
+
+## CI
+
+`.github/workflows/e2e-test.yml` runs on pull requests and on demand (`workflow_dispatch`, with iOS/Android toggles and
+a mock server branch). Like the iOS SDK, a cheap `Check E2E Required` job first runs `fastlane e2e_check_required`
+(`is_check_required` over the `e2e` sources in the Fastfile's `impact_analysis`). The simulator/emulator jobs run
+only when the PR touches those sources, or when the last change to them hasn't passed yet. `Test E2E Result` is the
+job to require in branch protection: it passes when the e2e jobs pass or are skipped as not required.
+
+iOS runs on `macos-15` (Xcode 26.2, iPhone 17 Pro, `ios.sim.release`). Android runs on `ubuntu-latest` with KVM and an
+API 36 x86_64 emulator (`android.att.release`). Both call `fastlane test_e2e skip_build:true`, which on CI adds
+`--retries 2 --headless` and keeps failure logs and screenshots in `artifacts/`. Those and the mock server logs are
+uploaded when a job fails.
+
+What CI caches. The workflow doesn't run on `develop`, and GitHub only lets a PR restore caches from its own branch or
+the base branch, so each PR fills its own: its first run builds cold, and later pushes to it reuse the caches.
+
+- **The built app** (`.app`; release + test `.apk`), keyed on the app and SDK sources. A change that only touches
+ `e2e/` reuses the binary and skips pods, Gradle and Xcode entirely.
+- `node_modules` of `yarn workspaces focus e2etestapp`, so only the e2e app's dependency tree is installed.
+- iOS: CocoaPods, ccache (the RN ccache wrappers need `CCACHE_BINARY` in the environment, which the workflow sets), the
+ Detox framework.
+- Android: Gradle (`gradle/actions/setup-gradle` with `cache-read-only: false`, `org.gradle.caching`), a single ABI
+ (`x86_64`). The emulator matches the native Android SDK's e2e jobs (API 36 AOSP image, `pixel` profile, no snapshot);
+ on API 34 (Google APIs image, then AOSP) it died mid-run with no crash output.
+- The mock server's gems, keyed on the mock server revision.
+
+## Gotchas
+
+- iOS runs with Detox's idle synchronization off (`detoxEnableSynchronization: 0` in `StreamTestCase.ts`): it can wedge
+ on RN's UIManager shadow queue, whose pending-work counter underflows to 2^64-1, so the app looks busy until the test
+ times out. Robots wait explicitly instead, and taps retry while the target is not hittable yet (e.g. under the
+ keyboard as it animates in). Android keeps Espresso's synchronization.
+- Don't pass `permissions` to `device.launchApp` unless a test needs one: on iOS Detox applies each with
+ `applesimutils --restartSB`, which restarts SpringBoard on every launch. That cost 1.5–3 min per test on CI (hitting the
+ hook timeout) and ~15s locally.
+- CI boots the simulator at the start of the job and pins it with `DETOX_IOS_UDID` (`.detoxrc.js`), so the boot
+ overlaps with setup instead of delaying the first test by ~7 min.
+- A freshly booted CI emulator can show "Quickstep isn't responding" (a launcher ANR). The dialog takes window focus, and
+ every Espresso action then fails with "Waited for the root of the view hierarchy to have window focus". The
+ `test_e2e` lane hides error dialogs and waits up to 60s for the emulator to settle, and `StreamTestCase` dismisses
+ any ANR dialog before each Android launch (`support/AndroidSystemDialogs.ts`). Read the focus from
+ `dumpsys window`: `dumpsys window windows` no longer prints `mCurrentFocus`.
+- `detox test --reuse` does not reinstall the app. After rebuilding, run without it, or you test the old binary.
+- Yarn's `enableScripts: false` skips Detox's postinstall; run `yarn detox build-framework-cache` once on a new machine.
+- The SDK's shared native sources are synced into `stream-chat-react-native` and gitignored; `yarn e2e:build` and
+ `e2e_pod_install` run `yarn sync-native` first.
+- Don't commit `ios/E2ETestApp.xcodeproj` after `USE_CCACHE=1 pod install`: it writes machine-specific ccache paths into
+ it.
+- Detox replaces Jest's global `expect`. Use it for element expectations and `expect as jestExpect` from
+ `@jest/globals` for values.
+- Fabric mounts some views that carry a `testID` (`message-list-item-*`, `message-text-container`) empty, with their
+ content as siblings. They are not usable as `withAncestor` scopes or visibility targets; the page objects scope message
+ elements to `message-content-wrapper` or the message list instead.
+- Detox iOS never reports avatars as visible (their remote image renders transparent until loaded), so avatar asserts
+ check existence.
+- A ported test keeps the native expectation. If the SDK disagrees, fix the SDK or skip the test against a ticket.
diff --git a/examples/E2ETestApp/e2e/jest.config.js b/examples/E2ETestApp/e2e/jest.config.js
new file mode 100644
index 0000000000..5456675188
--- /dev/null
+++ b/examples/E2ETestApp/e2e/jest.config.js
@@ -0,0 +1,18 @@
+/** @type {import('jest').Config} */
+module.exports = {
+ rootDir: '..',
+ testMatch: ['/e2e/tests/**/*.e2e.ts'],
+ testTimeout: 180000,
+ maxWorkers: 1,
+ globalSetup: 'detox/runners/jest/globalSetup',
+ globalTeardown: 'detox/runners/jest/globalTeardown',
+ reporters: ['detox/runners/jest/reporter'],
+ testEnvironment: 'detox/runners/jest/testEnvironment',
+ verbose: true,
+ transform: {
+ '\\.ts$': [
+ 'babel-jest',
+ { babelrc: false, configFile: false, presets: ['module:@react-native/babel-preset'] },
+ ],
+ },
+};
diff --git a/examples/E2ETestApp/e2e/mock-server/DataTypes.ts b/examples/E2ETestApp/e2e/mock-server/DataTypes.ts
new file mode 100644
index 0000000000..a13549c994
--- /dev/null
+++ b/examples/E2ETestApp/e2e/mock-server/DataTypes.ts
@@ -0,0 +1,25 @@
+export const forbiddenWord = 'wth';
+
+export enum AttachmentType {
+ IMAGE = 'image',
+ VIDEO = 'video',
+ FILE = 'file',
+}
+
+export enum ReactionType {
+ LOVE = 'love',
+ LOL = 'haha',
+ WOW = 'wow',
+ SAD = 'sad',
+ LIKE = 'like',
+}
+
+export enum MessageDeliveryStatus {
+ READ = 'read',
+ DELIVERED = 'delivered',
+ PENDING = 'pending',
+ SENT = 'sent',
+ FAILED = 'failed',
+ /** No delivery status icon is expected. */
+ NIL = 'nil',
+}
diff --git a/examples/E2ETestApp/e2e/mock-server/MockServer.ts b/examples/E2ETestApp/e2e/mock-server/MockServer.ts
new file mode 100644
index 0000000000..1d273d12cf
--- /dev/null
+++ b/examples/E2ETestApp/e2e/mock-server/MockServer.ts
@@ -0,0 +1,84 @@
+declare const process: { env: Record };
+
+/**
+ * Client for `stream-chat-test-mock-server`. The driver (`driver.rb`) spawns one mock server per
+ * test on its own port; `start` asks it for one and `stop` shuts it down.
+ *
+ * Unlike the native suites, the test code runs on the host, so robots always talk to `localhost`
+ * while the app gets `appUrl`, which is the host as the device sees it.
+ */
+export class MockServer {
+ static readonly driverPort = process.env.MOCK_DRIVER_PORT ?? '4569';
+ private static readonly driverUrl = `http://localhost:${MockServer.driverPort}`;
+
+ private port?: string;
+
+ /** The mock server as the test runner (host) sees it. */
+ get url(): string {
+ if (!this.port) {
+ throw new Error('MockServer is not started');
+ }
+ return `http://localhost:${this.port}`;
+ }
+
+ /** The mock server as the app under test sees it: the Android emulator reaches the host via 10.0.2.2. */
+ get appUrl(): string {
+ const host = device.getPlatform() === 'android' ? '10.0.2.2' : 'localhost';
+ return this.url.replace('localhost', host);
+ }
+
+ /** Never retried: every call to the driver's `/start` spawns a new server on a new port. */
+ async start(testName: string): Promise {
+ const response = await this.execute(
+ `${MockServer.driverUrl}/start/${encodeURIComponent(testName)}`,
+ );
+ this.port = (await response.text()).trim();
+ await this.waitUntilReady();
+ }
+
+ async stop(): Promise {
+ if (!this.port) {
+ return;
+ }
+ await this.getRequest('stop').catch(() => undefined);
+ this.port = undefined;
+ }
+
+ async postRequest(endpoint: string, body = ''): Promise {
+ const response = await this.execute(`${this.url}/${endpoint}`, {
+ body,
+ headers: { 'Content-Type': 'text/plain' },
+ method: 'POST',
+ });
+ return response.text();
+ }
+
+ async getRequest(endpoint: string): Promise {
+ const response = await this.execute(`${this.url}/${endpoint}`);
+ return response.text();
+ }
+
+ private async waitUntilReady(timeoutMs = 5000): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ try {
+ const response = await fetch(`${this.url}/ping`);
+ if (response.ok) {
+ return;
+ }
+ } catch {
+ // The server process is still booting.
+ }
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ }
+ throw new Error(`MockServer did not start within ${timeoutMs}ms on ${this.url}`);
+ }
+
+ private async execute(url: string, init?: RequestInit): Promise {
+ const response = await fetch(url, init);
+ if (!response.ok) {
+ throw new Error(`${init?.method ?? 'GET'} ${url} failed: ${response.status}`);
+ }
+ return response;
+ }
+}
diff --git a/examples/E2ETestApp/e2e/pages/ChannelListPage.ts b/examples/E2ETestApp/e2e/pages/ChannelListPage.ts
new file mode 100644
index 0000000000..4cc136b9b0
--- /dev/null
+++ b/examples/E2ETestApp/e2e/pages/ChannelListPage.ts
@@ -0,0 +1,32 @@
+export const ChannelListPage = {
+ ChannelList: {
+ get channelList() {
+ return by.id('channel-list-view');
+ },
+ get channels() {
+ return by.id('channel-preview-button');
+ },
+
+ Channel: {
+ get avatar() {
+ return by.id('avatar-image').withAncestor(by.id('channel-preview-button'));
+ },
+ get name() {
+ return by.id('channel-preview-title');
+ },
+ nameWithText(text: string) {
+ return by.id('channel-preview-title').and(by.text(text));
+ },
+ get messagePreview() {
+ return by.id('channel-preview-message');
+ },
+ /** "You:" or ":" in front of the preview text, rendered as its own text. */
+ get messageSender() {
+ return by.id('channel-preview-sender');
+ },
+ get unreadCount() {
+ return by.id('unread-count').withAncestor(by.id('channel-preview-button'));
+ },
+ },
+ },
+};
diff --git a/examples/E2ETestApp/e2e/pages/LoginPage.ts b/examples/E2ETestApp/e2e/pages/LoginPage.ts
new file mode 100644
index 0000000000..069eab4a75
--- /dev/null
+++ b/examples/E2ETestApp/e2e/pages/LoginPage.ts
@@ -0,0 +1,11 @@
+/** The mock server's app user (`appUser` in `src/config.ts`), the only one listed in e2e mode. */
+export const appUserId = 'luke_skywalker';
+
+export const LoginPage = {
+ get userSelector() {
+ return by.id('user-selector-screen');
+ },
+ get loginButton() {
+ return by.id(`user-selector-button-${appUserId}`);
+ },
+};
diff --git a/examples/E2ETestApp/e2e/pages/MessageListPage.ts b/examples/E2ETestApp/e2e/pages/MessageListPage.ts
new file mode 100644
index 0000000000..4559f48590
--- /dev/null
+++ b/examples/E2ETestApp/e2e/pages/MessageListPage.ts
@@ -0,0 +1,87 @@
+const messageCell = () => by.id(/^message-list-item-/);
+// Fabric mounts `message-list-item-*` empty with its content beside it, so the list, not the cell,
+// is the ancestor that scopes message elements (the channel list stays mounted underneath).
+const inMessageList = (matcher: Detox.NativeMatcher) =>
+ matcher.withAncestor(by.id(/^message-(flat|flash)-list$/));
+
+export const MessageListPage = {
+ Header: {
+ get backButton() {
+ return by.id('back-button');
+ },
+ },
+
+ Composer: {
+ get inputField() {
+ return by.id('auto-complete-text-input');
+ },
+ get sendButton() {
+ return by.id('send-button');
+ },
+ get attachmentsButton() {
+ return by.id('attach-button');
+ },
+ get cooldownIndicator() {
+ return by.id('cooldown-timer');
+ },
+ },
+
+ MessageList: {
+ get messageList() {
+ return by.id(/^message-(flat|flash)-list$/);
+ },
+ get messages() {
+ return messageCell();
+ },
+ get dateSeparator() {
+ return by.id('date-separator');
+ },
+ get typingIndicator() {
+ return by.id('typing-indicator');
+ },
+ get scrollToBottomButton() {
+ return by.id('scroll-to-bottom-button');
+ },
+
+ Message: {
+ /** Wraps the author's avatar; only rendered for other users' messages. */
+ get avatar() {
+ return by.id('message-author');
+ },
+ text(text: string) {
+ // Not under `message-text-container`: Fabric mounts that view empty, with the text beside it.
+ return by.text(text).withAncestor(by.id('message-content-wrapper'));
+ },
+ get timestamp() {
+ return by.id('message-status-time');
+ },
+ get deliveryStatusIsRead() {
+ return inMessageList(by.id('message-status-read'));
+ },
+ get deliveryStatusIsDelivered() {
+ return inMessageList(by.id('message-status-delivered'));
+ },
+ get deliveryStatusIsPending() {
+ return inMessageList(by.id('message-status-sending'));
+ },
+ get deliveryStatusIsSent() {
+ return inMessageList(by.id('message-status-sent'));
+ },
+ get deliveryStatusIsFailed() {
+ return inMessageList(by.id('message-error'));
+ },
+ get deletedMessage() {
+ return by.id('message-deleted');
+ },
+ get threadRepliesLabel() {
+ return by.id('message-replies');
+ },
+
+ Reactions: {
+ get reactions() {
+ return by.id('reaction-list-item');
+ },
+ },
+ },
+ },
+};
diff --git a/examples/E2ETestApp/e2e/robots/BackendRobot.ts b/examples/E2ETestApp/e2e/robots/BackendRobot.ts
new file mode 100644
index 0000000000..c62e3eea5c
--- /dev/null
+++ b/examples/E2ETestApp/e2e/robots/BackendRobot.ts
@@ -0,0 +1,129 @@
+import { Robot } from './Robot';
+
+import type { MockServer } from '../mock-server/MockServer';
+
+/** Drives server-side state through the mock server's test-control endpoints. */
+export class BackendRobot extends Robot {
+ constructor(private readonly mockServer: MockServer) {
+ super();
+ }
+
+ generateChannels({
+ channelsCount,
+ messagesCount = 0,
+ repliesCount = 0,
+ messagesText,
+ repliesText,
+ channelNames = [],
+ withDirectMessageChannel = false,
+ }: {
+ channelsCount: number;
+ messagesCount?: number;
+ repliesCount?: number;
+ messagesText?: string;
+ repliesText?: string;
+ channelNames?: string[];
+ withDirectMessageChannel?: boolean;
+ }): this {
+ const params = new URLSearchParams();
+ if (messagesText !== undefined) params.set('messages_text', messagesText);
+ if (repliesText !== undefined) params.set('replies_text', repliesText);
+ if (channelNames.length > 0) params.set('channel_names', channelNames.join(','));
+ if (withDirectMessageChannel) params.set('dm', 'true');
+ params.set('channels', `${channelsCount}`);
+ params.set('messages', `${messagesCount}`);
+ params.set('replies', `${repliesCount}`);
+ return this.post(`mock?${params}`);
+ }
+
+ failNewMessages(): this {
+ return this.post('fail_messages');
+ }
+
+ freezeNewMessages(): this {
+ return this.post('freeze_messages');
+ }
+
+ setCooldown(enabled: boolean, duration: number): this {
+ return this.post(`config/cooldown?enabled=${enabled}&duration=${duration}`);
+ }
+
+ /**
+ * Truncates the currently open channel on the server side. The app receives `channel.truncated`.
+ *
+ * @param withMessage also delivers a "Channel truncated" system message.
+ */
+ truncateChannel(withMessage: boolean): this {
+ return this.post(`truncate_channel?with_message=${withMessage}`);
+ }
+
+ /** Enables or disables read events on every channel. Call before the channel is opened. */
+ setReadEvents(enabled: boolean): this {
+ return this.post(`config/read_events?value=${enabled}`);
+ }
+
+ /** Adds a member to the currently open channel. The app receives `member.added` + `channel.updated`. */
+ addMember(userId: string): this {
+ return this.post(`add_member?user_id=${encodeURIComponent(userId)}`);
+ }
+
+ /** Removes a member from the currently open channel. The app receives `member.removed` + `channel.updated`. */
+ removeMember(userId: string): this {
+ return this.post(`remove_member?user_id=${encodeURIComponent(userId)}`);
+ }
+
+ /**
+ * Creates a reminder for the last message of the currently open channel.
+ *
+ * @param remindAtSeconds how far in the future it is due; omit for a "saved for later" reminder.
+ */
+ createReminder(remindAtSeconds?: number): this {
+ return this.post(
+ remindAtSeconds === undefined
+ ? 'create_reminder'
+ : `create_reminder?remind_at=${remindAtSeconds}`,
+ );
+ }
+
+ revokeToken(duration = 5): this {
+ return this.post(`jwt/revoke_token?duration=${duration}`);
+ }
+
+ invalidateToken(duration = 5): this {
+ return this.post(`jwt/invalidate_token?duration=${duration}`);
+ }
+
+ invalidateTokenDate(duration = 5): this {
+ return this.post(`jwt/invalidate_token_date?duration=${duration}`);
+ }
+
+ invalidateTokenSignature(duration = 5): this {
+ return this.post(`jwt/invalidate_token_signature?duration=${duration}`);
+ }
+
+ breakTokenGeneration(duration = 5): this {
+ return this.post(`jwt/break_token_generation?duration=${duration}`);
+ }
+
+ /**
+ * Waits until the app closes its WebSocket. The SDK keeps the socket open for a while after the
+ * app goes to background, so call this before server-side events that must not arrive live.
+ */
+ waitForWebSocketDisconnection(timeoutMs = 10000): this {
+ return this.perform(async () => {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const status = JSON.parse(await this.mockServer.getRequest('ws/status'));
+ if (!status.connected) {
+ return;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+ throw new Error(`WebSocket was not disconnected within ${timeoutMs}ms`);
+ });
+ }
+
+ private post(endpoint: string): this {
+ return this.perform(() => this.mockServer.postRequest(endpoint));
+ }
+}
diff --git a/examples/E2ETestApp/e2e/robots/ParticipantRobot.ts b/examples/E2ETestApp/e2e/robots/ParticipantRobot.ts
new file mode 100644
index 0000000000..0615c8a469
--- /dev/null
+++ b/examples/E2ETestApp/e2e/robots/ParticipantRobot.ts
@@ -0,0 +1,161 @@
+import { Robot } from './Robot';
+
+import type { AttachmentType, ReactionType } from '../mock-server/DataTypes';
+import type { MockServer } from '../mock-server/MockServer';
+
+const MULTIPLE_MESSAGES_INTERVAL_MS = 300;
+
+const quoteParam = (last: boolean) => (last ? 'quote_last=true' : 'quote_first=true');
+
+/** Acts as the other channel member (`count_dooku`) through the mock server's participant endpoints. */
+export class ParticipantRobot extends Robot {
+ static readonly userName = 'Count Dooku';
+ static readonly userId = 'count_dooku';
+
+ constructor(private readonly mockServer: MockServer) {
+ super();
+ }
+
+ startTyping(): this {
+ return this.post('participant/typing/start');
+ }
+
+ startTypingInThread(): this {
+ return this.post('participant/typing/start?thread=true');
+ }
+
+ stopTyping(): this {
+ return this.post('participant/typing/stop');
+ }
+
+ stopTypingInThread(): this {
+ return this.post('participant/typing/stop?thread=true');
+ }
+
+ readMessage(): this {
+ return this.post('participant/read');
+ }
+
+ /** Marks the newest message as delivered to the participant (`message.delivered`). */
+ markMessagesDelivered(): this {
+ return this.post('participant/delivered');
+ }
+
+ sendMessage(text: string, delay = 0): this {
+ return this.post(
+ delay > 0 ? `participant/message?delay=${delay}` : 'participant/message',
+ text,
+ );
+ }
+
+ /** Sends `count` messages named `${text}-1` through `${text}-${count}`, 300ms apart. */
+ sendMultipleMessages(text: string, count: number): this {
+ for (let index = 1; index <= count; index++) {
+ this.sendMessage(`${text}-${index}`).sleep(MULTIPLE_MESSAGES_INTERVAL_MS);
+ }
+ return this;
+ }
+
+ sendMessageInThread(text: string, alsoSendInChannel = false): this {
+ return this.post(
+ `participant/message?thread=true&thread_and_channel=${alsoSendInChannel}`,
+ text,
+ );
+ }
+
+ editMessage(text: string): this {
+ return this.post('participant/message?action=edit', text);
+ }
+
+ deleteMessage(hard = false): this {
+ return this.post(`participant/message?action=delete&hard_delete=${hard}`);
+ }
+
+ quoteMessage(text: string, last = true): this {
+ return this.post(`participant/message?${quoteParam(last)}`, text);
+ }
+
+ quoteMessageInThread(text: string, alsoSendInChannel = false, last = true): this {
+ return this.post(
+ `participant/message?${quoteParam(last)}&thread=true&thread_and_channel=${alsoSendInChannel}`,
+ text,
+ );
+ }
+
+ uploadGiphy(): this {
+ return this.post('participant/message?giphy=true');
+ }
+
+ uploadGiphyInThread(): this {
+ return this.post('participant/message?giphy=true&thread=true');
+ }
+
+ quoteMessageWithGiphy(last = true): this {
+ return this.post(`participant/message?giphy=true&${quoteParam(last)}`);
+ }
+
+ quoteMessageWithGiphyInThread(alsoSendInChannel = false, last = true): this {
+ return this.post(
+ `participant/message?giphy=true&${quoteParam(last)}&thread=true&thread_and_channel=${alsoSendInChannel}`,
+ );
+ }
+
+ pinMessage(): this {
+ return this.post('participant/message?action=pin');
+ }
+
+ unpinMessage(): this {
+ return this.post('participant/message?action=unpin');
+ }
+
+ uploadAttachment(type: AttachmentType, count = 1): this {
+ return this.post(`participant/message?${type}=${count}`);
+ }
+
+ quoteMessageWithAttachment(type: AttachmentType, count = 1, last = true): this {
+ return this.post(`participant/message?${quoteParam(last)}&${type}=${count}`);
+ }
+
+ uploadAttachmentInThread(type: AttachmentType, count = 1, alsoSendInChannel = false): this {
+ return this.post(
+ `participant/message?${type}=${count}&thread=true&thread_and_channel=${alsoSendInChannel}`,
+ );
+ }
+
+ quoteMessageWithAttachmentInThread(
+ type: AttachmentType,
+ count = 1,
+ alsoSendInChannel = false,
+ last = true,
+ ): this {
+ return this.post(
+ `participant/message?${quoteParam(last)}&${type}=${count}&thread=true&thread_and_channel=${alsoSendInChannel}`,
+ );
+ }
+
+ /** Votes for `option` in the poll of the newest poll message. */
+ castPollVote(option: string): this {
+ return this.post(`participant/poll_vote?option=${encodeURIComponent(option)}`);
+ }
+
+ /** Adds an answer (comment) to the poll of the newest poll message. */
+ addPollAnswer(answer: string): this {
+ return this.post(`participant/poll_vote?answer=${encodeURIComponent(answer)}`);
+ }
+
+ addReaction(type: ReactionType, delay = 0): this {
+ return this.post(
+ delay > 0
+ ? `participant/reaction?type=${type}&delay=${delay}`
+ : `participant/reaction?type=${type}`,
+ );
+ }
+
+ deleteReaction(type: ReactionType): this {
+ return this.post(`participant/reaction?type=${type}&delete=true`);
+ }
+
+ private post(endpoint: string, body?: string): this {
+ return this.perform(() => this.mockServer.postRequest(endpoint, body));
+ }
+}
diff --git a/examples/E2ETestApp/e2e/robots/Robot.ts b/examples/E2ETestApp/e2e/robots/Robot.ts
new file mode 100644
index 0000000000..44dbaaecb7
--- /dev/null
+++ b/examples/E2ETestApp/e2e/robots/Robot.ts
@@ -0,0 +1,33 @@
+/**
+ * Base for the fluent robots. Native robots chain synchronous calls
+ * (`userRobot.login().openChannel()`); Detox is async, so each call queues its action and
+ * returns `this`, and awaiting the robot runs the queue:
+ *
+ * await userRobot.login().openChannel().assertMessage('Hi');
+ *
+ * A failed action rejects the awaited chain and skips the rest of it.
+ */
+export abstract class Robot implements PromiseLike {
+ private queue: Promise = Promise.resolve();
+
+ /** Queues `action` after the previous ones. Used by the robots and their assert extensions. */
+ perform(action: () => Promise): this {
+ this.queue = this.queue.then(async () => {
+ await action();
+ });
+ return this;
+ }
+
+ sleep(ms: number): this {
+ return this.perform(() => new Promise((resolve) => setTimeout(resolve, ms)));
+ }
+
+ then(
+ onfulfilled?: ((value: void) => TResult1 | PromiseLike) | null,
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null,
+ ): PromiseLike {
+ const queue = this.queue;
+ this.queue = Promise.resolve();
+ return queue.then(onfulfilled, onrejected);
+ }
+}
diff --git a/examples/E2ETestApp/e2e/robots/UserRobot.ts b/examples/E2ETestApp/e2e/robots/UserRobot.ts
new file mode 100644
index 0000000000..f8e99e6bee
--- /dev/null
+++ b/examples/E2ETestApp/e2e/robots/UserRobot.ts
@@ -0,0 +1,135 @@
+import { Robot } from './Robot';
+
+import { ChannelListPage } from '../pages/ChannelListPage';
+import { LoginPage } from '../pages/LoginPage';
+import { MessageListPage } from '../pages/MessageListPage';
+import {
+ waitToAppear,
+ waitToAppearAndClick,
+ waitToAppearAndLongPress,
+ waitToDisappear,
+} from '../support/Element';
+
+const { ChannelList } = ChannelListPage;
+const { Composer, Header, MessageList } = MessageListPage;
+
+/** Drives the app as the logged-in user. Asserts live in the `UserRobot*Asserts` extensions. */
+export class UserRobot extends Robot {
+ login(): this {
+ return this.perform(() => waitToAppearAndClick(LoginPage.loginButton));
+ }
+
+ waitForChannelListToLoad(): this {
+ return this.perform(() => waitToAppear(ChannelList.channels, { index: 0 }));
+ }
+
+ waitForMessageListToLoad(): this {
+ return this.perform(() => waitToAppear(Composer.inputField));
+ }
+
+ openChannel(channelCellIndex = 0): this {
+ return this.perform(() =>
+ waitToAppearAndClick(ChannelList.channels, { index: channelCellIndex }),
+ ).waitForMessageListToLoad();
+ }
+
+ openChannelWithName(channelName: string): this {
+ return this.perform(() =>
+ waitToAppearAndClick(ChannelList.Channel.nameWithText(channelName)),
+ ).waitForMessageListToLoad();
+ }
+
+ openContextMenu(messageText: string): this {
+ return this.perform(() => waitToAppearAndLongPress(MessageList.Message.text(messageText)));
+ }
+
+ /**
+ * iOS types key by key, like XCUITest. Android sets the text, like the native suite's
+ * `typeText` (UiAutomator `setText`): typing through the soft keyboard leaves the text in the
+ * IME's composing state, which the composer doesn't pick up, so the send button never shows.
+ */
+ typeText(text: string): this {
+ return this.perform(async () => {
+ const inputField = await waitToAppear(Composer.inputField);
+ if (device.getPlatform() === 'android') {
+ await inputField.replaceText(text);
+ } else {
+ await inputField.typeText(text);
+ }
+ });
+ }
+
+ clearComposer(): this {
+ return this.perform(async () => {
+ const inputField = await waitToAppear(Composer.inputField);
+ await inputField.clearText();
+ });
+ }
+
+ tapOnSendButton(): this {
+ return this.perform(() => waitToAppearAndClick(Composer.sendButton));
+ }
+
+ sendMessage(text: string): this {
+ return this.typeText(text).tapOnSendButton();
+ }
+
+ tapOnBackButton(): this {
+ return this.perform(() => waitToAppearAndClick(Header.backButton));
+ }
+
+ /** Android hardware back; iOS has none, so it taps the header's back button there. */
+ pressBack(): this {
+ return this.perform(async () => {
+ if (device.getPlatform() === 'android') {
+ await device.pressBack();
+ } else {
+ await waitToAppearAndClick(Header.backButton);
+ }
+ });
+ }
+
+ tapOnScrollToBottomButton(): this {
+ return this.perform(() => waitToAppearAndClick(MessageList.scrollToBottomButton));
+ }
+
+ moveToChannelListFromMessageList(): this {
+ return this.tapOnBackButton()
+ .perform(() => waitToDisappear(Composer.inputField))
+ .waitForChannelListToLoad();
+ }
+
+ scrollChannelListDown(times = 3): this {
+ return this.swipe(ChannelList.channelList, 'up', times);
+ }
+
+ scrollChannelListUp(times = 3): this {
+ return this.swipe(ChannelList.channelList, 'down', times);
+ }
+
+ scrollMessageListDown(times = 3): this {
+ // The message list is inverted: newer messages are at the bottom, reached by swiping down.
+ return this.swipe(MessageList.messageList, 'down', times);
+ }
+
+ scrollMessageListUp(times = 3): this {
+ return this.swipe(MessageList.messageList, 'up', times);
+ }
+
+ backgroundApp(): this {
+ return this.perform(() => device.sendToHome());
+ }
+
+ foregroundApp(): this {
+ return this.perform(() => device.launchApp({ newInstance: false }));
+ }
+
+ private swipe(list: Detox.NativeMatcher, direction: Detox.Direction, times: number): this {
+ return this.perform(async () => {
+ const target = await waitToAppear(list);
+ for (let i = 0; i < times; i++) {
+ await target.swipe(direction, 'fast', 0.6);
+ }
+ });
+ }
+}
diff --git a/examples/E2ETestApp/e2e/robots/UserRobotChannelListAsserts.ts b/examples/E2ETestApp/e2e/robots/UserRobotChannelListAsserts.ts
new file mode 100644
index 0000000000..068ed8c6e6
--- /dev/null
+++ b/examples/E2ETestApp/e2e/robots/UserRobotChannelListAsserts.ts
@@ -0,0 +1,72 @@
+import { expect as jestExpect } from '@jest/globals';
+
+import { ParticipantRobot } from './ParticipantRobot';
+import { UserRobot } from './UserRobot';
+
+import { ChannelListPage } from '../pages/ChannelListPage';
+import { waitForCount, waitForExistence, waitForText, waitToDisappear } from '../support/Element';
+
+const { ChannelList } = ChannelListPage;
+const { Channel } = ChannelList;
+
+declare module './UserRobot' {
+ interface UserRobot {
+ assertChannelAvatar(): this;
+ assertChannelCount(count: number): this;
+ assertChannelListIsEmpty(): this;
+ assertChannelName(name: string, channelCellIndex?: number): this;
+ assertChannelUnreadCount(count: number): this;
+ /**
+ * @param fromCurrentUser `true` expects a "You: " prefix, `false` the participant's name,
+ * `undefined` only the text.
+ */
+ assertMessageInChannelPreview(text: string, fromCurrentUser?: boolean): this;
+ }
+}
+
+UserRobot.prototype.assertChannelAvatar = function () {
+ return this.perform(async () => {
+ jestExpect(await waitForExistence(Channel.avatar, { index: 0 })).toBe(true);
+ });
+};
+
+UserRobot.prototype.assertChannelCount = function (count) {
+ return this.perform(async () => {
+ jestExpect(await waitForCount(ChannelList.channels, count)).toBe(count);
+ });
+};
+
+UserRobot.prototype.assertChannelListIsEmpty = function () {
+ return this.perform(async () => {
+ await waitToDisappear(ChannelList.channels);
+ jestExpect(await waitForCount(ChannelList.channels, 0)).toBe(0);
+ });
+};
+
+UserRobot.prototype.assertChannelName = function (name, channelCellIndex = 0) {
+ return this.perform(async () => {
+ jestExpect(await waitForText(Channel.name, name, { index: channelCellIndex })).toBe(name);
+ });
+};
+
+UserRobot.prototype.assertChannelUnreadCount = function (count) {
+ return this.perform(async () => {
+ jestExpect(await waitForText(Channel.unreadCount, `${count}`)).toBe(`${count}`);
+ });
+};
+
+UserRobot.prototype.assertMessageInChannelPreview = function (text, fromCurrentUser) {
+ const expectedSender =
+ fromCurrentUser === true
+ ? 'You:'
+ : fromCurrentUser === false
+ ? `${ParticipantRobot.userName}:`
+ : undefined;
+ return this.perform(async () => {
+ const preview = await waitForText(Channel.messagePreview, text, { mustBeEqual: false });
+ jestExpect(preview.trimEnd()).toContain(text);
+ if (expectedSender !== undefined) {
+ jestExpect(await waitForText(Channel.messageSender, expectedSender)).toBe(expectedSender);
+ }
+ });
+};
diff --git a/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts b/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts
new file mode 100644
index 0000000000..7596ce79c8
--- /dev/null
+++ b/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts
@@ -0,0 +1,105 @@
+import { expect as jestExpect } from '@jest/globals';
+
+import { UserRobot } from './UserRobot';
+
+import { MessageDeliveryStatus } from '../mock-server/DataTypes';
+import { MessageListPage } from '../pages/MessageListPage';
+import {
+ defaultTimeout,
+ isDisplayed,
+ waitForCount,
+ waitForExistence,
+ waitToAppear,
+ waitToDisappear,
+} from '../support/Element';
+
+const { MessageList } = MessageListPage;
+const { Message } = MessageList;
+
+declare module './UserRobot' {
+ interface UserRobot {
+ assertMessage(text: string, isDisplayed?: boolean): this;
+ assertMessageAuthor(isCurrentUser: boolean): this;
+ assertMessageDeliveryStatus(status: MessageDeliveryStatus, count?: number): this;
+ assertMessageFailedIcon(isDisplayed: boolean): this;
+ assertTypingIndicator(isDisplayed: boolean): this;
+ assertScrollToBottomButton(isDisplayed: boolean): this;
+ }
+}
+
+export const assertVisibility = async (
+ selector: Detox.NativeMatcher,
+ shouldBeDisplayed: boolean,
+ timeout = defaultTimeout,
+) => {
+ if (shouldBeDisplayed) {
+ await waitToAppear(selector, { index: 0, timeout });
+ } else {
+ await waitToDisappear(selector, { index: 0, timeout });
+ jestExpect(await isDisplayed(selector, 0)).toBe(false);
+ }
+};
+
+const assertDeliveryStatusCount = async (selector: Detox.NativeMatcher, count?: number) => {
+ await assertVisibility(selector, true, 30000);
+ if (count !== undefined) {
+ jestExpect(await waitForCount(selector, count)).toBe(count);
+ }
+};
+
+UserRobot.prototype.assertMessage = function (text, shouldBeDisplayed = true) {
+ return this.perform(async () => {
+ await assertVisibility(Message.text(text), shouldBeDisplayed);
+ if (shouldBeDisplayed) {
+ jestExpect(await isDisplayed(Message.timestamp, 0)).toBe(true);
+ }
+ });
+};
+
+/** The current user's messages carry no avatar; other users' do. */
+UserRobot.prototype.assertMessageAuthor = function (isCurrentUser) {
+ return this.perform(async () => {
+ const exists = await waitForExistence(Message.avatar, {
+ index: 0,
+ shouldExist: !isCurrentUser,
+ });
+ jestExpect(exists).toBe(!isCurrentUser);
+ });
+};
+
+UserRobot.prototype.assertMessageDeliveryStatus = function (status, count) {
+ return this.perform(async () => {
+ switch (status) {
+ case MessageDeliveryStatus.READ:
+ return assertDeliveryStatusCount(Message.deliveryStatusIsRead, count);
+ case MessageDeliveryStatus.DELIVERED:
+ return assertDeliveryStatusCount(Message.deliveryStatusIsDelivered, count);
+ case MessageDeliveryStatus.PENDING:
+ return assertDeliveryStatusCount(Message.deliveryStatusIsPending, count);
+ case MessageDeliveryStatus.SENT:
+ return assertDeliveryStatusCount(Message.deliveryStatusIsSent, count);
+ case MessageDeliveryStatus.FAILED:
+ return assertDeliveryStatusCount(Message.deliveryStatusIsFailed, count);
+ case MessageDeliveryStatus.NIL:
+ await assertVisibility(Message.deliveryStatusIsRead, false);
+ await assertVisibility(Message.deliveryStatusIsDelivered, false);
+ await assertVisibility(Message.deliveryStatusIsPending, false);
+ await assertVisibility(Message.deliveryStatusIsSent, false);
+ return;
+ default:
+ throw new Error(`Unsupported delivery status: ${status}`);
+ }
+ });
+};
+
+UserRobot.prototype.assertMessageFailedIcon = function (shouldBeDisplayed) {
+ return this.perform(() => assertVisibility(Message.deliveryStatusIsFailed, shouldBeDisplayed));
+};
+
+UserRobot.prototype.assertTypingIndicator = function (shouldBeDisplayed) {
+ return this.perform(() => assertVisibility(MessageList.typingIndicator, shouldBeDisplayed));
+};
+
+UserRobot.prototype.assertScrollToBottomButton = function (shouldBeDisplayed) {
+ return this.perform(() => assertVisibility(MessageList.scrollToBottomButton, shouldBeDisplayed));
+};
diff --git a/examples/E2ETestApp/e2e/robots/index.ts b/examples/E2ETestApp/e2e/robots/index.ts
new file mode 100644
index 0000000000..bc92c7f124
--- /dev/null
+++ b/examples/E2ETestApp/e2e/robots/index.ts
@@ -0,0 +1,7 @@
+// The assert files extend UserRobot's prototype, so they must be loaded with it.
+import './UserRobotMessageListAsserts';
+import './UserRobotChannelListAsserts';
+
+export { BackendRobot } from './BackendRobot';
+export { ParticipantRobot } from './ParticipantRobot';
+export { UserRobot } from './UserRobot';
diff --git a/examples/E2ETestApp/e2e/support/AndroidSystemDialogs.ts b/examples/E2ETestApp/e2e/support/AndroidSystemDialogs.ts
new file mode 100644
index 0000000000..a7d2c61dcd
--- /dev/null
+++ b/examples/E2ETestApp/e2e/support/AndroidSystemDialogs.ts
@@ -0,0 +1,43 @@
+// Node built-ins, typed locally: the e2e project's types are limited to `detox/globals`.
+declare const process: { env: Record };
+declare const require: (module: 'child_process') => {
+ execFileSync: (file: string, args: string[], options: { encoding: 'utf8' }) => string;
+};
+
+const { execFileSync } = require('child_process');
+
+const sdkRoot = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT;
+const adbPath = sdkRoot ? `${sdkRoot}/platform-tools/adb` : 'adb';
+
+const adb = (...args: string[]) => {
+ const serial = process.env.DETOX_ADB_NAME;
+ const deviceArgs = serial && !serial.includes('*') ? ['-s', serial] : [];
+ return execFileSync(adbPath, [...deviceArgs, ...args], { encoding: 'utf8' });
+};
+
+/**
+ * Dismisses an " isn't responding" dialog, if one is showing. On a slow CI emulator the
+ * launcher (Quickstep) can hit an ANR; its dialog takes window focus, and Espresso then fails every
+ * test waiting for the app's window to get it. Same handling as the `wait_android_emu_idle` fastlane
+ * action: Enter picks "Close app", except for System UI, where it picks "Wait".
+ */
+export const dismissAnrDialog = () => {
+ try {
+ const focus = adb('shell', 'dumpsys', 'window')
+ .split('\n')
+ .find(
+ (line) => line.includes('mCurrentFocus') && line.includes('Application Not Responding'),
+ );
+ if (!focus) {
+ return;
+ }
+ console.warn(`Dismissing an ANR dialog: ${focus.trim()}`);
+ if (focus.includes('com.android.systemui')) {
+ adb('shell', 'input', 'keyevent', 'KEYCODE_DPAD_DOWN');
+ }
+ adb('shell', 'input', 'keyevent', 'KEYCODE_ENTER');
+ } catch (error) {
+ // Best effort: a failed check must not fail the test on its own.
+ console.warn(`Could not check for ANR dialogs: ${String(error)}`);
+ }
+};
diff --git a/examples/E2ETestApp/e2e/support/Element.ts b/examples/E2ETestApp/e2e/support/Element.ts
new file mode 100644
index 0000000000..edefaf1414
--- /dev/null
+++ b/examples/E2ETestApp/e2e/support/Element.ts
@@ -0,0 +1,164 @@
+/**
+ * Element helpers mirroring the Android suite's `uiautomator` extensions (`Element.kt`, `Wait.kt`),
+ * so robot code reads the same: `selector.waitToAppearAndClick()` becomes
+ * `await waitToAppearAndClick(selector)`.
+ */
+
+export const defaultTimeout = 15000;
+
+type Selector = Detox.NativeMatcher;
+
+type WaitOptions = {
+ /** Picks one match when the selector matches several elements. */
+ index?: number;
+ timeout?: number;
+};
+
+const findElement = (selector: Selector, index?: number) =>
+ index === undefined ? element(selector) : element(selector).atIndex(index);
+
+export const waitToAppear = async (
+ selector: Selector,
+ { index, timeout = defaultTimeout }: WaitOptions = {},
+) => {
+ const target = findElement(selector, index);
+ await waitFor(target).toBeVisible().withTimeout(timeout);
+ return target;
+};
+
+/**
+ * Retries `action` while Detox reports the element as not hittable (e.g. covered by the keyboard
+ * while it animates in). iOS runs without Detox's idle synchronization, which used to absorb that.
+ */
+const retryWhileNotHittable = async (action: () => Promise, timeout = defaultTimeout) => {
+ const deadline = Date.now() + timeout;
+ for (;;) {
+ try {
+ return await action();
+ } catch (error) {
+ if (Date.now() > deadline || !/not hittable/i.test(String(error))) {
+ throw error;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ }
+ }
+};
+
+export const waitToAppearAndClick = async (selector: Selector, options?: WaitOptions) => {
+ const target = await waitToAppear(selector, options);
+ await retryWhileNotHittable(() => target.tap(), options?.timeout);
+ return target;
+};
+
+export const waitToAppearAndLongPress = async (selector: Selector, options?: WaitOptions) => {
+ const target = await waitToAppear(selector, options);
+ await retryWhileNotHittable(() => target.longPress(), options?.timeout);
+ return target;
+};
+
+/** Waits until nothing matching `selector` is visible. Resolves (does not throw) on timeout. */
+export const waitToDisappear = async (
+ selector: Selector,
+ { index, timeout = defaultTimeout }: WaitOptions = {},
+) => {
+ try {
+ await waitFor(findElement(selector, index)).not.toBeVisible().withTimeout(timeout);
+ } catch {
+ // The caller asserts on `isDisplayed` afterwards, like `waitToDisappear().isDisplayed()` on Android.
+ }
+};
+
+/**
+ * Waits until `selector` exists (or, with `shouldExist: false`, until it no longer does), and
+ * returns whether it exists then. For elements Detox iOS never reports as visible, such as avatars
+ * whose remote image has not loaded: its visibility check needs rendered pixels.
+ */
+export const waitForExistence = async (
+ selector: Selector,
+ {
+ index,
+ shouldExist = true,
+ timeout = defaultTimeout,
+ }: WaitOptions & { shouldExist?: boolean } = {},
+) => {
+ const target = findElement(selector, index);
+ try {
+ if (shouldExist) {
+ await waitFor(target).toExist().withTimeout(timeout);
+ } else {
+ await waitFor(target).not.toExist().withTimeout(timeout);
+ }
+ return shouldExist;
+ } catch {
+ return !shouldExist;
+ }
+};
+
+export const isDisplayed = async (selector: Selector, index?: number) => {
+ try {
+ await expect(findElement(selector, index)).toBeVisible();
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+/** Polls until `predicate` holds, then returns `true`; returns `false` once `timeout` elapses. */
+export const waitUntil = async (
+ predicate: () => Promise,
+ timeout = defaultTimeout,
+ interval = 500,
+) => {
+ const deadline = Date.now() + timeout;
+ while (Date.now() < deadline) {
+ if (await predicate()) {
+ return true;
+ }
+ await new Promise((resolve) => setTimeout(resolve, interval));
+ }
+ return predicate();
+};
+
+/** Number of elements currently matching `selector` (Detox has no count query of its own). */
+export const count = async (selector: Selector) => {
+ try {
+ const attributes = await element(selector).getAttributes();
+ return 'elements' in attributes ? attributes.elements.length : 1;
+ } catch {
+ return 0;
+ }
+};
+
+/** Waits until `selector` matches exactly `expected` elements and returns the last count seen. */
+export const waitForCount = async (selector: Selector, expected: number, timeout?: number) => {
+ let actual = 0;
+ await waitUntil(async () => {
+ actual = await count(selector);
+ return actual === expected;
+ }, timeout);
+ return actual;
+};
+
+/** Waits until `selector` (or its `index`-th match) shows `text` and returns the text it shows. */
+export const waitForText = async (
+ selector: Selector,
+ text: string,
+ { index = 0, mustBeEqual = true, timeout }: WaitOptions & { mustBeEqual?: boolean } = {},
+) => {
+ let actual = '';
+ await waitUntil(async () => {
+ actual = await textOf(selector, index);
+ return mustBeEqual ? actual === text : actual.includes(text);
+ }, timeout);
+ return actual;
+};
+
+export const textOf = async (selector: Selector, index = 0) => {
+ try {
+ const attributes = await element(selector).getAttributes();
+ const match = 'elements' in attributes ? attributes.elements[index] : attributes;
+ return match?.text ?? match?.label ?? '';
+ } catch {
+ return '';
+ }
+};
diff --git a/examples/E2ETestApp/e2e/support/StreamTestCase.ts b/examples/E2ETestApp/e2e/support/StreamTestCase.ts
new file mode 100644
index 0000000000..4a4ddb1b9a
--- /dev/null
+++ b/examples/E2ETestApp/e2e/support/StreamTestCase.ts
@@ -0,0 +1,61 @@
+import { afterEach, beforeEach, expect as jestExpect } from '@jest/globals';
+
+import { dismissAnrDialog } from './AndroidSystemDialogs';
+
+import { MockServer } from '../mock-server/MockServer';
+import { BackendRobot, ParticipantRobot, UserRobot } from '../robots';
+
+/**
+ * Per-test fixture, the counterpart of `StreamTestCase` on iOS and Android: before each test it
+ * starts a fresh mock server and relaunches the app pointed at it; after each test it stops the
+ * server.
+ *
+ * describe('MessageListTests', () => {
+ * const { userRobot, participantRobot, backendRobot } = setUpStreamTestCase();
+ * it('test_...', async () => { ... });
+ * });
+ */
+export class StreamTestCase {
+ readonly mockServer = new MockServer();
+ readonly userRobot = new UserRobot();
+ readonly backendRobot = new BackendRobot(this.mockServer);
+ readonly participantRobot = new ParticipantRobot(this.mockServer);
+}
+
+/** `describe` + `it` names `MessageListTests test_x` → `test_x`, which names the mock server log. */
+const currentTestName = () =>
+ (jestExpect.getState().currentTestName ?? 'unknown')
+ .split(' ')
+ .pop()!
+ .replace(/[^\w-]/g, '_');
+
+export const setUpStreamTestCase = (): StreamTestCase => {
+ const testCase = new StreamTestCase();
+
+ beforeEach(async () => {
+ await testCase.mockServer.start(currentTestName());
+ if (device.getPlatform() === 'android') {
+ dismissAnrDialog();
+ }
+ await device.launchApp({
+ launchArgs: {
+ MOCK_SERVER_URL: testCase.mockServer.appUrl,
+ // iOS: Detox's idle tracking can wedge on RN's UIManager shadow queue (its pending-work
+ // counter underflows to 2^64-1), leaving the app "busy" until the test times out. Every
+ // robot action and assert waits explicitly, like the native XCUITest suites, so it runs
+ // without that synchronization. Android's Espresso synchronization stays on.
+ detoxEnableSynchronization: device.getPlatform() === 'ios' ? 0 : 1,
+ },
+ newInstance: true,
+ // No `permissions`: the app asks for none, and on iOS Detox applies each one with
+ // `applesimutils --restartSB`, restarting SpringBoard per permission on every launch
+ // (minutes per test on CI).
+ });
+ });
+
+ afterEach(async () => {
+ await testCase.mockServer.stop();
+ });
+
+ return testCase;
+};
diff --git a/examples/E2ETestApp/e2e/support/step.ts b/examples/E2ETestApp/e2e/support/step.ts
new file mode 100644
index 0000000000..6ff6d18688
--- /dev/null
+++ b/examples/E2ETestApp/e2e/support/step.ts
@@ -0,0 +1,14 @@
+/**
+ * A GIVEN / WHEN / THEN step, like `step("...") { }` on Android and `GIVEN("...") { }` on iOS.
+ * A failure is reported with the step it happened in.
+ */
+export const step = async (name: string, body: () => PromiseLike) => {
+ try {
+ await body();
+ } catch (error) {
+ if (error instanceof Error) {
+ error.message = `[${name}] ${error.message}`;
+ }
+ throw error;
+ }
+};
diff --git a/examples/E2ETestApp/e2e/tests/ChannelListTests.e2e.ts b/examples/E2ETestApp/e2e/tests/ChannelListTests.e2e.ts
new file mode 100644
index 0000000000..9a742a8e80
--- /dev/null
+++ b/examples/E2ETestApp/e2e/tests/ChannelListTests.e2e.ts
@@ -0,0 +1,43 @@
+import { describe, it } from '@jest/globals';
+
+import { step } from '../support/step';
+import { setUpStreamTestCase } from '../support/StreamTestCase';
+
+describe('ChannelListTests', () => {
+ const { backendRobot, participantRobot, userRobot } = setUpStreamTestCase();
+ const sampleText = 'Test';
+
+ it('test_channelListShowsGeneratedChannels', async () => {
+ await step('GIVEN backend has 3 channels', () =>
+ backendRobot.generateChannels({ channelsCount: 3, channelNames: ['Alpha', 'Beta', 'Gamma'] }),
+ );
+ await step('WHEN user logs in', () => userRobot.login().waitForChannelListToLoad());
+ await step('THEN user observes all channels in the list', () =>
+ userRobot.assertChannelCount(3).assertChannelAvatar(),
+ );
+ });
+
+ it('test_channelPreviewUpdates_whenParticipantSendsMessage', async () => {
+ await step('GIVEN user opens a channel', () => userRobot.login().openChannel());
+ await step('WHEN participant sends a message', () => participantRobot.sendMessage(sampleText));
+ await step('AND user goes back to the channel list', () =>
+ userRobot.assertMessage(sampleText).moveToChannelListFromMessageList(),
+ );
+ await step('THEN user observes the new message in preview', () =>
+ userRobot.assertMessageInChannelPreview(sampleText, false).assertChannelAvatar(),
+ );
+ });
+
+ it('test_channelPreviewUpdates_whenUserSendsMessage', async () => {
+ await step('GIVEN user opens a channel', () => userRobot.login().openChannel());
+ await step('WHEN user sends a message', () =>
+ userRobot.sendMessage(sampleText).assertMessage(sampleText),
+ );
+ await step('AND user goes back to the channel list', () =>
+ userRobot.moveToChannelListFromMessageList(),
+ );
+ await step('THEN user observes the new message in preview', () =>
+ userRobot.assertMessageInChannelPreview(sampleText, true),
+ );
+ });
+});
diff --git a/examples/E2ETestApp/e2e/tests/MessageListTests.e2e.ts b/examples/E2ETestApp/e2e/tests/MessageListTests.e2e.ts
new file mode 100644
index 0000000000..265ed0d70d
--- /dev/null
+++ b/examples/E2ETestApp/e2e/tests/MessageListTests.e2e.ts
@@ -0,0 +1,44 @@
+import { describe, it } from '@jest/globals';
+
+import { MessageDeliveryStatus } from '../mock-server/DataTypes';
+import { step } from '../support/step';
+import { setUpStreamTestCase } from '../support/StreamTestCase';
+
+describe('MessageListTests', () => {
+ const { backendRobot, participantRobot, userRobot } = setUpStreamTestCase();
+ const sampleText = 'Test';
+
+ it('test_messageListUpdates_whenUserSendsMessage', async () => {
+ await step('GIVEN user opens a channel', () => userRobot.login().openChannel());
+ await step('WHEN user sends a message', () => userRobot.sendMessage(sampleText));
+ await step('THEN message list updates', () =>
+ userRobot
+ .assertMessage(sampleText)
+ .assertMessageAuthor(true)
+ .assertMessageDeliveryStatus(MessageDeliveryStatus.SENT),
+ );
+ });
+
+ it('test_messageListUpdates_whenParticipantSendsMessage', async () => {
+ await step('GIVEN user opens a channel', () => userRobot.login().openChannel());
+ await step('WHEN participant sends a message', () => participantRobot.sendMessage(sampleText));
+ await step('THEN user receives a message', () =>
+ userRobot.assertMessage(sampleText).assertMessageAuthor(false),
+ );
+ });
+
+ it('test_typingIndicator', async () => {
+ await step('GIVEN user opens the channel', async () => {
+ await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 });
+ await userRobot.login().openChannel();
+ });
+ await step('WHEN participant starts typing', () => participantRobot.startTyping());
+ await step('THEN user observes typing indicator is shown', () =>
+ userRobot.assertTypingIndicator(true),
+ );
+ await step('WHEN participant stops typing', () => participantRobot.stopTyping());
+ await step('THEN user observes typing indicator has disappeared', () =>
+ userRobot.assertTypingIndicator(false),
+ );
+ });
+});
diff --git a/examples/E2ETestApp/e2e/tsconfig.json b/examples/E2ETestApp/e2e/tsconfig.json
new file mode 100644
index 0000000000..2f08e6cc4d
--- /dev/null
+++ b/examples/E2ETestApp/e2e/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "lib": ["es2022", "dom"],
+ "types": ["detox/globals"]
+ },
+ "include": ["./**/*.ts"],
+ "exclude": []
+}
diff --git a/examples/E2ETestApp/index.js b/examples/E2ETestApp/index.js
new file mode 100644
index 0000000000..d2660dcb40
--- /dev/null
+++ b/examples/E2ETestApp/index.js
@@ -0,0 +1,7 @@
+import 'react-native-gesture-handler';
+import { AppRegistry } from 'react-native';
+
+import App from './App';
+import { name as appName } from './app.json';
+
+AppRegistry.registerComponent(appName, () => App);
diff --git a/examples/E2ETestApp/ios/.xcode.env b/examples/E2ETestApp/ios/.xcode.env
new file mode 100644
index 0000000000..3d5782c715
--- /dev/null
+++ b/examples/E2ETestApp/ios/.xcode.env
@@ -0,0 +1,11 @@
+# This `.xcode.env` file is versioned and is used to source the environment
+# used when running script phases inside Xcode.
+# To customize your local environment, you can create an `.xcode.env.local`
+# file that is not versioned.
+
+# NODE_BINARY variable contains the PATH to the node executable.
+#
+# Customize the NODE_BINARY variable here.
+# For example, to use nvm with brew, add the following line
+# . "$(brew --prefix nvm)/nvm.sh" --no-use
+export NODE_BINARY=$(command -v node)
diff --git a/examples/E2ETestApp/ios/E2ETestApp.xcodeproj/project.pbxproj b/examples/E2ETestApp/ios/E2ETestApp.xcodeproj/project.pbxproj
new file mode 100644
index 0000000000..756385091b
--- /dev/null
+++ b/examples/E2ETestApp/ios/E2ETestApp.xcodeproj/project.pbxproj
@@ -0,0 +1,498 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 0C80B921A6F3F58F76C31292 /* libPods-E2ETestApp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-E2ETestApp.a */; };
+ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
+ 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
+ 761780EF2CA45674006654EF /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EE2CA45674006654EF /* SceneDelegate.swift */; };
+ 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
+ AE4AFD92A8A3756E73E2B1AB /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 13B07F961A680F5B00A75B9A /* E2ETestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = E2ETestApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = E2ETestApp/Images.xcassets; sourceTree = ""; };
+ 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = E2ETestApp/Info.plist; sourceTree = ""; };
+ 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = E2ETestApp/PrivacyInfo.xcprivacy; sourceTree = ""; };
+ 3B4392A12AC88292D35C810B /* Pods-E2ETestApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-E2ETestApp.debug.xcconfig"; path = "Target Support Files/Pods-E2ETestApp/Pods-E2ETestApp.debug.xcconfig"; sourceTree = ""; };
+ 5709B34CF0A7D63546082F79 /* Pods-E2ETestApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-E2ETestApp.release.xcconfig"; path = "Target Support Files/Pods-E2ETestApp/Pods-E2ETestApp.release.xcconfig"; sourceTree = ""; };
+ 5DCACB8F33CDC322A6C60F78 /* libPods-E2ETestApp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-E2ETestApp.a"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = E2ETestApp/AppDelegate.swift; sourceTree = ""; };
+ 761780EE2CA45674006654EF /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SceneDelegate.swift; path = E2ETestApp/SceneDelegate.swift; sourceTree = ""; };
+ 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = E2ETestApp/LaunchScreen.storyboard; sourceTree = ""; };
+ ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 0C80B921A6F3F58F76C31292 /* libPods-E2ETestApp.a in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 13B07FAE1A68108700A75B9A /* E2ETestApp */ = {
+ isa = PBXGroup;
+ children = (
+ 13B07FB51A68108700A75B9A /* Images.xcassets */,
+ 761780EC2CA45674006654EE /* AppDelegate.swift */,
+ 761780EE2CA45674006654EF /* SceneDelegate.swift */,
+ 13B07FB61A68108700A75B9A /* Info.plist */,
+ 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */,
+ 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */,
+ );
+ name = E2ETestApp;
+ sourceTree = "";
+ };
+ 2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
+ 5DCACB8F33CDC322A6C60F78 /* libPods-E2ETestApp.a */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+ 832341AE1AAA6A7D00B99B32 /* Libraries */ = {
+ isa = PBXGroup;
+ children = (
+ );
+ name = Libraries;
+ sourceTree = "";
+ };
+ 83CBB9F61A601CBA00E9B192 = {
+ isa = PBXGroup;
+ children = (
+ 13B07FAE1A68108700A75B9A /* E2ETestApp */,
+ 832341AE1AAA6A7D00B99B32 /* Libraries */,
+ 83CBBA001A601CBA00E9B192 /* Products */,
+ 2D16E6871FA4F8E400B85C8A /* Frameworks */,
+ BBD78D7AC51CEA395F1C20DB /* Pods */,
+ );
+ indentWidth = 2;
+ sourceTree = "";
+ tabWidth = 2;
+ usesTabs = 0;
+ };
+ 83CBBA001A601CBA00E9B192 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 13B07F961A680F5B00A75B9A /* E2ETestApp.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ BBD78D7AC51CEA395F1C20DB /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ 3B4392A12AC88292D35C810B /* Pods-E2ETestApp.debug.xcconfig */,
+ 5709B34CF0A7D63546082F79 /* Pods-E2ETestApp.release.xcconfig */,
+ );
+ path = Pods;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 13B07F861A680F5B00A75B9A /* E2ETestApp */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "E2ETestApp" */;
+ buildPhases = (
+ C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */,
+ 13B07F871A680F5B00A75B9A /* Sources */,
+ 13B07F8C1A680F5B00A75B9A /* Frameworks */,
+ 13B07F8E1A680F5B00A75B9A /* Resources */,
+ 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
+ 00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */,
+ E235C05ADACE081382539298 /* [CP] Copy Pods Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = E2ETestApp;
+ productName = E2ETestApp;
+ productReference = 13B07F961A680F5B00A75B9A /* E2ETestApp.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 83CBB9F71A601CBA00E9B192 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ LastUpgradeCheck = 1210;
+ TargetAttributes = {
+ 13B07F861A680F5B00A75B9A = {
+ LastSwiftMigration = 1120;
+ };
+ };
+ };
+ buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "E2ETestApp" */;
+ compatibilityVersion = "Xcode 12.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 83CBB9F61A601CBA00E9B192;
+ productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 13B07F861A680F5B00A75B9A /* E2ETestApp */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 13B07F8E1A680F5B00A75B9A /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
+ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
+ AE4AFD92A8A3756E73E2B1AB /* PrivacyInfo.xcprivacy in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "$(SRCROOT)/.xcode.env.local",
+ "$(SRCROOT)/.xcode.env",
+ );
+ name = "Bundle React Native code and images";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"\\\"$WITH_ENVIRONMENT\\\" \\\"$REACT_NATIVE_XCODE\\\"\"\n";
+ };
+ 00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-E2ETestApp/Pods-E2ETestApp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-E2ETestApp/Pods-E2ETestApp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-E2ETestApp/Pods-E2ETestApp-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-E2ETestApp-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
+ E235C05ADACE081382539298 /* [CP] Copy Pods Resources */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-E2ETestApp/Pods-E2ETestApp-resources-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Copy Pods Resources";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-E2ETestApp/Pods-E2ETestApp-resources-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-E2ETestApp/Pods-E2ETestApp-resources.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 13B07F871A680F5B00A75B9A /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */,
+ 761780EF2CA45674006654EF /* SceneDelegate.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 13B07F941A680F5B00A75B9A /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 3B4392A12AC88292D35C810B /* Pods-E2ETestApp.debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = E2ETestApp/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.1;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-ObjC",
+ "-lc++",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = io.getstream.reactnative.e2etestapp;
+ PRODUCT_NAME = E2ETestApp;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Debug;
+ };
+ 13B07F951A680F5B00A75B9A /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 5709B34CF0A7D63546082F79 /* Pods-E2ETestApp.release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ INFOPLIST_FILE = E2ETestApp/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.1;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-ObjC",
+ "-lc++",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = io.getstream.reactnative.e2etestapp;
+ PRODUCT_NAME = E2ETestApp;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Release;
+ };
+ 83CBBA201A601CBA00E9B192 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "c++20";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_SYMBOLS_PRIVATE_EXTERN = NO;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.1;
+ LD_RUNPATH_SEARCH_PATHS = (
+ /usr/lib/swift,
+ "$(inherited)",
+ );
+ LIBRARY_SEARCH_PATHS = (
+ "\"$(SDKROOT)/usr/lib/swift\"",
+ "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
+ "\"$(inherited)\"",
+ );
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ OTHER_CFLAGS = (
+ "$(inherited)",
+ "-DRCT_REMOVE_LEGACY_ARCH=1",
+ );
+ OTHER_CPLUSPLUSFLAGS = (
+ "$(OTHER_CFLAGS)",
+ "-DFOLLY_NO_CONFIG",
+ "-DFOLLY_MOBILE=1",
+ "-DFOLLY_USE_LIBCPP=1",
+ "-DFOLLY_CFG_NO_COROUTINES=1",
+ "-DFOLLY_HAVE_CLOCK_GETTIME=1",
+ "-DRCT_REMOVE_LEGACY_ARCH=1",
+ );
+ PODFILE_DIR = "$(SRCROOT)";
+ REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
+ USE_HERMES = true;
+ };
+ name = Debug;
+ };
+ 83CBBA211A601CBA00E9B192 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "c++20";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = YES;
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.1;
+ LD_RUNPATH_SEARCH_PATHS = (
+ /usr/lib/swift,
+ "$(inherited)",
+ );
+ LIBRARY_SEARCH_PATHS = (
+ "\"$(SDKROOT)/usr/lib/swift\"",
+ "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
+ "\"$(inherited)\"",
+ );
+ MTL_ENABLE_DEBUG_INFO = NO;
+ OTHER_CFLAGS = (
+ "$(inherited)",
+ "-DRCT_REMOVE_LEGACY_ARCH=1",
+ );
+ OTHER_CPLUSPLUSFLAGS = (
+ "$(OTHER_CFLAGS)",
+ "-DFOLLY_NO_CONFIG",
+ "-DFOLLY_MOBILE=1",
+ "-DFOLLY_USE_LIBCPP=1",
+ "-DFOLLY_CFG_NO_COROUTINES=1",
+ "-DFOLLY_HAVE_CLOCK_GETTIME=1",
+ "-DRCT_REMOVE_LEGACY_ARCH=1",
+ );
+ PODFILE_DIR = "$(SRCROOT)";
+ REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
+ SDKROOT = iphoneos;
+ USE_HERMES = true;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "E2ETestApp" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 13B07F941A680F5B00A75B9A /* Debug */,
+ 13B07F951A680F5B00A75B9A /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "E2ETestApp" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 83CBBA201A601CBA00E9B192 /* Debug */,
+ 83CBBA211A601CBA00E9B192 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
+}
diff --git a/examples/E2ETestApp/ios/E2ETestApp.xcodeproj/xcshareddata/xcschemes/E2ETestApp.xcscheme b/examples/E2ETestApp/ios/E2ETestApp.xcodeproj/xcshareddata/xcschemes/E2ETestApp.xcscheme
new file mode 100644
index 0000000000..cd9bdbadeb
--- /dev/null
+++ b/examples/E2ETestApp/ios/E2ETestApp.xcodeproj/xcshareddata/xcschemes/E2ETestApp.xcscheme
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/E2ETestApp/ios/E2ETestApp.xcworkspace/contents.xcworkspacedata b/examples/E2ETestApp/ios/E2ETestApp.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000000..bd672bb7e5
--- /dev/null
+++ b/examples/E2ETestApp/ios/E2ETestApp.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/examples/E2ETestApp/ios/E2ETestApp/AppDelegate.swift b/examples/E2ETestApp/ios/E2ETestApp/AppDelegate.swift
new file mode 100644
index 0000000000..0d14d1b0ac
--- /dev/null
+++ b/examples/E2ETestApp/ios/E2ETestApp/AppDelegate.swift
@@ -0,0 +1,11 @@
+import UIKit
+
+@main
+class AppDelegate: UIResponder, UIApplicationDelegate {
+ func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
+ ) -> Bool {
+ true
+ }
+}
diff --git a/examples/E2ETestApp/ios/E2ETestApp/Images.xcassets/AppIcon.appiconset/Contents.json b/examples/E2ETestApp/ios/E2ETestApp/Images.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000000..81213230de
--- /dev/null
+++ b/examples/E2ETestApp/ios/E2ETestApp/Images.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,53 @@
+{
+ "images" : [
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "20x20"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "20x20"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "29x29"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "29x29"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "40x40"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "40x40"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "60x60"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "60x60"
+ },
+ {
+ "idiom" : "ios-marketing",
+ "scale" : "1x",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/examples/E2ETestApp/ios/E2ETestApp/Images.xcassets/Contents.json b/examples/E2ETestApp/ios/E2ETestApp/Images.xcassets/Contents.json
new file mode 100644
index 0000000000..2d92bd53fd
--- /dev/null
+++ b/examples/E2ETestApp/ios/E2ETestApp/Images.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/examples/E2ETestApp/ios/E2ETestApp/Info.plist b/examples/E2ETestApp/ios/E2ETestApp/Info.plist
new file mode 100644
index 0000000000..cf6c5bcfd6
--- /dev/null
+++ b/examples/E2ETestApp/ios/E2ETestApp/Info.plist
@@ -0,0 +1,78 @@
+
+
+
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ E2ETestApp
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ LSRequiresIPhoneOS
+
+ NSAppTransportSecurity
+
+
+ NSAllowsArbitraryLoads
+
+ NSAllowsLocalNetworking
+
+
+ NSLocationWhenInUseUsageDescription
+
+ RCTNewArchEnabled
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneConfigurationName
+ Default Configuration
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).SceneDelegate
+
+
+
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+
+ UIViewControllerBasedStatusBarAppearance
+
+
+
diff --git a/examples/E2ETestApp/ios/E2ETestApp/LaunchScreen.storyboard b/examples/E2ETestApp/ios/E2ETestApp/LaunchScreen.storyboard
new file mode 100644
index 0000000000..600e8dc84b
--- /dev/null
+++ b/examples/E2ETestApp/ios/E2ETestApp/LaunchScreen.storyboard
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/E2ETestApp/ios/E2ETestApp/PrivacyInfo.xcprivacy b/examples/E2ETestApp/ios/E2ETestApp/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000000..fc90547a7a
--- /dev/null
+++ b/examples/E2ETestApp/ios/E2ETestApp/PrivacyInfo.xcprivacy
@@ -0,0 +1,37 @@
+
+
+
+
+ NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryFileTimestamp
+ NSPrivacyAccessedAPITypeReasons
+
+ C617.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategorySystemBootTime
+ NSPrivacyAccessedAPITypeReasons
+
+ 35F9.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryUserDefaults
+ NSPrivacyAccessedAPITypeReasons
+
+ CA92.1
+
+
+
+ NSPrivacyCollectedDataTypes
+
+ NSPrivacyTracking
+
+
+
diff --git a/examples/E2ETestApp/ios/E2ETestApp/SceneDelegate.swift b/examples/E2ETestApp/ios/E2ETestApp/SceneDelegate.swift
new file mode 100644
index 0000000000..b5c4d45824
--- /dev/null
+++ b/examples/E2ETestApp/ios/E2ETestApp/SceneDelegate.swift
@@ -0,0 +1,56 @@
+import React
+import React_RCTAppDelegate
+import ReactAppDependencyProvider
+import UIKit
+
+class SceneDelegate: RCTDefaultReactNativeFactoryDelegate, UIWindowSceneDelegate {
+ var window: UIWindow?
+ var reactNativeFactory: RCTReactNativeFactory?
+
+ func scene(
+ _ scene: UIScene,
+ willConnectTo session: UISceneSession,
+ options connectionOptions: UIScene.ConnectionOptions
+ ) {
+ guard let windowScene = scene as? UIWindowScene else {
+ return
+ }
+
+ dependencyProvider = RCTAppDependencyProvider()
+ reactNativeFactory = RCTReactNativeFactory(delegate: self)
+ window = UIWindow(windowScene: windowScene)
+
+ #if DEBUG
+ let devMenuConfiguration = RCTDevMenuConfiguration(
+ devMenuEnabled: true,
+ shakeGestureEnabled: true,
+ keyboardShortcutsEnabled: true
+ )
+ reactNativeFactory?.devMenuConfiguration = devMenuConfiguration
+ #endif
+
+ reactNativeFactory?.startReactNative(
+ withModuleName: "E2ETestApp",
+ in: window,
+ connectionOptions: connectionOptions
+ )
+
+
+ }
+
+ func scene(_ scene: UIScene, openURLContexts URLContexts: Set) {
+ RCTLinkingManager.scene(scene, openURLContexts: URLContexts)
+ }
+
+ func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
+ RCTLinkingManager.scene(scene, continue: userActivity)
+ }
+
+ override func bundleURL() -> URL? {
+ #if DEBUG
+ RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
+ #else
+ Bundle.main.url(forResource: "main", withExtension: "jsbundle")
+ #endif
+ }
+}
diff --git a/examples/E2ETestApp/ios/Podfile b/examples/E2ETestApp/ios/Podfile
new file mode 100644
index 0000000000..10e8a8c808
--- /dev/null
+++ b/examples/E2ETestApp/ios/Podfile
@@ -0,0 +1,45 @@
+# Resolve react_native_pods.rb with node to allow for hoisting
+require Pod::Executable.execute_command('node', ['-p',
+ 'require.resolve(
+ "react-native/scripts/react_native_pods.rb",
+ {paths: [process.argv[1]]},
+ )', __dir__]).strip
+
+platform :ios, min_ios_version_supported
+prepare_react_native_project!
+
+linkage = ENV['USE_FRAMEWORKS']
+if linkage != nil
+ Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green
+ use_frameworks! :linkage => linkage.to_sym
+end
+
+target 'E2ETestApp' do
+ config = use_native_modules!
+
+ use_react_native!(
+ :path => config[:reactNativePath],
+ # An absolute path to your application root.
+ :app_path => "#{Pod::Config.instance.installation_root}/.."
+ )
+
+ post_install do |installer|
+ react_native_post_install(
+ installer,
+ config[:reactNativePath],
+ :mac_catalyst_enabled => false,
+ # CI sets USE_CCACHE=1 so compiled objects are reused across runs.
+ :ccache_enabled => ENV['USE_CCACHE'] == '1',
+ )
+
+ # Some pods pin an iOS deployment target (e.g. RNSVG at 12.4) below the range supported by
+ # current Xcode. Clamp them up to the app's minimum, as the SampleApp does.
+ installer.pods_project.targets.each do |target|
+ target.build_configurations.each do |config|
+ if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < min_ios_version_supported.to_f
+ config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = min_ios_version_supported
+ end
+ end
+ end
+ end
+end
diff --git a/examples/E2ETestApp/ios/Podfile.lock b/examples/E2ETestApp/ios/Podfile.lock
new file mode 100644
index 0000000000..cb27ec2bdd
--- /dev/null
+++ b/examples/E2ETestApp/ios/Podfile.lock
@@ -0,0 +1,2554 @@
+PODS:
+ - boost (1.84.0):
+ - ReactNativeDependencies
+ - DoubleConversion (1.1.6):
+ - ReactNativeDependencies
+ - fast_float (8.0.0):
+ - ReactNativeDependencies
+ - FBLazyVector (0.88.0):
+ - React-Core-prebuilt
+ - fmt (12.1.0):
+ - ReactNativeDependencies
+ - glog (0.3.5):
+ - ReactNativeDependencies
+ - hermes-engine (260318099.0.4):
+ - hermes-engine/Pre-built (= 260318099.0.4)
+ - hermes-engine/Pre-built (260318099.0.4)
+ - RCT-Folly (2024.11.18.00):
+ - RCT-Folly/Default (= 2024.11.18.00)
+ - ReactNativeDependencies
+ - RCT-Folly/Default (2024.11.18.00):
+ - ReactNativeDependencies
+ - RCTDeprecation (0.88.0):
+ - React-Core-prebuilt
+ - RCTRequired (0.88.0):
+ - React-Core-prebuilt
+ - RCTSwiftUI (0.88.0)
+ - RCTSwiftUIWrapper (0.88.0):
+ - RCTSwiftUI
+ - RCTTypeSafety (0.88.0):
+ - FBLazyVector (= 0.88.0)
+ - RCTRequired (= 0.88.0)
+ - React-Core (= 0.88.0)
+ - React (0.88.0):
+ - React-Core (= 0.88.0)
+ - React-Core/DevSupport (= 0.88.0)
+ - React-Core/RCTWebSocket (= 0.88.0)
+ - React-RCTActionSheet (= 0.88.0)
+ - React-RCTAnimation (= 0.88.0)
+ - React-RCTBlob (= 0.88.0)
+ - React-RCTImage (= 0.88.0)
+ - React-RCTLinking (= 0.88.0)
+ - React-RCTNetwork (= 0.88.0)
+ - React-RCTSettings (= 0.88.0)
+ - React-RCTText (= 0.88.0)
+ - React-RCTVibration (= 0.88.0)
+ - React-bridging (0.88.0):
+ - React-callinvoker
+ - React-Core-prebuilt
+ - React-jsi
+ - React-timing
+ - ReactNativeDependencies
+ - React-callinvoker (0.88.0):
+ - React-callinvoker/callinvokerUmbrella (= 0.88.0)
+ - React-cxxstableapi
+ - React-callinvoker/callinvokerUmbrella (0.88.0):
+ - React-cxxstableapi
+ - React-Core (0.88.0):
+ - React-Core-prebuilt
+ - React-Core/Default (= 0.88.0)
+ - React-Core-prebuilt (0.88.0):
+ - ReactNativeDependencies
+ - React-Core/CoreModulesHeaders (0.88.0):
+ - React-Core-prebuilt
+ - React-Core/Default (0.88.0):
+ - React-Core-prebuilt
+ - React-Core/DevSupport (0.88.0):
+ - React-Core-prebuilt
+ - React-Core/RCTActionSheetHeaders (0.88.0):
+ - React-Core-prebuilt
+ - React-Core/RCTAnimationHeaders (0.88.0):
+ - React-Core-prebuilt
+ - React-Core/RCTBlobHeaders (0.88.0):
+ - React-Core-prebuilt
+ - React-Core/RCTImageHeaders (0.88.0):
+ - React-Core-prebuilt
+ - React-Core/RCTLinkingHeaders (0.88.0):
+ - React-Core-prebuilt
+ - React-Core/RCTNetworkHeaders (0.88.0):
+ - React-Core-prebuilt
+ - React-Core/RCTSettingsHeaders (0.88.0):
+ - React-Core-prebuilt
+ - React-Core/RCTTextHeaders (0.88.0):
+ - React-Core-prebuilt
+ - React-Core/RCTVibrationHeaders (0.88.0):
+ - React-Core-prebuilt
+ - React-Core/RCTWebSocket (0.88.0):
+ - React-Core-prebuilt
+ - React-CoreModules (0.88.0):
+ - RCTTypeSafety (= 0.88.0)
+ - React-Core-prebuilt
+ - React-Core/CoreModulesHeaders (= 0.88.0)
+ - React-debug
+ - React-featureflags
+ - React-jsi (= 0.88.0)
+ - React-jsinspector
+ - React-jsinspectorcdp
+ - React-jsinspectortracing
+ - React-NativeModulesApple
+ - React-networking
+ - React-RCTBlob
+ - React-RCTFBReactNativeSpec
+ - React-RCTImage (= 0.88.0)
+ - React-runtimeexecutor
+ - React-utils
+ - ReactCommon
+ - ReactNativeDependencies
+ - React-cxxreact (0.88.0):
+ - hermes-engine
+ - React-callinvoker (= 0.88.0)
+ - React-Core-prebuilt
+ - React-debug (= 0.88.0)
+ - React-jserrorhandler (= 0.88.0)
+ - React-jsi (= 0.88.0)
+ - React-jsinspector
+ - React-jsinspectorcdp
+ - React-jsinspectortracing
+ - React-logger (= 0.88.0)
+ - React-perflogger (= 0.88.0)
+ - React-runtimeexecutor
+ - React-timing (= 0.88.0)
+ - React-utils
+ - ReactNativeDependencies
+ - React-cxxstableapi (0.88.0)
+ - React-debug (0.88.0):
+ - React-cxxstableapi
+ - React-debug/debugUmbrella (= 0.88.0)
+ - React-debug/redbox (= 0.88.0)
+ - React-debug/debugUmbrella (0.88.0):
+ - React-cxxstableapi
+ - React-debug/redbox (0.88.0):
+ - React-cxxstableapi
+ - React-defaultsnativemodule (0.88.0):
+ - hermes-engine
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-domnativemodule
+ - React-Fabric/animated
+ - React-featureflags
+ - React-featureflagsnativemodule
+ - React-idlecallbacksnativemodule
+ - React-intersectionobservernativemodule
+ - React-jsi
+ - React-jsiexecutor
+ - React-microtasksnativemodule
+ - React-mutationobservernativemodule
+ - React-RCTFBReactNativeSpec
+ - React-resizeobservernativemodule
+ - React-viewtransitionnativemodule
+ - React-webperformancenativemodule
+ - ReactNativeDependencies
+ - Yoga
+ - React-domnativemodule (0.88.0):
+ - hermes-engine
+ - React-bridging
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-Fabric
+ - React-Fabric/bridging
+ - React-FabricComponents
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-RCTFBReactNativeSpec
+ - React-runtimeexecutor
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-Fabric (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric/animated (= 0.88.0)
+ - React-Fabric/animationbackend (= 0.88.0)
+ - React-Fabric/animations (= 0.88.0)
+ - React-Fabric/attributedstring (= 0.88.0)
+ - React-Fabric/bridging (= 0.88.0)
+ - React-Fabric/componentregistry (= 0.88.0)
+ - React-Fabric/componentregistrynative (= 0.88.0)
+ - React-Fabric/components (= 0.88.0)
+ - React-Fabric/consistency (= 0.88.0)
+ - React-Fabric/core (= 0.88.0)
+ - React-Fabric/dom (= 0.88.0)
+ - React-Fabric/imagemanager (= 0.88.0)
+ - React-Fabric/leakchecker (= 0.88.0)
+ - React-Fabric/mounting (= 0.88.0)
+ - React-Fabric/observers (= 0.88.0)
+ - React-Fabric/scheduler (= 0.88.0)
+ - React-Fabric/telemetry (= 0.88.0)
+ - React-Fabric/uimanager (= 0.88.0)
+ - React-Fabric/viewtransition (= 0.88.0)
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/animated (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric/animationbackend
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/animationbackend (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/animations (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/attributedstring (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/bridging (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/componentregistry (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/componentregistrynative (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/components (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric/components/legacyviewmanagerinterop (= 0.88.0)
+ - React-Fabric/components/root (= 0.88.0)
+ - React-Fabric/components/scrollview (= 0.88.0)
+ - React-Fabric/components/view (= 0.88.0)
+ - React-Fabric/components/viewUmbrella (= 0.88.0)
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/components/legacyviewmanagerinterop (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/components/root (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/components/scrollview (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/components/view (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-renderercss
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-Fabric/components/viewUmbrella (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/consistency (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/core (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/dom (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/imagemanager (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/leakchecker (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/mounting (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-jsinspectortracing
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/observers (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric/observers/events (= 0.88.0)
+ - React-Fabric/observers/intersection (= 0.88.0)
+ - React-Fabric/observers/mutation (= 0.88.0)
+ - React-Fabric/observers/resize (= 0.88.0)
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/observers/events (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/observers/intersection (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/observers/mutation (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/observers/resize (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/scheduler (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric/animationbackend
+ - React-Fabric/observers/events
+ - React-Fabric/viewtransition
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-performancecdpmetrics
+ - React-performancetimeline
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/telemetry (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/uimanager (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric/uimanager/consistency (= 0.88.0)
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererconsistency
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/uimanager/consistency (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererconsistency
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-Fabric/viewtransition (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-FabricComponents (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-FabricComponents/components (= 0.88.0)
+ - React-FabricComponents/textlayoutmanager (= 0.88.0)
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-RCTFBReactNativeSpec
+ - React-rendererdebug
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-FabricComponents/components (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-FabricComponents/components/inputaccessory (= 0.88.0)
+ - React-FabricComponents/components/iostextinput (= 0.88.0)
+ - React-FabricComponents/components/modal (= 0.88.0)
+ - React-FabricComponents/components/rncore (= 0.88.0)
+ - React-FabricComponents/components/safeareaview (= 0.88.0)
+ - React-FabricComponents/components/switch (= 0.88.0)
+ - React-FabricComponents/components/text (= 0.88.0)
+ - React-FabricComponents/components/textinput (= 0.88.0)
+ - React-FabricComponents/components/unimplementedview (= 0.88.0)
+ - React-FabricComponents/components/virtualview (= 0.88.0)
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-RCTFBReactNativeSpec
+ - React-rendererdebug
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-FabricComponents/components/inputaccessory (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-RCTFBReactNativeSpec
+ - React-rendererdebug
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-FabricComponents/components/iostextinput (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-RCTFBReactNativeSpec
+ - React-rendererdebug
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-FabricComponents/components/modal (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-RCTFBReactNativeSpec
+ - React-rendererdebug
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-FabricComponents/components/rncore (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-RCTFBReactNativeSpec
+ - React-rendererdebug
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-FabricComponents/components/safeareaview (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-RCTFBReactNativeSpec
+ - React-rendererdebug
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-FabricComponents/components/switch (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-RCTFBReactNativeSpec
+ - React-rendererdebug
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-FabricComponents/components/text (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-RCTFBReactNativeSpec
+ - React-rendererdebug
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-FabricComponents/components/textinput (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-RCTFBReactNativeSpec
+ - React-rendererdebug
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-FabricComponents/components/unimplementedview (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-RCTFBReactNativeSpec
+ - React-rendererdebug
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-FabricComponents/components/virtualview (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-RCTFBReactNativeSpec
+ - React-rendererdebug
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-FabricComponents/textlayoutmanager (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-logger
+ - React-RCTFBReactNativeSpec
+ - React-rendererdebug
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-FabricImage (0.88.0):
+ - hermes-engine
+ - RCTRequired (= 0.88.0)
+ - RCTTypeSafety (= 0.88.0)
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-jsi
+ - React-jsiexecutor (= 0.88.0)
+ - React-logger
+ - React-rendererdebug
+ - React-utils
+ - ReactCommon
+ - ReactNativeDependencies
+ - Yoga
+ - React-featureflags (0.88.0):
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-featureflags/featureflagsUmbrella (= 0.88.0)
+ - ReactNativeDependencies
+ - React-featureflags/featureflagsUmbrella (0.88.0):
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - ReactNativeDependencies
+ - React-featureflagsnativemodule (0.88.0):
+ - hermes-engine
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-featureflags
+ - React-jsi
+ - React-jsiexecutor
+ - React-RCTFBReactNativeSpec
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-graphics (0.88.0):
+ - hermes-engine
+ - React-Core-prebuilt
+ - React-featureflags
+ - React-jsi
+ - React-jsiexecutor
+ - React-rendererdebug
+ - React-utils
+ - ReactNativeDependencies
+ - React-hermes (0.88.0):
+ - hermes-engine
+ - React-Core-prebuilt
+ - React-cxxreact (= 0.88.0)
+ - React-cxxstableapi
+ - React-jsi
+ - React-jsiexecutor (= 0.88.0)
+ - React-jsinspector
+ - React-jsinspectorcdp
+ - React-jsinspectortracing
+ - React-jsitooling
+ - React-oscompat
+ - React-perflogger (= 0.88.0)
+ - React-runtimeexecutor
+ - ReactNativeDependencies
+ - React-idlecallbacksnativemodule (0.88.0):
+ - hermes-engine
+ - React-bridging
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-jsi
+ - React-jsiexecutor
+ - React-RCTFBReactNativeSpec
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-ImageManager (0.88.0):
+ - React-Core-prebuilt
+ - React-Core/Default
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-graphics
+ - React-rendererdebug
+ - React-utils
+ - ReactNativeDependencies
+ - React-intersectionobservernativemodule (0.88.0):
+ - hermes-engine
+ - React-bridging
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-Fabric
+ - React-Fabric/bridging
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-RCTFBReactNativeSpec
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-jserrorhandler (0.88.0):
+ - hermes-engine
+ - React-bridging
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-jsi
+ - ReactNativeDependencies
+ - React-jsi (0.88.0):
+ - hermes-engine
+ - React-Core-prebuilt
+ - ReactNativeDependencies
+ - React-jsiexecutor (0.88.0):
+ - hermes-engine
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-jserrorhandler
+ - React-jsi
+ - React-jsinspector
+ - React-jsinspectorcdp
+ - React-jsinspectortracing
+ - React-jsitooling
+ - React-perflogger
+ - React-runtimeexecutor
+ - React-utils
+ - ReactNativeDependencies
+ - React-jsinspector (0.88.0):
+ - hermes-engine
+ - React-Core-prebuilt
+ - React-featureflags
+ - React-jsi
+ - React-jsinspectorcdp
+ - React-jsinspectornetwork
+ - React-jsinspectortracing
+ - React-oscompat
+ - React-perflogger (= 0.88.0)
+ - React-runtimeexecutor
+ - React-utils
+ - ReactNativeDependencies
+ - React-jsinspectorcdp (0.88.0):
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - ReactNativeDependencies
+ - React-jsinspectornetwork (0.88.0):
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-jsinspectorcdp
+ - ReactNativeDependencies
+ - React-jsinspectortracing (0.88.0):
+ - hermes-engine
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-jsi
+ - React-jsinspectornetwork
+ - React-oscompat
+ - React-timing
+ - React-utils
+ - ReactNativeDependencies
+ - React-jsitooling (0.88.0):
+ - hermes-engine
+ - React-Core-prebuilt
+ - React-cxxreact (= 0.88.0)
+ - React-cxxstableapi
+ - React-debug
+ - React-jsi (= 0.88.0)
+ - React-jsinspector
+ - React-jsinspectorcdp
+ - React-jsinspectortracing
+ - React-runtimeexecutor
+ - React-utils
+ - ReactNativeDependencies
+ - React-jsitracing (0.88.0):
+ - React-jsi
+ - React-logger (0.88.0):
+ - React-Core-prebuilt
+ - ReactNativeDependencies
+ - React-Mapbuffer (0.88.0):
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Mapbuffer/mapbufferUmbrella (= 0.88.0)
+ - ReactNativeDependencies
+ - React-Mapbuffer/mapbufferUmbrella (0.88.0):
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - ReactNativeDependencies
+ - React-microtasksnativemodule (0.88.0):
+ - hermes-engine
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-jsi
+ - React-jsiexecutor
+ - React-RCTFBReactNativeSpec
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-mutationobservernativemodule (0.88.0):
+ - hermes-engine
+ - React-bridging
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-Fabric
+ - React-Fabric/bridging
+ - React-Fabric/observers/mutation
+ - React-featureflags
+ - React-jsi
+ - React-jsiexecutor
+ - React-RCTFBReactNativeSpec
+ - React-runtimeexecutor
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - react-native-launch-arguments (4.1.1):
+ - React
+ - react-native-netinfo (12.0.1):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - react-native-safe-area-context (5.10.1):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-jsi
+ - react-native-safe-area-context/common (= 5.10.1)
+ - react-native-safe-area-context/fabric (= 5.10.1)
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - react-native-safe-area-context/common (5.10.1):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - react-native-safe-area-context/fabric (5.10.1):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-jsi
+ - react-native-safe-area-context/common
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-NativeModulesApple (0.88.0):
+ - hermes-engine
+ - React-bridging
+ - React-callinvoker
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-debug
+ - React-featureflags
+ - React-jsi
+ - React-jsinspector
+ - React-jsinspectorcdp
+ - React-runtimeexecutor
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - React-networking (0.88.0):
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-jsinspectornetwork
+ - React-jsinspectortracing
+ - React-performancetimeline
+ - React-timing
+ - ReactNativeDependencies
+ - React-oscompat (0.88.0):
+ - React-cxxstableapi
+ - React-perflogger (0.88.0):
+ - React-Core-prebuilt
+ - ReactNativeDependencies
+ - React-performancecdpmetrics (0.88.0):
+ - hermes-engine
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-jsi
+ - React-performancetimeline
+ - React-runtimeexecutor
+ - React-timing
+ - ReactNativeDependencies
+ - React-performancetimeline (0.88.0):
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-featureflags
+ - React-jsinspector
+ - React-jsinspectortracing
+ - React-perflogger
+ - React-timing
+ - ReactNativeDependencies
+ - React-RCTActionSheet (0.88.0):
+ - React-Core/RCTActionSheetHeaders (= 0.88.0)
+ - React-RCTAnimatedModuleProvider (0.88.0):
+ - hermes-engine
+ - React-Core
+ - React-Core-prebuilt
+ - React-Fabric/animated
+ - React-featureflags
+ - ReactCommon
+ - ReactNativeDependencies
+ - Yoga
+ - React-RCTAnimation (0.88.0):
+ - RCTTypeSafety
+ - React-Core-prebuilt
+ - React-Core/RCTAnimationHeaders
+ - React-debug
+ - React-featureflags
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFBReactNativeSpec
+ - ReactCommon
+ - ReactNativeDependencies
+ - React-RCTAppDelegate (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-Core-prebuilt
+ - React-CoreModules
+ - React-debug
+ - React-defaultsnativemodule
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-jsitooling
+ - React-NativeModulesApple
+ - React-RCTAnimatedModuleProvider
+ - React-RCTFabric
+ - React-RCTFBReactNativeSpec
+ - React-RCTImage
+ - React-RCTLinking
+ - React-RCTNetwork
+ - React-RCTRuntime
+ - React-rendererdebug
+ - React-RuntimeApple
+ - React-RuntimeCore
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactCommon
+ - ReactNativeDependencies
+ - React-RCTBlob (0.88.0):
+ - hermes-engine
+ - React-Core-prebuilt
+ - React-Core/RCTBlobHeaders
+ - React-Core/RCTWebSocket
+ - React-jsi
+ - React-jsinspector
+ - React-jsinspectorcdp
+ - React-NativeModulesApple
+ - React-RCTFBReactNativeSpec
+ - React-RCTNetwork
+ - ReactCommon
+ - ReactNativeDependencies
+ - React-RCTFabric (0.88.0):
+ - React-Core-prebuilt
+ - React-RCTFBReactNativeSpec (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFBReactNativeSpec/components (= 0.88.0)
+ - ReactCommon
+ - ReactNativeDependencies
+ - React-RCTFBReactNativeSpec/components (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-NativeModulesApple
+ - React-rendererdebug
+ - React-utils
+ - ReactCommon
+ - ReactNativeDependencies
+ - Yoga
+ - React-RCTImage (0.88.0):
+ - RCTTypeSafety
+ - React-Core-prebuilt
+ - React-Core/RCTImageHeaders
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFBReactNativeSpec
+ - React-RCTNetwork
+ - ReactCommon
+ - ReactNativeDependencies
+ - React-RCTLinking (0.88.0):
+ - React-Core/RCTLinkingHeaders (= 0.88.0)
+ - React-jsi (= 0.88.0)
+ - React-NativeModulesApple
+ - React-RCTFBReactNativeSpec
+ - ReactCommon
+ - ReactCommon/turbomodule/core (= 0.88.0)
+ - React-RCTNetwork (0.88.0):
+ - RCTTypeSafety
+ - React-Core-prebuilt
+ - React-Core/RCTNetworkHeaders
+ - React-debug
+ - React-featureflags
+ - React-jsi
+ - React-jsinspectorcdp
+ - React-jsinspectornetwork
+ - React-NativeModulesApple
+ - React-networking
+ - React-RCTFBReactNativeSpec
+ - ReactCommon
+ - ReactNativeDependencies
+ - React-RCTRuntime (0.88.0):
+ - React-Core-prebuilt
+ - React-RCTSettings (0.88.0):
+ - RCTTypeSafety
+ - React-Core-prebuilt
+ - React-Core/RCTSettingsHeaders
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFBReactNativeSpec
+ - ReactCommon
+ - ReactNativeDependencies
+ - React-RCTText (0.88.0):
+ - React-Core/RCTTextHeaders (= 0.88.0)
+ - Yoga
+ - React-RCTVibration (0.88.0):
+ - React-Core-prebuilt
+ - React-Core/RCTVibrationHeaders
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFBReactNativeSpec
+ - ReactCommon
+ - ReactNativeDependencies
+ - React-rendererconsistency (0.88.0)
+ - React-renderercss (0.88.0):
+ - React-debug
+ - React-utils
+ - React-rendererdebug (0.88.0):
+ - React-Core-prebuilt
+ - React-debug
+ - ReactNativeDependencies
+ - React-resizeobservernativemodule (0.88.0):
+ - hermes-engine
+ - React-bridging
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-Fabric
+ - React-Fabric/bridging
+ - React-Fabric/observers/resize
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-RCTFBReactNativeSpec
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-RuntimeApple (0.88.0):
+ - hermes-engine
+ - React-callinvoker
+ - React-Core-prebuilt
+ - React-Core/Default
+ - React-CoreModules
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-featureflags
+ - React-jserrorhandler
+ - React-jsi
+ - React-jsiexecutor
+ - React-jsinspector
+ - React-jsitooling
+ - React-Mapbuffer
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-RCTFBReactNativeSpec
+ - React-RuntimeCore
+ - React-runtimeexecutor
+ - React-RuntimeHermes
+ - React-runtimescheduler
+ - React-utils
+ - ReactNativeDependencies
+ - React-RuntimeCore (0.88.0):
+ - hermes-engine
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-Fabric
+ - React-featureflags
+ - React-jserrorhandler
+ - React-jsi
+ - React-jsiexecutor
+ - React-jsinspector
+ - React-jsitooling
+ - React-performancetimeline
+ - React-runtimeexecutor
+ - React-runtimescheduler
+ - React-utils
+ - ReactNativeDependencies
+ - React-runtimeexecutor (0.88.0):
+ - React-Core-prebuilt
+ - React-debug
+ - React-featureflags
+ - React-jsi (= 0.88.0)
+ - React-utils
+ - ReactNativeDependencies
+ - React-RuntimeHermes (0.88.0):
+ - hermes-engine
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-featureflags
+ - React-hermes
+ - React-jsi
+ - React-jsinspector
+ - React-jsinspectorcdp
+ - React-jsinspectortracing
+ - React-jsitooling
+ - React-jsitracing
+ - React-RuntimeCore
+ - React-runtimeexecutor
+ - React-utils
+ - ReactNativeDependencies
+ - React-runtimescheduler (0.88.0):
+ - hermes-engine
+ - React-callinvoker
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-debug
+ - React-featureflags
+ - React-jserrorhandler
+ - React-jsi
+ - React-jsinspectortracing
+ - React-performancetimeline
+ - React-rendererconsistency
+ - React-rendererdebug
+ - React-runtimeexecutor
+ - React-timing
+ - React-utils
+ - ReactNativeDependencies
+ - React-timing (0.88.0):
+ - React-cxxstableapi
+ - React-debug
+ - React-timing/timingUmbrella (= 0.88.0)
+ - React-timing/timingUmbrella (0.88.0):
+ - React-cxxstableapi
+ - React-debug
+ - React-utils (0.88.0):
+ - hermes-engine
+ - React-Core-prebuilt
+ - React-debug
+ - React-jsi (= 0.88.0)
+ - ReactNativeDependencies
+ - React-viewtransitionnativemodule (0.88.0):
+ - hermes-engine
+ - React-Core-prebuilt
+ - React-Fabric
+ - React-Fabric/bridging
+ - React-jsi
+ - React-jsiexecutor
+ - React-RCTFBReactNativeSpec
+ - React-runtimeexecutor
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - React-webperformancenativemodule (0.88.0):
+ - hermes-engine
+ - React-bridging
+ - React-Core-prebuilt
+ - React-cxxreact
+ - React-cxxstableapi
+ - React-jsi
+ - React-jsiexecutor
+ - React-performancetimeline
+ - React-RCTFBReactNativeSpec
+ - React-runtimeexecutor
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - ReactAppDependencyProvider (0.88.0):
+ - ReactCodegen
+ - ReactCodegen (0.88.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-debug
+ - React-Fabric
+ - React-FabricImage
+ - React-featureflags
+ - React-graphics
+ - React-jsi
+ - React-jsiexecutor
+ - React-NativeModulesApple
+ - React-RCTAppDelegate
+ - React-rendererdebug
+ - React-utils
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - ReactCommon (0.88.0):
+ - React-Core-prebuilt
+ - ReactCommon/turbomodule (= 0.88.0)
+ - ReactNativeDependencies
+ - ReactCommon/turbomodule (0.88.0):
+ - hermes-engine
+ - React-callinvoker (= 0.88.0)
+ - React-Core-prebuilt
+ - React-jsi (= 0.88.0)
+ - React-logger (= 0.88.0)
+ - React-perflogger (= 0.88.0)
+ - ReactCommon/turbomodule/core (= 0.88.0)
+ - ReactNativeDependencies
+ - ReactCommon/turbomodule/core (0.88.0):
+ - hermes-engine
+ - React-bridging
+ - React-callinvoker (= 0.88.0)
+ - React-Core-prebuilt
+ - React-cxxreact (= 0.88.0)
+ - React-debug (= 0.88.0)
+ - React-featureflags (= 0.88.0)
+ - React-jsi (= 0.88.0)
+ - React-logger (= 0.88.0)
+ - React-perflogger (= 0.88.0)
+ - React-utils (= 0.88.0)
+ - ReactNativeDependencies
+ - ReactNativeDependencies (0.88.0)
+ - RNGestureHandler (3.4.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - RNWorklets (>= 0.8.0)
+ - Yoga
+ - RNReanimated (4.7.1):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - RNReanimated/apple (= 4.7.1)
+ - RNReanimated/common (= 4.7.1)
+ - RNReanimated/view (= 4.7.1)
+ - RNWorklets
+ - Yoga
+ - RNReanimated/apple (4.7.1):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - RNWorklets
+ - Yoga
+ - RNReanimated/common (4.7.1):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - RNWorklets
+ - Yoga
+ - RNReanimated/view (4.7.1):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - RNWorklets
+ - Yoga
+ - RNSVG (15.15.5):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - RNSVG/common (= 15.15.5)
+ - Yoga
+ - RNSVG/common (15.15.5):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - RNWorklets (0.13.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - RNWorklets/apple (= 0.13.0)
+ - RNWorklets/common (= 0.13.0)
+ - Yoga
+ - RNWorklets/apple (0.13.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - RNWorklets/common (0.13.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - SocketRocket (0.7.1):
+ - ReactNativeDependencies
+ - stream-chat-react-native (9.10.1):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - Teleport (1.2.2):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Teleport/common (= 1.2.2)
+ - Yoga
+ - Teleport/common (1.2.2):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - Yoga (0.0.0):
+ - React-Core-prebuilt
+
+DEPENDENCIES:
+ - boost (from `build/rndeps-facades/boost`)
+ - DoubleConversion (from `build/rndeps-facades/DoubleConversion`)
+ - fast_float (from `build/rndeps-facades/fast_float`)
+ - FBLazyVector (from `build/rncore-facades/FBLazyVector`)
+ - fmt (from `build/rndeps-facades/fmt`)
+ - glog (from `build/rndeps-facades/glog`)
+ - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
+ - RCT-Folly (from `build/rndeps-facades/RCT-Folly`)
+ - RCTDeprecation (from `build/rncore-facades/RCTDeprecation`)
+ - RCTRequired (from `build/rncore-facades/RCTRequired`)
+ - RCTSwiftUI (from `../node_modules/react-native/ReactApple/RCTSwiftUI`)
+ - RCTSwiftUIWrapper (from `../node_modules/react-native/ReactApple/RCTSwiftUIWrapper`)
+ - RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`)
+ - React (from `../node_modules/react-native/`)
+ - React-bridging (from `../node_modules/react-native/ReactCommon/react/bridging`)
+ - React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`)
+ - React-Core (from `build/rncore-facades/React-Core`)
+ - React-Core-prebuilt (from `../node_modules/react-native/React-Core-prebuilt.podspec`)
+ - React-Core/RCTWebSocket (from `build/rncore-facades/React-Core`)
+ - React-CoreModules (from `../node_modules/react-native/React/CoreModules`)
+ - React-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`)
+ - React-cxxstableapi (from `../node_modules/react-native/ReactCommon/react/cxxstableapi`)
+ - React-debug (from `../node_modules/react-native/ReactCommon/react/debug`)
+ - React-defaultsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/defaults`)
+ - React-domnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/dom`)
+ - React-Fabric (from `../node_modules/react-native/ReactCommon`)
+ - React-FabricComponents (from `../node_modules/react-native/ReactCommon`)
+ - React-FabricImage (from `../node_modules/react-native/ReactCommon`)
+ - React-featureflags (from `../node_modules/react-native/ReactCommon/react/featureflags`)
+ - React-featureflagsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`)
+ - React-graphics (from `../node_modules/react-native/ReactCommon/react/renderer/graphics`)
+ - React-hermes (from `../node_modules/react-native/ReactCommon/hermes`)
+ - React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`)
+ - React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`)
+ - React-intersectionobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/intersectionobserver`)
+ - React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`)
+ - React-jsi (from `../node_modules/react-native/ReactCommon/jsi`)
+ - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`)
+ - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector-modern`)
+ - React-jsinspectorcdp (from `../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`)
+ - React-jsinspectornetwork (from `../node_modules/react-native/ReactCommon/jsinspector-modern/network`)
+ - React-jsinspectortracing (from `../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`)
+ - React-jsitooling (from `../node_modules/react-native/ReactCommon/jsitooling`)
+ - React-jsitracing (from `../node_modules/react-native/ReactCommon/hermes/executor/`)
+ - React-logger (from `../node_modules/react-native/ReactCommon/logger`)
+ - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
+ - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
+ - React-mutationobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver`)
+ - react-native-launch-arguments (from `../node_modules/react-native-launch-arguments`)
+ - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
+ - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
+ - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
+ - React-networking (from `../node_modules/react-native/ReactCommon/react/networking`)
+ - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`)
+ - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
+ - React-performancecdpmetrics (from `../node_modules/react-native/ReactCommon/react/performance/cdpmetrics`)
+ - React-performancetimeline (from `../node_modules/react-native/ReactCommon/react/performance/timeline`)
+ - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
+ - React-RCTAnimatedModuleProvider (from `../node_modules/react-native/ReactApple/RCTAnimatedModuleProvider`)
+ - React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`)
+ - React-RCTAppDelegate (from `../node_modules/react-native/Libraries/AppDelegate`)
+ - React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`)
+ - React-RCTFabric (from `build/rncore-facades/React-RCTFabric`)
+ - React-RCTFBReactNativeSpec (from `../node_modules/react-native/React`)
+ - React-RCTImage (from `../node_modules/react-native/Libraries/Image`)
+ - React-RCTLinking (from `../node_modules/react-native/Libraries/LinkingIOS`)
+ - React-RCTNetwork (from `../node_modules/react-native/Libraries/Network`)
+ - React-RCTRuntime (from `build/rncore-facades/React-RCTRuntime`)
+ - React-RCTSettings (from `../node_modules/react-native/Libraries/Settings`)
+ - React-RCTText (from `../node_modules/react-native/Libraries/Text`)
+ - React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`)
+ - React-rendererconsistency (from `../node_modules/react-native/ReactCommon/react/renderer/consistency`)
+ - React-renderercss (from `../node_modules/react-native/ReactCommon/react/renderer/css`)
+ - React-rendererdebug (from `../node_modules/react-native/ReactCommon/react/renderer/debug`)
+ - React-resizeobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/resizeobserver`)
+ - React-RuntimeApple (from `../node_modules/react-native/ReactCommon/react/runtime/platform/ios`)
+ - React-RuntimeCore (from `../node_modules/react-native/ReactCommon/react/runtime`)
+ - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`)
+ - React-RuntimeHermes (from `../node_modules/react-native/ReactCommon/react/runtime`)
+ - React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)
+ - React-timing (from `../node_modules/react-native/ReactCommon/react/timing`)
+ - React-utils (from `../node_modules/react-native/ReactCommon/react/utils`)
+ - React-viewtransitionnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/viewtransition`)
+ - React-webperformancenativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/webperformance`)
+ - ReactAppDependencyProvider (from `build/generated/ios/ReactAppDependencyProvider`)
+ - ReactCodegen (from `build/generated/ios/ReactCodegen`)
+ - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
+ - ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)
+ - RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
+ - RNReanimated (from `../node_modules/react-native-reanimated`)
+ - RNSVG (from `../node_modules/react-native-svg`)
+ - RNWorklets (from `../node_modules/react-native-worklets`)
+ - SocketRocket (from `build/rndeps-facades/SocketRocket`)
+ - stream-chat-react-native (from `../node_modules/stream-chat-react-native`)
+ - Teleport (from `../node_modules/react-native-teleport`)
+ - Yoga (from `build/rncore-facades/Yoga`)
+
+EXTERNAL SOURCES:
+ boost:
+ :path: build/rndeps-facades/boost
+ DoubleConversion:
+ :path: build/rndeps-facades/DoubleConversion
+ fast_float:
+ :path: build/rndeps-facades/fast_float
+ FBLazyVector:
+ :path: build/rncore-facades/FBLazyVector
+ fmt:
+ :path: build/rndeps-facades/fmt
+ glog:
+ :path: build/rndeps-facades/glog
+ hermes-engine:
+ :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
+ :tag: hermes-v260318099.0.2
+ RCT-Folly:
+ :path: build/rndeps-facades/RCT-Folly
+ RCTDeprecation:
+ :path: build/rncore-facades/RCTDeprecation
+ RCTRequired:
+ :path: build/rncore-facades/RCTRequired
+ RCTSwiftUI:
+ :path: "../node_modules/react-native/ReactApple/RCTSwiftUI"
+ RCTSwiftUIWrapper:
+ :path: "../node_modules/react-native/ReactApple/RCTSwiftUIWrapper"
+ RCTTypeSafety:
+ :path: "../node_modules/react-native/Libraries/TypeSafety"
+ React:
+ :path: "../node_modules/react-native/"
+ React-bridging:
+ :path: "../node_modules/react-native/ReactCommon/react/bridging"
+ React-callinvoker:
+ :path: "../node_modules/react-native/ReactCommon/callinvoker"
+ React-Core:
+ :path: build/rncore-facades/React-Core
+ React-Core-prebuilt:
+ :podspec: "../node_modules/react-native/React-Core-prebuilt.podspec"
+ React-CoreModules:
+ :path: "../node_modules/react-native/React/CoreModules"
+ React-cxxreact:
+ :path: "../node_modules/react-native/ReactCommon/cxxreact"
+ React-cxxstableapi:
+ :path: "../node_modules/react-native/ReactCommon/react/cxxstableapi"
+ React-debug:
+ :path: "../node_modules/react-native/ReactCommon/react/debug"
+ React-defaultsnativemodule:
+ :path: "../node_modules/react-native/ReactCommon/react/nativemodule/defaults"
+ React-domnativemodule:
+ :path: "../node_modules/react-native/ReactCommon/react/nativemodule/dom"
+ React-Fabric:
+ :path: "../node_modules/react-native/ReactCommon"
+ React-FabricComponents:
+ :path: "../node_modules/react-native/ReactCommon"
+ React-FabricImage:
+ :path: "../node_modules/react-native/ReactCommon"
+ React-featureflags:
+ :path: "../node_modules/react-native/ReactCommon/react/featureflags"
+ React-featureflagsnativemodule:
+ :path: "../node_modules/react-native/ReactCommon/react/nativemodule/featureflags"
+ React-graphics:
+ :path: "../node_modules/react-native/ReactCommon/react/renderer/graphics"
+ React-hermes:
+ :path: "../node_modules/react-native/ReactCommon/hermes"
+ React-idlecallbacksnativemodule:
+ :path: "../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks"
+ React-ImageManager:
+ :path: "../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios"
+ React-intersectionobservernativemodule:
+ :path: "../node_modules/react-native/ReactCommon/react/nativemodule/intersectionobserver"
+ React-jserrorhandler:
+ :path: "../node_modules/react-native/ReactCommon/jserrorhandler"
+ React-jsi:
+ :path: "../node_modules/react-native/ReactCommon/jsi"
+ React-jsiexecutor:
+ :path: "../node_modules/react-native/ReactCommon/jsiexecutor"
+ React-jsinspector:
+ :path: "../node_modules/react-native/ReactCommon/jsinspector-modern"
+ React-jsinspectorcdp:
+ :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/cdp"
+ React-jsinspectornetwork:
+ :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/network"
+ React-jsinspectortracing:
+ :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/tracing"
+ React-jsitooling:
+ :path: "../node_modules/react-native/ReactCommon/jsitooling"
+ React-jsitracing:
+ :path: "../node_modules/react-native/ReactCommon/hermes/executor/"
+ React-logger:
+ :path: "../node_modules/react-native/ReactCommon/logger"
+ React-Mapbuffer:
+ :path: "../node_modules/react-native/ReactCommon"
+ React-microtasksnativemodule:
+ :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
+ React-mutationobservernativemodule:
+ :path: "../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver"
+ react-native-launch-arguments:
+ :path: "../node_modules/react-native-launch-arguments"
+ react-native-netinfo:
+ :path: "../node_modules/@react-native-community/netinfo"
+ react-native-safe-area-context:
+ :path: "../node_modules/react-native-safe-area-context"
+ React-NativeModulesApple:
+ :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
+ React-networking:
+ :path: "../node_modules/react-native/ReactCommon/react/networking"
+ React-oscompat:
+ :path: "../node_modules/react-native/ReactCommon/oscompat"
+ React-perflogger:
+ :path: "../node_modules/react-native/ReactCommon/reactperflogger"
+ React-performancecdpmetrics:
+ :path: "../node_modules/react-native/ReactCommon/react/performance/cdpmetrics"
+ React-performancetimeline:
+ :path: "../node_modules/react-native/ReactCommon/react/performance/timeline"
+ React-RCTActionSheet:
+ :path: "../node_modules/react-native/Libraries/ActionSheetIOS"
+ React-RCTAnimatedModuleProvider:
+ :path: "../node_modules/react-native/ReactApple/RCTAnimatedModuleProvider"
+ React-RCTAnimation:
+ :path: "../node_modules/react-native/Libraries/NativeAnimation"
+ React-RCTAppDelegate:
+ :path: "../node_modules/react-native/Libraries/AppDelegate"
+ React-RCTBlob:
+ :path: "../node_modules/react-native/Libraries/Blob"
+ React-RCTFabric:
+ :path: build/rncore-facades/React-RCTFabric
+ React-RCTFBReactNativeSpec:
+ :path: "../node_modules/react-native/React"
+ React-RCTImage:
+ :path: "../node_modules/react-native/Libraries/Image"
+ React-RCTLinking:
+ :path: "../node_modules/react-native/Libraries/LinkingIOS"
+ React-RCTNetwork:
+ :path: "../node_modules/react-native/Libraries/Network"
+ React-RCTRuntime:
+ :path: build/rncore-facades/React-RCTRuntime
+ React-RCTSettings:
+ :path: "../node_modules/react-native/Libraries/Settings"
+ React-RCTText:
+ :path: "../node_modules/react-native/Libraries/Text"
+ React-RCTVibration:
+ :path: "../node_modules/react-native/Libraries/Vibration"
+ React-rendererconsistency:
+ :path: "../node_modules/react-native/ReactCommon/react/renderer/consistency"
+ React-renderercss:
+ :path: "../node_modules/react-native/ReactCommon/react/renderer/css"
+ React-rendererdebug:
+ :path: "../node_modules/react-native/ReactCommon/react/renderer/debug"
+ React-resizeobservernativemodule:
+ :path: "../node_modules/react-native/ReactCommon/react/nativemodule/resizeobserver"
+ React-RuntimeApple:
+ :path: "../node_modules/react-native/ReactCommon/react/runtime/platform/ios"
+ React-RuntimeCore:
+ :path: "../node_modules/react-native/ReactCommon/react/runtime"
+ React-runtimeexecutor:
+ :path: "../node_modules/react-native/ReactCommon/runtimeexecutor"
+ React-RuntimeHermes:
+ :path: "../node_modules/react-native/ReactCommon/react/runtime"
+ React-runtimescheduler:
+ :path: "../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler"
+ React-timing:
+ :path: "../node_modules/react-native/ReactCommon/react/timing"
+ React-utils:
+ :path: "../node_modules/react-native/ReactCommon/react/utils"
+ React-viewtransitionnativemodule:
+ :path: "../node_modules/react-native/ReactCommon/react/nativemodule/viewtransition"
+ React-webperformancenativemodule:
+ :path: "../node_modules/react-native/ReactCommon/react/nativemodule/webperformance"
+ ReactAppDependencyProvider:
+ :path: build/generated/ios/ReactAppDependencyProvider
+ ReactCodegen:
+ :path: build/generated/ios/ReactCodegen
+ ReactCommon:
+ :path: "../node_modules/react-native/ReactCommon"
+ ReactNativeDependencies:
+ :podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
+ RNGestureHandler:
+ :path: "../node_modules/react-native-gesture-handler"
+ RNReanimated:
+ :path: "../node_modules/react-native-reanimated"
+ RNSVG:
+ :path: "../node_modules/react-native-svg"
+ RNWorklets:
+ :path: "../node_modules/react-native-worklets"
+ SocketRocket:
+ :path: build/rndeps-facades/SocketRocket
+ stream-chat-react-native:
+ :path: "../node_modules/stream-chat-react-native"
+ Teleport:
+ :path: "../node_modules/react-native-teleport"
+ Yoga:
+ :path: build/rncore-facades/Yoga
+
+SPEC CHECKSUMS:
+ boost: 8502bfdc46e2804408af2e163049ece181009af4
+ DoubleConversion: 501a575c40c0b50c89685762dd3e415e6bb26fb0
+ fast_float: 7c925d1cebf8328d4018d061f49377e9f887e3b4
+ FBLazyVector: fdebac2dfe9c53bca49f8218fbdf1e24e36c8be0
+ fmt: ba0886e864ebb21d14c98b2e12e164b8002f5b93
+ glog: 3cf7cb46f743c4bc758ae3e6d9c15bd1b4cfd6d4
+ hermes-engine: a1158910bb7e300b9e3d2f1e2d433f817ec1a4b5
+ RCT-Folly: 82921e585c0400eda46fac1068297aa9dfb035e4
+ RCTDeprecation: e134f2a2c096a6450f5fcbbe949531f913a2ff04
+ RCTRequired: f1a68295837ee8f91e112ed1b5694d12e7ff1784
+ RCTSwiftUI: 4b21ec5b1f86acb9c96bb429100ad88d4023ed76
+ RCTSwiftUIWrapper: 68bd9fac4e710259cb2b202472adc5f4d113e872
+ RCTTypeSafety: 77078a9c26030dd84a6bdae74ed900159f89a098
+ React: 8dafd0ef058191e9e7238cfa6b3deaa24398e5dd
+ React-bridging: 658efeb1b4704d5f60ea2fb6e8ca826ab0ae3234
+ React-callinvoker: 4e40c208c1ea46686c084a7c23da6a65cbb99482
+ React-Core: a831be15fcea0e345a24cb10d6cc0b87e9913d03
+ React-Core-prebuilt: 0fd2555a42f40e7770ced1c7dfc5ee1dbfe041cd
+ React-CoreModules: a86fbe698fd817c17b3936832dc1106a130c4694
+ React-cxxreact: 48ca56bd4f26de869478a803a5544b101a57cde6
+ React-cxxstableapi: f35502b4c542a0d44031b42f91c4ca813d267e16
+ React-debug: 238e15d17dfded9419a6d739d4c8b60968bad02e
+ React-defaultsnativemodule: c028edc7e48914344b823931aec1cc4ffabe631e
+ React-domnativemodule: b09770fc69f5ce5759de98f8065cab900b6d87d6
+ React-Fabric: a157c978603fa2796f349f3a27d17861e55804c5
+ React-FabricComponents: c7c58a1deea5db215e9d5a2aec890e208d5d5075
+ React-FabricImage: 234346bf5a3ef918f3873e845b57b93b2b1f31c0
+ React-featureflags: a00ac5c38e63d5f86de895ca923f07f5ff76de66
+ React-featureflagsnativemodule: 6b93ba0aef46757b9d54166475abc5e4fa80e70d
+ React-graphics: 634bc2e5884b4a29f56aa8aef719e03171a23f6b
+ React-hermes: 9ce7aaf6784709e196ac51bfd89dd6c551e3fb4c
+ React-idlecallbacksnativemodule: e02e194086278377ced48b7fd7816b6c925b9e7e
+ React-ImageManager: 452d4b3587f21d3384868e2abaab7c0a952b1d1c
+ React-intersectionobservernativemodule: 3ddbc206bc95c086f1df6eea04aa617ae02627d2
+ React-jserrorhandler: a0d842e1f74cc10e597fde2a6447860483df2b13
+ React-jsi: 489de0620905b88a17ccb0ac2328046af43e7ebd
+ React-jsiexecutor: f0e2ae6d98b587d3505ebde246599ee3ad97cee1
+ React-jsinspector: 515bf11caeda5df444f39aec63e377ef77ad7696
+ React-jsinspectorcdp: be9ac71a77880a6fcc95375e7e61a79e152a3203
+ React-jsinspectornetwork: a4bc1475897a2301d114d7a8605af3e8760f9e52
+ React-jsinspectortracing: 7d35952c944dc337d1a4167605c1338108880219
+ React-jsitooling: 1886dc5058e9feded2136b0d9ba5c189c6af5faa
+ React-jsitracing: 76b58feee3fb0539c8967e5b9de1a40ad8014f09
+ React-logger: 3d2b406a780fed50ad0bbd07452b373c2a515308
+ React-Mapbuffer: a2dae4f2cb335ebb97f2240494d0c2bab84cd339
+ React-microtasksnativemodule: 8b3aaa5f3b907ecf071b105589868d9f027a448d
+ React-mutationobservernativemodule: 11d77d3f7360578f07847843544ada23cc473049
+ react-native-launch-arguments: eab68037b01ea7c78a0b3b55da3a5e967e9aaf92
+ react-native-netinfo: 687391ecedb199c6d67fbc46c87cbae3277de967
+ react-native-safe-area-context: b1c9f15a36e597e9cef4c5526e209ef2645bc2e0
+ React-NativeModulesApple: 52f3def085f129de1307e3eb78e26729ff70f421
+ React-networking: 1e6441349ac72bfd4f7a79c300c3dd76993a7058
+ React-oscompat: e64123dd8199f9e99cede19fa542c3da3445481e
+ React-perflogger: f1466e0f46ce706aef73a559337eca85f51a40b7
+ React-performancecdpmetrics: f238752e99eb7c69c782020827bff38a4b1b2ef2
+ React-performancetimeline: f1b216fa02200c859cd18ef345d4230cd712137a
+ React-RCTActionSheet: 81662769b1f576e9c2e074deb41d6b83d1c7e29f
+ React-RCTAnimatedModuleProvider: a9829773197f782c19ad86c5438a2cc199203774
+ React-RCTAnimation: ad9dd2c6c3b4d07e583c2b79354858c4f34481a0
+ React-RCTAppDelegate: 70398a06e10ada27cdf77dc2b43f883126a91ac0
+ React-RCTBlob: f3d86b491b8f28c36ced0cb750878b62f72e1380
+ React-RCTFabric: 3767f97183c40ba3dadf007b17a01be2cd4e632a
+ React-RCTFBReactNativeSpec: d7ad5d9edc1865f38b9aac5b7091c3abb6978eed
+ React-RCTImage: 46ad408f777dce991d4095d7e73a568212be6baa
+ React-RCTLinking: 547427cc885bfa74b3cbba405971c5270defde7a
+ React-RCTNetwork: 52c00c0fe07327df9b9fe9881b4f5550dc5ced37
+ React-RCTRuntime: 081fd457d305c3aee76c335c568e693ed107f48e
+ React-RCTSettings: 9cd0b2be41404574ec280b6b8e73c2208c842050
+ React-RCTText: 5a6d38456eabc0a1a7c0d47d592390d35c6de57c
+ React-RCTVibration: c1815b4f471462954a4b7d8835155e053cec5412
+ React-rendererconsistency: b7996da82c8d3f4a7928b39be3e437b72ad92012
+ React-renderercss: 4c256900eba87661178771766da0171493f88848
+ React-rendererdebug: 4d9df8cf2cac6eee7002a4d14358c79fdf6ad8c5
+ React-resizeobservernativemodule: 750d334554e7c44bacf14568a8a0d0048cc4c278
+ React-RuntimeApple: 2045a6206895ad5ce90d141d1bd384888fd06c42
+ React-RuntimeCore: b051facffbc7ca038097278cabf810d6d86dd328
+ React-runtimeexecutor: b06ec9bb745a97cbd3ce25e3e02be7b8058fb374
+ React-RuntimeHermes: fcfa8baca49a6d37d331147568d51d5b8b0a175d
+ React-runtimescheduler: c2afe74d1db006bafd9270186101f5378b1bef81
+ React-timing: 6a1a8cfb0caae8de243aba16fa92fa81de23efaf
+ React-utils: 2384e5db98378df8c0bf2c0d5489ec1b8e80b398
+ React-viewtransitionnativemodule: 8505b253586f792c6c722869a664889797ecae3e
+ React-webperformancenativemodule: 67f32fde92ed7e8470ee1c669bdb7c2fcb760141
+ ReactAppDependencyProvider: 782ee9546c9cd3b53b73f1e9e3dd728540085fc7
+ ReactCodegen: 7b71f4464442e9b6eae41094d11076d56c51c0e3
+ ReactCommon: 145a14b942b19fc2fe38927f261955ada56978aa
+ ReactNativeDependencies: d3b7423ba1e52525e42085c8470109abde583d2e
+ RNGestureHandler: b5512a093e6923dd1c31fbdd8e6186cd0ebb3541
+ RNReanimated: 3018727dbf272754d0f31ef73f619e49ade7baf0
+ RNSVG: 8b5f2fdb9f0245a863cdd6e9c48a7689821a08b0
+ RNWorklets: 488806637a21eb17812a48dadca7cb8c698c8cfd
+ SocketRocket: 37aec555668fb852ec12e3c0de59a86ca58f0871
+ stream-chat-react-native: 7f1fdf2d1ce87c0b8a6c1d581308dff0c84e8e21
+ Teleport: 90f0d7e81c6b40db9b253e1ef73b2f4f4f3f6b53
+ Yoga: d1c536142c5ff8ec8cd856ab2a7c227a1d875c8e
+
+PODFILE CHECKSUM: 84fecefd0a807b1cfa8b4184065b9bbd44fa58d1
+
+COCOAPODS: 1.15.2
diff --git a/examples/E2ETestApp/metro.config.js b/examples/E2ETestApp/metro.config.js
new file mode 100644
index 0000000000..c96816d4be
--- /dev/null
+++ b/examples/E2ETestApp/metro.config.js
@@ -0,0 +1,81 @@
+const { getDefaultConfig } = require('@react-native/metro-config');
+const { resolveUniqueModule } = require('@rnx-kit/metro-config');
+
+/**
+ * Metro configuration
+ * https://reactnative.dev/docs/metro
+ *
+ * @type {import('@react-native/metro-config').MetroConfig}
+ */
+const config = getDefaultConfig(__dirname);
+
+const PATH = require('path');
+const metroConfigPackageJsonPath = require.resolve('metro-config/package.json', {
+ paths: [__dirname],
+});
+const metroExclusionList = require(
+ PATH.join(PATH.dirname(metroConfigPackageJsonPath), 'src/defaults/exclusionList.js'),
+);
+const exclusionList = metroExclusionList.default || metroExclusionList;
+const packageDirPath = PATH.resolve(__dirname, '../../package');
+const nativePackageDirPath = PATH.resolve(__dirname, '../../package/native-package');
+
+const symlinked = {
+ 'stream-chat-react-native': nativePackageDirPath,
+ 'stream-chat-react-native-core': packageDirPath,
+};
+
+// find what all modules need to be unique for the app (mainly react and react-native)
+// note: we filter the symlinked modules as they are already unique
+// and as they dont follow the workspace pattern the auto-generated path to the module is incorrect
+const dependencyPackageNames = Object.keys(require('./package.json').dependencies);
+
+const uniqueModules = dependencyPackageNames.map((packageName) => {
+ if (symlinked[packageName]) {
+ const modulePath = symlinked[packageName];
+ const escapedPackageName = PATH.normalize(packageName).replace(/\\/g, '\\\\');
+
+ // exclude the symlinked package from being resolved from node_modules
+ // example: .*\/node_modules\/stream-chat-react-native-core\/.*
+ // the above would avoid native-package to resolve core from its own node_modules
+ const exclusionRE = new RegExp(
+ `.*${PATH.sep}node_modules\\${PATH.sep}${escapedPackageName}\\${PATH.sep}.*`,
+ );
+
+ return {
+ packageName, // name of the package
+ modulePath,
+ blockPattern: exclusionRE, // paths that match this pattern will be blocked from being resolved
+ };
+ }
+ const [modulePath, blockPattern] = resolveUniqueModule(packageName, __dirname);
+ // resolveUniqueModule emits a regex ending in `/` (package-dir boundary). metro-config's
+ // exclusionList anchors every alternation branch with `$`, which would require file paths to
+ // literally end with `/node_modules//`. Append `.*` so the anchor lands at end-of-file
+ // instead of end-of-directory, otherwise the blocklist silently matches nothing.
+ const fixedBlockPattern = new RegExp(blockPattern.source + '.*');
+ return {
+ packageName, // name of the package
+ modulePath, // actual path to the module in the project's node modules
+ blockPattern: fixedBlockPattern, // paths that match this pattern will be blocked from being resolved
+ };
+});
+
+// block the other paths for unique modules from being resolved
+const blockList = uniqueModules.map(({ blockPattern }) => blockPattern);
+
+// provide the path for the unique modules
+const extraNodeModules = uniqueModules.reduce((acc, item) => {
+ acc[item.packageName] = item.modulePath;
+ return acc;
+}, {});
+
+config.resolver.blockList = exclusionList(blockList);
+config.resolver.extraNodeModules = extraNodeModules;
+
+config.resolver.nodeModulesPaths = [PATH.resolve(__dirname, 'node_modules')];
+
+// add the package dir for metro to access the package folder
+config.watchFolders = [packageDirPath];
+
+module.exports = config;
diff --git a/examples/E2ETestApp/package.json b/examples/E2ETestApp/package.json
new file mode 100644
index 0000000000..84093f57a0
--- /dev/null
+++ b/examples/E2ETestApp/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "e2etestapp",
+ "version": "0.0.1",
+ "private": true,
+ "description": "Minimal app driven by the Detox e2e suite (e2e/). Only the SDK and its required peer dependencies.",
+ "scripts": {
+ "sync-native": "bash ../../package/scripts/reconcile-shared-native.sh native-package && bash ../../package/scripts/sync-shared-native.sh native-package",
+ "android": "yarn sync-native && react-native run-android",
+ "ios": "yarn sync-native && react-native run-ios",
+ "start": "yarn sync-native && react-native start",
+ "typecheck": "tsc --noEmit && tsc --noEmit -p e2e",
+ "e2e:build": "yarn sync-native && detox build",
+ "e2e:test": "detox test"
+ },
+ "dependencies": {
+ "@react-native-community/netinfo": "^12.0.1",
+ "react": "19.3.0",
+ "react-native": "0.88.0",
+ "react-native-gesture-handler": "^3.4.0",
+ "react-native-launch-arguments": "^4.1.1",
+ "react-native-reanimated": "4.7.1",
+ "react-native-safe-area-context": "^5.10.1",
+ "react-native-svg": "^15.15.5",
+ "react-native-teleport": "^1.2.2",
+ "react-native-worklets": "^0.13.0",
+ "stream-chat": "^9.54.2",
+ "stream-chat-react-native": "workspace:^"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.29.0",
+ "@babel/runtime": "^7.29.2",
+ "@jest/globals": "^30.4.1",
+ "@react-native-community/cli": "20.2.0",
+ "@react-native-community/cli-platform-android": "20.2.0",
+ "@react-native-community/cli-platform-ios": "20.2.0",
+ "@react-native/babel-preset": "0.88.0",
+ "@react-native/metro-config": "0.88.0",
+ "@react-native/typescript-config": "0.88.0",
+ "@rnx-kit/metro-config": "^2.2.6",
+ "@stream-io/typescript-config": "workspace:^",
+ "@types/react": "^19.3.0",
+ "babel-jest": "30.0.0",
+ "detox": "^20.51.4",
+ "jest": "^30.4.2",
+ "typescript": "6.0.3"
+ },
+ "engines": {
+ "node": ">= 22.11.0"
+ }
+}
diff --git a/examples/E2ETestApp/src/config.ts b/examples/E2ETestApp/src/config.ts
new file mode 100644
index 0000000000..8d10c2d95b
--- /dev/null
+++ b/examples/E2ETestApp/src/config.ts
@@ -0,0 +1,26 @@
+import { LaunchArguments } from 'react-native-launch-arguments';
+
+import type { UserResponse } from 'stream-chat';
+
+/**
+ * Launch arguments passed by the Detox suite (`e2e/support/StreamTestCase.ts`). The counterpart of
+ * `USE_MOCK_SERVER` + `MOCK_SERVER_*` on iOS and the `BASE_URL` intent extra on Android.
+ */
+type E2ELaunchArguments = {
+ /** Base URL of the per-test `stream-chat-test-mock-server` instance, e.g. `http://localhost:54321`. */
+ MOCK_SERVER_URL?: string;
+};
+
+export const mockServerUrl = LaunchArguments.value().MOCK_SERVER_URL;
+
+/** The app user every mock server fixture is recorded for. */
+export const appUser: UserResponse = {
+ id: 'luke_skywalker',
+ image: 'https://vignette.wikia.nocookie.net/starwars/images/2/20/LukeTLJ.jpg',
+ name: 'Luke Skywalker',
+};
+
+// The mock server accepts any key and token; these match the native SDKs' e2e apps.
+export const apiKey = '8br4watad788';
+export const appUserToken =
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibHVrZV9za3l3YWxrZXIifQ.kFSLHRB5X62t0Zlc7nwczWUfsQMwfkpylC6jCUZ6Mc0';
diff --git a/examples/E2ETestApp/tsconfig.json b/examples/E2ETestApp/tsconfig.json
new file mode 100644
index 0000000000..d1be171213
--- /dev/null
+++ b/examples/E2ETestApp/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": ["@react-native/typescript-config", "@stream-io/typescript-config/base.json"],
+ "compilerOptions": {
+ "types": []
+ },
+ "exclude": ["node_modules", "e2e"]
+}
diff --git a/examples/ExpoChatGPTSample/components/VoiceMode/VoiceMode.tsx b/examples/ExpoChatGPTSample/components/VoiceMode/VoiceMode.tsx
new file mode 100644
index 0000000000..d1a0984bff
--- /dev/null
+++ b/examples/ExpoChatGPTSample/components/VoiceMode/VoiceMode.tsx
@@ -0,0 +1,132 @@
+import { useState } from 'react';
+import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import Svg, { Line } from 'react-native-svg';
+
+import {
+ StreamTheme,
+ useDictationAudioLevel,
+ type VoiceAgentState,
+ VoiceGlow,
+ VoiceWave,
+} from 'stream-chat-expo/ai-components';
+
+import { useVoiceConversation } from './useVoiceConversation';
+
+const BACKDROP = '#0b0b10';
+const COLORS = ['#005fff', '#7b5cff', '#00c2ff'];
+
+const LABELS: Record = {
+ connecting: 'Connecting…',
+ idle: 'Tap the glow to talk',
+ listening: 'Listening',
+ speaking: 'Speaking',
+ thinking: 'Thinking',
+};
+
+const VoiceModeOverlay = ({ onClose }: { onClose: () => void }) => {
+ const { top, bottom } = useSafeAreaInsets();
+ const { error, listen, phase, userIsTalking } = useVoiceConversation(true);
+ const micLevel = useDictationAudioLevel();
+
+ return (
+
+
+
+
+ ✕
+
+
+
+
+
+
+ {error ? 'Couldn’t hear you. Tap the glow to try again.' : LABELS[phase]}
+
+
+
+
+ );
+};
+
+/**
+ * A static waveform icon. The button stays still: a live `VoiceGlow` here would redraw every frame
+ * for as long as the chat is open.
+ */
+const VoiceIcon = () => (
+
+);
+
+/** Opens a full-screen voice conversation with the AI agent. */
+export const VoiceMode = () => {
+ const [open, setOpen] = useState(false);
+
+ return (
+ <>
+
+ setOpen(true)}
+ style={styles.button}
+ >
+
+
+
+ setOpen(false)} visible={open}>
+ {open ? setOpen(false)} /> : null}
+
+ >
+ );
+};
+
+const styles = StyleSheet.create({
+ button: {
+ alignItems: 'center',
+ backgroundColor: '#ffffff',
+ borderColor: '#ececec',
+ borderRadius: 24,
+ borderWidth: 1,
+ height: 48,
+ justifyContent: 'center',
+ overflow: 'hidden',
+ width: 48,
+ },
+ close: { color: '#ffffff', fontSize: 22 },
+ entry: { alignItems: 'flex-end', paddingHorizontal: 16, paddingBottom: 8 },
+ glow: { aspectRatio: 1, width: '100%' },
+ glowContainer: { alignSelf: 'stretch', flex: 1, justifyContent: 'center' },
+ header: { alignItems: 'flex-end', paddingHorizontal: 20 },
+ label: { color: '#c9cbd3', fontSize: 17, marginVertical: 16, textAlign: 'center' },
+ overlay: { backgroundColor: BACKDROP, flex: 1 },
+ wave: { alignSelf: 'stretch', height: 140 },
+});
diff --git a/examples/ExpoChatGPTSample/components/VoiceMode/index.ts b/examples/ExpoChatGPTSample/components/VoiceMode/index.ts
new file mode 100644
index 0000000000..abe97f1086
--- /dev/null
+++ b/examples/ExpoChatGPTSample/components/VoiceMode/index.ts
@@ -0,0 +1 @@
+export * from './VoiceMode';
diff --git a/examples/ExpoChatGPTSample/components/VoiceMode/useVoiceConversation.ts b/examples/ExpoChatGPTSample/components/VoiceMode/useVoiceConversation.ts
new file mode 100644
index 0000000000..adb5e2627e
--- /dev/null
+++ b/examples/ExpoChatGPTSample/components/VoiceMode/useVoiceConversation.ts
@@ -0,0 +1,159 @@
+import { useEffect, useRef, useState } from 'react';
+
+import * as Speech from 'expo-speech';
+
+import { AIStates, useAIState, useChannelContext, useStableCallback } from 'stream-chat-expo';
+
+import {
+ dictationStore,
+ useDictation,
+ useDictationState,
+ useDictationTranscript,
+ type VoiceAgentState,
+} from 'stream-chat-expo/ai-components';
+
+import { useSendAIMessage } from '../../hooks/useSendAIMessage';
+
+/** A turn the agent is never seen working on ends after this long. */
+const TURN_TIMEOUT_MS = 20_000;
+
+const WORKING_STATES: string[] = [AIStates.Thinking, AIStates.Generating, AIStates.ExternalSources];
+
+// Marks voice mode's dictation as its own, so the composer underneath ignores it.
+const DICTATION_SOURCE = { source: 'voice-mode' };
+
+/** Markdown read aloud as plain words. */
+const toSpeech = (text: string) =>
+ text
+ .replace(/```[\s\S]*?```/g, ' ')
+ .replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
+ .replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
+ .replace(/[*_#>`~|]/g, '')
+ .replace(/\s+/g, ' ')
+ .trim();
+
+/**
+ * A hands-free conversation with the AI agent: it listens with dictation, sends what it heard,
+ * waits for the agent to answer, reads the answer aloud and listens again.
+ *
+ * Returns what the agent is doing, for `VoiceGlow`, and whether the user is being listened to,
+ * for `VoiceWave`.
+ */
+export const useVoiceConversation = (active: boolean) => {
+ const [phase, setPhase] = useState('idle');
+ const { start, cancel } = useDictation(undefined, DICTATION_SOURCE);
+ const { state: dictationState, error } = useDictationState();
+ const { transcript } = useDictationTranscript();
+ const send = useSendAIMessage();
+ const { channel } = useChannelContext();
+ const { aiState } = useAIState(channel);
+
+ // The messages already in the channel when the question was sent, so the reply is found by
+ // identity rather than by comparing the server's timestamps with the device's clock.
+ const turn = useRef<{ seenIds: Set; sawWorking: boolean } | null>(null);
+ const timeout = useRef | null>(null);
+ // An error left over from an earlier turn, ignored until dictation starts again and clears it.
+ const staleError = useRef(dictationStore.getLatestValue().error);
+
+ const listen = useStableCallback(() => {
+ if (!active) return;
+ turn.current = null;
+ staleError.current = dictationStore.getLatestValue().error;
+ setPhase('connecting');
+ void start();
+ });
+
+ const latestReply = useStableCallback(() => {
+ const seenIds = turn.current?.seenIds ?? new Set();
+ const messages = channel?.state.latestMessages ?? [];
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const message = messages[i];
+ if (seenIds.has(message.id)) break;
+ if (message.ai_generated && message.text) return message.text;
+ }
+ return null;
+ });
+
+ /** Ends the agent's turn: reads its answer aloud if there is one, then listens again. */
+ const finishTurn = useStableCallback(() => {
+ if (timeout.current) clearTimeout(timeout.current);
+ timeout.current = null;
+ const reply = latestReply();
+ turn.current = null;
+ if (!active) return;
+ if (!reply) {
+ listen();
+ return;
+ }
+ setPhase('speaking');
+ Speech.speak(toSpeech(reply), {
+ onDone: listen,
+ onError: listen,
+ // Its own audio session on iOS, so the reply is heard whatever dictation left the app's in.
+ useApplicationAudioSession: false,
+ });
+ });
+
+ // Starts listening when voice mode opens, and stops everything when it closes.
+ useEffect(() => {
+ if (!active) return;
+ listen();
+ return () => {
+ if (timeout.current) clearTimeout(timeout.current);
+ timeout.current = null;
+ turn.current = null;
+ cancel();
+ void Speech.stop();
+ setPhase('idle');
+ };
+ }, [active, cancel, listen]);
+
+ // Follows dictation: listening while it records, and sends what it heard once it stops.
+ useEffect(() => {
+ if (!active || turn.current) return;
+ if (dictationState === 'listening') {
+ setPhase('listening');
+ return;
+ }
+ if (dictationState !== 'idle' || (phase !== 'listening' && phase !== 'connecting')) return;
+ if (error && error !== staleError.current) {
+ setPhase('idle');
+ return;
+ }
+ if (phase === 'connecting') return;
+ const text = transcript.trim();
+ if (!text) {
+ // Nothing was heard; keep listening.
+ listen();
+ return;
+ }
+ turn.current = {
+ sawWorking: false,
+ seenIds: new Set((channel?.state.latestMessages ?? []).map((message) => message.id)),
+ };
+ setPhase('thinking');
+ void send({ text });
+ timeout.current = setTimeout(() => {
+ if (!turn.current?.sawWorking) finishTurn();
+ }, TURN_TIMEOUT_MS);
+ }, [active, channel, dictationState, error, finishTurn, listen, phase, send, transcript]);
+
+ // Follows the agent: its turn ends once it has been seen working and is done.
+ useEffect(() => {
+ const current = turn.current;
+ if (!active || !current) return;
+ if (WORKING_STATES.includes(aiState)) {
+ current.sawWorking = true;
+ } else if (current.sawWorking) {
+ finishTurn();
+ }
+ }, [active, aiState, finishTurn]);
+
+ return {
+ // Only an error from the current attempt, not one left over while dictation restarts.
+ error: phase === 'idle' ? error : null,
+ listen,
+ phase,
+ userIsTalking: phase === 'listening',
+ };
+};
diff --git a/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts b/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts
new file mode 100644
index 0000000000..c81147a43b
--- /dev/null
+++ b/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts
@@ -0,0 +1,35 @@
+import { useMessageComposer, useMessageInputContext, useStableCallback } from 'stream-chat-expo';
+
+import type { ComposerViewProps } from 'stream-chat-expo/ai-components';
+
+export type AIMessage = Parameters[0];
+
+/**
+ * Sends a message to the AI channel through the channel's composer, with its attachments. Shared
+ * by the composer and voice mode, so both go through `preSendMessageRequest` (which starts the
+ * agent on the first message).
+ */
+export const useSendAIMessage = () => {
+ const messageComposer = useMessageComposer();
+ const { sendMessage } = useMessageInputContext();
+
+ return useStableCallback(async ({ text, attachments }: AIMessage) => {
+ messageComposer.textComposer.setText(text);
+ if (attachments && attachments.length > 0) {
+ const localAttachments = await Promise.all(
+ attachments.map((asset) =>
+ messageComposer.attachmentManager.fileToLocalUploadAttachment({
+ ...asset,
+ // The picker can leave these out (e.g. some iOS photos have no file name).
+ name: asset.name ?? asset.uri.split('/').pop() ?? 'image',
+ size: asset.size ?? 0,
+ type: asset.type ?? 'image/jpeg',
+ }),
+ ),
+ );
+ messageComposer.attachmentManager.upsertAttachments(localAttachments);
+ }
+
+ await sendMessage();
+ });
+};
diff --git a/examples/ExpoChatGPTSample/http/requests.ts b/examples/ExpoChatGPTSample/http/requests.ts
index e8c20536b0..fce4cd3941 100644
--- a/examples/ExpoChatGPTSample/http/requests.ts
+++ b/examples/ExpoChatGPTSample/http/requests.ts
@@ -1,12 +1,19 @@
import { post } from './api';
+// The AI provider the sample backend uses, as in the React SDK's ai-chatbot example.
+const PLATFORM = 'openai';
+
export const startAI = (channelId: string) =>
post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/start-ai-agent', {
channel_id: channelId,
+ platform: PLATFORM,
});
export const stopAI = (channelId: string) =>
post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/stop-ai-agent', {
channel_id: channelId,
});
export const summarize = (text: string) =>
- post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/summarize', { text });
+ post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/summarize', {
+ platform: PLATFORM,
+ text,
+ });
diff --git a/examples/ExpoChatGPTSample/package.json b/examples/ExpoChatGPTSample/package.json
index c044710ef1..73a01a0e80 100644
--- a/examples/ExpoChatGPTSample/package.json
+++ b/examples/ExpoChatGPTSample/package.json
@@ -19,6 +19,7 @@
"expo-image-picker": "~58.0.10",
"expo-linking": "~58.0.12",
"expo-router": "~58.0.18",
+ "expo-speech": "~58.0.3",
"react": "19.3.0",
"react-native": "0.88.0",
"react-native-gesture-handler": "~3.4.0",
@@ -28,7 +29,7 @@
"react-native-svg": "15.15.5",
"react-native-teleport": "^1.2.2",
"react-native-worklets": "0.13.0",
- "stream-chat": "^9.54.1",
+ "stream-chat": "^9.54.2",
"stream-chat-expo": "workspace:^",
"stream-chat-react-native-core": "workspace:^",
"victory-native": "^42.0.1"
diff --git a/examples/ExpoChatGPTSample/screens/ChatContent.tsx b/examples/ExpoChatGPTSample/screens/ChatContent.tsx
index d715ccbee1..b12acf5b3f 100644
--- a/examples/ExpoChatGPTSample/screens/ChatContent.tsx
+++ b/examples/ExpoChatGPTSample/screens/ChatContent.tsx
@@ -17,9 +17,7 @@ import {
ThemeProvider,
useAIState,
useChannelContext,
- useMessageComposer,
useMessageContext,
- useMessageInputContext,
useStableCallback,
useTheme,
WithComponents,
@@ -28,12 +26,13 @@ import {
import {
AITypingIndicatorView,
ComposerView,
- type ComposerViewProps,
StreamingMessageView,
} from 'stream-chat-expo/ai-components';
import { bottomSheetOptions } from '../bottomSheetOptions';
+import { VoiceMode } from '../components/VoiceMode';
import { useAppContext } from '../contexts/AppContext';
+import { useSendAIMessage } from '../hooks/useSendAIMessage';
import { startAI, summarize } from '../http/requests';
const CustomMessage = (props: MessageProps) => {
@@ -93,8 +92,6 @@ const CustomStreamingMessageView = () => {
};
const CustomComposerView = () => {
- const messageComposer = useMessageComposer();
- const { sendMessage } = useMessageInputContext();
const { channel } = useChannelContext();
const { aiState } = useAIState(channel);
@@ -114,27 +111,7 @@ const CustomComposerView = () => {
[safeAreaInsets],
);
- const serializeToMessage = useStableCallback(
- async ({ text, attachments }: Parameters[0]) => {
- messageComposer.textComposer.setText(text);
- if (attachments && attachments.length > 0) {
- const localAttachments = await Promise.all(
- attachments.map((asset) =>
- messageComposer.attachmentManager.fileToLocalUploadAttachment({
- ...asset,
- // The picker can leave these out (e.g. some iOS photos have no file name).
- name: asset.name ?? asset.uri.split('/').pop() ?? 'image',
- size: asset.size ?? 0,
- type: asset.type ?? 'image/jpeg',
- }),
- ),
- );
- messageComposer.attachmentManager.upsertAttachments(localAttachments);
- }
-
- await sendMessage();
- },
- );
+ const serializeToMessage = useSendAIMessage();
return (
{
>
+
diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json
index 814aced8bc..7ea24ba04c 100644
--- a/examples/ExpoMessaging/package.json
+++ b/examples/ExpoMessaging/package.json
@@ -51,7 +51,7 @@
"react-native-teleport": "^1.2.2",
"react-native-web": "~0.21.4",
"react-native-worklets": "0.13.0",
- "stream-chat": "^9.54.1",
+ "stream-chat": "^9.54.2",
"stream-chat-expo": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
diff --git a/examples/SampleApp/fastlane/Fastfile b/examples/SampleApp/fastlane/Fastfile
index f2680950ee..c491a81025 100644
--- a/examples/SampleApp/fastlane/Fastfile
+++ b/examples/SampleApp/fastlane/Fastfile
@@ -5,6 +5,7 @@ github_repo = ENV['GITHUB_REPOSITORY'] || 'GetStream/stream-chat-react-native'
root_path = File.absolute_path('../../../')
sdk_size_ext = 'KB'
@force_check = false
+@impact = nil
build_output_directory = "./app-build"
# iOS Platform Configuration
@@ -20,6 +21,7 @@ apk_path = "#{build_output_directory}/#{output_apk_name}"
before_all do
+ @impact = impact_analysis
if is_ci
setup_ci
setup_git_config
@@ -183,9 +185,109 @@ platform :android do
end
end
-##########################
-###### Common lanes ######
-##########################
+#######################
+###### E2E lanes ######
+#######################
+
+# The e2e suite drives the dedicated examples/E2ETestApp; its lanes live here with the repo's other lanes.
+e2e_app_path = File.absolute_path('../../E2ETestApp')
+mock_server_repo_name = 'stream-chat-test-mock-server'
+mock_server_driver_port = ENV['MOCK_DRIVER_PORT'] || '4569'
+
+desc 'Starts the stream-chat-test-mock-server driver. Options: local_server (path to a checkout), branch'
+lane :start_mock_server do |options|
+ Dir.chdir(e2e_app_path) do
+ mock_server_repo = options[:local_server] || mock_server_repo_name
+ stop_mock_server unless is_ci
+
+ unless options[:local_server]
+ branch = options[:branch].to_s.empty? ? 'main' : options[:branch]
+ sh("rm -rf #{mock_server_repo}") if File.directory?(mock_server_repo)
+ sh("git clone --depth 1 -b #{branch} https://github.com/#{github_repo.split('/').first}/#{mock_server_repo}.git")
+ end
+
+ Dir.chdir(mock_server_repo) do
+ FileUtils.mkdir_p('logs')
+ # The mock server has its own Gemfile; don't let the SampleApp bundle leak into it.
+ Bundler.with_unbundled_env do
+ # CI caches the mock server's gems in MOCK_SERVER_BUNDLE_PATH.
+ sh("bundle config set --local path '#{ENV['MOCK_SERVER_BUNDLE_PATH']}'") if ENV['MOCK_SERVER_BUNDLE_PATH']
+ sh('bundle install')
+ sh("bundle exec ruby driver.rb #{mock_server_driver_port} > logs/driver.log 2>&1 &")
+ end
+ end
+
+ wait_for_mock_server_driver(port: mock_server_driver_port)
+ end
+end
+
+lane :stop_mock_server do
+ Net::HTTP.get_response(URI("http://localhost:#{mock_server_driver_port}/stop")) rescue nil
+end
+
+private_lane :wait_for_mock_server_driver do |options|
+ require 'socket'
+ ready = false
+ 60.times do
+ begin
+ TCPSocket.new('localhost', options[:port].to_i).close
+ ready = true
+ break
+ rescue Errno::ECONNREFUSED
+ sleep(1)
+ end
+ end
+ UI.user_error!("Mock server driver did not start on port #{options[:port]}") unless ready
+end
+
+desc 'Writes `required=true|false` to $GITHUB_OUTPUT, so CI can skip the e2e jobs before building anything'
+lane :e2e_check_required do
+ required = is_check_required(sources: @impact[:e2e][:sources], force_check: @force_check, required_checks: @impact[:e2e][:checks])
+ File.write(ENV['GITHUB_OUTPUT'], "required=#{required}\n", mode: 'a') if ENV['GITHUB_OUTPUT']
+ required
+end
+
+desc 'Installs the E2ETestApp pods with this bundle (the e2e app has no Gemfile of its own)'
+lane :e2e_pod_install do
+ Dir.chdir(e2e_app_path) do
+ sh('yarn sync-native')
+ sh('bundle exec pod install --project-directory=ios')
+ end
+end
+
+# A freshly booted CI emulator is slow enough for the launcher to hit an ANR ("Quickstep isn't
+# responding"). The dialog takes window focus, and Espresso then fails every test waiting for it.
+private_lane :prepare_android_emulator do
+ sh('adb shell settings put global hide_error_dialogs 1')
+ # Gives the post-boot CPU load a minute to settle, dismissing ANR dialogs meanwhile. Capped: the
+ # load can stay above the threshold for minutes, and each test dismisses ANR dialogs too.
+ wait_android_emu_idle(timeout: 60)
+end
+
+desc 'Runs the Detox e2e tests. Options: platform (ios|android), configuration (release|debug), ' \
+ 'device (android: emu|att), skip_build, test (a test file or -t filter), local_server, mock_server_branch'
+lane :test_e2e do |options|
+ next unless is_check_required(sources: @impact[:e2e][:sources], force_check: @force_check, required_checks: @impact[:e2e][:checks])
+
+ platform = options[:platform] || 'ios'
+ device = platform == 'ios' ? 'sim' : (options[:device] || 'emu')
+ detox_config = "#{platform}.#{device}.#{options[:configuration] || 'release'}"
+
+ start_mock_server(local_server: options[:local_server], branch: options[:mock_server_branch])
+ # An attached emulator is already booted; Detox boots `emu` devices itself.
+ prepare_android_emulator if platform == 'android' && device == 'att'
+
+ begin
+ Dir.chdir(e2e_app_path) do
+ sh("yarn e2e:build -c #{detox_config}") unless options[:skip_build]
+ # On CI: retry flaky tests, run the simulator headless, and keep logs + screenshots of failures in ./artifacts.
+ ci_flags = is_ci ? '--retries 2 --headless --record-logs failing --take-screenshots failing' : ''
+ sh("MOCK_DRIVER_PORT=#{mock_server_driver_port} yarn detox test -c #{detox_config} --cleanup #{ci_flags} #{options[:test]}")
+ end
+ ensure
+ stop_mock_server
+ end
+end
lane :file_size do |options|
File.size(options[:path]) / 1024.0
@@ -213,10 +315,7 @@ lane :frameworks_sizes do
end
lane :show_frameworks_sizes do |options|
- # Besides the SDK itself, changes to how the size is measured (this lane, its plugin, the root
- # `minify-*` scripts) re-run the check too.
- sources = ['package/', 'package.json', 'examples/SampleApp/fastlane/', 'examples/SampleApp/Gemfile.lock']
- next unless is_check_required(sources: sources, force_check: @force_check)
+ next unless is_check_required(sources: @impact[:size][:sources], force_check: @force_check)
sizes = options[:sizes] || frameworks_sizes
show_sdk_size(branch_sizes: sizes, github_repo: github_repo, size_ext: sdk_size_ext)
@@ -232,3 +331,15 @@ lane :update_img_shields_sdk_sizes do |options|
size_ext: sdk_size_ext
)
end
+
+lane :impact_analysis do
+ {
+ e2e: {
+ sources: ['package/', 'examples/E2ETestApp/', 'examples/SampleApp/fastlane/', 'package.json'],
+ checks: ['Test E2E Result']
+ },
+ size: {
+ sources: ['package/', 'package.json']
+ }
+ }
+end
diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json
index 2bfd129d17..b67283884a 100644
--- a/examples/SampleApp/package.json
+++ b/examples/SampleApp/package.json
@@ -64,7 +64,7 @@
"react-native-teleport": "^1.2.2",
"react-native-video": "^6.19.3",
"react-native-worklets": "^0.13.0",
- "stream-chat": "^9.54.1",
+ "stream-chat": "^9.54.2",
"stream-chat-react-native": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
diff --git a/package.json b/package.json
index 73df92c8a5..ee702cd07b 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"package/native-package",
"package/expo-package",
"examples/SampleApp",
+ "examples/E2ETestApp",
"examples/ExpoMessaging",
"examples/ExpoChatGPTSample"
],
@@ -71,7 +72,7 @@
"lint-fix": "prettier --write . && eslint . --fix --max-warnings 0",
"prettier": "prettier --list-different .",
"prettier-fix": "prettier --write .",
- "typecheck": "yarn workspaces foreach -A --parallel --include 'stream-chat-react-native-core' --include 'stream-chat-react-native' --include 'stream-chat-expo' --include 'sampleapp' --include 'ExpoMessaging' --include 'expochatgptsample' run typecheck",
+ "typecheck": "yarn workspaces foreach -A --parallel --include 'stream-chat-react-native-core' --include 'stream-chat-react-native' --include 'stream-chat-expo' --include 'sampleapp' --include 'e2etestapp' --include 'ExpoMessaging' --include 'expochatgptsample' run typecheck",
"build": "yarn workspace stream-chat-react-native-core build",
"test:coverage": "yarn workspace stream-chat-react-native-core test:coverage",
"test:unit": "yarn workspace stream-chat-react-native-core test:unit",
diff --git a/package/jest-setup.tsx b/package/jest-setup.tsx
index e6b56b908c..e4519c92c7 100644
--- a/package/jest-setup.tsx
+++ b/package/jest-setup.tsx
@@ -65,8 +65,16 @@ jest.mock('react-native-reanimated', () => {
makeMutable: (init: T) => ({ addListener: noop, removeListener: noop, value: init }),
useSharedValue: (init: T) =>
new Proxy(RNReanimatedmock.useSharedValue(init), {
- get: (target, prop) =>
- prop === 'addListener' || prop === 'removeListener' ? noop : Reflect.get(target, prop),
+ get: (target, prop) => {
+ if (prop === 'addListener' || prop === 'removeListener') return noop;
+ // The mock's shared values have no `modify`; run it in place, as on the UI thread.
+ if (prop === 'modify') {
+ return (modifier: (value: T) => T) => {
+ target.value = modifier(target.value);
+ };
+ }
+ return Reflect.get(target, prop);
+ },
}),
NativeEventsManager: class {
attachEvents() {}
@@ -150,13 +158,43 @@ jest.mock('react-native-teleport', () => {
jest.mock('react-native-safe-area-context', () => mockSafeAreaContext);
-// Skia and victory-native are optional peers used by the `ai-components` charts. Skia's JS needs its
-// native module at import time, so both are replaced with inert stand-ins that render plain views.
-jest.mock('@shopify/react-native-skia', () => ({
- LinearGradient: () => null,
- matchFont: () => ({}),
- vec: (x: number, y: number) => ({ x, y }),
-}));
+// Skia and victory-native are optional peers used by the `ai-components` charts and voice
+// visualizations. Skia's JS needs its native module at import time, so both are replaced with inert
+// stand-ins that render plain views.
+jest.mock('@shopify/react-native-skia', () => {
+ const React = require('react');
+ const rn = require('react-native');
+ const Primitive = () => null;
+ const pathBuilder = () => {
+ const commands: Array<[string, number, number]> = [];
+ const builder = {
+ build: () => ({ commands }),
+ lineTo: (x: number, y: number) => {
+ commands.push(['L', x, y]);
+ return builder;
+ },
+ moveTo: (x: number, y: number) => {
+ commands.push(['M', x, y]);
+ return builder;
+ },
+ };
+ return builder;
+ };
+ return {
+ Canvas: ({ children, style }: { children?: unknown; style?: unknown }) =>
+ React.createElement(rn.View, { style, testID: 'skia-canvas' }, children),
+ Fill: ({ children }: { children?: unknown }) => children ?? null,
+ LinearGradient: Primitive,
+ matchFont: () => ({}),
+ Path: Primitive,
+ Shader: Primitive,
+ Skia: {
+ PathBuilder: { Make: pathBuilder },
+ RuntimeEffect: { Make: (source: string) => ({ source }) },
+ },
+ vec: (x: number, y: number) => ({ x, y }),
+ };
+});
jest.mock('victory-native', () => {
const React = require('react');
diff --git a/package/package.json b/package/package.json
index 25fb948e73..66d5739df6 100644
--- a/package/package.json
+++ b/package/package.json
@@ -84,7 +84,7 @@
"react-native-markdown-package": "1.8.2",
"react-native-url-polyfill": "^2.0.0",
"react-syntax-highlighter": "^16.1.0",
- "stream-chat": "^9.54.1",
+ "stream-chat": "^9.54.2",
"use-sync-external-store": "^1.5.0",
"zod": "^4.1.12"
},
diff --git a/package/shared-native/android/dictation/AIDictationController.kt b/package/shared-native/android/dictation/AIDictationController.kt
index a06fae8cf9..69335507fa 100644
--- a/package/shared-native/android/dictation/AIDictationController.kt
+++ b/package/shared-native/android/dictation/AIDictationController.kt
@@ -6,6 +6,7 @@ import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
+import android.os.SystemClock
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
@@ -24,7 +25,8 @@ import java.util.concurrent.atomic.AtomicBoolean
*
* Shared by the `AIDictation` TurboModule of both SDK packages (each package's codegen generates its
* own `NativeAIDictationSpec`), which forward their calls here. Events are delivered through
- * [emitEvent] with the names in [EVENT_ON_STATE], [EVENT_ON_RESULT] and [EVENT_ON_ERROR].
+ * [emitEvent] with the names in [EVENT_ON_STATE], [EVENT_ON_RESULT], [EVENT_ON_ERROR] and
+ * [EVENT_ON_LEVEL].
*/
class AIDictationController(
private val reactContext: ReactApplicationContext,
@@ -41,6 +43,16 @@ class AIDictationController(
const val EVENT_ON_STATE = "AIDictationOnState"
const val EVENT_ON_RESULT = "AIDictationOnResult"
const val EVENT_ON_ERROR = "AIDictationOnError"
+ const val EVENT_ON_LEVEL = "AIDictationOnLevel"
+
+ // The recognizer's RMS reads about -2 dB in a quiet room and up to about 10 dB for speech
+ // close to the microphone; this span maps onto the level from 0 to 1.
+ private const val LEVEL_FLOOR_DB = 0f
+ private const val LEVEL_CEILING_DB = 10f
+
+ // The shortest time between two level events, about 30 a second as on iOS: the recognizer
+ // can report RMS faster than the voice visualizations need.
+ private const val LEVEL_INTERVAL_MS = 33L
private const val STATE_IDLE = "idle"
private const val STATE_STARTING = "starting"
@@ -63,6 +75,7 @@ class AIDictationController(
private var currentLocale: Locale = Locale.getDefault()
private var silenceTimeoutMs: Long = DEFAULT_SILENCE_TIMEOUT_MS
private var lastSpeechTimeMs: Long = 0L
+ private var lastLevelAtMs: Long = 0L
private var isSpeechInProgress: Boolean = false
@@ -328,7 +341,9 @@ class AIDictationController(
lastSpeechTimeMs = System.currentTimeMillis()
}
- override fun onRmsChanged(rmsdB: Float) = Unit
+ override fun onRmsChanged(rmsdB: Float) {
+ emitLevel(rmsdB)
+ }
override fun onBufferReceived(buffer: ByteArray?) = Unit
override fun onEndOfSpeech() {
@@ -471,6 +486,21 @@ class AIDictationController(
emitEvent(EVENT_ON_STATE, map)
}
+ /**
+ * Emits the microphone's level to JS, from 0 to 1, for the voice visualizations, at most about
+ * 30 times a second. The recognizer reports it as RMS in dB while it listens.
+ */
+ private fun emitLevel(rmsdB: Float) {
+ val now = SystemClock.elapsedRealtime()
+ if (now - lastLevelAtMs < LEVEL_INTERVAL_MS) return
+ lastLevelAtMs = now
+ val level = ((rmsdB - LEVEL_FLOOR_DB) / (LEVEL_CEILING_DB - LEVEL_FLOOR_DB)).coerceIn(0f, 1f)
+ val map = Arguments.createMap().apply {
+ putDouble("level", level.toDouble())
+ }
+ emitEvent(EVENT_ON_LEVEL, map)
+ }
+
/**
* Emits a generic error event to JS with a code and message.
*/
diff --git a/package/shared-native/ios/AIDictation.mm b/package/shared-native/ios/AIDictation.mm
index 5c657e9668..c3b0302ba4 100644
--- a/package/shared-native/ios/AIDictation.mm
+++ b/package/shared-native/ios/AIDictation.mm
@@ -17,6 +17,7 @@
static NSString *const AIDictationOnStateEventName = @"AIDictationOnState";
static NSString *const AIDictationOnResultEventName = @"AIDictationOnResult";
static NSString *const AIDictationOnErrorEventName = @"AIDictationOnError";
+static NSString *const AIDictationOnLevelEventName = @"AIDictationOnLevel";
@interface AIDictation ()
@end
@@ -46,7 +47,12 @@ - (instancetype)init
- (NSArray *)supportedEvents
{
- return @[ AIDictationOnStateEventName, AIDictationOnResultEventName, AIDictationOnErrorEventName ];
+ return @[
+ AIDictationOnStateEventName,
+ AIDictationOnResultEventName,
+ AIDictationOnErrorEventName,
+ AIDictationOnLevelEventName,
+ ];
}
- (std::shared_ptr)getTurboModule:
@@ -109,6 +115,11 @@ - (void)emitOnError:(NSDictionary *)value
[self sendEventWithName:AIDictationOnErrorEventName body:value];
}
+- (void)emitOnLevel:(NSDictionary *)value
+{
+ [self sendEventWithName:AIDictationOnLevelEventName body:value];
+}
+
@end
#endif
diff --git a/package/shared-native/ios/AIDictationModule.swift b/package/shared-native/ios/AIDictationModule.swift
index b753af2c93..f933e8ed43 100644
--- a/package/shared-native/ios/AIDictationModule.swift
+++ b/package/shared-native/ios/AIDictationModule.swift
@@ -15,6 +15,7 @@ import React
func emitOnState(_ value: NSDictionary)
func emitOnResult(_ value: NSDictionary)
func emitOnError(_ value: NSDictionary)
+ func emitOnLevel(_ value: NSDictionary)
}
@objc(AIDictationModule)
@@ -67,6 +68,19 @@ public class AIDictationModule: NSObject {
// Timer used for the simple silence monitor.
private var silenceTimer: Timer?
+ // When the microphone level was last emitted, read and written only on the audio tap's thread.
+ private var lastLevelTime: TimeInterval = 0
+ // The audio session as dictation found it, restored once dictation ends. Its `.record`
+ // category mutes all playback, so leaving it in place would silence the app.
+ private var savedAudioSession: (
+ category: AVAudioSession.Category,
+ mode: AVAudioSession.Mode,
+ options: AVAudioSession.CategoryOptions
+ )?
+ // The loudness, in dBFS, that maps to a level of 0 and of 1.
+ private let LEVEL_FLOOR_DB: Float = -60
+ private let LEVEL_CEILING_DB: Float = -35
+
// Set while the permission prompts of the first `start()` are on screen. `stop()` and
// `cancel()` clear it, which abandons that start.
private var isAwaitingPermission: Bool = false
@@ -194,6 +208,7 @@ public class AIDictationModule: NSObject {
try self.startRecognition()
} catch {
self.isListening = false
+ self.stopAudioEngine()
self.emitState(self.STATE_IDLE)
self.emitError(
self.ERROR_INTERNAL,
@@ -282,15 +297,34 @@ public class AIDictationModule: NSObject {
* - Uses `.record` category and `.measurement` mode for best recognition
* quality.
* - Ducks other audio and notifies other sessions on deactivation.
+ * - Remembers the session's previous configuration for `restoreAudioSession()`.
*
* Throws if the session cannot be configured or activated.
*/
private func setupAudioSession() throws {
let session = AVAudioSession.sharedInstance()
+ if savedAudioSession == nil {
+ savedAudioSession = (session.category, session.mode, session.categoryOptions)
+ }
try session.setCategory(.record, mode: .measurement, options: [.duckOthers])
try session.setActive(true, options: .notifyOthersOnDeactivation)
}
+ /**
+ * Gives the audio session back as dictation found it: deactivates it, so audio that was ducked
+ * resumes, and restores its category, mode and options, so the app can play sound again.
+ *
+ * Failures are ignored: deactivation fails while other audio in the app is still running, and
+ * restoring the category is still worth trying then.
+ */
+ private func restoreAudioSession() {
+ guard let saved = savedAudioSession else { return }
+ savedAudioSession = nil
+ let session = AVAudioSession.sharedInstance()
+ try? session.setActive(false, options: .notifyOthersOnDeactivation)
+ try? session.setCategory(saved.category, mode: saved.mode, options: saved.options)
+ }
+
/**
* Starts a fresh `SFSpeechRecognitionTask` and connects it to `AVAudioEngine`.
*
@@ -324,6 +358,7 @@ public class AIDictationModule: NSObject {
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) {
[weak self] (buffer, _) in
self?.recognitionRequest?.append(buffer)
+ self?.measureLevel(buffer)
}
audioEngine.prepare()
@@ -420,13 +455,15 @@ public class AIDictationModule: NSObject {
}
/**
- * Stops the audio engine and removes any installed tap on the input node.
+ * Stops the audio engine, removes any installed tap on the input node and gives the audio
+ * session back.
*/
private func stopAudioEngine() {
if audioEngine.isRunning {
audioEngine.stop()
audioEngine.inputNode.removeTap(onBus: 0)
}
+ restoreAudioSession()
}
// -------------------------------------------------------------------------
@@ -549,6 +586,35 @@ public class AIDictationModule: NSObject {
eventSink?.emitOnState(payload)
}
+ /**
+ * Emits the microphone's level to JS for the voice visualizations, from 0 to 1, about 30 times
+ * a second at most. Called on the audio tap's thread.
+ *
+ * The level is the buffer's loudness in dBFS mapped onto 0 to 1. The session records in
+ * `.measurement` mode, which turns off the input's gain processing, so speech arrives quiet:
+ * about -52 to -44 dBFS at a normal distance on an iPhone. A quiet room (about -60 dBFS) reads
+ * 0 and loud speech close to the phone (about -35 dBFS) reads 1, so talking lands around the
+ * middle.
+ */
+ private func measureLevel(_ buffer: AVAudioPCMBuffer) {
+ let now = ProcessInfo.processInfo.systemUptime
+ guard now - lastLevelTime >= 1.0 / 30.0,
+ let samples = buffer.floatChannelData?[0],
+ buffer.frameLength > 0 else { return }
+ lastLevelTime = now
+ let count = Int(buffer.frameLength)
+ var sum: Float = 0
+ for i in 0..` | no | The level of whoever is talking, from 0 to 1, read once a frame on the UI thread. Feed it at 15 Hz or more for the motion to follow the words. While it is `null` or absent, speech animates on its own. |
+| `colors` | `string[]` | no | One to five colors. Past five are ignored, and colors that cannot be read are skipped. Defaults to the theme's `voiceVisualization.colors`. |
+| `expressivity` | `number` | no | From 0 to 2: how far and how fast it moves. Colors never change with it. Defaults to `1`. |
+| `stateLabels` | `Partial>` | no | What screen readers read as the value for each state, for example to translate them. Defaults to "Ready", "Connecting", "Listening", "Thinking" and "Speaking". |
+
+```tsx
+import { useDictationAudioLevel, VoiceGlow, VoiceWave } from 'stream-chat-react-native/ai-components';
+
+const VoiceAgent = ({ agentState, isListening }) => {
+ const micLevel = useDictationAudioLevel();
+
+ return (
+ <>
+
+
+ >
+ );
+};
+```
+
+The motion runs entirely on the UI thread with Reanimated and Skia, so the visualizations stay smooth while the JS thread is busy streaming a reply, and a changing `audioLevel` re-renders nothing.
+
+**Audio level.** `useDictationAudioLevel()` gives the microphone's level while `useDictation` (or the `SpeechToTextButton`) is listening, and `null` otherwise. For any other source, such as a remote participant's audio level from Stream Video, keep a shared value of your own and write to it: `const level = useSharedValue(null)`, then `level.value = participant.audioLevel`.
+
+**Interaction.** Tapping the shape bounces it and sends out a ripple, and a hovering mouse or pen ripples it too. Touches are only observed, never claimed, so a `Pressable` around the visualization still gets its press; touches beside the shape reach whatever is behind it.
+
+**Theming.** The theme's `voiceVisualization.colors` are the default palette (the accent color unless you set them). The visualizations draw for a light or a dark background following `voiceVisualization.backdropColor` when you set it, and the system color scheme otherwise.
+
+**Accessibility.** Each is an image named "Voice agent" with its state as the value ("Ready", "Connecting", "Listening", "Thinking", "Speaking"), as on iOS and Android. Pass `stateLabels` to translate the state values, `accessibilityLabel` to change the label, or `accessibilityValue` to replace the value outright. Under the system's Reduce Motion (animations off on Android), they barely move and ignore taps.
+
+**Differences from the native SDKs.** The glow has no `rendering` option: the shader runs on every Android version through Skia, so there is no Canvas fallback, and it does not switch to a cheaper look in battery saver as on Android. A visualization keeps animating while it is mounted, even when scrolled off screen; it pauses only while the app is in the background.
+
### Theming
Each one of the components in the SDK is fully theme-compatible. The `StreamTheme` provider takes care of this for you.
diff --git a/package/src/ai-components/__tests__/VoiceVisualization.test.tsx b/package/src/ai-components/__tests__/VoiceVisualization.test.tsx
new file mode 100644
index 0000000000..4953c7872a
--- /dev/null
+++ b/package/src/ai-components/__tests__/VoiceVisualization.test.tsx
@@ -0,0 +1,208 @@
+import React from 'react';
+import { AccessibilityInfo, Pressable } from 'react-native';
+
+import { act, fireEvent, render, renderHook, screen } from '@testing-library/react-native';
+
+import { type DeepPartial, StreamTheme, type Theme, VoiceGlow, VoiceWave } from '../index';
+import { rgbToHsv } from '../voice-visualization/palette';
+import { GLOW_STYLE, WAVE_STYLE } from '../voice-visualization/styles';
+import type { VoiceVisualizationProps } from '../voice-visualization/types';
+import { useVoiceVisualization } from '../voice-visualization/useVoiceVisualization';
+import { waveColors, wavePath } from '../voice-visualization/waveGeometry';
+
+const themed =
+ (style?: DeepPartial) =>
+ ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+
+const layout = (width: number, height: number) => ({
+ nativeEvent: { layout: { height, width, x: 0, y: 0 } },
+});
+
+const renderVisualization = (
+ props: Partial = {},
+ style?: DeepPartial,
+ visualStyle = GLOW_STYLE,
+) =>
+ renderHook(
+ (hookProps: VoiceVisualizationProps) => useVoiceVisualization(hookProps, visualStyle),
+ { initialProps: { state: 'idle', ...props }, wrapper: themed(style) },
+ );
+
+const hueOf = (color: [number, number, number]) => rgbToHsv(color).hue;
+
+describe('VoiceGlow and VoiceWave', () => {
+ afterEach(() => jest.restoreAllMocks());
+
+ it.each([
+ ['VoiceGlow', VoiceGlow],
+ ['VoiceWave', VoiceWave],
+ ])('%s names itself and its state, as on iOS and Android', (_, Component) => {
+ const { rerender } = render(, { wrapper: themed() });
+
+ const root = screen.getByLabelText('Voice agent');
+ expect(root.props.accessibilityRole).toBe('image');
+ expect(root.props.accessibilityValue).toEqual({ text: 'Ready' });
+
+ rerender();
+ expect(screen.getByLabelText('Voice agent').props.accessibilityValue).toEqual({
+ text: 'Speaking',
+ });
+ });
+
+ it('takes a label and value of its own', () => {
+ render(
+ ,
+ { wrapper: themed() },
+ );
+
+ expect(screen.getByLabelText('Assistant').props.accessibilityValue).toEqual({
+ text: 'Talking',
+ });
+ });
+
+ it.each([
+ ['VoiceGlow', VoiceGlow],
+ ['VoiceWave', VoiceWave],
+ ])('%s draws once it has a size', (_, Component) => {
+ render(, { wrapper: themed() });
+ expect(screen.queryByTestId('skia-canvas')).toBeNull();
+
+ fireEvent(screen.getByLabelText('Voice agent'), 'layout', layout(300, 200));
+
+ expect(screen.getByTestId('skia-canvas')).toBeTruthy();
+ expect(screen.getByTestId('voice-visualization-hit-area')).toBeTruthy();
+ });
+
+ it('lets a press on the shape reach a parent', () => {
+ const onPress = jest.fn();
+ render(
+
+
+ ,
+ { wrapper: themed() },
+ );
+ fireEvent(screen.getByLabelText('Voice agent'), 'layout', layout(300, 300));
+
+ const hitArea = screen.getByTestId('voice-visualization-hit-area');
+ fireEvent(hitArea, 'touchEnd', { nativeEvent: { locationX: 96, locationY: 96 } });
+ fireEvent.press(hitArea);
+
+ expect(onPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('compiles the glow shader once for every glow', () => {
+ // A fresh copy of the module, so a shader compiled by an earlier test doesn't hide a miss.
+ jest.isolateModules(() => {
+ const { Skia: freshSkia } = require('@shopify/react-native-skia');
+ const { getGlowEffect } = require('../voice-visualization/glowShader');
+ const make = jest.spyOn(freshSkia.RuntimeEffect, 'Make');
+
+ const first = getGlowEffect();
+ const second = getGlowEffect();
+
+ expect(make).toHaveBeenCalledTimes(1);
+ expect(second).toBe(first);
+ });
+ });
+
+ it('keeps the touch area as a real view, so taps on the shape land', () => {
+ render(, { wrapper: themed() });
+ fireEvent(screen.getByLabelText('Voice agent'), 'layout', layout(300, 200));
+
+ expect(screen.getByTestId('voice-visualization-hit-area').props.collapsable).toBe(false);
+ });
+
+ it('reads translated state values', () => {
+ render(, {
+ wrapper: themed(),
+ });
+
+ expect(screen.getByLabelText('Voice agent').props.accessibilityValue).toEqual({
+ text: 'Denkt nach',
+ });
+ });
+});
+
+describe('voice visualization motion', () => {
+ afterEach(() => jest.restoreAllMocks());
+
+ it('uses its own colors first', () => {
+ const { result } = renderVisualization({ colors: ['#ff0000', 'nope', '#00ff00'] });
+
+ expect(result.current.driver.value.colors[0].hue).toBe(hueOf([1, 0, 0]));
+ expect(result.current.driver.value.colors[1].hue).toBe(hueOf([0, 1, 0]));
+ expect(result.current.driver.value.count).toBe(2);
+ });
+
+ it('falls back to the theme, then to the default accent', () => {
+ const fromTheme = renderVisualization(
+ { colors: [] },
+ {
+ voiceVisualization: { colors: ['#00ff00'] },
+ },
+ );
+ expect(fromTheme.result.current.driver.value.colors[0].hue).toBe(hueOf([0, 1, 0]));
+
+ const fallback = renderVisualization(
+ { colors: ['nope'] },
+ {
+ voiceVisualization: { colors: ['also-nope'] },
+ },
+ );
+ expect(fallback.result.current.driver.value.colors[0].hue).toBe(hueOf([0, 95 / 255, 1]));
+ });
+
+ it('draws for a dark backdrop', () => {
+ const light = renderVisualization({}, { voiceVisualization: { backdropColor: '#ffffff' } });
+ const dark = renderVisualization({}, { voiceVisualization: { backdropColor: '#101010' } });
+
+ expect(light.result.current.dark.value).toBe(0);
+ expect(dark.result.current.dark.value).toBe(1);
+ });
+
+ it('takes the wave’s smooth level response and the glow’s steady one', () => {
+ const glow = renderVisualization({}, undefined, GLOW_STYLE);
+ const wave = renderVisualization({}, undefined, WAVE_STYLE);
+
+ expect(glow.result.current.driver.value.levelResponse).toBe(GLOW_STYLE.levelResponse);
+ expect(wave.result.current.driver.value.levelResponse).toBe(WAVE_STYLE.levelResponse);
+ });
+
+ it('bounces on a tap, unless motion is reduced', async () => {
+ const motion = renderVisualization();
+ act(() => motion.result.current.tap(0.1, 0.1));
+ expect(motion.result.current.driver.value.tapAge).toBe(0);
+ expect(motion.result.current.driver.value.tapX).toBe(0.1);
+
+ jest.spyOn(AccessibilityInfo, 'isReduceMotionEnabled').mockResolvedValueOnce(true);
+ const reduced = renderVisualization();
+ await act(async () => {
+ await Promise.resolve();
+ });
+ act(() => reduced.result.current.tap(0.1, 0.1));
+ expect(reduced.result.current.driver.value.tapAge).toBeGreaterThan(0);
+ });
+
+ it('draws the wave as one stroke with its ends faded out', () => {
+ const { result } = renderVisualization({ state: 'speaking' }, undefined, WAVE_STYLE);
+ const d = result.current.driver.value;
+
+ const path = wavePath(d, 300, 200) as unknown as { commands: Array<[string, number, number]> };
+ expect(path.commands).toHaveLength(161);
+ expect(path.commands[0][0]).toBe('M');
+ expect(path.commands[0][1]).toBeCloseTo(150 - 0.42 * 200, 4);
+ expect(path.commands[160][1]).toBeCloseTo(150 + 0.42 * 200, 4);
+
+ const stops = waveColors(d, 0);
+ expect(stops).toHaveLength(13);
+ expect(stops[0][3]).toBe(0);
+ expect(stops[12][3]).toBe(0);
+ expect(stops[6][3]).toBeCloseTo(1, 4);
+ });
+});
diff --git a/package/src/ai-components/__tests__/dictation.test.tsx b/package/src/ai-components/__tests__/dictation.test.tsx
index 83219584f9..4755ea5e5d 100644
--- a/package/src/ai-components/__tests__/dictation.test.tsx
+++ b/package/src/ai-components/__tests__/dictation.test.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { DeviceEventEmitter } from 'react-native';
-import { act, fireEvent, render, screen } from '@testing-library/react-native';
+import { act, fireEvent, render, renderHook, screen } from '@testing-library/react-native';
import * as aiComponents from '../index';
import { AIDictation } from '../native/AIDictation';
@@ -162,5 +162,62 @@ describe('ai-components dictation', () => {
state: 'idle',
});
});
+
+ it('streams the microphone level while listening and clears it afterwards', () => {
+ mockNativeModule = createNativeModule();
+ const levels: number[] = [];
+ const unsubscribe = AIDictation.addLevelListener(({ level }) => levels.push(level));
+ const { result, unmount } = renderHook(() => aiComponents.useDictationAudioLevel());
+ expect(result.current.value).toBeNull();
+
+ act(() => {
+ DeviceEventEmitter.emit('AIDictationOnState', { state: 'listening' });
+ DeviceEventEmitter.emit('AIDictationOnLevel', { level: 0.4 });
+ });
+ expect(levels).toEqual([0.4]);
+ expect(result.current.value).toBe(0.4);
+
+ act(() => {
+ DeviceEventEmitter.emit('AIDictationOnState', { state: 'idle' });
+ });
+ expect(result.current.value).toBeNull();
+
+ act(() => {
+ DeviceEventEmitter.emit('AIDictationOnLevel', { level: 0.7 });
+ DeviceEventEmitter.emit('AIDictationOnError', { code: 'X', message: 'boom' });
+ });
+ expect(result.current.value).toBeNull();
+
+ unsubscribe();
+ unmount();
+ act(() => {
+ DeviceEventEmitter.emit('AIDictationOnLevel', { level: 0.9 });
+ });
+ expect(levels).toEqual([0.4, 0.7]);
+ });
+
+ it('leaves the composer text alone for dictation started elsewhere', async () => {
+ mockNativeModule = createNativeModule();
+ const composer = renderComposer();
+ fireEvent.changeText(composer.getByPlaceholderText('Ask anything'), 'my draft');
+
+ const { result } = renderHook(() =>
+ aiComponents.useDictation(undefined, { source: 'voice-mode' }),
+ );
+ await act(async () => {
+ await result.current.start();
+ });
+ act(() => {
+ DeviceEventEmitter.emit('AIDictationOnState', { state: 'listening' });
+ DeviceEventEmitter.emit('AIDictationOnResult', { isFinal: true, text: 'spoken question' });
+ DeviceEventEmitter.emit('AIDictationOnState', { state: 'idle' });
+ });
+
+ expect(aiComponents.dictationStore.getLatestValue()).toMatchObject({
+ source: 'voice-mode',
+ transcript: 'spoken question',
+ });
+ expect(composer.getByPlaceholderText('Ask anything').props.value).toBe('my draft');
+ });
});
});
diff --git a/package/src/ai-components/__tests__/voiceDriver.test.ts b/package/src/ai-components/__tests__/voiceDriver.test.ts
new file mode 100644
index 0000000000..4c49a1a68d
--- /dev/null
+++ b/package/src/ai-components/__tests__/voiceDriver.test.ts
@@ -0,0 +1,317 @@
+import {
+ bounceScale,
+ createBounce,
+ easeOut,
+ scaleAt,
+ startBounce,
+} from '../voice-visualization/bounce';
+import {
+ advanceDriver,
+ createDriverState,
+ driverReact,
+ driverScale,
+ ease,
+ hoverDriver,
+ LEVEL_RESPONSE,
+ type LevelResponse,
+ NO_TAP,
+ tapDriver,
+ type VoiceDriverInput,
+ type VoiceDriverState,
+} from '../voice-visualization/driver';
+import type { Hsv, VoiceAgentState } from '../voice-visualization/types';
+
+// Ported from stream-chat-android's VoiceVisualizationDriverTest and VoiceVisualizationBounceTest,
+// so the platforms keep moving alike.
+
+const blue: Hsv = { hue: 218 / 360, saturation: 1, value: 1 };
+const red: Hsv = { hue: 0, saturation: 1, value: 1 };
+const FRAME = 1 / 60;
+
+const input = (
+ state: VoiceAgentState,
+ overrides: Partial> = {},
+): VoiceDriverInput => ({
+ audioLevel: null,
+ colors: [blue],
+ expressivity: 1,
+ reduceMotion: false,
+ state,
+ ...overrides,
+});
+
+/** A driver with the input it reads each frame, as the view keeps them. */
+const createDriver = (initial: VoiceDriverInput, response?: LevelResponse) => {
+ const driver = { input: initial, state: createDriverState(initial, response) };
+ return {
+ ...driver,
+ advance: (seconds: number) => advanceDriver(driver.state, driver.input, seconds),
+ run: (seconds: number) => {
+ for (let i = 0; i < Math.floor(seconds / FRAME); i++) {
+ advanceDriver(driver.state, driver.input, FRAME);
+ }
+ },
+ setInput: (next: VoiceDriverInput) => {
+ driver.input = next;
+ },
+ get d(): VoiceDriverState {
+ return driver.state;
+ },
+ get input() {
+ return driver.input;
+ },
+ };
+};
+
+const expectNear = (actual: number, expected: number, tolerance: number) =>
+ expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance);
+
+describe('voice visualization driver', () => {
+ it('starts settled on the state', () => {
+ const { d } = createDriver(input('speaking'));
+
+ expect(driverReact(d)).toBe(1);
+ expect(d.load).toBe(0);
+ expect(d.flow).toBe(0);
+ expect(d.bright).toBe(1);
+ expect(d.time).toBe(0);
+ expect(d.count).toBe(1);
+ expect(driverScale(d)).toBe(1);
+ expect(d.tapAge).toBe(NO_TAP);
+ expect(d.colors[0]).toEqual(blue);
+ expect(d.colors[4]).toEqual(blue);
+ });
+
+ it('does not share its colors with the caller’s palette', () => {
+ const palette = [{ ...blue }];
+ const driver = createDriver(input('idle', { colors: palette }));
+ driver.setInput(input('idle', { colors: [red] }));
+
+ driver.run(1);
+
+ expect(palette[0]).toEqual(blue);
+ });
+
+ it('eases toward the motion of a new state', () => {
+ const driver = createDriver(input('idle'));
+ driver.setInput(input('connecting'));
+
+ driver.advance(FRAME);
+ expectNear(driver.d.load, 0.08, 1e-4);
+ driver.run(2);
+ expectNear(driver.d.load, 1, 1e-3);
+ expectNear(driverReact(driver.d), 0, 1e-3);
+ });
+
+ it('eases the same at any frame rate', () => {
+ const at60 = createDriver(input('idle'));
+ const at120 = createDriver(input('idle'));
+ at60.setInput(input('speaking'));
+ at120.setInput(input('speaking'));
+
+ for (let i = 0; i < 30; i++) at60.advance(1 / 60);
+ for (let i = 0; i < 60; i++) at120.advance(1 / 120);
+
+ expectNear(driverReact(at60.d), driverReact(at120.d), 1e-4);
+ expectNear(at60.d.time, at120.d.time, 1e-2);
+ });
+
+ it('clamps a long frame', () => {
+ const driver = createDriver(input('idle'));
+
+ driver.advance(5);
+
+ expectNear(driver.d.time, 0.05 * 0.4, 1e-5);
+ });
+
+ it('turns the flow only while thinking', () => {
+ const driver = createDriver(input('speaking'));
+ driver.run(1);
+ expect(driver.d.flowSpin).toBe(0);
+
+ driver.setInput(input('thinking'));
+ driver.run(1);
+ expect(driver.d.flowSpin).toBeGreaterThan(0);
+ });
+
+ it('keeps the colors turning in every state', () => {
+ const driver = createDriver(input('idle'));
+
+ driver.run(1);
+
+ expect(driver.d.orbSpin).toBeGreaterThan(0);
+ });
+
+ it('scales amplitude and speed with expressivity', () => {
+ const lively = createDriver(input('speaking', { expressivity: 2 }));
+ const still = createDriver(input('speaking', { expressivity: 0 }));
+
+ expectNear(driverReact(lively.d), 1.85, 1e-5);
+ expectNear(driverReact(still.d), 0.15, 1e-5);
+ lively.advance(0.05);
+ expectNear(lively.d.time, 0.05 * 1.25 * 1.4, 1e-5);
+ });
+
+ it('slows time and ignores the pointer under reduced motion', () => {
+ const driver = createDriver(input('idle', { reduceMotion: true }));
+
+ tapDriver(driver.d, driver.input, 0.1, 0.1);
+ hoverDriver(driver.d, driver.input, 0.1, 0.1);
+ driver.advance(0.05);
+
+ expectNear(driver.d.time, 0.05 * 0.07 * 0.4, 1e-6);
+ expect(driver.d.tapAge).toBeGreaterThan(NO_TAP);
+ expect(driverScale(driver.d)).toBe(1);
+ expect(driver.d.hoverAmount).toBe(0);
+ });
+
+ it('stops hovering once motion is reduced', () => {
+ const driver = createDriver(input('idle'));
+ hoverDriver(driver.d, driver.input, 0.1, 0.1);
+ driver.run(1);
+
+ driver.setInput(input('idle', { reduceMotion: true }));
+ driver.run(1);
+
+ expectNear(driver.d.hoverAmount, 0, 1e-3);
+ });
+
+ it('ripples from a tap and bounces', () => {
+ const driver = createDriver(input('idle'));
+
+ tapDriver(driver.d, driver.input, 0.1, -0.2);
+ expect(driver.d.tapX).toBe(0.1);
+ expect(driver.d.tapY).toBe(-0.2);
+ expect(driver.d.tapAge).toBe(0);
+
+ driver.advance(0.05);
+ expectNear(driver.d.tapAge, 0.05, 1e-6);
+ expect(driverScale(driver.d)).toBeLessThan(1);
+ driver.run(0.5);
+ expect(driverScale(driver.d)).toBe(1);
+ });
+
+ it('eases hovering in and out', () => {
+ const driver = createDriver(input('idle'));
+
+ hoverDriver(driver.d, driver.input, 0.2, 0.1);
+ driver.run(1);
+ expectNear(driver.d.hoverAmount, 1, 1e-3);
+ expect(driver.d.hoverX).toBe(0.2);
+
+ hoverDriver(driver.d, driver.input, null, null);
+ driver.run(1);
+ expectNear(driver.d.hoverAmount, 0, 1e-3);
+ expect(driver.d.hoverX).toBe(0.2);
+ });
+
+ it('lifts the motion to a live audio level', () => {
+ const driver = createDriver(input('idle', { audioLevel: 0.8 }));
+
+ driver.run(2);
+ expectNear(driver.d.mic, 0.8, 1e-3);
+ expectNear(driver.d.voice, 1, 1e-3);
+ expectNear(driverReact(driver.d), 0.8, 1e-3);
+
+ driver.setInput(input('idle'));
+ driver.run(2);
+ expectNear(driver.d.voice, 0, 1e-3);
+ expectNear(driverReact(driver.d), 0.12, 1e-3);
+ });
+
+ const settled = (response: LevelResponse, audioLevel: number) => {
+ const driver = createDriver(input('idle', { audioLevel }), response);
+ driver.run(1);
+ return driver;
+ };
+
+ it('catches up with a louder level within a word when smooth', () => {
+ const driver = settled(LEVEL_RESPONSE.smooth, 0);
+
+ driver.setInput(input('idle', { audioLevel: 1 }));
+ driver.run(0.1);
+ expect(driver.d.mic).toBeGreaterThanOrEqual(0.5);
+ expect(driver.d.mic).toBeLessThanOrEqual(0.8);
+ driver.run(0.3);
+ expect(driver.d.mic).toBeGreaterThan(0.95);
+ });
+
+ it('lets a quieter level fall away over a pause, not a syllable, when smooth', () => {
+ const driver = settled(LEVEL_RESPONSE.smooth, 1);
+
+ driver.setInput(input('idle', { audioLevel: 0 }));
+ driver.run(0.1);
+ expect(driver.d.mic).toBeGreaterThan(0.7);
+ driver.run(0.9);
+ expect(driver.d.mic).toBeLessThan(0.1);
+ });
+
+ it('follows a quieter level within a syllable when steady', () => {
+ const driver = settled(LEVEL_RESPONSE.steady, 1);
+
+ driver.setInput(input('idle', { audioLevel: 0 }));
+ driver.run(0.1);
+ expect(driver.d.mic).toBeLessThan(0.1);
+ });
+
+ it('eases a hue the short way round', () => {
+ const driver = createDriver(
+ input('idle', { colors: [{ hue: 0.95, saturation: 1, value: 1 }] }),
+ );
+ driver.setInput(input('idle', { colors: [{ hue: 0.05, saturation: 1, value: 1 }] }));
+
+ driver.advance(FRAME);
+ expectNear(driver.d.colors[0].hue, 0.962, 1e-4);
+ driver.run(2);
+ expectNear(driver.d.colors[0].hue, 0.05, 1e-3);
+ });
+
+ it('repeats the first color in empty slots and crossfades the count', () => {
+ const driver = createDriver(input('idle', { colors: [blue, red] }));
+ expect(driver.d.colors[1]).toEqual(red);
+ expect(driver.d.colors[2]).toEqual(blue);
+
+ driver.setInput(input('idle', { colors: [blue, red, red] }));
+ driver.advance(FRAME);
+ expectNear(driver.d.count, 2.12, 1e-4);
+ });
+
+ it('moves the per-frame share at 60 Hz', () => {
+ expectNear(ease(0.08, 1 / 60), 0.08, 1e-5);
+ expect(ease(0.08, 0)).toBe(0);
+ });
+});
+
+describe('voice visualization bounce', () => {
+ it('follows the keyframes', () => {
+ expect(scaleAt(0)).toBe(1);
+ expectNear(scaleAt(0.15), 0.965, 1e-5);
+ expectNear(scaleAt(0.3), 0.93, 1e-5);
+ expectNear(scaleAt(0.62), 1.03, 1e-5);
+ expectNear(scaleAt(1), 1, 1e-5);
+ });
+
+ it('times them with the CSS ease-out', () => {
+ expectNear(easeOut(0), 0, 1e-4);
+ expectNear(easeOut(0.5), 0.684, 2e-3);
+ expectNear(easeOut(1), 1, 1e-4);
+ });
+
+ it('dips, overshoots and settles', () => {
+ const bounce = createBounce();
+ expect(bounceScale(bounce)).toBe(1);
+
+ startBounce(bounce);
+ const scales = Array.from({ length: 21 }, () => {
+ const scale = bounceScale(bounce);
+ bounce.age += 1 / 60;
+ return scale;
+ });
+
+ expectNear(scales[0], 1, 1e-9);
+ expectNear(Math.min(...scales), 0.93, 0.01);
+ expectNear(Math.max(...scales), 1.03, 0.01);
+ expect(scales.indexOf(Math.min(...scales))).toBeLessThan(scales.indexOf(Math.max(...scales)));
+ expect(bounceScale(bounce)).toBe(1);
+ });
+});
diff --git a/package/src/ai-components/__tests__/voicePalette.test.ts b/package/src/ai-components/__tests__/voicePalette.test.ts
new file mode 100644
index 0000000000..fc2591b988
--- /dev/null
+++ b/package/src/ai-components/__tests__/voicePalette.test.ts
@@ -0,0 +1,124 @@
+import { audioLevelInput, expressivityInput, speechEnv } from '../voice-visualization/math';
+import { luminance, parseColor, rgbToHsv, toHsvPalette } from '../voice-visualization/palette';
+import { snoise } from '../voice-visualization/simplex';
+import { GLOW_STYLE, WAVE_STYLE } from '../voice-visualization/styles';
+
+describe('voice visualization palette', () => {
+ it('keeps a color’s full hue, saturation and value', () => {
+ const hsv = rgbToHsv([0, 95 / 255, 1]);
+
+ expect(hsv.hue * 360).toBeCloseTo(217.6, 1);
+ expect(hsv.saturation).toBeCloseTo(1, 4);
+ expect(hsv.value).toBeCloseTo(1, 4);
+ });
+
+ it('keeps a muted color muted', () => {
+ // hsv(108°, 25%, 60%)
+ const hsv = rgbToHsv([0.48, 0.6, 0.45]);
+
+ expect(hsv.hue).toBeCloseTo(0.3, 2);
+ expect(hsv.saturation).toBeCloseTo(0.25, 2);
+ expect(hsv.value).toBeCloseTo(0.6, 4);
+ });
+
+ it('gives a gray no saturation', () => {
+ const hsv = rgbToHsv([0.5, 0.5, 0.5]);
+
+ expect(hsv.saturation).toBe(0);
+ expect(hsv.value).toBe(0.5);
+ });
+
+ it.each([
+ ['#005fff', [0, 95 / 255, 1]],
+ ['#0af', [0, 170 / 255, 1]],
+ ['#005fff80', [0, 95 / 255, 1]],
+ ['rgb(0, 95, 255)', [0, 95 / 255, 1]],
+ ['rgba(0, 95, 255, 0.5)', [0, 95 / 255, 1]],
+ ['hsl(0, 100%, 50%)', [1, 0, 0]],
+ ['rebeccapurple', [0.4, 0.2, 0.6]],
+ ['#000000', [0, 0, 0]],
+ ])('reads %s', (color, expected) => {
+ const rgb = parseColor(color);
+ expect(rgb).not.toBeNull();
+ rgb?.forEach((channel, i) => expect(channel).toBeCloseTo(expected[i], 2));
+ });
+
+ it('skips colors it cannot read', () => {
+ expect(parseColor('not-a-color')).toBeNull();
+ expect(parseColor('#12345')).toBeNull();
+ expect(parseColor('')).toBeNull();
+ expect(toHsvPalette(['nope', '#ff0000'])).toHaveLength(1);
+ });
+
+ it('keeps only the first five colors', () => {
+ expect(toHsvPalette(Array.from({ length: 7 }, () => '#ff0000'))).toHaveLength(5);
+ });
+
+ it('measures luminance for the light or dark look', () => {
+ expect(luminance('#ffffff')).toBeCloseTo(1, 4);
+ expect(luminance('#000000')).toBe(0);
+ expect(luminance('#1c1e22')).toBeLessThan(0.5);
+ expect(luminance('')).toBeNull();
+ });
+});
+
+describe('voice visualization inputs', () => {
+ it('clamps expressivity to 0-2 and replaces one that is not a number', () => {
+ expect(expressivityInput(3)).toBe(2);
+ expect(expressivityInput(-1)).toBe(0);
+ expect(expressivityInput(0.5)).toBe(0.5);
+ expect(expressivityInput(Number.NaN)).toBe(1);
+ expect(expressivityInput(Number.POSITIVE_INFINITY)).toBe(1);
+ expect(expressivityInput(undefined)).toBe(1);
+ });
+
+ it('clamps the audio level to 0-1 and treats one that is not a number as no audio', () => {
+ expect(audioLevelInput(2)).toBe(1);
+ expect(audioLevelInput(-1)).toBe(0);
+ expect(audioLevelInput(0.4)).toBe(0.4);
+ expect(audioLevelInput(Number.NaN)).toBeNull();
+ expect(audioLevelInput(null)).toBeNull();
+ expect(audioLevelInput(undefined)).toBeNull();
+ });
+});
+
+describe('voice visualization noise', () => {
+ it('matches the shader’s simplex noise as a GPU computes it', () => {
+ // Read back from the shader's own snoise on a GPU (stream-chat-react's fixtures).
+ const gpu: Array<[number, number, number, number]> = [
+ [0.3, 21, 0, 0.7696],
+ [1.7, 5, 0, 0.208],
+ [0.12, 0, 0, 0.049],
+ [2.5, 9, 0, 0.3869],
+ [10.2, 14, 0, 0.603],
+ [3.3, 1.1, 0.9, -0.7541],
+ [0.05, 0.05, 0.05, -0.5481],
+ ];
+ for (const [x, y, z, expected] of gpu) {
+ expect(snoise(x, y, z)).toBeCloseTo(expected, 2);
+ }
+ });
+
+ it('gives speech phrases and pauses', () => {
+ const samples = Array.from({ length: 2000 }, (_, i) => speechEnv(i * 0.01));
+
+ expect(Math.min(...samples)).toBe(0);
+ expect(Math.max(...samples)).toBeGreaterThan(0.6);
+ expect(Math.max(...samples)).toBeLessThanOrEqual(1);
+ });
+});
+
+describe('voice visualization hit areas', () => {
+ it('takes the globe within a third of the shorter side', () => {
+ expect(GLOW_STYLE.contains(0, 0)).toBe(true);
+ expect(GLOW_STYLE.contains(0.2, 0.2)).toBe(true);
+ expect(GLOW_STYLE.contains(0.3, 0.3)).toBe(false);
+ });
+
+ it('takes the string across, and only near its line', () => {
+ expect(WAVE_STYLE.contains(0.45, 0)).toBe(true);
+ expect(WAVE_STYLE.contains(0, 0.1)).toBe(true);
+ expect(WAVE_STYLE.contains(0, 0.2)).toBe(false);
+ expect(WAVE_STYLE.contains(0.55, 0)).toBe(false);
+ });
+});
diff --git a/package/src/ai-components/index.ts b/package/src/ai-components/index.ts
index c0eec23884..f037bbfa42 100644
--- a/package/src/ai-components/index.ts
+++ b/package/src/ai-components/index.ts
@@ -5,6 +5,7 @@ export * from './charts';
export * from './message-composer';
export * from './StreamingMessageView';
export * from './AITypingIndicatorView';
+export * from './voice-visualization';
// components
export * from './components';
@@ -15,7 +16,8 @@ export * from './contexts';
// services
export * from './services';
export * from './transcription/useDictation';
-export { isDictationAvailable } from './native/AIDictation';
+export * from './transcription/useDictationAudioLevel';
+export { isDictationAvailable, type DictationLevel } from './native/AIDictation';
// state-stores
export * from './store';
diff --git a/package/src/ai-components/message-composer/ComposerView.tsx b/package/src/ai-components/message-composer/ComposerView.tsx
index c2ae35564a..f214323ece 100644
--- a/package/src/ai-components/message-composer/ComposerView.tsx
+++ b/package/src/ai-components/message-composer/ComposerView.tsx
@@ -19,7 +19,7 @@ import type {
AbstractMediaPickerService,
MediaPickerState,
} from '../services/media-picker-service/AbstractMediaPickerService';
-import { useComposerText } from '../store';
+import { COMPOSER_DICTATION_SOURCE, useComposerText } from '../store';
import type { ComposerState } from '../store';
import { useDictationState } from '../store/dictation/useDictationState';
import { useDictationTranscript } from '../store/dictation/useDictationTranscript';
@@ -100,7 +100,7 @@ export const AIMessageInput = () => {
} = useTheme();
const styles = useStyles();
- const { error, isRecording } = useDictationState();
+ const { error, isRecording, source } = useDictationState();
const { text } = useComposerText();
const { transcript } = useDictationTranscript();
const { setText } = useMessageComposerContext();
@@ -109,10 +109,13 @@ export const AIMessageInput = () => {
useEffect(() => {
const transcriptChanged = transcript !== previousTranscript.current;
previousTranscript.current = transcript;
+ // Only dictation from the composer's own button fills the input; dictation started elsewhere
+ // (a voice mode, say) must not overwrite what the user typed.
+ if (source !== COMPOSER_DICTATION_SOURCE) return;
if (!error && (isRecording || transcriptChanged)) {
setText(transcript);
}
- }, [isRecording, transcript, error, setText]);
+ }, [isRecording, transcript, error, setText, source]);
return (
({
@@ -33,6 +33,9 @@ export const SpeechToTextButton = (props: SpeechToTextButtonProps) => {
return ;
};
+// Marks this button's dictation as the composer's, so `ComposerView` fills its input with it.
+const DICTATION_OPTIONS = { source: COMPOSER_DICTATION_SOURCE };
+
const AvailableSpeechToTextButton = ({ options }: SpeechToTextButtonProps) => {
const {
theme: {
@@ -41,7 +44,7 @@ const AvailableSpeechToTextButton = ({ options }: SpeechToTextButtonProps) => {
},
} = useTheme();
- const { cancel, toggle } = useDictation(options);
+ const { cancel, toggle } = useDictation(options, DICTATION_OPTIONS);
const { isRecording } = useStateStore(dictationStore, selector);
useEffect(() => {
diff --git a/package/src/ai-components/native/AIDictation.ts b/package/src/ai-components/native/AIDictation.ts
index 450abae7c8..47ad2437ef 100644
--- a/package/src/ai-components/native/AIDictation.ts
+++ b/package/src/ai-components/native/AIDictation.ts
@@ -11,6 +11,10 @@ import NativeAIDictation, {
const ON_STATE_EVENT = 'AIDictationOnState';
const ON_RESULT_EVENT = 'AIDictationOnResult';
const ON_ERROR_EVENT = 'AIDictationOnError';
+const ON_LEVEL_EVENT = 'AIDictationOnLevel';
+
+/** The microphone's level while dictation listens, from 0 to 1. */
+export type DictationLevel = { level: number };
let eventEmitter: NativeEventEmitter | undefined;
const getEventEmitter = () => {
@@ -74,4 +78,11 @@ export const AIDictation = {
);
return sub ? () => sub.remove() : noop;
},
+ /** The microphone's level while dictation listens, about 10 to 30 times a second. */
+ addLevelListener(listener: (payload: DictationLevel) => void) {
+ const sub = getEventEmitter()?.addListener(ON_LEVEL_EVENT, (event) =>
+ listener(event as DictationLevel),
+ );
+ return sub ? () => sub.remove() : noop;
+ },
};
diff --git a/package/src/ai-components/store/dictation/store.ts b/package/src/ai-components/store/dictation/store.ts
index 9c3c0514d1..41e4fbb874 100644
--- a/package/src/ai-components/store/dictation/store.ts
+++ b/package/src/ai-components/store/dictation/store.ts
@@ -7,6 +7,11 @@ export type DictationStoreState = {
transcript: string;
isRecording: boolean;
error?: DictationError | null;
+ /**
+ * Who started the current dictation, as passed to `useDictation`, or `null`. Lets a consumer
+ * such as `ComposerView` pick up only the transcripts meant for it.
+ */
+ source?: string | null;
};
const DEFAULT_STATE: DictationStoreState = {
@@ -14,8 +19,12 @@ const DEFAULT_STATE: DictationStoreState = {
transcript: '',
isRecording: false,
error: null,
+ source: null,
};
+/** The `source` of dictation started by the composer's own `SpeechToTextButton`. */
+export const COMPOSER_DICTATION_SOURCE = 'composer';
+
export const dictationStore = new StateStore(DEFAULT_STATE);
const reset = () => dictationStore.next(DEFAULT_STATE);
diff --git a/package/src/ai-components/store/dictation/useDictationState.ts b/package/src/ai-components/store/dictation/useDictationState.ts
index a40627005d..6a79bcb009 100644
--- a/package/src/ai-components/store/dictation/useDictationState.ts
+++ b/package/src/ai-components/store/dictation/useDictationState.ts
@@ -2,10 +2,11 @@ import { useStateStore } from '@stream-io/state-store/react-bindings';
import { dictationStore, type DictationStoreState } from './store';
-const selector = ({ state, isRecording, error }: DictationStoreState) => ({
+const selector = ({ state, isRecording, error, source }: DictationStoreState) => ({
state,
isRecording,
error,
+ source,
});
export const useDictationState = () => useStateStore(dictationStore, selector);
diff --git a/package/src/ai-components/store/index.ts b/package/src/ai-components/store/index.ts
index f737510a79..12ae1a9e26 100644
--- a/package/src/ai-components/store/index.ts
+++ b/package/src/ai-components/store/index.ts
@@ -9,3 +9,5 @@ export * from './composer/useComposerText';
// dictation
export * from './dictation/store';
+export * from './dictation/useDictationState';
+export * from './dictation/useDictationTranscript';
diff --git a/package/src/ai-components/theme.ts b/package/src/ai-components/theme.ts
index 8d61874359..c2047a2d7b 100644
--- a/package/src/ai-components/theme.ts
+++ b/package/src/ai-components/theme.ts
@@ -56,6 +56,19 @@ export type Theme = {
aiTypingIndicatorView: {
text: TextStyle;
};
+ /**
+ * `VoiceGlow` and `VoiceWave`. Optional, so a hand-built `Theme` without it keeps type-checking;
+ * the visualizations fall back to the default accent then.
+ */
+ voiceVisualization?: {
+ /** One to five colors, used when a visualization is given no `colors` of its own. */
+ colors: string[];
+ /**
+ * The color the visualizations sit on, which tells them to draw for a light or a dark
+ * background. Defaults to the system's color scheme.
+ */
+ backdropColor?: string;
+ };
};
export const defaultTheme: Theme = {
@@ -135,4 +148,7 @@ export const defaultTheme: Theme = {
aiTypingIndicatorView: {
text: {},
},
+ voiceVisualization: {
+ colors: [colors.accent_blue],
+ },
};
diff --git a/package/src/ai-components/transcription/useDictation.ts b/package/src/ai-components/transcription/useDictation.ts
index eb3e3c9522..bfbf050675 100644
--- a/package/src/ai-components/transcription/useDictation.ts
+++ b/package/src/ai-components/transcription/useDictation.ts
@@ -27,7 +27,19 @@ const selector = ({ isRecording }: DictationStoreState) => ({
});
// TODO: add proper jsdocs
-export const useDictation = (defaultOptions: DictationStartOptions = DEFAULT_OPTIONS) => {
+export type UseDictationOptions = {
+ /**
+ * Who is dictating, stored as the dictation store's `source` when dictation starts. The
+ * `ComposerView` only fills its input with dictation whose source is the composer's own, so
+ * dictation started elsewhere (a voice mode, say) leaves the composer's text alone.
+ */
+ source?: string;
+};
+
+export const useDictation = (
+ defaultOptions: DictationStartOptions = DEFAULT_OPTIONS,
+ { source }: UseDictationOptions = {},
+) => {
const { isRecording } = useStateStore(dictationStore, selector);
useEffect(() => {
@@ -65,11 +77,12 @@ export const useDictation = (defaultOptions: DictationStartOptions = DEFAULT_OPT
code: 'NO_PERMISSION',
message: 'Microphone permission not granted',
},
+ source: source ?? null,
});
return;
}
- dictationStore.partialNext({ error: null, transcript: '' });
+ dictationStore.partialNext({ error: null, source: source ?? null, transcript: '' });
const opts: DictationStartOptions = {
...defaultOptions,
@@ -78,7 +91,7 @@ export const useDictation = (defaultOptions: DictationStartOptions = DEFAULT_OPT
AIDictation.start(opts);
},
- [defaultOptions],
+ [defaultOptions, source],
);
const stop = useCallback(() => {
diff --git a/package/src/ai-components/transcription/useDictationAudioLevel.ts b/package/src/ai-components/transcription/useDictationAudioLevel.ts
new file mode 100644
index 0000000000..16574389a7
--- /dev/null
+++ b/package/src/ai-components/transcription/useDictationAudioLevel.ts
@@ -0,0 +1,38 @@
+import { useEffect } from 'react';
+
+import { type SharedValue, useSharedValue } from 'react-native-reanimated';
+
+import { AIDictation } from '../native/AIDictation';
+
+/**
+ * The microphone's live level while dictation listens, from 0 to 1, for the `audioLevel` of
+ * `VoiceWave` and `VoiceGlow`. It is `null` while dictation is not listening, which lets a
+ * visualization animate speech on its own.
+ *
+ * The level is a shared value rather than state, so a level changing many times a second
+ * re-renders nothing: the visualizations read it once a frame on the UI thread.
+ */
+export const useDictationAudioLevel = (): SharedValue => {
+ const level = useSharedValue(null);
+
+ useEffect(() => {
+ const unsubscribeLevel = AIDictation.addLevelListener((payload) => {
+ level.value = typeof payload?.level === 'number' ? payload.level : null;
+ });
+ const unsubscribeState = AIDictation.addStateListener(({ state }) => {
+ if (state !== 'listening' && state !== 'starting') level.value = null;
+ });
+ const unsubscribeError = AIDictation.addErrorListener(() => {
+ level.value = null;
+ });
+
+ return () => {
+ unsubscribeLevel();
+ unsubscribeState();
+ unsubscribeError();
+ level.value = null;
+ };
+ }, [level]);
+
+ return level;
+};
diff --git a/package/src/ai-components/voice-visualization/VoiceGlow.tsx b/package/src/ai-components/voice-visualization/VoiceGlow.tsx
new file mode 100644
index 0000000000..f521ce2444
--- /dev/null
+++ b/package/src/ai-components/voice-visualization/VoiceGlow.tsx
@@ -0,0 +1,79 @@
+import React from 'react';
+
+import { useDerivedValue } from 'react-native-reanimated';
+
+import { Canvas, Fill, Shader } from '@shopify/react-native-skia';
+
+import { driverReact, driverScale } from './driver';
+import { getGlowEffect } from './glowShader';
+import { GLOW_STYLE } from './styles';
+import type { VoiceVisualizationProps } from './types';
+import type { VoiceVisualization } from './useVoiceVisualization';
+import { type VoiceCanvasSize, VoiceVisualizationView } from './VoiceVisualizationView';
+
+export type VoiceGlowProps = VoiceVisualizationProps;
+
+const GlowCanvas = ({
+ size: { height, width },
+ visualization: { dark, driver },
+}: {
+ size: VoiceCanvasSize;
+ visualization: VoiceVisualization;
+}) => {
+ const effect = getGlowEffect();
+ const uniforms = useDerivedValue(() => {
+ const d = driver.value;
+ const color = (i: number) => [d.colors[i].hue, d.colors[i].saturation, d.colors[i].value, 0];
+ return {
+ bright: d.bright,
+ col0: color(0),
+ col1: color(1),
+ col2: color(2),
+ col3: color(3),
+ col4: color(4),
+ count: d.count,
+ dark: dark.value,
+ flow: d.flow,
+ flowSpin: d.flowSpin,
+ hover: [d.hoverX, d.hoverY],
+ hoverAmt: d.hoverAmount,
+ load: d.load,
+ mic: d.mic,
+ orbSpin: d.orbSpin,
+ react: driverReact(d),
+ resolution: [width, height],
+ sat: d.sat,
+ scale: driverScale(d),
+ tap: [d.tapX, d.tapY],
+ tapTime: d.tapAge,
+ time: d.time,
+ voice: d.voice,
+ };
+ });
+
+ if (!effect) return null;
+
+ return (
+
+ );
+};
+
+const renderGlow = (visualization: VoiceVisualization, size: VoiceCanvasSize) => (
+
+);
+
+/**
+ * A voice agent as a soft, airy globe: color masses orbit inside it and swell with speech, and the
+ * gaps between them let the background show through. Tapping the globe bounces it and ripples it
+ * from the touch, and so does a hovering mouse or pen.
+ *
+ * It has no size of its own and draws in the middle of the view it is given, about half as wide
+ * as its shorter side, so give it a square.
+ */
+export const VoiceGlow = (props: VoiceGlowProps) => (
+
+);
diff --git a/package/src/ai-components/voice-visualization/VoiceVisualizationView.tsx b/package/src/ai-components/voice-visualization/VoiceVisualizationView.tsx
new file mode 100644
index 0000000000..8276245d84
--- /dev/null
+++ b/package/src/ai-components/voice-visualization/VoiceVisualizationView.tsx
@@ -0,0 +1,179 @@
+import React, { type ReactNode, useCallback, useMemo, useState } from 'react';
+import {
+ type GestureResponderEvent,
+ type LayoutChangeEvent,
+ PixelRatio,
+ type PointerEvent,
+ StyleSheet,
+ View,
+} from 'react-native';
+
+import Animated from 'react-native-reanimated';
+
+import type { VoiceVisualizationStyle } from './styles';
+import type { VoiceAgentState, VoiceVisualizationProps } from './types';
+import { useVoiceVisualization, type VoiceVisualization } from './useVoiceVisualization';
+
+/** What the renderer draws in: the canvas size, in its own units, and the screen's pixels per unit. */
+export type VoiceCanvasSize = { height: number; pixelRatio: number; width: number };
+
+type ViewProps = VoiceVisualizationProps & {
+ /** Draws the visualization into a canvas of the given size. */
+ renderCanvas: (visualization: VoiceVisualization, size: VoiceCanvasSize) => ReactNode;
+ visualStyle: VoiceVisualizationStyle;
+};
+
+const DEFAULT_STATE_LABELS: Record = {
+ connecting: 'Connecting',
+ idle: 'Ready',
+ listening: 'Listening',
+ speaking: 'Speaking',
+ thinking: 'Thinking',
+};
+
+/**
+ * What VoiceWave and VoiceGlow share: the motion, the canvas kept at the view's size and at most
+ * the style's density, reduced motion, taps and hover, and accessibility.
+ *
+ * It names itself "Voice agent" with its state as the value, as on iOS and Android. Touches are
+ * only watched, never claimed, so a press handler on the view or a parent still gets them, and
+ * only the shape takes them: around it they reach whatever is behind.
+ */
+export const VoiceVisualizationView = ({
+ accessibilityLabel = 'Voice agent',
+ accessibilityValue,
+ audioLevel,
+ colors,
+ expressivity,
+ onLayout,
+ pointerEvents = 'box-none',
+ renderCanvas,
+ state,
+ stateLabels,
+ style,
+ visualStyle,
+ ...restProps
+}: ViewProps) => {
+ const visualization = useVoiceVisualization(
+ { audioLevel, colors, expressivity, state },
+ visualStyle,
+ );
+ const { hover, opacity, tap } = visualization;
+ const [layout, setLayout] = useState({ height: 0, width: 0 });
+
+ const handleLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ onLayout?.(event);
+ const { height, width } = event.nativeEvent.layout;
+ setLayout((current) =>
+ current.width === width && current.height === height ? current : { height, width },
+ );
+ },
+ [onLayout],
+ );
+
+ const side = Math.min(layout.width, layout.height);
+ const pixelRatio = PixelRatio.get();
+ // Drawn at no more than the style's density, then scaled up to the view: the glow's soft
+ // gradients look the same at 2x with fewer pixels to fill, while the wave's line needs 3x.
+ const density = Math.min(1, visualStyle.maxScale / pixelRatio);
+ const canvasSize = useMemo(
+ () => ({
+ height: layout.height * density,
+ pixelRatio: pixelRatio * density,
+ width: layout.width * density,
+ }),
+ [density, layout.height, layout.width, pixelRatio],
+ );
+ const hitWidth = visualStyle.hitArea.width * side;
+ const hitHeight = visualStyle.hitArea.height * side;
+
+ /** A touch on the hit area, centred on the view and normalized to its shorter side, y up. */
+ const toLocalPoint = useCallback(
+ (locationX: number, locationY: number) => ({
+ x: (locationX - hitWidth / 2) / (side || 1),
+ y: (hitHeight / 2 - locationY) / (side || 1),
+ }),
+ [hitHeight, hitWidth, side],
+ );
+
+ const handleTouchEnd = useCallback(
+ (event: GestureResponderEvent) => {
+ const { x, y } = toLocalPoint(event.nativeEvent.locationX, event.nativeEvent.locationY);
+ if (visualStyle.contains(x, y)) tap(x, y);
+ },
+ [tap, toLocalPoint, visualStyle],
+ );
+
+ const handlePointerMove = useCallback(
+ (event: PointerEvent) => {
+ // Only a hovering mouse or pen ripples it; a finger has no hover.
+ const { buttons, offsetX, offsetY, pointerType } = event.nativeEvent;
+ if (pointerType === 'touch') return;
+ const { x, y } = toLocalPoint(offsetX, offsetY);
+ const hovering = buttons === 0 && visualStyle.contains(x, y);
+ hover(hovering ? x : null, hovering ? y : null);
+ },
+ [hover, toLocalPoint, visualStyle],
+ );
+
+ const handlePointerLeave = useCallback(() => hover(null, null), [hover]);
+
+ return (
+
+ {side > 0 ? (
+ <>
+
+ {renderCanvas(visualization, canvasSize)}
+
+
+ >
+ ) : null}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ canvas: { position: 'absolute' },
+ hitArea: { position: 'absolute' },
+});
diff --git a/package/src/ai-components/voice-visualization/VoiceWave.tsx b/package/src/ai-components/voice-visualization/VoiceWave.tsx
new file mode 100644
index 0000000000..b367c3ba05
--- /dev/null
+++ b/package/src/ai-components/voice-visualization/VoiceWave.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+
+import { useDerivedValue } from 'react-native-reanimated';
+
+import { Canvas, LinearGradient, Path, vec } from '@shopify/react-native-skia';
+
+import { WAVE_STYLE } from './styles';
+import type { VoiceVisualizationProps } from './types';
+import type { VoiceVisualization } from './useVoiceVisualization';
+import { type VoiceCanvasSize, VoiceVisualizationView } from './VoiceVisualizationView';
+import {
+ WAVE_END,
+ WAVE_POSITIONS,
+ WAVE_START,
+ waveColors,
+ wavePath,
+ waveStrokeWidth,
+} from './waveGeometry';
+
+export type VoiceWaveProps = VoiceVisualizationProps;
+
+const WaveCanvas = ({
+ size: { height, pixelRatio, width },
+ visualization: { dark, driver },
+}: {
+ size: VoiceCanvasSize;
+ visualization: VoiceVisualization;
+}) => {
+ const path = useDerivedValue(() => wavePath(driver.value, width, height));
+ const colors = useDerivedValue(() => waveColors(driver.value, dark.value));
+ const m = Math.min(width, height);
+
+ return (
+
+ );
+};
+
+const renderWave = (visualization: VoiceVisualization, size: VoiceCanvasSize) => (
+
+);
+
+/**
+ * A voice agent as a single string, flat at its ends, carrying a wave packet that erupts with
+ * speech while its colors flow along it. Tapping the string ripples it from the touch, and so does
+ * a hovering mouse or pen.
+ *
+ * It has no size of its own and draws in the middle of the view it is given, about 80% as wide as
+ * its shorter side, so give it a square or a wide box.
+ */
+export const VoiceWave = (props: VoiceWaveProps) => (
+
+);
diff --git a/package/src/ai-components/voice-visualization/bounce.ts b/package/src/ai-components/voice-visualization/bounce.ts
new file mode 100644
index 0000000000..7e4c50a6e1
--- /dev/null
+++ b/package/src/ai-components/voice-visualization/bounce.ts
@@ -0,0 +1,61 @@
+/**
+ * The bounce of a tap: a dip to 93% and an overshoot to 103% before settling, over 340 ms.
+ *
+ * These are authored keyframes rather than a spring: the ease-out curve times the whole run and
+ * the keyframes are linear between their stops.
+ */
+export const BOUNCE_DURATION = 0.34;
+
+const OFFSETS = [0, 0.3, 0.62, 1];
+const SCALES = [1, 0.93, 1.03, 1];
+
+/** How long ago the last bounce started, in seconds; infinite before the first. */
+export type BounceState = { age: number };
+
+export const createBounce = (): BounceState => ({ age: Number.POSITIVE_INFINITY });
+
+export const startBounce = (bounce: BounceState) => {
+ 'worklet';
+ bounce.age = 0;
+};
+
+export const advanceBounce = (bounce: BounceState, seconds: number) => {
+ 'worklet';
+ bounce.age += seconds;
+};
+
+/** CSS `ease-out`, `cubic-bezier(0, 0, 0.58, 1)`. */
+export const easeOut = (x: number) => {
+ 'worklet';
+ const bx = 3 * 0.58;
+ const ax = 1 - bx;
+ const by = 3;
+ const ay = 1 - by;
+ let low = 0;
+ let high = 1;
+ for (let i = 0; i < 24; i++) {
+ const middle = (low + high) / 2;
+ if ((ax * middle + bx) * middle * middle < x) low = middle;
+ else high = middle;
+ }
+ const t = (low + high) / 2;
+ return (ay * t + by) * t * t;
+};
+
+/** The keyframed scale at `progress`, from 0 to 1. */
+export const scaleAt = (progress: number) => {
+ 'worklet';
+ for (let i = 1; i < OFFSETS.length; i++) {
+ if (progress <= OFFSETS[i]) {
+ const fraction = (progress - OFFSETS[i - 1]) / (OFFSETS[i] - OFFSETS[i - 1]);
+ return SCALES[i - 1] + (SCALES[i] - SCALES[i - 1]) * fraction;
+ }
+ }
+ return 1;
+};
+
+export const bounceScale = (bounce: BounceState) => {
+ 'worklet';
+ if (bounce.age >= BOUNCE_DURATION) return 1;
+ return scaleAt(easeOut(Math.max(bounce.age, 0) / BOUNCE_DURATION));
+};
diff --git a/package/src/ai-components/voice-visualization/driver.ts b/package/src/ai-components/voice-visualization/driver.ts
new file mode 100644
index 0000000000..7d9c7fc070
--- /dev/null
+++ b/package/src/ai-components/voice-visualization/driver.ts
@@ -0,0 +1,240 @@
+import { advanceBounce, type BounceState, bounceScale, createBounce, startBounce } from './bounce';
+import { MAXIMUM_COLORS } from './palette';
+import type { Hsv, VoiceAgentState } from './types';
+
+/**
+ * The share of the way the live level moves each frame at 60 Hz, toward a louder or a quieter
+ * level.
+ */
+export type LevelResponse = { rise: number; fall: number };
+
+export const LEVEL_RESPONSE = {
+ /** Follows the level closely, both ways. The glow's. */
+ steady: { fall: 0.35, rise: 0.35 },
+ /**
+ * Follows the level like a VU meter: about 0.1 s up to a louder reading, about 0.3 s down from
+ * it, so a meter's per-syllable jumps swell with the phrase instead. The wave's.
+ */
+ smooth: { fall: 0.05, rise: 0.16 },
+} satisfies Record;
+
+export type VoiceDriverInput = {
+ state: VoiceAgentState;
+ /** One to five colors. */
+ colors: Hsv[];
+ /** From 0 to 2. */
+ expressivity: number;
+ /** From 0 to 1, or null when no live audio drives the motion. */
+ audioLevel: number | null;
+ reduceMotion: boolean;
+};
+
+/**
+ * The motion of a voice visualization: where it is in time, how it moves, and its colors. A plain
+ * object rather than a class, so it can live in a shared value and be advanced on the UI thread.
+ */
+export type VoiceDriverState = {
+ speed: number;
+ bright: number;
+ sat: number;
+ load: number;
+ flow: number;
+ baseReact: number;
+ expressivity: number;
+ time: number;
+ flowSpin: number;
+ orbSpin: number;
+ count: number;
+ tapX: number;
+ tapY: number;
+ tapAge: number;
+ hoverX: number;
+ hoverY: number;
+ hoverAmount: number;
+ isHovering: boolean;
+ mic: number;
+ voice: number;
+ /** The colors drawn, eased in place toward the input's color slots. */
+ colors: Hsv[];
+ bounce: BounceState;
+ levelResponse: LevelResponse;
+};
+
+/**
+ * How a state moves. `load`, `flow` and `react` each select a motion pattern, one per state, so
+ * easing between states crossfades the patterns.
+ */
+type VoiceMotion = {
+ bright: number;
+ flow: number;
+ load: number;
+ react: number;
+ sat: number;
+ speed: number;
+};
+
+const MOTIONS: Record = {
+ connecting: { bright: 1, flow: 0, load: 1, react: 0, sat: 1, speed: 1 },
+ idle: { bright: 1, flow: 0, load: 0, react: 0.12, sat: 1, speed: 0.4 },
+ listening: { bright: 1, flow: 0, load: 0, react: 0.45, sat: 1, speed: 0.9 },
+ speaking: { bright: 1, flow: 0, load: 0, react: 1, sat: 1, speed: 1.25 },
+ thinking: { bright: 1, flow: 1, load: 0, react: 0, sat: 1, speed: 1.25 },
+};
+
+export const NO_TAP = 100;
+const MAX_FRAME = 0.05;
+/** How fast time runs under reduced motion. */
+export const REDUCED = 0.07;
+const DRIVERS = 0.08;
+const GENTLE = 0.12;
+const VOICE = 0.1;
+
+/** Expressivity scales amplitude and speed, each with a floor so the motion breathes at 0. */
+export const scales = (expressivity: number) => {
+ 'worklet';
+ return { amplitude: 0.15 + 0.85 * expressivity, speed: 0.6 + 0.4 * expressivity };
+};
+
+/**
+ * The share of the way a value moves in `dt` seconds when it moves `perFrame` of the way each
+ * frame at 60 Hz.
+ */
+export const ease = (perFrame: number, dt: number) => {
+ 'worklet';
+ return 1 - Math.pow(1 - perFrame, dt * 60);
+};
+
+const rem = (x: number, m: number) => {
+ 'worklet';
+ return ((x % m) + m) % m;
+};
+
+/** Eases an HSV color in place, its hue taking the shorter way round the wheel. */
+const easeHsv = (color: Hsv, target: Hsv, fraction: number) => {
+ 'worklet';
+ const hueStep = (rem(target.hue - color.hue + 1.5, 1) - 0.5) * fraction;
+ color.hue = rem(color.hue + hueStep + 1, 1);
+ color.saturation += (target.saturation - color.saturation) * fraction;
+ color.value += (target.value - color.value) * fraction;
+};
+
+/** The five color slots the renderers read; past the palette they repeat its first color. */
+export const slots = (colors: Hsv[]): Hsv[] => {
+ 'worklet';
+ const first = colors[0] ?? { hue: 0, saturation: 0, value: 0 };
+ const result: Hsv[] = [];
+ for (let i = 0; i < MAXIMUM_COLORS; i++) result.push(colors[i] ?? first);
+ return result;
+};
+
+/** A driver settled on `input`, so the first frame shows it rather than easing into it. */
+export const createDriverState = (
+ input: VoiceDriverInput,
+ levelResponse: LevelResponse = LEVEL_RESPONSE.steady,
+): VoiceDriverState => {
+ const m = MOTIONS[input.state];
+ const s = scales(input.expressivity);
+ return {
+ baseReact: m.react * s.amplitude,
+ bounce: createBounce(),
+ bright: m.bright,
+ // Copies, as they are eased in place and the palette belongs to the caller.
+ colors: slots(input.colors).map((color) => ({ ...color })),
+ count: input.colors.length,
+ expressivity: input.expressivity,
+ flow: m.flow * s.amplitude,
+ flowSpin: 0,
+ hoverAmount: 0,
+ hoverX: 0,
+ hoverY: 0,
+ isHovering: false,
+ levelResponse,
+ load: m.load * s.amplitude,
+ mic: 0,
+ orbSpin: 0,
+ sat: m.sat,
+ speed: m.speed * s.speed,
+ tapAge: NO_TAP,
+ tapX: 0,
+ tapY: 0,
+ time: 0,
+ voice: 0,
+ };
+};
+
+/** Live audio lifts the motion to its level, whatever the state. */
+export const driverReact = (d: VoiceDriverState) => {
+ 'worklet';
+ return d.baseReact + (Math.max(d.baseReact, d.mic) - d.baseReact) * d.voice;
+};
+
+/** The scale of a tap's bounce. */
+export const driverScale = (d: VoiceDriverState) => {
+ 'worklet';
+ return bounceScale(d.bounce);
+};
+
+/** A tap at (x, y), centred and normalized to the shorter side with y up. */
+export const tapDriver = (d: VoiceDriverState, input: VoiceDriverInput, x: number, y: number) => {
+ 'worklet';
+ if (input.reduceMotion) return;
+ d.tapX = x;
+ d.tapY = y;
+ d.tapAge = 0;
+ startBounce(d.bounce);
+};
+
+/** Where a pointer hovers, or null once it has left. */
+export const hoverDriver = (
+ d: VoiceDriverState,
+ input: VoiceDriverInput,
+ x: number | null,
+ y: number | null,
+) => {
+ 'worklet';
+ if (x === null || y === null || input.reduceMotion) {
+ d.isHovering = false;
+ return;
+ }
+ d.hoverX = x;
+ d.hoverY = y;
+ d.isHovering = true;
+};
+
+/**
+ * Advances the motion by `seconds`, easing each value toward the input's motion by a fixed share
+ * per frame at 60 Hz, converted to time, so it moves the same at any refresh rate.
+ */
+export const advanceDriver = (d: VoiceDriverState, input: VoiceDriverInput, seconds: number) => {
+ 'worklet';
+ // Clamped so a frame after a stall cannot leap the motion.
+ const dt = Math.min(Math.max(seconds, 0), MAX_FRAME);
+ if (input.reduceMotion) d.isHovering = false;
+ const m = MOTIONS[input.state];
+ const s = scales(input.expressivity);
+ const drivers = ease(DRIVERS, dt);
+ d.speed += (m.speed * s.speed - d.speed) * drivers;
+ d.bright += (m.bright - d.bright) * drivers;
+ d.sat += (m.sat - d.sat) * drivers;
+ d.load += (m.load * s.amplitude - d.load) * drivers;
+ d.flow += (m.flow * s.amplitude - d.flow) * drivers;
+ d.baseReact += (m.react * s.amplitude - d.baseReact) * drivers;
+ d.expressivity += (input.expressivity - d.expressivity) * drivers;
+ // Integrated rather than computed as time times a weight, so a change of state eases the turns
+ // instead of jumping their angles.
+ const step = dt * (input.reduceMotion ? REDUCED : 1) * d.speed;
+ d.time += step;
+ d.flowSpin += step * d.flow;
+ d.orbSpin += step * (1 + 0.5 * d.flow + 0.1 * d.baseReact);
+ d.tapAge += dt;
+ advanceBounce(d.bounce, dt);
+ const gentle = ease(GENTLE, dt);
+ d.hoverAmount += ((d.isHovering ? 1 : 0) - d.hoverAmount) * gentle;
+ d.voice += ((input.audioLevel === null ? 0 : 1) - d.voice) * ease(VOICE, dt);
+ const level = input.audioLevel ?? 0;
+ const response = level > d.mic ? d.levelResponse.rise : d.levelResponse.fall;
+ d.mic += (level - d.mic) * ease(response, dt);
+ const targets = slots(input.colors);
+ for (let i = 0; i < d.colors.length; i++) easeHsv(d.colors[i], targets[i], gentle);
+ d.count += (input.colors.length - d.count) * gentle;
+};
diff --git a/package/src/ai-components/voice-visualization/glowShader.ts b/package/src/ai-components/voice-visualization/glowShader.ts
new file mode 100644
index 0000000000..fe2d8019d6
--- /dev/null
+++ b/package/src/ai-components/voice-visualization/glowShader.ts
@@ -0,0 +1,227 @@
+import { Skia, type SkRuntimeEffect } from '@shopify/react-native-skia';
+
+/**
+ * The glow's shader, SkSL: the AGSL glow of stream-chat-android (itself ported from the iOS Metal
+ * glow) as is, since AGSL is SkSL, so the platforms look alike. Every pixel is computed on the
+ * GPU: color masses warped by simplex noise, concentric waves, ripples from a tap or a hovering
+ * pointer, light and a halo. Pixels past the halo return at once, so only the glow itself costs.
+ */
+const SOURCE = `
+uniform float2 resolution;
+uniform float4 col0;
+uniform float4 col1;
+uniform float4 col2;
+uniform float4 col3;
+uniform float4 col4;
+uniform float time;
+uniform float count;
+uniform float bright;
+uniform float sat;
+uniform float load;
+uniform float flow;
+uniform float flowSpin;
+uniform float orbSpin;
+uniform float react;
+uniform float dark;
+uniform float mic;
+uniform float voice;
+uniform float scale;
+uniform float2 tap;
+uniform float tapTime;
+uniform float2 hover;
+uniform float hoverAmt;
+
+float sq(float x) { return x * x; }
+float ss(float e0, float e1, float x) {
+ float t = clamp((x - e0) / (e1 - e0), 0.0, 1.0);
+ return t * t * (3.0 - 2.0 * t);
+}
+float3 hsv2rgb(float3 c) {
+ float4 K = float4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
+ float3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
+ return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
+}
+float3 desat(float3 c, float s) {
+ float l = dot(c, float3(0.299, 0.587, 0.114));
+ return mix(float3(l), c, s);
+}
+float3 saturate3(float3 c, float s) {
+ float l = dot(c, float3(0.299, 0.587, 0.114));
+ return clamp(mix(float3(l), c, s), 0.0, 1.0);
+}
+float2 rot2(float2 p, float a) {
+ float s = sin(a); float c = cos(a);
+ return float2(c * p.x + s * p.y, -s * p.x + c * p.y);
+}
+float3 vivid(float4 hsv) {
+ return hsv2rgb(float3(hsv.x, clamp(hsv.y + dark * 0.06, 0.0, 1.0), hsv.z));
+}
+// Waves spreading out from the last tap, fading within a second and a half.
+float tapRipple(float2 p) {
+ float age = tapTime;
+ if (age > 1.5) return 0.0;
+ float d = distance(p, tap);
+ float front = age * 0.75;
+ float env = exp(-sq((d - front) / 0.14));
+ float decay = exp(-age * 2.6);
+ return sin(d * 34.0 - age * 24.0) * env * decay;
+}
+// A gentle ripple following a hovering pointer.
+float hoverRipple(float2 p) {
+ if (hoverAmt <= 0.001) return 0.0;
+ float d = distance(p, hover);
+ float env = exp(-sq(d / 0.22));
+ return sin(d * 30.0 - time * 7.0) * env * hoverAmt * 0.6;
+}
+float4 permute(float4 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); }
+float4 taylorInvSqrt(float4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
+float snoise(float3 v) {
+ const float2 C = float2(1.0 / 6.0, 1.0 / 3.0);
+ const float4 D = float4(0.0, 0.5, 1.0, 2.0);
+ float3 i = floor(v + dot(v, C.yyy));
+ float3 x0 = v - i + dot(i, C.xxx);
+ float3 g = step(x0.yzx, x0.xyz);
+ float3 l = 1.0 - g;
+ float3 i1 = min(g.xyz, l.zxy);
+ float3 i2 = max(g.xyz, l.zxy);
+ float3 x1 = x0 - i1 + 1.0 * C.xxx;
+ float3 x2 = x0 - i2 + 2.0 * C.xxx;
+ float3 x3 = x0 - 1.0 + 3.0 * C.xxx;
+ i = mod(i, 289.0);
+ float4 p = permute(permute(permute(
+ i.z + float4(0.0, i1.z, i2.z, 1.0))
+ + i.y + float4(0.0, i1.y, i2.y, 1.0))
+ + i.x + float4(0.0, i1.x, i2.x, 1.0));
+ float n_ = 1.0 / 7.0;
+ float3 ns = n_ * D.wyz - D.xzx;
+ float4 j = p - 49.0 * floor(p * ns.z * ns.z);
+ float4 x_ = floor(j * ns.z);
+ float4 y_ = floor(j - 7.0 * x_);
+ float4 x = x_ * ns.x + ns.yyyy;
+ float4 y = y_ * ns.x + ns.yyyy;
+ float4 h = 1.0 - abs(x) - abs(y);
+ float4 b0 = float4(x.xy, y.xy);
+ float4 b1 = float4(x.zw, y.zw);
+ float4 s0 = floor(b0) * 2.0 + 1.0;
+ float4 s1 = floor(b1) * 2.0 + 1.0;
+ float4 sh = -step(h, float4(0.0));
+ float4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
+ float4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
+ float3 p0 = float3(a0.xy, h.x);
+ float3 p1 = float3(a0.zw, h.y);
+ float3 p2 = float3(a1.xy, h.z);
+ float3 p3 = float3(a1.zw, h.w);
+ float4 norm = taylorInvSqrt(float4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
+ p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
+ float4 m = max(0.6 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
+ m = m * m;
+ return 42.0 * dot(m * m, float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
+}
+float speechEnv(float t) {
+ float gate = ss(-0.25, 0.35, snoise(float3(t * 0.6, 21.0, 0.0)));
+ float syl = 0.55 + 0.45 * snoise(float3(t * 3.0, 5.0, 0.0));
+ return clamp(gate * syl, 0.0, 1.0);
+}
+float2 coords(float2 pos) {
+ float m = min(resolution.x, resolution.y);
+ return float2(pos.x - 0.5 * resolution.x, 0.5 * resolution.y - pos.y) / m / scale;
+}
+
+half4 main(float2 fragCoord) {
+ float2 q = coords(fragCoord);
+ float t = time;
+ float r = length(q);
+
+ float speak = ss(0.6, 1.0, react);
+ float speech = react * mix(0.55 + 0.25 * sin(t * 1.6), speechEnv(t), speak);
+ speech = mix(speech, mic, voice);
+ float speechSmooth = mix(speech, react * (0.55 + 0.25 * sin(t * 1.6)), 0.5 * speak);
+ float energy = 0.12 + 0.30 * react + 0.18 * flow + 0.28 * load + 0.06 * speechSmooth;
+ float R = 0.24 * (1.0 + speechSmooth * 0.20 + load * 0.05 * sin(t * 2.0) + react * 0.04 * sin(t * 1.6));
+ float p = r / R;
+ if (p > 2.2) return half4(0.0);
+
+ float core = exp(-2.0 * p * p);
+ float body = ss(1.06, 0.62, p);
+
+ float2 P = q / R;
+ float pl = length(P);
+ float nt = t * 0.15 + flowSpin * 0.05;
+ float nAmp = 0.20 + 0.28 * energy;
+ float2 sp = P + nAmp * float2(snoise(float3(P * 0.7, nt)), snoise(float3(P * 0.7 + 4.7, nt)));
+ float ang = atan(P.y, P.x);
+ float wave = (0.018 + 0.025 * energy) * sin(pl * 5.0 - t * 2.0) * ss(0.0, 0.4, pl);
+ sp += wave * float2(-sin(ang), cos(ang));
+ sp = rot2(sp, flowSpin * 0.30);
+ float2 gtoP = q - (hoverAmt > 0.001 ? hover : tap);
+ sp += (gtoP / (length(gtoP) + 1e-4)) * (tapRipple(q) + hoverRipple(q)) * 0.5;
+ float bt = t;
+ float a0 = orbSpin * 0.42 + 2.0 * snoise(float3(bt * 0.12, 0.0, 0.0));
+ float a1 = -orbSpin * 0.34 + 2.0 * snoise(float3(bt * 0.10, 5.0, 0.0)) + 2.0;
+ float a2 = orbSpin * 0.30 + 2.0 * snoise(float3(bt * 0.11, 9.0, 0.0)) + 1.0;
+ float a3 = -orbSpin * 0.26 + 2.0 * snoise(float3(bt * 0.09, 14.0, 0.0)) + 3.5;
+ float2 c0 = 0.40 * float2(cos(a0), sin(a0));
+ float2 c1 = 0.38 * float2(cos(a1), sin(a1));
+ float2 c2c = 0.36 * float2(cos(a2), sin(a2));
+ float2 c3c = 0.34 * float2(cos(a3), sin(a3));
+ float b0 = ss(1.15, 0.1, length(sp - c0));
+ float b1 = ss(1.30, 0.1, length(sp - c1));
+ float b2 = ss(1.20, 0.1, length(sp - c2c));
+ float b3 = ss(1.25, 0.1, length(sp - c3c));
+
+ float3 k0 = saturate3(vivid(col0), 1.7);
+ float3 kL = saturate3(clamp(vivid(col0) * 1.18, 0.0, 1.0), 1.3);
+ float3 kD = saturate3(vivid(col0) * 0.85, 1.3);
+ float3 colB = mix(kL, saturate3(vivid(col1), 1.7), clamp(count - 1.0, 0.0, 1.0));
+ float3 colC = mix(kD, saturate3(vivid(col2), 1.7), clamp(count - 2.0, 0.0, 1.0));
+ float3 colD = saturate3(vivid(col3), 1.7);
+ float3 colE = saturate3(vivid(col4), 1.7);
+ float3 base = saturate3(mix(vivid(col0), float3(1.0), 0.06), 1.3);
+ float3 col = base;
+ col = mix(col, k0, b0);
+ col = mix(col, colB, b1);
+ col = mix(col, colC, b0 * 0.5);
+ col = mix(col, colD, b2 * clamp(count - 3.0, 0.0, 1.0));
+ col = mix(col, colE, b3 * clamp(count - 4.0, 0.0, 1.0));
+ col = saturate3(col, 1.15);
+ col = clamp(col * 1.12, 0.0, 1.0);
+ col = desat(col, sat);
+
+ float z = sqrt(max(1.0 - p * p, 0.0));
+ float3 nrm = normalize(float3(q / R, z + 1e-4));
+ float3 Ld = normalize(float3(-0.35, 0.45, 0.85));
+ float diff = clamp(dot(nrm, Ld), 0.0, 1.0);
+ col *= 0.80 + 0.20 * diff;
+ float fres = pow(1.0 - clamp(nrm.z, 0.0, 1.0), 2.5);
+ col += fres * 0.10 * mix(float3(1.0), k0, 0.6);
+
+ float3 haloCol = mix(mix(vivid(col0), float3(1.0), 0.75), mix(vivid(col0), float3(1.0), 0.32), dark);
+ float halo = exp(-sq(max(r - R * 0.8, 0.0) / 0.09));
+ float haloA = halo * (mix(0.05, 0.10, dark) + 0.05 * react);
+ float waves = clamp(b0 + 0.8 * b1, 0.0, 1.0);
+ float bodyA = clamp(body * (0.15 + 0.60 * waves + 0.06 * core) * (0.92 + 0.08 * fres), 0.0, 1.0);
+ float fade = mix(1.0, 0.45 + 0.35 * sin(t * 2.0), load);
+ float3 pm = col * bodyA + haloCol * haloA * (1.0 - bodyA);
+ float al = bodyA + haloA * (1.0 - bodyA);
+ float gain = bright * fade;
+ return half4(half3(pm * gain), half(al * gain));
+}
+`;
+
+let effect: SkRuntimeEffect | null | undefined;
+
+/**
+ * The compiled shader, shared by every glow: it compiles once, the first time a glow draws. Null
+ * if Skia cannot compile it, in which case the glow draws nothing.
+ */
+export const getGlowEffect = () => {
+ if (effect === undefined) {
+ effect = Skia.RuntimeEffect.Make(SOURCE);
+ if (!effect && __DEV__) {
+ console.warn(
+ '[stream-chat-react-native/ai-components] VoiceGlow could not compile its shader, so it draws nothing. Please report this with your @shopify/react-native-skia version.',
+ );
+ }
+ }
+ return effect;
+};
diff --git a/package/src/ai-components/voice-visualization/index.ts b/package/src/ai-components/voice-visualization/index.ts
new file mode 100644
index 0000000000..f8d09f5afb
--- /dev/null
+++ b/package/src/ai-components/voice-visualization/index.ts
@@ -0,0 +1,3 @@
+export { VoiceGlow, type VoiceGlowProps } from './VoiceGlow';
+export { VoiceWave, type VoiceWaveProps } from './VoiceWave';
+export type { VoiceAgentState, VoiceVisualizationProps } from './types';
diff --git a/package/src/ai-components/voice-visualization/math.ts b/package/src/ai-components/voice-visualization/math.ts
new file mode 100644
index 0000000000..45b2134cff
--- /dev/null
+++ b/package/src/ai-components/voice-visualization/math.ts
@@ -0,0 +1,73 @@
+import { snoise } from './simplex';
+
+/** The shader math the wave runs on the CPU, once per frame, on the UI thread. */
+
+export const mix = (a: number, b: number, t: number) => {
+ 'worklet';
+ return a + (b - a) * t;
+};
+
+export const fract = (x: number) => {
+ 'worklet';
+ return x - Math.floor(x);
+};
+
+export const clamp = (x: number, low: number, high: number) => {
+ 'worklet';
+ return Math.min(Math.max(x, low), high);
+};
+
+/** GLSL's smoothstep, evaluated the same with its edges either way round. */
+export const smoothstep = (edge0: number, edge1: number, x: number) => {
+ 'worklet';
+ const t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
+ return t * t * (3 - 2 * t);
+};
+
+/** HSV, hue a fraction of a turn, into `out` as RGB. */
+export const hsvToRgb = (hue: number, saturation: number, value: number, out: number[]) => {
+ 'worklet';
+ for (let i = 0; i < 3; i++) {
+ const k = i === 0 ? 1 : i === 1 ? 2 / 3 : 1 / 3;
+ const p = Math.abs(fract(hue + k) * 6 - 3);
+ out[i] = value * mix(1, clamp(p - 1, 0, 1), saturation);
+ }
+};
+
+/** A palette color as is; dark mode lifts saturation a touch so it stays vivid on black. */
+export const vivid = (
+ color: { hue: number; saturation: number; value: number },
+ dark: number,
+ out: number[],
+) => {
+ 'worklet';
+ hsvToRgb(color.hue, clamp(color.saturation + dark * 0.06, 0, 1), color.value, out);
+};
+
+/** Pushes `c` away from its luminance by `amount`; below 1 desaturates. */
+export const saturate = (c: number[], amount: number) => {
+ 'worklet';
+ const l = 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2];
+ for (let i = 0; i < 3; i++) c[i] = clamp(mix(l, c[i], amount), 0, 1);
+};
+
+/**
+ * How much the agent is talking, 0 to 1: a slow phrase gate times a faster syllable wobble. The
+ * shader's own envelope, on the same simplex noise, so the wave and the glow speak alike.
+ */
+export const speechEnv = (t: number) => {
+ 'worklet';
+ const gate = smoothstep(-0.25, 0.35, snoise(t * 0.6, 21, 0));
+ const syllable = 0.55 + 0.45 * snoise(t * 3, 5, 0);
+ return clamp(gate * syllable, 0, 1);
+};
+
+/** `expressivity` from 0 to 2; one that is not a number gets the default, 1. */
+export const expressivityInput = (expressivity: number | undefined) =>
+ typeof expressivity === 'number' && Number.isFinite(expressivity) ? clamp(expressivity, 0, 2) : 1;
+
+/** `level` from 0 to 1; one that is not a number counts as no live audio, so it can't stall the motion. */
+export const audioLevelInput = (level: number | null | undefined) => {
+ 'worklet';
+ return typeof level === 'number' && Number.isFinite(level) ? clamp(level, 0, 1) : null;
+};
diff --git a/package/src/ai-components/voice-visualization/palette.ts b/package/src/ai-components/voice-visualization/palette.ts
new file mode 100644
index 0000000000..b23c5b8dd9
--- /dev/null
+++ b/package/src/ai-components/voice-visualization/palette.ts
@@ -0,0 +1,59 @@
+import { processColor } from 'react-native';
+
+import type { Hsv } from './types';
+
+export const MAXIMUM_COLORS = 5;
+
+export type Rgb = [number, number, number];
+
+/** Used when neither the `colors` prop nor the theme gives a readable color. */
+export const FALLBACK_COLOR = '#005fff';
+
+/**
+ * Reads any color React Native understands (names, `#rgb[a]`, `#rrggbb[aa]`, `rgb()`, `hsl()`, ...)
+ * into sRGB channels from 0 to 1, or null when it cannot be read. Platform colors have no
+ * channels to read and count as unreadable.
+ */
+export const parseColor = (color: string): Rgb | null => {
+ if (typeof color !== 'string' || !color.trim()) return null;
+ const processed = processColor(color.trim());
+ if (typeof processed !== 'number') return null;
+ // 0xAARRGGBB, signed on Android.
+ const argb = processed < 0 ? processed + 2 ** 32 : processed;
+ const channel = (shift: number) => (Math.floor(argb / 2 ** shift) % 256) / 255;
+ return [channel(16), channel(8), channel(0)];
+};
+
+/** RGB channels from 0 to 1 as HSV, keeping full saturation and value so muted colors render true. */
+export const rgbToHsv = ([r, g, b]: Rgb): Hsv => {
+ const high = Math.max(r, g, b);
+ const low = Math.min(r, g, b);
+ const delta = high - low;
+ let hue = 0;
+ if (delta !== 0) {
+ if (high === r) hue = ((((g - b) / delta) % 6) + 6) % 6;
+ else if (high === g) hue = (b - r) / delta + 2;
+ else hue = (r - g) / delta + 4;
+ }
+ return {
+ hue: (((hue / 6) % 1) + 1) % 1,
+ saturation: high === 0 ? 0 : Math.min(Math.max(delta / high, 0), 1),
+ value: Math.min(Math.max(high, 0), 1),
+ };
+};
+
+/** Colors as the HSV the renderers read, at most five; colors that cannot be read are skipped. */
+export const toHsvPalette = (colors: readonly string[]): Hsv[] =>
+ colors
+ .map(parseColor)
+ .filter((rgb): rgb is Rgb => rgb !== null)
+ .slice(0, MAXIMUM_COLORS)
+ .map(rgbToHsv);
+
+/** Relative luminance of a color, or null when it cannot be read. */
+export const luminance = (color: string) => {
+ const rgb = parseColor(color);
+ if (!rgb) return null;
+ const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+};
diff --git a/package/src/ai-components/voice-visualization/simplex.ts b/package/src/ai-components/voice-visualization/simplex.ts
new file mode 100644
index 0000000000..f95594d990
--- /dev/null
+++ b/package/src/ai-components/voice-visualization/simplex.ts
@@ -0,0 +1,92 @@
+/**
+ * 3-D simplex noise, a port of the glow shader's `snoise` (Ashima Arts / Stefan Gustavson), so the
+ * wave drawn on the CPU moves with the same speech rhythm as the shader, as on iOS and the web.
+ *
+ * It runs in float32 like a GPU, with `mod` as a multiply by a float32 reciprocal, because the
+ * hash is sensitive to rounding: in exact arithmetic some inputs hash to a neighbouring gradient.
+ * Every function is a worklet, as the motion runs on the UI thread.
+ */
+
+const INV_289 = Math.fround(1 / 289);
+const ONE_THIRD = Math.fround(1 / 3);
+const ONE_SIXTH = Math.fround(1 / 6);
+const NS_X = Math.fround(2 / 7);
+const NS_Y = Math.fround(Math.fround(0.5 * Math.fround(1 / 7)) - 1);
+const NS_Z = Math.fround(1 / 7);
+
+const mod289 = (x: number) => {
+ 'worklet';
+ const f = Math.fround;
+ return f(x - f(289 * Math.floor(f(x * INV_289))));
+};
+
+const permute = (x: number) => {
+ 'worklet';
+ const f = Math.fround;
+ return mod289(f(f(f(x * 34) + 1) * x));
+};
+
+const taylorInvSqrt = (r: number) => {
+ 'worklet';
+ const f = Math.fround;
+ return f(1.79284291400159 - f(0.85373472095314 * r));
+};
+
+/** A corner's contribution, its gradient from its hash as the shader's x_/y_/h/sh steps compute it. */
+const corner = (p: number, x: number, y: number, z: number) => {
+ 'worklet';
+ const f = Math.fround;
+ const j = f(p - f(49 * Math.floor(f(f(p * NS_Z) * NS_Z))));
+ const xi = Math.floor(f(j * NS_Z));
+ const yi = Math.floor(f(j - f(7 * xi)));
+ const gx0 = f(f(xi * NS_X) + NS_Y);
+ const gy0 = f(f(yi * NS_X) + NS_Y);
+ const gz = f(f(1 - Math.abs(gx0)) - Math.abs(gy0));
+ const sh = gz <= 0 ? -1 : 0;
+ const gx = f(gx0 + f((Math.floor(gx0) * 2 + 1) * sh));
+ const gy = f(gy0 + f((Math.floor(gy0) * 2 + 1) * sh));
+ const norm = taylorInvSqrt(gx * gx + gy * gy + gz * gz);
+ const m = Math.max(0.6 - (x * x + y * y + z * z), 0);
+ return m * m * m * m * (gx * norm * x + gy * norm * y + gz * norm * z);
+};
+
+/** Simplex noise at (x, y, z), from about -1 to 1. */
+export const snoise = (inX: number, inY: number, inZ: number) => {
+ 'worklet';
+ const f = Math.fround;
+ const vx = f(inX);
+ const vy = f(inY);
+ const vz = f(inZ);
+ const s = f(f(vx + vy + vz) * ONE_THIRD);
+ const ix = Math.floor(f(vx + s));
+ const iy = Math.floor(f(vy + s));
+ const iz = Math.floor(f(vz + s));
+ const t = f(f(ix + iy + iz) * ONE_SIXTH);
+ const x0 = f(f(vx - ix) + t);
+ const y0 = f(f(vy - iy) + t);
+ const z0 = f(f(vz - iz) + t);
+
+ const gx = x0 >= y0 ? 1 : 0;
+ const gy = y0 >= z0 ? 1 : 0;
+ const gz = z0 >= x0 ? 1 : 0;
+ const i1x = Math.min(gx, 1 - gz);
+ const i1y = Math.min(gy, 1 - gx);
+ const i1z = Math.min(gz, 1 - gy);
+ const i2x = Math.max(gx, 1 - gz);
+ const i2y = Math.max(gy, 1 - gx);
+ const i2z = Math.max(gz, 1 - gy);
+
+ const mx = mod289(ix);
+ const my = mod289(iy);
+ const mz = mod289(iz);
+ const hash = (dx: number, dy: number, dz: number) =>
+ permute(f(f(permute(f(f(permute(f(mz + dz)) + my) + dy)) + mx) + dx));
+
+ return (
+ 42 *
+ (corner(hash(0, 0, 0), x0, y0, z0) +
+ corner(hash(i1x, i1y, i1z), x0 - i1x + 1 / 6, y0 - i1y + 1 / 6, z0 - i1z + 1 / 6) +
+ corner(hash(i2x, i2y, i2z), x0 - i2x + 1 / 3, y0 - i2y + 1 / 3, z0 - i2z + 1 / 3) +
+ corner(hash(1, 1, 1), x0 - 0.5, y0 - 0.5, z0 - 0.5))
+ );
+};
diff --git a/package/src/ai-components/voice-visualization/styles.ts b/package/src/ai-components/voice-visualization/styles.ts
new file mode 100644
index 0000000000..4a6a01bee5
--- /dev/null
+++ b/package/src/ai-components/voice-visualization/styles.ts
@@ -0,0 +1,42 @@
+import { LEVEL_RESPONSE, type LevelResponse } from './driver';
+
+/**
+ * What tells the glow and the wave apart besides how they draw, as on iOS.
+ *
+ * - `levelResponse`: the wave's height tracks the level directly, so it follows it smoothly;
+ * per-syllable jumps would make it jitter. The glow follows it closely.
+ * - `maxScale`: the densest it renders. The glow is soft all over, so 2x is enough; the wave's
+ * thin line is drawn at the screen's own scale, up to 3x, to stay crisp.
+ * - `contains`: whether a point, centred and normalized to the shorter side with y up, is on the
+ * shape rather than the empty space around it.
+ */
+export type VoiceVisualizationStyle = {
+ contains: (x: number, y: number) => boolean;
+ /** The shape's bounds as a share of the shorter side, for the area that takes touches. */
+ hitArea: { width: number; height: number };
+ levelResponse: LevelResponse;
+ maxScale: number;
+};
+
+/** How far from the centre a tap still lands on the globe, as a share of the shorter side. */
+const GLOBE_REACH = 0.32;
+
+export const GLOW_STYLE: VoiceVisualizationStyle = {
+ contains: (x, y) => {
+ 'worklet';
+ return Math.hypot(x, y) < GLOBE_REACH;
+ },
+ hitArea: { height: 2 * GLOBE_REACH, width: 2 * GLOBE_REACH },
+ levelResponse: LEVEL_RESPONSE.steady,
+ maxScale: 2,
+};
+
+export const WAVE_STYLE: VoiceVisualizationStyle = {
+ contains: (x, y) => {
+ 'worklet';
+ return Math.abs(x) < 0.5 && Math.abs(y) < 0.14;
+ },
+ hitArea: { height: 0.28, width: 1 },
+ levelResponse: LEVEL_RESPONSE.smooth,
+ maxScale: 3,
+};
diff --git a/package/src/ai-components/voice-visualization/types.ts b/package/src/ai-components/voice-visualization/types.ts
new file mode 100644
index 0000000000..d767638289
--- /dev/null
+++ b/package/src/ai-components/voice-visualization/types.ts
@@ -0,0 +1,44 @@
+import type { ViewProps } from 'react-native';
+
+import type { SharedValue } from 'react-native-reanimated';
+
+/**
+ * What a voice agent is doing, which a voice visualization shows as motion.
+ *
+ * The states differ only in how a visualization moves, never in its colors, and changing the
+ * state eases from one motion into the next.
+ *
+ * - `idle`: waiting to be spoken to, a calm, barely-there shimmer.
+ * - `connecting`: not ready yet, a loader sweep with a pulsing breath.
+ * - `listening`: hearing somebody, a gentle expand and contract.
+ * - `thinking`: working on an answer, a slow turn or a travelling wave.
+ * - `speaking`: talking, strong motion that ebbs and pauses like speech.
+ */
+export type VoiceAgentState = 'idle' | 'connecting' | 'listening' | 'thinking' | 'speaking';
+
+export type VoiceVisualizationProps = Omit & {
+ /** What the agent is doing, which sets how the visualization moves. */
+ state: VoiceAgentState;
+ /**
+ * The level of whoever is talking, from 0 to 1, read once a frame on the UI thread. Feed it at
+ * 15 Hz or more for the visualization to move with the words; `null` animates speech on its own.
+ * See `useDictationAudioLevel` for the level of the microphone while dictating.
+ */
+ audioLevel?: SharedValue;
+ /**
+ * One to five colors. Past five are ignored, and colors that cannot be read are skipped. When
+ * omitted or empty, the theme's `voiceVisualization.colors` are used.
+ */
+ colors?: string[];
+ /** From 0 to 2: how far and how fast it moves. Colors never change with it. Defaults to 1. */
+ expressivity?: number;
+ /**
+ * What screen readers read as the value for each state, for example to translate them. Defaults
+ * to "Ready", "Connecting", "Listening", "Thinking" and "Speaking". The label, "Voice agent", is
+ * `accessibilityLabel`.
+ */
+ stateLabels?: Partial>;
+};
+
+/** A color as hue (a fraction of a turn), saturation and value, each from 0 to 1. */
+export type Hsv = { hue: number; saturation: number; value: number };
diff --git a/package/src/ai-components/voice-visualization/useReduceMotion.ts b/package/src/ai-components/voice-visualization/useReduceMotion.ts
new file mode 100644
index 0000000000..ed3188738f
--- /dev/null
+++ b/package/src/ai-components/voice-visualization/useReduceMotion.ts
@@ -0,0 +1,34 @@
+import { useEffect, useState } from 'react';
+import { AccessibilityInfo } from 'react-native';
+
+/**
+ * Whether the system asks for less motion: Reduce Motion on iOS, animations off on Android. It
+ * follows the setting while mounted.
+ *
+ * The AI entry point cannot reach the core SDK's own hook, which also only applies once its
+ * accessibility layer is enabled; the voice visualizations always honour the setting, as on iOS
+ * and Android.
+ */
+export const useReduceMotion = () => {
+ const [reduceMotion, setReduceMotion] = useState(false);
+
+ useEffect(() => {
+ let mounted = true;
+ AccessibilityInfo.isReduceMotionEnabled?.()
+ .then((enabled) => {
+ // It starts off; only turning it on needs a render.
+ if (mounted && enabled) setReduceMotion(true);
+ })
+ .catch(() => undefined);
+ const subscription = AccessibilityInfo.addEventListener?.(
+ 'reduceMotionChanged',
+ setReduceMotion,
+ );
+ return () => {
+ mounted = false;
+ subscription?.remove();
+ };
+ }, []);
+
+ return reduceMotion;
+};
diff --git a/package/src/ai-components/voice-visualization/useVoiceVisualization.ts b/package/src/ai-components/voice-visualization/useVoiceVisualization.ts
new file mode 100644
index 0000000000..2579318396
--- /dev/null
+++ b/package/src/ai-components/voice-visualization/useVoiceVisualization.ts
@@ -0,0 +1,153 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useColorScheme } from 'react-native';
+
+import {
+ type FrameInfo,
+ runOnUI,
+ useFrameCallback,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated';
+
+import {
+ advanceDriver,
+ createDriverState,
+ hoverDriver,
+ tapDriver,
+ type VoiceDriverInput,
+} from './driver';
+import { audioLevelInput, expressivityInput } from './math';
+import { FALLBACK_COLOR, luminance, toHsvPalette } from './palette';
+import type { VoiceVisualizationStyle } from './styles';
+import type { VoiceVisualizationProps } from './types';
+import { useReduceMotion } from './useReduceMotion';
+
+import { useTheme } from '../contexts';
+
+/** How long the visualization takes to fade in once its first frame is drawn, as on iOS. */
+const FADE_IN_MS = 300;
+
+/** Whether to draw for a dark background: the theme's backdrop when readable, else the scheme. */
+const useDark = (backdropColor: string | undefined) => {
+ const scheme = useColorScheme();
+ return useMemo(() => {
+ const backdrop = backdropColor ? luminance(backdropColor) : null;
+ if (backdrop !== null) return backdrop < 0.5 ? 1 : 0;
+ return scheme === 'dark' ? 1 : 0;
+ }, [backdropColor, scheme]);
+};
+
+/**
+ * A voice visualization's motion, advanced once a frame on the UI thread.
+ *
+ * The driver lives in a shared value the renderers derive from, so a frame redraws without
+ * re-rendering React, and `audioLevel` is read in the frame callback, so a level changing many
+ * times a second re-renders nothing either.
+ */
+export const useVoiceVisualization = (
+ { audioLevel, colors, expressivity, state }: VoiceVisualizationProps,
+ style: VoiceVisualizationStyle,
+) => {
+ const { theme } = useTheme();
+ const reduceMotion = useReduceMotion();
+ const themeColors = theme.voiceVisualization?.colors;
+ const dark = useDark(theme.voiceVisualization?.backdropColor);
+
+ const colorsKey = (colors ?? []).join('|');
+ const themeKey = (themeColors ?? []).join('|');
+ const palette = useMemo(() => {
+ const own = toHsvPalette(colors ?? []);
+ if (own.length) return own;
+ const fromTheme = toHsvPalette(themeColors ?? []);
+ return fromTheme.length ? fromTheme : toHsvPalette([FALLBACK_COLOR]);
+ // Keyed by the colors themselves, so a new array of the same colors changes nothing.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [colorsKey, themeKey]);
+
+ const input: VoiceDriverInput = {
+ audioLevel: null,
+ colors: palette,
+ expressivity: expressivityInput(expressivity),
+ reduceMotion,
+ state,
+ };
+
+ // Starts settled on the first input, so the first frame shows it rather than easing into it.
+ const [initialDriver] = useState(() => createDriverState(input, style.levelResponse));
+ const driver = useSharedValue(initialDriver);
+ const inputValue = useSharedValue(input);
+ const darkValue = useSharedValue(dark);
+ const opacity = useSharedValue(0);
+ const fadingIn = useSharedValue(false);
+
+ useEffect(() => {
+ inputValue.value = {
+ audioLevel: null,
+ colors: palette,
+ expressivity: expressivityInput(expressivity),
+ reduceMotion,
+ state,
+ };
+ }, [expressivity, inputValue, palette, reduceMotion, state]);
+
+ useEffect(() => {
+ darkValue.value = dark;
+ }, [dark, darkValue]);
+
+ const onFrame = useCallback(
+ (frame: FrameInfo) => {
+ 'worklet';
+ const seconds = (frame.timeSincePreviousFrame ?? 0) / 1000;
+ const current = inputValue.value;
+ const level = audioLevelInput(audioLevel ? audioLevel.value : null);
+ const next = level === current.audioLevel ? current : { ...current, audioLevel: level };
+ driver.modify((d) => {
+ 'worklet';
+ advanceDriver(d, next, seconds);
+ return d;
+ });
+ // It fades in once it has something to show, rather than flashing empty while the first
+ // frame lands.
+ if (!fadingIn.value) {
+ fadingIn.value = true;
+ opacity.value = withTiming(1, { duration: FADE_IN_MS });
+ }
+ },
+ [audioLevel, driver, fadingIn, inputValue, opacity],
+ );
+ useFrameCallback(onFrame);
+
+ /** A tap at a point centred and normalized to the shorter side, y up. */
+ const tap = useCallback(
+ (x: number, y: number) => {
+ runOnUI((tapX: number, tapY: number) => {
+ 'worklet';
+ driver.modify((d) => {
+ 'worklet';
+ tapDriver(d, inputValue.value, tapX, tapY);
+ return d;
+ });
+ })(x, y);
+ },
+ [driver, inputValue],
+ );
+
+ /** Where a pointer hovers, or null once it has left. */
+ const hover = useCallback(
+ (x: number | null, y: number | null) => {
+ runOnUI((hoverX: number | null, hoverY: number | null) => {
+ 'worklet';
+ driver.modify((d) => {
+ 'worklet';
+ hoverDriver(d, inputValue.value, hoverX, hoverY);
+ return d;
+ });
+ })(x, y);
+ },
+ [driver, inputValue],
+ );
+
+ return { dark: darkValue, driver, hover, opacity, tap };
+};
+
+export type VoiceVisualization = ReturnType;
diff --git a/package/src/ai-components/voice-visualization/waveGeometry.ts b/package/src/ai-components/voice-visualization/waveGeometry.ts
new file mode 100644
index 0000000000..3af2a03c00
--- /dev/null
+++ b/package/src/ai-components/voice-visualization/waveGeometry.ts
@@ -0,0 +1,137 @@
+import { Skia, type SkPath } from '@shopify/react-native-skia';
+
+import { driverReact, driverScale, type VoiceDriverState } from './driver';
+import { clamp, mix, saturate, smoothstep, speechEnv, vivid } from './math';
+
+const POINTS = 160;
+export const WAVE_STOPS = 13;
+export const WAVE_START = -0.42;
+export const WAVE_END = 0.42;
+
+/** The stops' positions along the gradient, evenly spaced. */
+export const WAVE_POSITIONS = Array.from({ length: WAVE_STOPS }, (_, s) => s / (WAVE_STOPS - 1));
+
+const sq = (x: number) => {
+ 'worklet';
+ return x * x;
+};
+
+const bandCenter = (i: number, seg: number, t: number) => {
+ 'worklet';
+ switch (i) {
+ case 0:
+ return 0.5 * seg + 0.12 * Math.sin(t * 0.13);
+ case 1:
+ return 1.5 * seg + 0.13 * Math.sin(t * 0.11 + 2.1);
+ case 2:
+ return 2.5 * seg + 0.12 * Math.sin(t * 0.17 + 4.2);
+ case 3:
+ return 3.5 * seg + 0.12 * Math.sin(t * 0.15 + 1);
+ default:
+ return 4.5 * seg + 0.12 * Math.sin(t * 0.1 + 3.3);
+ }
+};
+
+/** The string's height above its centre line at each point, centred and normalized, y up. */
+export const waveHeights = (d: VoiceDriverState) => {
+ 'worklet';
+ const t = d.time;
+ const react = driverReact(d);
+ const center = d.load * 0.3 * Math.sin(t * 1.3);
+ const freq = 24 + 26 * react + 10 * d.flow;
+ const speak = smoothstep(0.6, 1, react);
+ const drive = mix(speak * 0.26 * speechEnv(t), 0.3 * d.mic, d.voice);
+ const reach = 0.018 + 0.022 * react + drive + 0.14 * d.flow + 0.1 * d.load;
+ const tapAlive = d.tapAge < 1.5;
+ const hovered = d.hoverAmount > 0.001;
+ const xs: number[] = [];
+ const ys: number[] = [];
+ for (let i = 0; i <= POINTS; i++) {
+ const x = WAVE_START + ((WAVE_END - WAVE_START) * i) / POINTS;
+ let y = Math.exp(-sq((x - center) / 0.18)) * reach * Math.sin(x * freq - t * 5);
+ if (tapAlive) {
+ y +=
+ Math.exp(-sq((x - d.tapX) / 0.12)) *
+ Math.sin((x - d.tapX) * 40 - d.tapAge * 24) *
+ Math.exp(-d.tapAge * 2.6) *
+ 0.05;
+ }
+ if (hovered) {
+ y +=
+ Math.exp(-sq((x - d.hoverX) / 0.12)) *
+ Math.sin((x - d.hoverX) * 40 - t * 7) *
+ d.hoverAmount *
+ 0.04;
+ }
+ xs.push(x);
+ ys.push(y);
+ }
+ return { xs, ys };
+};
+
+type LegacyPath = SkPath & {
+ lineTo: (x: number, y: number) => void;
+ moveTo: (x: number, y: number) => void;
+};
+
+/**
+ * The wave as a path in a `width` by `height` canvas. Newer Skia versions build immutable paths with
+ * `PathBuilder`; earlier 2.x versions mutate `SkPath` itself.
+ */
+export const wavePath = (d: VoiceDriverState, width: number, height: number): SkPath => {
+ 'worklet';
+ const m = Math.min(width, height);
+ const scale = driverScale(d);
+ const { xs, ys } = waveHeights(d);
+ const px = (i: number) => width / 2 + xs[i] * m * scale;
+ const py = (i: number) => height / 2 - ys[i] * m * scale;
+ if (Skia.PathBuilder) {
+ const builder = Skia.PathBuilder.Make();
+ builder.moveTo(px(0), py(0));
+ for (let i = 1; i < xs.length; i++) builder.lineTo(px(i), py(i));
+ return builder.build();
+ }
+ const path = Skia.Path.Make() as LegacyPath;
+ path.moveTo(px(0), py(0));
+ for (let i = 1; i < xs.length; i++) path.lineTo(px(i), py(i));
+ return path;
+};
+
+/**
+ * The string's colors at its gradient stops, as RGBA from 0 to 1: up to five bands drifting along
+ * it, weighed by how near their centres are, its ends fading out.
+ */
+export const waveColors = (d: VoiceDriverState, dark: number) => {
+ 'worklet';
+ const band: number[][] = [];
+ for (let i = 0; i < 5; i++) {
+ const rgb = [0, 0, 0];
+ vivid(d.colors[i], dark, rgb);
+ band.push(rgb);
+ }
+ const seg = 1 / Math.max(d.count, 1);
+ const bandWidth = seg * 0.7;
+ const stops: Float32Array[] = [];
+ for (let s = 0; s < WAVE_STOPS; s++) {
+ const x = WAVE_START + ((WAVE_END - WAVE_START) * s) / (WAVE_STOPS - 1);
+ const gx = clamp(x + 0.5, 0, 1);
+ const color = [0, 0, 0];
+ let total = 1e-4;
+ for (let i = 0; i < 5; i++) {
+ const present = i === 0 ? 1 : clamp(d.count - i, 0, 1);
+ const weight = Math.exp(-sq((gx - bandCenter(i, seg, d.time)) / bandWidth)) * present;
+ for (let j = 0; j < 3; j++) color[j] += band[i][j] * weight;
+ total += weight;
+ }
+ for (let j = 0; j < 3; j++) color[j] /= total;
+ saturate(color, 1.2);
+ saturate(color, d.sat);
+ const alpha = clamp(smoothstep(0.4, 0.24, Math.abs(x)) * d.bright, 0, 1);
+ stops.push(new Float32Array([color[0], color[1], color[2], alpha]));
+ }
+ return stops;
+};
+
+/** The line is 0.6% of the shorter side, and never under 1.5 device pixels, as on Android and iOS. */
+export const waveStrokeWidth = (width: number, height: number, pixelRatio: number) =>
+ Math.max(0.006 * Math.min(width, height), 1.5 / pixelRatio);
diff --git a/package/src/components/ChannelPreview/ChannelLastMessagePreview.tsx b/package/src/components/ChannelPreview/ChannelLastMessagePreview.tsx
index 30d323c839..e005c42649 100644
--- a/package/src/components/ChannelPreview/ChannelLastMessagePreview.tsx
+++ b/package/src/components/ChannelPreview/ChannelLastMessagePreview.tsx
@@ -29,7 +29,7 @@ export const ChannelLastMessagePreview = ({ message }: ChannelLastMessagePreview
width={16}
/>
) : null}
-
+
{messagePreviewTitle}
diff --git a/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx
index 96ea6dc072..0a08077eda 100644
--- a/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx
+++ b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx
@@ -81,7 +81,11 @@ export const ChannelMessagePreviewDeliveryStatus = ({
}
if (!isLastMessageByCurrentUser) {
- return {message?.user?.name || message?.user?.id}:;
+ return (
+
+ {message?.user?.name || message?.user?.id}:
+
+ );
}
return (
@@ -107,7 +111,9 @@ export const ChannelMessagePreviewDeliveryStatus = ({
) : status === MessageDeliveryStatus.SENT ? (
) : null}
- {t('You')}:
+
+ {t('You')}:
+
);
diff --git a/package/src/components/ChannelPreview/ChannelPreviewTitle.tsx b/package/src/components/ChannelPreview/ChannelPreviewTitle.tsx
index e91d33b41f..28e1b7982c 100644
--- a/package/src/components/ChannelPreview/ChannelPreviewTitle.tsx
+++ b/package/src/components/ChannelPreview/ChannelPreviewTitle.tsx
@@ -21,7 +21,7 @@ export const ChannelPreviewTitle = ({ channel, title }: ChannelPreviewTitleProps
const displayName = useChannelPreviewDisplayName(channel);
return (
-
+
{title ?? displayName}
);
diff --git a/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewPollLabel.test.tsx b/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewPollLabel.test.tsx
index 51980c760a..1b8f6e72db 100644
--- a/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewPollLabel.test.tsx
+++ b/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewPollLabel.test.tsx
@@ -80,7 +80,7 @@ describe('useChannelPreviewPollLabel', () => {
poll.removeVote(voteA.id, MESSAGE_ID);
});
- expect(poll.data.latest_votes_by_option['option-a']).toEqual([]);
+ expect(poll.data.latest_votes_by_option['option-a']).toBeUndefined();
expect(result.current).toBe('You voted: {{ option }}');
});
});
diff --git a/package/src/components/Message/MessageItemView/MessageStatus.tsx b/package/src/components/Message/MessageItemView/MessageStatus.tsx
index db86a63105..b9bb309ea5 100644
--- a/package/src/components/Message/MessageItemView/MessageStatus.tsx
+++ b/package/src/components/Message/MessageItemView/MessageStatus.tsx
@@ -73,6 +73,7 @@ const MessageStatusWithContext = (props: MessageStatusPropsWithContext) => {
@@ -80,13 +81,26 @@ const MessageStatusWithContext = (props: MessageStatusPropsWithContext) => {
) : sending ? (
-
+
) : sent ? (
-
+
) : null}
>
diff --git a/package/src/components/Message/MessageItemView/__tests__/MessageStatus.test.tsx b/package/src/components/Message/MessageItemView/__tests__/MessageStatus.test.tsx
index acf705c17f..7985e04baf 100644
--- a/package/src/components/Message/MessageItemView/__tests__/MessageStatus.test.tsx
+++ b/package/src/components/Message/MessageItemView/__tests__/MessageStatus.test.tsx
@@ -125,4 +125,25 @@ describe('MessageStatus', () => {
});
},
);
+
+ it.each([
+ [1, 1, 'sending', 'message-status-sending'],
+ [2, 2, 'received', 'message-status-read'],
+ [1, 1, 'received', 'message-status-sent'],
+ [2, 1, 'received', 'message-status-delivered'],
+ ] as [number, number, string, string][])(
+ 'should expose a status testID when deliveredToCount is %s, readBy is %s and status is %s',
+ async (deliveredToCount, readBy, status, testID) => {
+ const message = generateMessage({ user: generateUser() });
+ const { getByTestId } = renderMessageStatus({
+ deliveredToCount,
+ message: { ...message, status },
+ readBy,
+ });
+ // The icon row is hidden from screen readers in favour of the hidden text label.
+ await waitFor(() => {
+ expect(getByTestId(testID, { includeHiddenElements: true })).toBeTruthy();
+ });
+ },
+ );
});
diff --git a/package/src/components/MessageInput/MessageComposer.tsx b/package/src/components/MessageInput/MessageComposer.tsx
index d0c9bba4e8..8fd021221a 100644
--- a/package/src/components/MessageInput/MessageComposer.tsx
+++ b/package/src/components/MessageInput/MessageComposer.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo } from 'react';
-import { StyleSheet, View } from 'react-native';
+import { Platform, StyleSheet, View } from 'react-native';
import Animated, {
Extrapolation,
@@ -316,7 +316,9 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => {
}));
const { bottom } = useSafeAreaInsets();
- const BOTTOM_OFFSET = isKeyboardVisible || selectedPicker ? 16 : bottom ? bottom : 16;
+ const safeAreaBottomOffset = Platform.OS === 'android' ? bottom + 16 : bottom;
+ const BOTTOM_OFFSET =
+ isKeyboardVisible || selectedPicker ? 16 : bottom ? safeAreaBottomOffset : 16;
const micPositionContextValue = useMemo(
() => ({ micPositionX, micPositionY }),
diff --git a/yarn.lock b/yarn.lock
index 89c2493a2f..44741c2181 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1610,6 +1610,20 @@ __metadata:
languageName: node
linkType: hard
+"@colors/colors@npm:1.6.0":
+ version: 1.6.0
+ resolution: "@colors/colors@npm:1.6.0"
+ checksum: 10c0/9328a0778a5b0db243af54455b79a69e3fb21122d6c15ef9e9fcc94881d8d17352d8b2b2590f9bdd46fac5c2d6c1636dcfc14358a20c70e22daf89e1a759b629
+ languageName: node
+ linkType: hard
+
+"@colors/colors@npm:^1.6.0":
+ version: 1.6.1
+ resolution: "@colors/colors@npm:1.6.1"
+ checksum: 10c0/8fb995f2421210284305fd07b4b70e9873a417cda8b0d66ba3f7beccd2e0f4ebbe0b2ba3afbef8f07740ad707c379d94e836cffd6a8df394387e210b4338b4c9
+ languageName: node
+ linkType: hard
+
"@commitlint/cli@npm:^21.0.0":
version: 21.0.0
resolution: "@commitlint/cli@npm:21.0.0"
@@ -1839,6 +1853,17 @@ __metadata:
languageName: node
linkType: hard
+"@dabh/diagnostics@npm:^2.0.8":
+ version: 2.0.9
+ resolution: "@dabh/diagnostics@npm:2.0.9"
+ dependencies:
+ "@so-ric/colorspace": "npm:^1.1.6"
+ enabled: "npm:2.0.x"
+ kuler: "npm:^2.0.0"
+ checksum: 10c0/3d341a52fbf2277883c4aa1153ce092932d9ecad79a54749553206b83e568cd563f66c6e1cc1416a403bd5662335193a19e8bcc1082f047f28b6402eeb16c01d
+ languageName: node
+ linkType: hard
+
"@emnapi/core@npm:^1.4.3":
version: 1.10.0
resolution: "@emnapi/core@npm:1.10.0"
@@ -3060,6 +3085,13 @@ __metadata:
languageName: node
linkType: hard
+"@flatten-js/interval-tree@npm:^1.1.2":
+ version: 1.1.4
+ resolution: "@flatten-js/interval-tree@npm:1.1.4"
+ checksum: 10c0/e4055e99b1b5958e0a1adea7413d9602f3d7ebed0dd47fcac1ad995a97151e02187fe1aa778c14be56c7faa3a29b2a71ece84be43312eff6850ebf316b3490b3
+ languageName: node
+ linkType: hard
+
"@gorhom/bottom-sheet@npm:^5.2.14":
version: 5.2.14
resolution: "@gorhom/bottom-sheet@npm:5.2.14"
@@ -3544,6 +3576,13 @@ __metadata:
languageName: node
linkType: hard
+"@jest/diff-sequences@npm:30.5.0":
+ version: 30.5.0
+ resolution: "@jest/diff-sequences@npm:30.5.0"
+ checksum: 10c0/a8d4fef9b266cb025b2f1c1be9ae2e807077def93f36e34a628aff383910fa7a26ba0921a43744841231ead54ea87469fb2acf89de47c12ad4fead802e0ccfc3
+ languageName: node
+ linkType: hard
+
"@jest/environment@npm:30.4.1":
version: 30.4.1
resolution: "@jest/environment@npm:30.4.1"
@@ -3556,6 +3595,18 @@ __metadata:
languageName: node
linkType: hard
+"@jest/environment@npm:30.5.2":
+ version: 30.5.2
+ resolution: "@jest/environment@npm:30.5.2"
+ dependencies:
+ "@jest/fake-timers": "npm:30.5.2"
+ "@jest/types": "npm:30.5.1"
+ "@types/node": "npm:*"
+ jest-mock: "npm:30.5.2"
+ checksum: 10c0/f060f2d94dfccd7de4890a419e803735921d8358a3ecc8e424e5f6a632da80fc37372f6e54f1397876b721cec5bae2da515e6dce2ac6558bd90f0c1cbeb5c105
+ languageName: node
+ linkType: hard
+
"@jest/environment@npm:^29.7.0":
version: 29.7.0
resolution: "@jest/environment@npm:29.7.0"
@@ -3577,6 +3628,15 @@ __metadata:
languageName: node
linkType: hard
+"@jest/expect-utils@npm:30.5.2":
+ version: 30.5.2
+ resolution: "@jest/expect-utils@npm:30.5.2"
+ dependencies:
+ "@jest/get-type": "npm:30.5.0"
+ checksum: 10c0/0db00595907f6705c5acc8d10ee9d11d74960cd459beaae66bd7a1d6845c7df1d123295e957bb56b6882b380f2e7d55d277ced7646e0229fc4a67abeb713c048
+ languageName: node
+ linkType: hard
+
"@jest/expect@npm:30.4.1":
version: 30.4.1
resolution: "@jest/expect@npm:30.4.1"
@@ -3587,6 +3647,16 @@ __metadata:
languageName: node
linkType: hard
+"@jest/expect@npm:30.5.2":
+ version: 30.5.2
+ resolution: "@jest/expect@npm:30.5.2"
+ dependencies:
+ expect: "npm:30.5.2"
+ jest-snapshot: "npm:30.5.2"
+ checksum: 10c0/ceebb291be34707e3d2ac99bc5e4a5039927e01d28650b1dc51888dfa3963a3a75a3c72f0abe4df9aa243dc3b31af72e467072d75b52bd11b80a84ebe765d8f9
+ languageName: node
+ linkType: hard
+
"@jest/fake-timers@npm:30.4.1":
version: 30.4.1
resolution: "@jest/fake-timers@npm:30.4.1"
@@ -3601,6 +3671,20 @@ __metadata:
languageName: node
linkType: hard
+"@jest/fake-timers@npm:30.5.2":
+ version: 30.5.2
+ resolution: "@jest/fake-timers@npm:30.5.2"
+ dependencies:
+ "@jest/types": "npm:30.5.1"
+ "@sinonjs/fake-timers": "npm:^15.4.0"
+ "@types/node": "npm:*"
+ jest-message-util: "npm:30.5.1"
+ jest-mock: "npm:30.5.2"
+ jest-util: "npm:30.5.1"
+ checksum: 10c0/c8ff08f541743210abaf07b75bfe6224458c816a500d5be954690fd1661cd14b054201dfe2a6ebdc0083290d4c03d73bbac4f3c1fbef5c3b16d1981642dfff57
+ languageName: node
+ linkType: hard
+
"@jest/fake-timers@npm:^29.7.0":
version: 29.7.0
resolution: "@jest/fake-timers@npm:29.7.0"
@@ -3622,6 +3706,13 @@ __metadata:
languageName: node
linkType: hard
+"@jest/get-type@npm:30.5.0":
+ version: 30.5.0
+ resolution: "@jest/get-type@npm:30.5.0"
+ checksum: 10c0/820a2af9902522d169ae4ede17a99249baad59ff6aead02f8100acbc937b082090b04d5f32ec0d9938491cf89c9fc99930ed469202700c110c0239c7f7ac67a2
+ languageName: node
+ linkType: hard
+
"@jest/globals@npm:30.4.1":
version: 30.4.1
resolution: "@jest/globals@npm:30.4.1"
@@ -3634,6 +3725,18 @@ __metadata:
languageName: node
linkType: hard
+"@jest/globals@npm:^30.4.1":
+ version: 30.5.2
+ resolution: "@jest/globals@npm:30.5.2"
+ dependencies:
+ "@jest/environment": "npm:30.5.2"
+ "@jest/expect": "npm:30.5.2"
+ "@jest/types": "npm:30.5.1"
+ jest-mock: "npm:30.5.2"
+ checksum: 10c0/e60738080d4866350b2152d98ddf99728cc1ac19983b80bea5d5ab219f82ef271fadd102b0cee2c01808367d6e054bea1c4ec4aa7c69e45b4e14b79a30f76538
+ languageName: node
+ linkType: hard
+
"@jest/pattern@npm:30.0.0":
version: 30.0.0
resolution: "@jest/pattern@npm:30.0.0"
@@ -3654,6 +3757,30 @@ __metadata:
languageName: node
linkType: hard
+"@jest/pattern@npm:30.5.0":
+ version: 30.5.0
+ resolution: "@jest/pattern@npm:30.5.0"
+ dependencies:
+ "@types/node": "npm:*"
+ jest-regex-util: "npm:30.5.0"
+ checksum: 10c0/3e960b25c7a32fee97a2cdf9ea810ada41f676c84db3c1afab138ee089e0e1cf4849372dd576acb18ac5219309695991c38e23ddacd9b95802ee3f76b4caaea4
+ languageName: node
+ linkType: hard
+
+"@jest/react-is-18@npm:react-is@^18.3.1, react-is-18@npm:react-is@^18.3.1, react-is@npm:^18.0.0":
+ version: 18.3.1
+ resolution: "react-is@npm:18.3.1"
+ checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072
+ languageName: node
+ linkType: hard
+
+"@jest/react-is-19@npm:react-is@^19.2.5":
+ version: 19.3.1
+ resolution: "react-is@npm:19.3.1"
+ checksum: 10c0/428e02d986d56f1859c57756084489f6d2900fd686905003c405b32f1168738b78772bfa4bb6c706f5ac821199171522c28bf6434281f524f901459390a19606
+ languageName: node
+ linkType: hard
+
"@jest/reporters@npm:30.4.1":
version: 30.4.1
resolution: "@jest/reporters@npm:30.4.1"
@@ -3708,6 +3835,15 @@ __metadata:
languageName: node
linkType: hard
+"@jest/schemas@npm:30.5.0":
+ version: 30.5.0
+ resolution: "@jest/schemas@npm:30.5.0"
+ dependencies:
+ "@sinclair/typebox": "npm:^0.34.0"
+ checksum: 10c0/632c45a06a0984051c908fa9b3c9445bac7be8b7a6d1cc7ce2ecae7d4e44c612509ad41fca16449607c4ea49a333e0d3f10bb1510bafb4b6d9051ca15d9b4b14
+ languageName: node
+ linkType: hard
+
"@jest/schemas@npm:^29.6.3":
version: 29.6.3
resolution: "@jest/schemas@npm:29.6.3"
@@ -3729,6 +3865,18 @@ __metadata:
languageName: node
linkType: hard
+"@jest/snapshot-utils@npm:30.5.1":
+ version: 30.5.1
+ resolution: "@jest/snapshot-utils@npm:30.5.1"
+ dependencies:
+ "@jest/types": "npm:30.5.1"
+ chalk: "npm:^4.1.2"
+ graceful-fs: "npm:^4.2.11"
+ natural-compare: "npm:^1.4.0"
+ checksum: 10c0/0e31aa45c4be7b6e7dabcccc7316458f53bda262ab42f23b749430110d0ac908d4905db993708723a4f6457bc9208c636fd7b886e3ffc26974d55126cf597d6e
+ languageName: node
+ linkType: hard
+
"@jest/source-map@npm:30.0.1":
version: 30.0.1
resolution: "@jest/source-map@npm:30.0.1"
@@ -3809,6 +3957,28 @@ __metadata:
languageName: node
linkType: hard
+"@jest/transform@npm:30.5.2":
+ version: 30.5.2
+ resolution: "@jest/transform@npm:30.5.2"
+ dependencies:
+ "@babel/core": "npm:^7.27.4"
+ "@jest/types": "npm:30.5.1"
+ "@jridgewell/trace-mapping": "npm:^0.3.31"
+ babel-plugin-istanbul: "npm:^8.0.0"
+ chalk: "npm:^4.1.2"
+ convert-source-map: "npm:^2.0.0"
+ fast-json-stable-stringify: "npm:^2.1.0"
+ graceful-fs: "npm:^4.2.11"
+ jest-haste-map: "npm:30.5.1"
+ jest-regex-util: "npm:30.5.0"
+ jest-util: "npm:30.5.1"
+ pirates: "npm:^4.0.7"
+ slash: "npm:^3.0.0"
+ write-file-atomic: "npm:^5.0.1"
+ checksum: 10c0/be3f9aa812a442402ed41a37d0f11d174a0a9037038dc1326c32d1d55e22fc7bc5f5146284c5a18db3da572afb81739f0a93d3f1d519e811d25dacaeb380c647
+ languageName: node
+ linkType: hard
+
"@jest/transform@npm:^29.7.0":
version: 29.7.0
resolution: "@jest/transform@npm:29.7.0"
@@ -3862,6 +4032,21 @@ __metadata:
languageName: node
linkType: hard
+"@jest/types@npm:30.5.1":
+ version: 30.5.1
+ resolution: "@jest/types@npm:30.5.1"
+ dependencies:
+ "@jest/pattern": "npm:30.5.0"
+ "@jest/schemas": "npm:30.5.0"
+ "@types/istanbul-lib-coverage": "npm:^2.0.6"
+ "@types/istanbul-reports": "npm:^3.0.4"
+ "@types/node": "npm:*"
+ "@types/yargs": "npm:^17.0.33"
+ chalk: "npm:^4.1.2"
+ checksum: 10c0/da9e46449371ddd43b89627c93fb7088fdad01e9f57dce376c65914a6f4617a558f54ec51a917ca10c9b9794a1e76c3cb450308eca303f830bb21e9529b1719a
+ languageName: node
+ linkType: hard
+
"@jest/types@npm:^29.6.3":
version: 29.6.3
resolution: "@jest/types@npm:29.6.3"
@@ -4399,6 +4584,140 @@ __metadata:
languageName: node
linkType: hard
+"@parcel/watcher-android-arm64@npm:2.6.0":
+ version: 2.6.0
+ resolution: "@parcel/watcher-android-arm64@npm:2.6.0"
+ conditions: os=android & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-darwin-arm64@npm:2.6.0":
+ version: 2.6.0
+ resolution: "@parcel/watcher-darwin-arm64@npm:2.6.0"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-darwin-x64@npm:2.6.0":
+ version: 2.6.0
+ resolution: "@parcel/watcher-darwin-x64@npm:2.6.0"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-freebsd-x64@npm:2.6.0":
+ version: 2.6.0
+ resolution: "@parcel/watcher-freebsd-x64@npm:2.6.0"
+ conditions: os=freebsd & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-linux-arm-glibc@npm:2.6.0":
+ version: 2.6.0
+ resolution: "@parcel/watcher-linux-arm-glibc@npm:2.6.0"
+ conditions: os=linux & cpu=arm & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-linux-arm-musl@npm:2.6.0":
+ version: 2.6.0
+ resolution: "@parcel/watcher-linux-arm-musl@npm:2.6.0"
+ conditions: os=linux & cpu=arm & libc=musl
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-linux-arm64-glibc@npm:2.6.0":
+ version: 2.6.0
+ resolution: "@parcel/watcher-linux-arm64-glibc@npm:2.6.0"
+ conditions: os=linux & cpu=arm64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-linux-arm64-musl@npm:2.6.0":
+ version: 2.6.0
+ resolution: "@parcel/watcher-linux-arm64-musl@npm:2.6.0"
+ conditions: os=linux & cpu=arm64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-linux-x64-glibc@npm:2.6.0":
+ version: 2.6.0
+ resolution: "@parcel/watcher-linux-x64-glibc@npm:2.6.0"
+ conditions: os=linux & cpu=x64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-linux-x64-musl@npm:2.6.0":
+ version: 2.6.0
+ resolution: "@parcel/watcher-linux-x64-musl@npm:2.6.0"
+ conditions: os=linux & cpu=x64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-win32-arm64@npm:2.6.0":
+ version: 2.6.0
+ resolution: "@parcel/watcher-win32-arm64@npm:2.6.0"
+ conditions: os=win32 & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-win32-x64@npm:2.6.0":
+ version: 2.6.0
+ resolution: "@parcel/watcher-win32-x64@npm:2.6.0"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher@npm:^2.6.0":
+ version: 2.6.0
+ resolution: "@parcel/watcher@npm:2.6.0"
+ dependencies:
+ "@parcel/watcher-android-arm64": "npm:2.6.0"
+ "@parcel/watcher-darwin-arm64": "npm:2.6.0"
+ "@parcel/watcher-darwin-x64": "npm:2.6.0"
+ "@parcel/watcher-freebsd-x64": "npm:2.6.0"
+ "@parcel/watcher-linux-arm-glibc": "npm:2.6.0"
+ "@parcel/watcher-linux-arm-musl": "npm:2.6.0"
+ "@parcel/watcher-linux-arm64-glibc": "npm:2.6.0"
+ "@parcel/watcher-linux-arm64-musl": "npm:2.6.0"
+ "@parcel/watcher-linux-x64-glibc": "npm:2.6.0"
+ "@parcel/watcher-linux-x64-musl": "npm:2.6.0"
+ "@parcel/watcher-win32-arm64": "npm:2.6.0"
+ "@parcel/watcher-win32-x64": "npm:2.6.0"
+ detect-libc: "npm:^2.0.3"
+ is-glob: "npm:^4.0.3"
+ node-addon-api: "npm:^7.0.0"
+ node-gyp: "npm:latest"
+ picomatch: "npm:^4.0.4"
+ dependenciesMeta:
+ "@parcel/watcher-android-arm64":
+ optional: true
+ "@parcel/watcher-darwin-arm64":
+ optional: true
+ "@parcel/watcher-darwin-x64":
+ optional: true
+ "@parcel/watcher-freebsd-x64":
+ optional: true
+ "@parcel/watcher-linux-arm-glibc":
+ optional: true
+ "@parcel/watcher-linux-arm-musl":
+ optional: true
+ "@parcel/watcher-linux-arm64-glibc":
+ optional: true
+ "@parcel/watcher-linux-arm64-musl":
+ optional: true
+ "@parcel/watcher-linux-x64-glibc":
+ optional: true
+ "@parcel/watcher-linux-x64-musl":
+ optional: true
+ "@parcel/watcher-win32-arm64":
+ optional: true
+ "@parcel/watcher-win32-x64":
+ optional: true
+ checksum: 10c0/0d6799bdf7d59a1ab5e4a79859035f387a66078586d9543eef0b9bd01c9d61cebe3b64eb96f0bd1f8bf50f07ed7266a1628604b66dc63025a3cee19be2e60d65
+ languageName: node
+ linkType: hard
+
"@pkgjs/parseargs@npm:^0.11.0":
version: 0.11.0
resolution: "@pkgjs/parseargs@npm:0.11.0"
@@ -6056,6 +6375,16 @@ __metadata:
languageName: node
linkType: hard
+"@so-ric/colorspace@npm:^1.1.6":
+ version: 1.1.6
+ resolution: "@so-ric/colorspace@npm:1.1.6"
+ dependencies:
+ color: "npm:^5.0.2"
+ text-hex: "npm:1.0.x"
+ checksum: 10c0/f3ad26afefbb8d6101ea7c385cd5f402d4291c2ffc9cabe37030d5fdb8bda980ee534a0d7c250f8233fc3a59b99272410177cd98b219f6b3770f91a0fdb6eb3e
+ languageName: node
+ linkType: hard
+
"@stream-io/state-store@npm:^1.1.6":
version: 1.1.6
resolution: "@stream-io/state-store@npm:1.1.6"
@@ -6569,6 +6898,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/triple-beam@npm:^1.3.2":
+ version: 1.3.5
+ resolution: "@types/triple-beam@npm:1.3.5"
+ checksum: 10c0/d5d7f25da612f6d79266f4f1bb9c1ef8f1684e9f60abab251e1261170631062b656ba26ff22631f2760caeafd372abc41e64867cde27fba54fafb73a35b9056a
+ languageName: node
+ linkType: hard
+
"@types/ungap__structured-clone@npm:^1.2.0":
version: 1.2.0
resolution: "@types/ungap__structured-clone@npm:1.2.0"
@@ -6920,6 +7256,33 @@ __metadata:
languageName: node
linkType: hard
+"@wix-pilot/core@npm:^3.4.2":
+ version: 3.4.2
+ resolution: "@wix-pilot/core@npm:3.4.2"
+ dependencies:
+ chalk: "npm:^4.1.0"
+ pngjs: "npm:^7.0.0"
+ winston: "npm:^3.17.0"
+ peerDependencies:
+ expect: "*"
+ peerDependenciesMeta:
+ expect:
+ optional: true
+ checksum: 10c0/606dcd0951cfdfb6bda69718512e6daedda22b15dcc4b2029739e3d02b2d676b30d5d97a6d6558d12a8cfae332b6beb48e32ac620eae4ca5eefa43543dd9e14a
+ languageName: node
+ linkType: hard
+
+"@wix-pilot/detox@npm:^1.0.13":
+ version: 1.0.13
+ resolution: "@wix-pilot/detox@npm:1.0.13"
+ peerDependencies:
+ "@wix-pilot/core": ^3.4.1
+ detox: ">=20.33.0"
+ expect: 29.x.x || 28.x.x || ^27.2.5
+ checksum: 10c0/210b1834ce628cfacdc7417ed8596455045bd6c87bc4ef01b99558ddeeab00648d8ed73abdd212f7cb3d96110b0d36bf8812fd31e1d61a4d3b8f7cad41ab69bf
+ languageName: node
+ linkType: hard
+
"@xmldom/xmldom@npm:^0.8.8":
version: 0.8.13
resolution: "@xmldom/xmldom@npm:0.8.13"
@@ -6981,7 +7344,7 @@ __metadata:
react-native-teleport: "npm:^1.2.2"
react-native-web: "npm:~0.21.4"
react-native-worklets: "npm:0.13.0"
- stream-chat: "npm:^9.54.1"
+ stream-chat: "npm:^9.54.2"
stream-chat-expo: "workspace:^"
stream-chat-react-native-core: "workspace:^"
typescript: "npm:6.0.3"
@@ -7090,7 +7453,7 @@ __metadata:
languageName: node
linkType: hard
-"ajv@npm:^8.11.0":
+"ajv@npm:^8.11.0, ajv@npm:^8.6.3":
version: 8.20.0
resolution: "ajv@npm:8.20.0"
dependencies:
@@ -7442,6 +7805,13 @@ __metadata:
languageName: node
linkType: hard
+"async@npm:^3.2.3":
+ version: 3.2.6
+ resolution: "async@npm:3.2.6"
+ checksum: 10c0/36484bb15ceddf07078688d95e27076379cc2f87b10c03b6dd8a83e89475a3c8df5848859dd06a4c95af1e4c16fc973de0171a77f18ea00be899aca2a4f85e70
+ languageName: node
+ linkType: hard
+
"asynckit@npm:^0.4.0":
version: 0.4.0
resolution: "asynckit@npm:0.4.0"
@@ -7565,6 +7935,19 @@ __metadata:
languageName: node
linkType: hard
+"babel-plugin-istanbul@npm:^8.0.0":
+ version: 8.0.2
+ resolution: "babel-plugin-istanbul@npm:8.0.2"
+ dependencies:
+ "@babel/helper-plugin-utils": "npm:^7.0.0"
+ "@istanbuljs/load-nyc-config": "npm:^1.0.0"
+ "@istanbuljs/schema": "npm:^0.1.3"
+ istanbul-lib-instrument: "npm:^6.0.2"
+ test-exclude: "npm:^7.0.1"
+ checksum: 10c0/795577336196114ea19d2cd9efae41cde66b08bc8b7a326ed35bfe7da85b5756ebe19d9fcf3c98d3bd0e194d1258229178414a79fe3a623a66a449e468cf7af5
+ languageName: node
+ linkType: hard
+
"babel-plugin-jest-hoist@npm:30.0.0":
version: 30.0.0
resolution: "babel-plugin-jest-hoist@npm:30.0.0"
@@ -7926,6 +8309,13 @@ __metadata:
languageName: node
linkType: hard
+"bluebird@npm:^3.5.4":
+ version: 3.7.2
+ resolution: "bluebird@npm:3.7.2"
+ checksum: 10c0/680de03adc54ff925eaa6c7bb9a47a0690e8b5de60f4792604aae8ed618c65e6b63a7893b57ca924beaf53eee69c5af4f8314148c08124c550fe1df1add897d2
+ languageName: node
+ linkType: hard
+
"body-parser@npm:^2.2.2":
version: 2.3.0
resolution: "body-parser@npm:2.3.0"
@@ -8021,6 +8411,13 @@ __metadata:
languageName: node
linkType: hard
+"browser-process-hrtime@npm:^1.0.0":
+ version: 1.0.0
+ resolution: "browser-process-hrtime@npm:1.0.0"
+ checksum: 10c0/65da78e51e9d7fa5909147f269c54c65ae2e03d1cf797cc3cfbbe49f475578b8160ce4a76c36c1a2ffbff26c74f937d73096c508057491ddf1a6dfd11143f72d
+ languageName: node
+ linkType: hard
+
"browserslist@npm:^4.20.4, browserslist@npm:^4.24.0, browserslist@npm:^4.25.0, browserslist@npm:^4.28.1":
version: 4.28.2
resolution: "browserslist@npm:4.28.2"
@@ -8069,6 +8466,87 @@ __metadata:
languageName: node
linkType: hard
+"bunyamin@npm:^1.5.2":
+ version: 1.6.3
+ resolution: "bunyamin@npm:1.6.3"
+ dependencies:
+ "@flatten-js/interval-tree": "npm:^1.1.2"
+ multi-sort-stream: "npm:^1.0.4"
+ stream-json: "npm:^1.7.5"
+ trace-event-lib: "npm:^1.3.1"
+ peerDependencies:
+ "@types/bunyan": ^1.8.8
+ bunyan: ^1.8.15 || ^2.0.0
+ peerDependenciesMeta:
+ "@types/bunyan":
+ optional: true
+ bunyan:
+ optional: true
+ checksum: 10c0/150a93cbf0ea83ff38f73de42742fc162e24d87551d9b5454769a4c2966b900a4e2a63dbd3d1f616a1178e65f0966cd76b2a48981764de7ae93b55de79410c83
+ languageName: node
+ linkType: hard
+
+"bunyan-debug-stream@npm:^3.1.0":
+ version: 3.1.1
+ resolution: "bunyan-debug-stream@npm:3.1.1"
+ dependencies:
+ chalk: "npm:^4.1.2"
+ peerDependencies:
+ bunyan: "*"
+ peerDependenciesMeta:
+ bunyan:
+ optional: true
+ checksum: 10c0/3682454c08910ffbe4b9d9c3e4ba97d36ca5d78e9bac5d71f639c12e35cfa921df76dd736d08c061c673c5cb232fcc3ecb05cbcc4868cd60c816edc4e6f06028
+ languageName: node
+ linkType: hard
+
+"bunyan@npm:^1.8.12":
+ version: 1.8.15
+ resolution: "bunyan@npm:1.8.15"
+ dependencies:
+ dtrace-provider: "npm:~0.8"
+ moment: "npm:^2.19.3"
+ mv: "npm:~2"
+ safe-json-stringify: "npm:~1"
+ dependenciesMeta:
+ dtrace-provider:
+ optional: true
+ moment:
+ optional: true
+ mv:
+ optional: true
+ safe-json-stringify:
+ optional: true
+ bin:
+ bunyan: bin/bunyan
+ checksum: 10c0/c7b3adc07a4db3256f857dcba42b97dd6c35ab054cb26766643aae2b90e1b614795cdf231774ddaf374572d952f52ef4f4205047e15414e155e478aa0672e041
+ languageName: node
+ linkType: hard
+
+"bunyan@npm:^2.0.5":
+ version: 2.0.5
+ resolution: "bunyan@npm:2.0.5"
+ dependencies:
+ dtrace-provider: "npm:~0.8"
+ exeunt: "npm:1.1.0"
+ moment: "npm:^2.19.3"
+ mv: "npm:~2"
+ safe-json-stringify: "npm:~1"
+ dependenciesMeta:
+ dtrace-provider:
+ optional: true
+ moment:
+ optional: true
+ mv:
+ optional: true
+ safe-json-stringify:
+ optional: true
+ bin:
+ bunyan: bin/bunyan
+ checksum: 10c0/b3a2f0ccc4473c7a83d94f64805dd4ae70e4f7afff8658262a782a8a41ad71e062749e67ec7be57ab3ae724d3d199247ef4b6976ac6c0c67a114f442224977aa
+ languageName: node
+ linkType: hard
+
"bytes@npm:3.1.2, bytes@npm:^3.1.2, bytes@npm:~3.1.2":
version: 3.1.2
resolution: "bytes@npm:3.1.2"
@@ -8095,6 +8573,13 @@ __metadata:
languageName: node
linkType: hard
+"caf@npm:^15.0.1":
+ version: 15.0.1
+ resolution: "caf@npm:15.0.1"
+ checksum: 10c0/9a852c3c93c44499ab1e2cc58c264dd99bb2eaeb17b46df84e6fa5302d6829f776e84a1c13da5bbc2830ba274087eddbde9fe23334f7eb00ce89da9333d1a5cc
+ languageName: node
+ linkType: hard
+
"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2":
version: 1.0.2
resolution: "call-bind-apply-helpers@npm:1.0.2"
@@ -8166,7 +8651,7 @@ __metadata:
languageName: node
linkType: hard
-"camelcase@npm:^6.2.0, camelcase@npm:^6.3.0":
+"camelcase@npm:^6.0.0, camelcase@npm:^6.2.0, camelcase@npm:^6.3.0":
version: 6.3.0
resolution: "camelcase@npm:6.3.0"
checksum: 10c0/0d701658219bd3116d12da3eab31acddb3f9440790c0792e0d398f0a520a6a4058018e546862b6fba89d7ae990efaeb97da71e1913e9ebf5a8b5621a3d55c710
@@ -8533,6 +9018,15 @@ __metadata:
languageName: node
linkType: hard
+"color-convert@npm:^3.1.3":
+ version: 3.1.3
+ resolution: "color-convert@npm:3.1.3"
+ dependencies:
+ color-name: "npm:^2.0.0"
+ checksum: 10c0/427648b442c6ea6dab5ba03f4962201ee59f128c80b25d5a0f7d9aab0ef52519a9db8a9bb3cf40b73f86eb19b5ca6aeb0ab930665f3d14973ce776d7d0448a15
+ languageName: node
+ linkType: hard
+
"color-name@npm:1.1.3":
version: 1.1.3
resolution: "color-name@npm:1.1.3"
@@ -8547,6 +9041,13 @@ __metadata:
languageName: node
linkType: hard
+"color-name@npm:^2.0.0":
+ version: 2.1.1
+ resolution: "color-name@npm:2.1.1"
+ checksum: 10c0/6a1096d761b84ad18bdf1464d449800dbb1e613c914aafd9551514884f5e0cc47b5478fea32048188ec7a28424c6ded55fd4a81f39ca351e2ca4dd7729e05128
+ languageName: node
+ linkType: hard
+
"color-string@npm:^1.9.0":
version: 1.9.1
resolution: "color-string@npm:1.9.1"
@@ -8557,6 +9058,15 @@ __metadata:
languageName: node
linkType: hard
+"color-string@npm:^2.1.3":
+ version: 2.1.4
+ resolution: "color-string@npm:2.1.4"
+ dependencies:
+ color-name: "npm:^2.0.0"
+ checksum: 10c0/18a9fefec153d885e0dbfb076f3a65cdcd19f52d96c719f2f261e90e5b7dafd13c51baac399d7099eac290f004d340045ab9467312dcc8afefe6f877ec5c4428
+ languageName: node
+ linkType: hard
+
"color@npm:^4.2.3":
version: 4.2.3
resolution: "color@npm:4.2.3"
@@ -8567,6 +9077,16 @@ __metadata:
languageName: node
linkType: hard
+"color@npm:^5.0.2":
+ version: 5.0.3
+ resolution: "color@npm:5.0.3"
+ dependencies:
+ color-convert: "npm:^3.1.3"
+ color-string: "npm:^2.1.3"
+ checksum: 10c0/f08a03c5113ae4aa36dba9d2438596b194b897e18b961310643cb63872add1da507cd238df264eb434bbdbe3a377ec41f90d877531acca611523cfcd365db1b6
+ languageName: node
+ linkType: hard
+
"colorette@npm:^1.0.7":
version: 1.4.0
resolution: "colorette@npm:1.4.0"
@@ -9155,6 +9675,13 @@ __metadata:
languageName: node
linkType: hard
+"decamelize@npm:^4.0.0":
+ version: 4.0.0
+ resolution: "decamelize@npm:4.0.0"
+ checksum: 10c0/e06da03fc05333e8cd2778c1487da67ffbea5b84e03ca80449519b8fa61f888714bbc6f459ea963d5641b4aa98832130eb5cd193d90ae9f0a27eee14be8e278d
+ languageName: node
+ linkType: hard
+
"decode-named-character-reference@npm:^1.0.0":
version: 1.3.0
resolution: "decode-named-character-reference@npm:1.3.0"
@@ -9309,6 +9836,58 @@ __metadata:
languageName: node
linkType: hard
+"detox@npm:^20.51.4":
+ version: 20.51.4
+ resolution: "detox@npm:20.51.4"
+ dependencies:
+ "@wix-pilot/core": "npm:^3.4.2"
+ "@wix-pilot/detox": "npm:^1.0.13"
+ ajv: "npm:^8.6.3"
+ bunyan: "npm:^1.8.12"
+ bunyan-debug-stream: "npm:^3.1.0"
+ caf: "npm:^15.0.1"
+ chalk: "npm:^4.0.0"
+ execa: "npm:^5.1.1"
+ find-up: "npm:^5.0.0"
+ fs-extra: "npm:^11.0.0"
+ funpermaproxy: "npm:^1.1.0"
+ glob: "npm:^8.0.3"
+ ini: "npm:^1.3.4"
+ jest-environment-emit: "npm:^1.2.0"
+ json-cycle: "npm:^1.3.0"
+ lodash: "npm:^4.17.11"
+ multi-sort-stream: "npm:^1.0.3"
+ multipipe: "npm:^4.0.0"
+ node-ipc: "npm:9.2.1"
+ promisify-child-process: "npm:^4.1.2"
+ proper-lockfile: "npm:^3.0.2"
+ resolve-from: "npm:^5.0.0"
+ sanitize-filename: "npm:^1.6.1"
+ semver: "npm:^7.0.0"
+ serialize-error: "npm:^8.0.1"
+ shell-quote: "npm:^1.7.2"
+ signal-exit: "npm:^3.0.3"
+ stream-json: "npm:^1.7.4"
+ strip-ansi: "npm:^6.0.1"
+ telnet-client: "npm:1.2.8"
+ tmp: "npm:^0.2.1"
+ trace-event-lib: "npm:^1.3.1"
+ which: "npm:^1.3.1"
+ ws: "npm:^7.0.0"
+ yargs: "npm:^17.0.0"
+ yargs-parser: "npm:^21.0.0"
+ yargs-unparser: "npm:^2.0.0"
+ peerDependencies:
+ jest: 30.x.x || 29.x.x || 28.x.x || ^27.2.5
+ peerDependenciesMeta:
+ jest:
+ optional: true
+ bin:
+ detox: local-cli/cli.js
+ checksum: 10c0/4be674954c6ca60d8efb5d396848eefd5241018c78f64b503a462b0a66b9e7805d0af984318546ee8810063a95f9d92892d3e1e3d9b589ce703d2210da80fb16
+ languageName: node
+ linkType: hard
+
"diff-sequences@npm:^29.6.3":
version: 29.6.3
resolution: "diff-sequences@npm:29.6.3"
@@ -9402,6 +9981,16 @@ __metadata:
languageName: node
linkType: hard
+"dtrace-provider@npm:~0.8":
+ version: 0.8.8
+ resolution: "dtrace-provider@npm:0.8.8"
+ dependencies:
+ nan: "npm:^2.14.0"
+ node-gyp: "npm:latest"
+ checksum: 10c0/33bfc18462dd59ae1de094c64b7b093d2f7f67dec48f138df3a7507c09aaed2a964a245e7bdf2bde7d1a6cc467b11d7396e0fb13a6b882642d42a44cc08c61da
+ languageName: node
+ linkType: hard
+
"dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
@@ -9413,7 +10002,7 @@ __metadata:
languageName: node
linkType: hard
-"duplexer2@npm:~0.1.0":
+"duplexer2@npm:^0.1.2, duplexer2@npm:~0.1.0":
version: 0.1.4
resolution: "duplexer2@npm:0.1.4"
dependencies:
@@ -9422,6 +10011,41 @@ __metadata:
languageName: node
linkType: hard
+"e2etestapp@workspace:examples/E2ETestApp":
+ version: 0.0.0-use.local
+ resolution: "e2etestapp@workspace:examples/E2ETestApp"
+ dependencies:
+ "@babel/core": "npm:^7.29.0"
+ "@babel/runtime": "npm:^7.29.2"
+ "@jest/globals": "npm:^30.4.1"
+ "@react-native-community/cli": "npm:20.2.0"
+ "@react-native-community/cli-platform-android": "npm:20.2.0"
+ "@react-native-community/cli-platform-ios": "npm:20.2.0"
+ "@react-native-community/netinfo": "npm:^12.0.1"
+ "@react-native/babel-preset": "npm:0.88.0"
+ "@react-native/metro-config": "npm:0.88.0"
+ "@react-native/typescript-config": "npm:0.88.0"
+ "@rnx-kit/metro-config": "npm:^2.2.6"
+ "@stream-io/typescript-config": "workspace:^"
+ "@types/react": "npm:^19.3.0"
+ babel-jest: "npm:30.0.0"
+ detox: "npm:^20.51.4"
+ jest: "npm:^30.4.2"
+ react: "npm:19.3.0"
+ react-native: "npm:0.88.0"
+ react-native-gesture-handler: "npm:^3.4.0"
+ react-native-launch-arguments: "npm:^4.1.1"
+ react-native-reanimated: "npm:4.7.1"
+ react-native-safe-area-context: "npm:^5.10.1"
+ react-native-svg: "npm:^15.15.5"
+ react-native-teleport: "npm:^1.2.2"
+ react-native-worklets: "npm:^0.13.0"
+ stream-chat: "npm:^9.54.2"
+ stream-chat-react-native: "workspace:^"
+ typescript: "npm:6.0.3"
+ languageName: unknown
+ linkType: soft
+
"eastasianwidth@npm:^0.2.0":
version: 0.2.0
resolution: "eastasianwidth@npm:0.2.0"
@@ -9429,6 +10053,13 @@ __metadata:
languageName: node
linkType: hard
+"easy-stack@npm:^1.0.1":
+ version: 1.0.1
+ resolution: "easy-stack@npm:1.0.1"
+ checksum: 10c0/1eaf066169a20f6cc3cafd2bb36b00baacd60b6414c8d8bf51bfd50bc6f1c487140c8af86bbb8e1ff9ded2faea5e138c55a37867fc79cbbc985bf5a5ebe4b109
+ languageName: node
+ linkType: hard
+
"ecdsa-sig-formatter@npm:1.0.11":
version: 1.0.11
resolution: "ecdsa-sig-formatter@npm:1.0.11"
@@ -9494,6 +10125,13 @@ __metadata:
languageName: node
linkType: hard
+"enabled@npm:2.0.x":
+ version: 2.0.0
+ resolution: "enabled@npm:2.0.0"
+ checksum: 10c0/3b2c2af9bc7f8b9e291610f2dde4a75cf6ee52a68f4dd585482fbdf9a55d65388940e024e56d40bb03e05ef6671f5f53021fa8b72a20e954d7066ec28166713f
+ languageName: node
+ linkType: hard
+
"encodeurl@npm:~1.0.2":
version: 1.0.2
resolution: "encodeurl@npm:1.0.2"
@@ -10189,6 +10827,13 @@ __metadata:
languageName: node
linkType: hard
+"event-pubsub@npm:4.3.0":
+ version: 4.3.0
+ resolution: "event-pubsub@npm:4.3.0"
+ checksum: 10c0/47fa4fb5b55b3ed08b912862cc913e03603fa063cd3ec5cf3dfeb39a19314d3ca327e938a2cf70685254ab3a71af8178969963c705a030c6081d625bec835114
+ languageName: node
+ linkType: hard
+
"execa@npm:^4.0.3":
version: 4.1.0
resolution: "execa@npm:4.1.0"
@@ -10260,6 +10905,13 @@ __metadata:
languageName: node
linkType: hard
+"exeunt@npm:1.1.0":
+ version: 1.1.0
+ resolution: "exeunt@npm:1.1.0"
+ checksum: 10c0/dc2012d9016c01ec321e4d642d2f165ce7dc4a4d141c523a818416035bcfe11bd1d9abc142f6f608d6a7719824295131a1b8c89006eb308aafd3aba385bf40d2
+ languageName: node
+ linkType: hard
+
"exit-x@npm:^0.2.2":
version: 0.2.2
resolution: "exit-x@npm:0.2.2"
@@ -10288,6 +10940,20 @@ __metadata:
languageName: node
linkType: hard
+"expect@npm:30.5.2":
+ version: 30.5.2
+ resolution: "expect@npm:30.5.2"
+ dependencies:
+ "@jest/expect-utils": "npm:30.5.2"
+ "@jest/get-type": "npm:30.5.0"
+ jest-matcher-utils: "npm:30.5.2"
+ jest-message-util: "npm:30.5.1"
+ jest-mock: "npm:30.5.2"
+ jest-util: "npm:30.5.1"
+ checksum: 10c0/31bf2307499008f267c84992a8ea27f76e97f207f685075439aa12f60b93dd81d024e5fcb0142755ed499233056f27fa51d01843d04cba437dfe7588387c352f
+ languageName: node
+ linkType: hard
+
"expo-asset@npm:~58.0.14":
version: 58.0.14
resolution: "expo-asset@npm:58.0.14"
@@ -10640,6 +11306,15 @@ __metadata:
languageName: node
linkType: hard
+"expo-speech@npm:~58.0.3":
+ version: 58.0.3
+ resolution: "expo-speech@npm:58.0.3"
+ peerDependencies:
+ expo: "*"
+ checksum: 10c0/552c3a34a96210cd0405ee278f8065500203ae87881e5b43500a9251ca2d58b6956e14e37e922872b6480669eff7a0a1ad8ff5aca500eea912cd7ae6d71bcf4d
+ languageName: node
+ linkType: hard
+
"expo-splash-screen@npm:~58.0.8":
version: 58.0.8
resolution: "expo-splash-screen@npm:58.0.8"
@@ -10747,6 +11422,7 @@ __metadata:
expo-image-picker: "npm:~58.0.10"
expo-linking: "npm:~58.0.12"
expo-router: "npm:~58.0.18"
+ expo-speech: "npm:~58.0.3"
react: "npm:19.3.0"
react-native: "npm:0.88.0"
react-native-gesture-handler: "npm:~3.4.0"
@@ -10756,7 +11432,7 @@ __metadata:
react-native-svg: "npm:15.15.5"
react-native-teleport: "npm:^1.2.2"
react-native-worklets: "npm:0.13.0"
- stream-chat: "npm:^9.54.1"
+ stream-chat: "npm:^9.54.2"
stream-chat-expo: "workspace:^"
stream-chat-react-native-core: "workspace:^"
typescript: "npm:6.0.3"
@@ -10972,6 +11648,13 @@ __metadata:
languageName: node
linkType: hard
+"fecha@npm:^4.2.0":
+ version: 4.2.3
+ resolution: "fecha@npm:4.2.3"
+ checksum: 10c0/0e895965959cf6a22bb7b00f0bf546f2783836310f510ddf63f463e1518d4c96dec61ab33fdfd8e79a71b4856a7c865478ce2ee8498d560fe125947703c9b1cf
+ languageName: node
+ linkType: hard
+
"fetch-nodeshim@npm:^0.4.10":
version: 0.4.10
resolution: "fetch-nodeshim@npm:0.4.10"
@@ -11147,6 +11830,15 @@ __metadata:
languageName: node
linkType: hard
+"flat@npm:^5.0.2":
+ version: 5.0.2
+ resolution: "flat@npm:5.0.2"
+ bin:
+ flat: cli.js
+ checksum: 10c0/f178b13482f0cd80c7fede05f4d10585b1f2fdebf26e12edc138e32d3150c6ea6482b7f12813a1091143bad52bb6d3596bca51a162257a21163c0ff438baa5fe
+ languageName: node
+ linkType: hard
+
"flatted@npm:^3.2.9":
version: 3.4.2
resolution: "flatted@npm:3.4.2"
@@ -11193,6 +11885,13 @@ __metadata:
languageName: node
linkType: hard
+"fn.name@npm:1.x.x":
+ version: 1.1.0
+ resolution: "fn.name@npm:1.1.0"
+ checksum: 10c0/8ad62aa2d4f0b2a76d09dba36cfec61c540c13a0fd72e5d94164e430f987a7ce6a743112bbeb14877c810ef500d1f73d7f56e76d029d2e3413f20d79e3460a9a
+ languageName: node
+ linkType: hard
+
"follow-redirects@npm:^1.16.0":
version: 1.16.0
resolution: "follow-redirects@npm:1.16.0"
@@ -11369,6 +12068,13 @@ __metadata:
languageName: node
linkType: hard
+"funpermaproxy@npm:^1.1.0":
+ version: 1.1.0
+ resolution: "funpermaproxy@npm:1.1.0"
+ checksum: 10c0/058d5f56499dac0a275d7115c74230e339f7481618b96c21e8cafa8260a6db7c16f1e4127f5be2aeb167876ff5f33846503a07148279b2f68ca45d04771d666e
+ languageName: node
+ linkType: hard
+
"generator-function@npm:^2.0.0":
version: 2.0.1
resolution: "generator-function@npm:2.0.1"
@@ -11544,7 +12250,7 @@ __metadata:
languageName: node
linkType: hard
-"glob@npm:^10.5.0":
+"glob@npm:^10.4.1, glob@npm:^10.5.0":
version: 10.5.0
resolution: "glob@npm:10.5.0"
dependencies:
@@ -11571,6 +12277,19 @@ __metadata:
languageName: node
linkType: hard
+"glob@npm:^6.0.1":
+ version: 6.0.4
+ resolution: "glob@npm:6.0.4"
+ dependencies:
+ inflight: "npm:^1.0.4"
+ inherits: "npm:2"
+ minimatch: "npm:2 || 3"
+ once: "npm:^1.3.0"
+ path-is-absolute: "npm:^1.0.0"
+ checksum: 10c0/520146ebce0f4594b8357338f86281b38ee14214debce398a2902176a28f18e0f98911ea48516d85022de64fbbaa57f074aa13715d1daa5d70e21b82cea22183
+ languageName: node
+ linkType: hard
+
"glob@npm:^7.1.3, glob@npm:^7.1.4":
version: 7.2.3
resolution: "glob@npm:7.2.3"
@@ -11671,7 +12390,7 @@ __metadata:
languageName: node
linkType: hard
-"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.3, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9":
+"graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.3, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9":
version: 4.2.11
resolution: "graceful-fs@npm:4.2.11"
checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2
@@ -13166,6 +13885,18 @@ __metadata:
languageName: node
linkType: hard
+"jest-diff@npm:30.5.2":
+ version: 30.5.2
+ resolution: "jest-diff@npm:30.5.2"
+ dependencies:
+ "@jest/diff-sequences": "npm:30.5.0"
+ "@jest/get-type": "npm:30.5.0"
+ chalk: "npm:^4.1.2"
+ pretty-format: "npm:30.5.1"
+ checksum: 10c0/b11524e4b7a0c7b83c5b6a3775f705b1002bf086c211e9bd2e481686e3fa0ee1a87e61fa7453232aef2808f085f254f4f8fcbb41b69681d1b9384ec321cf6002
+ languageName: node
+ linkType: hard
+
"jest-diff@npm:^29.0.1, jest-diff@npm:^29.7.0":
version: 29.7.0
resolution: "jest-diff@npm:29.7.0"
@@ -13200,6 +13931,39 @@ __metadata:
languageName: node
linkType: hard
+"jest-environment-emit@npm:^1.2.0":
+ version: 1.2.0
+ resolution: "jest-environment-emit@npm:1.2.0"
+ dependencies:
+ bunyamin: "npm:^1.5.2"
+ bunyan: "npm:^2.0.5"
+ bunyan-debug-stream: "npm:^3.1.0"
+ funpermaproxy: "npm:^1.1.0"
+ lodash.merge: "npm:^4.6.2"
+ node-ipc: "npm:9.2.1"
+ strip-ansi: "npm:^6.0.0"
+ tslib: "npm:^2.5.3"
+ peerDependencies:
+ "@jest/environment": ">=27.2.5"
+ "@jest/types": ">=27.2.5"
+ jest: ">=27.2.5"
+ jest-environment-jsdom: ">=27.2.5"
+ jest-environment-node: ">=27.2.5"
+ peerDependenciesMeta:
+ "@jest/environment":
+ optional: true
+ "@jest/types":
+ optional: true
+ jest:
+ optional: true
+ jest-environment-jsdom:
+ optional: true
+ jest-environment-node:
+ optional: true
+ checksum: 10c0/4e1230b447b54c54eecbc6e52782e2133a07b0556a43d34b9f7733b4be8d3a390be7a444863abed31cef638a259815f65b41b588dabc9f9ee478af9ea0ab419b
+ languageName: node
+ linkType: hard
+
"jest-environment-node@npm:30.4.1":
version: 30.4.1
resolution: "jest-environment-node@npm:30.4.1"
@@ -13280,6 +14044,25 @@ __metadata:
languageName: node
linkType: hard
+"jest-haste-map@npm:30.5.1":
+ version: 30.5.1
+ resolution: "jest-haste-map@npm:30.5.1"
+ dependencies:
+ "@jest/types": "npm:30.5.1"
+ "@parcel/watcher": "npm:^2.6.0"
+ "@types/node": "npm:*"
+ anymatch: "npm:^3.1.3"
+ fb-watchman: "npm:^2.0.2"
+ fdir: "npm:^6.5.0"
+ graceful-fs: "npm:^4.2.11"
+ jest-regex-util: "npm:30.5.0"
+ jest-util: "npm:30.5.1"
+ jest-worker: "npm:30.5.1"
+ picomatch: "npm:^4.0.3"
+ checksum: 10c0/a9838762f84c54b1d551650e3c956f8d72370a15b2e9291d4b0c9aab0b4d4f3d1758bbc619ffb028af567d5dc53a73c5e72af078c56c5fa47cd73a2a309ca89a
+ languageName: node
+ linkType: hard
+
"jest-haste-map@npm:^29.7.0":
version: 29.7.0
resolution: "jest-haste-map@npm:29.7.0"
@@ -13325,6 +14108,18 @@ __metadata:
languageName: node
linkType: hard
+"jest-matcher-utils@npm:30.5.2":
+ version: 30.5.2
+ resolution: "jest-matcher-utils@npm:30.5.2"
+ dependencies:
+ "@jest/get-type": "npm:30.5.0"
+ chalk: "npm:^4.1.2"
+ jest-diff: "npm:30.5.2"
+ pretty-format: "npm:30.5.1"
+ checksum: 10c0/30f075c6f897335415817ad25e6caae8994de87c31ad59ae8f501e2c8bf6c925635abb481c562436b6add42a784902f105ee2fa0eca3357bd26e038f7e004cb8
+ languageName: node
+ linkType: hard
+
"jest-matcher-utils@npm:^29.0.1, jest-matcher-utils@npm:^29.7.0":
version: 29.7.0
resolution: "jest-matcher-utils@npm:29.7.0"
@@ -13355,6 +14150,24 @@ __metadata:
languageName: node
linkType: hard
+"jest-message-util@npm:30.5.1":
+ version: 30.5.1
+ resolution: "jest-message-util@npm:30.5.1"
+ dependencies:
+ "@babel/code-frame": "npm:^7.27.1"
+ "@jest/types": "npm:30.5.1"
+ "@types/stack-utils": "npm:^2.0.3"
+ chalk: "npm:^4.1.2"
+ graceful-fs: "npm:^4.2.11"
+ jest-util: "npm:30.5.1"
+ picomatch: "npm:^4.0.3"
+ pretty-format: "npm:30.5.1"
+ slash: "npm:^3.0.0"
+ stack-utils: "npm:^2.0.6"
+ checksum: 10c0/f542d07f8ceba19da26c547aa86abc51e6fad518999a18853e7c2acc2a55845ad759aa94cbe650d0453b036b81fcafee710d467b55e1e509f9939db033e76919
+ languageName: node
+ linkType: hard
+
"jest-message-util@npm:^29.7.0":
version: 29.7.0
resolution: "jest-message-util@npm:29.7.0"
@@ -13383,6 +14196,18 @@ __metadata:
languageName: node
linkType: hard
+"jest-mock@npm:30.5.2":
+ version: 30.5.2
+ resolution: "jest-mock@npm:30.5.2"
+ dependencies:
+ "@jest/expect-utils": "npm:30.5.2"
+ "@jest/types": "npm:30.5.1"
+ "@types/node": "npm:*"
+ jest-util: "npm:30.5.1"
+ checksum: 10c0/24cc9ac6449c536ef5009c531e14ee6b145f5d68db6e4c5616a8f775c5c81173e83e2c6c47922d413da4dbf804eab555b91ab424b0bf918495ee1b29d8a8ebaa
+ languageName: node
+ linkType: hard
+
"jest-mock@npm:^29.7.0":
version: 29.7.0
resolution: "jest-mock@npm:29.7.0"
@@ -13420,6 +14245,13 @@ __metadata:
languageName: node
linkType: hard
+"jest-regex-util@npm:30.5.0":
+ version: 30.5.0
+ resolution: "jest-regex-util@npm:30.5.0"
+ checksum: 10c0/f0e5ef257b906862821e1fb58c1d3d7bb3c4fc1fb11915673f04d3693673e0656b8f502c9871900e13e58329039c73e5b5c59a2622cbd6ba5c7fc7463b60ce48
+ languageName: node
+ linkType: hard
+
"jest-regex-util@npm:^29.6.3":
version: 29.6.3
resolution: "jest-regex-util@npm:29.6.3"
@@ -13542,6 +14374,35 @@ __metadata:
languageName: node
linkType: hard
+"jest-snapshot@npm:30.5.2":
+ version: 30.5.2
+ resolution: "jest-snapshot@npm:30.5.2"
+ dependencies:
+ "@babel/core": "npm:^7.27.4"
+ "@babel/generator": "npm:^7.27.5"
+ "@babel/plugin-syntax-jsx": "npm:^7.27.1"
+ "@babel/plugin-syntax-typescript": "npm:^7.27.1"
+ "@babel/types": "npm:^7.27.3"
+ "@jest/expect-utils": "npm:30.5.2"
+ "@jest/get-type": "npm:30.5.0"
+ "@jest/snapshot-utils": "npm:30.5.1"
+ "@jest/transform": "npm:30.5.2"
+ "@jest/types": "npm:30.5.1"
+ babel-preset-current-node-syntax: "npm:^1.2.0"
+ chalk: "npm:^4.1.2"
+ expect: "npm:30.5.2"
+ graceful-fs: "npm:^4.2.11"
+ jest-diff: "npm:30.5.2"
+ jest-matcher-utils: "npm:30.5.2"
+ jest-message-util: "npm:30.5.1"
+ jest-util: "npm:30.5.1"
+ pretty-format: "npm:30.5.1"
+ semver: "npm:^7.7.2"
+ synckit: "npm:^0.11.8"
+ checksum: 10c0/cef6b102f743cbefd5d8ad0cf15b5694c09522904c43ace56537990475805b7c6eaebf82e6c5564e225e8db7617ebdda77340acc9a47d9909f338fbb84fca345
+ languageName: node
+ linkType: hard
+
"jest-util@npm:30.0.0":
version: 30.0.0
resolution: "jest-util@npm:30.0.0"
@@ -13560,13 +14421,27 @@ __metadata:
version: 30.4.1
resolution: "jest-util@npm:30.4.1"
dependencies:
- "@jest/types": "npm:30.4.1"
+ "@jest/types": "npm:30.4.1"
+ "@types/node": "npm:*"
+ chalk: "npm:^4.1.2"
+ ci-info: "npm:^4.2.0"
+ graceful-fs: "npm:^4.2.11"
+ picomatch: "npm:^4.0.3"
+ checksum: 10c0/3efe1f25e5a172d04c6af8612d82867ab603b7c1bd8cb89073ff834679b44eba178793cf3af162cf5e25be13aa736ebd23a7826683acc85bddc5873f305b1f6e
+ languageName: node
+ linkType: hard
+
+"jest-util@npm:30.5.1":
+ version: 30.5.1
+ resolution: "jest-util@npm:30.5.1"
+ dependencies:
+ "@jest/types": "npm:30.5.1"
"@types/node": "npm:*"
chalk: "npm:^4.1.2"
ci-info: "npm:^4.2.0"
graceful-fs: "npm:^4.2.11"
picomatch: "npm:^4.0.3"
- checksum: 10c0/3efe1f25e5a172d04c6af8612d82867ab603b7c1bd8cb89073ff834679b44eba178793cf3af162cf5e25be13aa736ebd23a7826683acc85bddc5873f305b1f6e
+ checksum: 10c0/5f3b250f1ada29b877cab75db5808f1318af99ba7f68032e0966ddb6d853186ad2dbaeac679842684c4676dfd35083470f2d26eee65a28161fcd9e43c7146fab
languageName: node
linkType: hard
@@ -13654,6 +14529,19 @@ __metadata:
languageName: node
linkType: hard
+"jest-worker@npm:30.5.1":
+ version: 30.5.1
+ resolution: "jest-worker@npm:30.5.1"
+ dependencies:
+ "@types/node": "npm:*"
+ "@ungap/structured-clone": "npm:^1.3.0"
+ jest-util: "npm:30.5.1"
+ merge-stream: "npm:^2.0.0"
+ supports-color: "npm:^8.1.1"
+ checksum: 10c0/fe3806769014920c5256ef01e2e9a5aba91a7ded90d7c86a82196f3da03070087c8c6b591f8bbb0b500c184c7cc8c5f22191e51cce504163b24a8b42c4cadd8b
+ languageName: node
+ linkType: hard
+
"jest-worker@npm:^29.6.3, jest-worker@npm:^29.7.0":
version: 29.7.0
resolution: "jest-worker@npm:29.7.0"
@@ -13723,6 +14611,22 @@ __metadata:
languageName: node
linkType: hard
+"js-message@npm:1.0.7":
+ version: 1.0.7
+ resolution: "js-message@npm:1.0.7"
+ checksum: 10c0/2dc2ff5a594613a9690c157a921999237164a3c213523f406dee23985ed81c92be9afdc7b34e67a2838d447d7d79cbf1662048effe8367bedcd71a950e4e292e
+ languageName: node
+ linkType: hard
+
+"js-queue@npm:2.0.2":
+ version: 2.0.2
+ resolution: "js-queue@npm:2.0.2"
+ dependencies:
+ easy-stack: "npm:^1.0.1"
+ checksum: 10c0/cdadc65576c6d46817f47c27d7a57a1f3acbe3a4da2a4bc7303218f06d6ee7971358db28ec0d6b6835b4cd93332c52792b535064fee706453ef9d2bfe03b4a1a
+ languageName: node
+ linkType: hard
+
"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0":
version: 4.0.0
resolution: "js-tokens@npm:4.0.0"
@@ -13787,6 +14691,13 @@ __metadata:
languageName: node
linkType: hard
+"json-cycle@npm:^1.3.0":
+ version: 1.5.0
+ resolution: "json-cycle@npm:1.5.0"
+ checksum: 10c0/43ba321cf186b22573826ee76d64601bdfe57fe787d74162626c453bd860595c4c62ebfc338b5a64994fb4e4d7a947e6c2ef5f202e3a2ee4b01ca23ebc0cd710
+ languageName: node
+ linkType: hard
+
"json-parse-better-errors@npm:^1.0.1":
version: 1.0.2
resolution: "json-parse-better-errors@npm:1.0.2"
@@ -13983,6 +14894,13 @@ __metadata:
languageName: node
linkType: hard
+"kuler@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "kuler@npm:2.0.0"
+ checksum: 10c0/0a4e99d92ca373f8f74d1dc37931909c4d0d82aebc94cf2ba265771160fc12c8df34eaaac80805efbda367e2795cb1f1dd4c3d404b6b1cf38aec94035b503d2d
+ languageName: node
+ linkType: hard
+
"lan-network@npm:^0.2.1":
version: 0.2.1
resolution: "lan-network@npm:0.2.1"
@@ -14440,7 +15358,7 @@ __metadata:
languageName: node
linkType: hard
-"lodash@npm:4.18.1, lodash@npm:^4.17.15, lodash@npm:^4.17.4":
+"lodash@npm:4.18.1, lodash@npm:^4.17.11, lodash@npm:^4.17.15, lodash@npm:^4.17.4":
version: 4.18.1
resolution: "lodash@npm:4.18.1"
checksum: 10c0/757228fc68805c59789e82185135cf85f05d0b2d3d54631d680ca79ec21944ec8314d4533639a14b8bcfbd97a517e78960933041a5af17ecb693ec6eecb99a27
@@ -14476,6 +15394,20 @@ __metadata:
languageName: node
linkType: hard
+"logform@npm:^2.7.0":
+ version: 2.7.0
+ resolution: "logform@npm:2.7.0"
+ dependencies:
+ "@colors/colors": "npm:1.6.0"
+ "@types/triple-beam": "npm:^1.3.2"
+ fecha: "npm:^4.2.0"
+ ms: "npm:^2.1.1"
+ safe-stable-stringify: "npm:^2.3.1"
+ triple-beam: "npm:^1.3.0"
+ checksum: 10c0/4789b4b37413c731d1835734cb799240d31b865afde6b7b3e06051d6a4127bfda9e88c99cfbf296d084a315ccbed2647796e6a56b66e725bcb268c586f57558f
+ languageName: node
+ linkType: hard
+
"logkitty@npm:^0.7.1":
version: 0.7.1
resolution: "logkitty@npm:0.7.1"
@@ -15281,6 +16213,15 @@ __metadata:
languageName: node
linkType: hard
+"minimatch@npm:2 || 3, minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2, minimatch@npm:^3.1.5":
+ version: 3.1.5
+ resolution: "minimatch@npm:3.1.5"
+ dependencies:
+ brace-expansion: "npm:^1.1.7"
+ checksum: 10c0/2ecbdc0d33f07bddb0315a8b5afbcb761307a8778b48f0b312418ccbced99f104a2d17d8aca7573433c70e8ccd1c56823a441897a45e384ea76ef401a26ace70
+ languageName: node
+ linkType: hard
+
"minimatch@npm:^10.0.3, minimatch@npm:^10.1.1, minimatch@npm:^10.2.2, minimatch@npm:^10.2.5":
version: 10.2.5
resolution: "minimatch@npm:10.2.5"
@@ -15290,15 +16231,6 @@ __metadata:
languageName: node
linkType: hard
-"minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2, minimatch@npm:^3.1.5":
- version: 3.1.5
- resolution: "minimatch@npm:3.1.5"
- dependencies:
- brace-expansion: "npm:^1.1.7"
- checksum: 10c0/2ecbdc0d33f07bddb0315a8b5afbcb761307a8778b48f0b312418ccbced99f104a2d17d8aca7573433c70e8ccd1c56823a441897a45e384ea76ef401a26ace70
- languageName: node
- linkType: hard
-
"minimatch@npm:^5.0.1":
version: 5.1.9
resolution: "minimatch@npm:5.1.9"
@@ -15423,6 +16355,17 @@ __metadata:
languageName: node
linkType: hard
+"mkdirp@npm:~0.5.1":
+ version: 0.5.6
+ resolution: "mkdirp@npm:0.5.6"
+ dependencies:
+ minimist: "npm:^1.2.6"
+ bin:
+ mkdirp: bin/cmd.js
+ checksum: 10c0/e2e2be789218807b58abced04e7b49851d9e46e88a2f9539242cc8a92c9b5c3a0b9bab360bd3014e02a140fc4fbc58e31176c408b493f8a2a6f4986bd7527b01
+ languageName: node
+ linkType: hard
+
"moment-timezone@npm:^0.6.0":
version: 0.6.2
resolution: "moment-timezone@npm:0.6.2"
@@ -15432,6 +16375,13 @@ __metadata:
languageName: node
linkType: hard
+"moment@npm:^2.19.3":
+ version: 2.31.0
+ resolution: "moment@npm:2.31.0"
+ checksum: 10c0/3755b186b8ff62c565685ce69dde55bf46003c0329139be5c8dd7845cd567bfd7d02be3a13e379a25acb9e98f6b87136c4776745e440615a4f5ca8dd9a8815b9
+ languageName: node
+ linkType: hard
+
"moment@npm:^2.29.4":
version: 2.30.1
resolution: "moment@npm:2.30.1"
@@ -15453,6 +16403,23 @@ __metadata:
languageName: node
linkType: hard
+"multi-sort-stream@npm:^1.0.3, multi-sort-stream@npm:^1.0.4":
+ version: 1.0.4
+ resolution: "multi-sort-stream@npm:1.0.4"
+ checksum: 10c0/74820fd8f4287798f56f5c0b390d52d0fa0efd46b22a880390c6182c4b2ff6bd608dc90974fe7b0981c9243ae4f857d3389e74ebf6d071d7465056c29772196d
+ languageName: node
+ linkType: hard
+
+"multipipe@npm:^4.0.0":
+ version: 4.0.0
+ resolution: "multipipe@npm:4.0.0"
+ dependencies:
+ duplexer2: "npm:^0.1.2"
+ object-assign: "npm:^4.1.0"
+ checksum: 10c0/60b547f85a3c692fe24c4e15988bdbb6c7985665fd7c4f6228bbf2630f699badeefd8d492bff5b295ea02a284e12917ff21e324a2f660e3d5667bb656a01792d
+ languageName: node
+ linkType: hard
+
"multitars@npm:^1.0.2":
version: 1.0.2
resolution: "multitars@npm:1.0.2"
@@ -15467,6 +16434,17 @@ __metadata:
languageName: node
linkType: hard
+"mv@npm:~2":
+ version: 2.1.1
+ resolution: "mv@npm:2.1.1"
+ dependencies:
+ mkdirp: "npm:~0.5.1"
+ ncp: "npm:~2.0.0"
+ rimraf: "npm:~2.4.0"
+ checksum: 10c0/5da59a9f4ec16da0867289b5018c81c25c59b06bb9da717bc7bd0b40363d6653dc88d6da32a9434fd7416bfc3f67184c306ea44d3856ff97f3214cc96960efcd
+ languageName: node
+ linkType: hard
+
"mz@npm:^2.4.0":
version: 2.7.0
resolution: "mz@npm:2.7.0"
@@ -15478,6 +16456,15 @@ __metadata:
languageName: node
linkType: hard
+"nan@npm:^2.14.0":
+ version: 2.29.0
+ resolution: "nan@npm:2.29.0"
+ dependencies:
+ node-gyp: "npm:latest"
+ checksum: 10c0/c90586afb3f101f64c2997e7481f7c2c5528e196399a1c2653e4fff7354a01f35009e10fd43aa567a4b68be0ac8ed45a60794f5ca0a863120b74aecf4a45ad2c
+ languageName: node
+ linkType: hard
+
"nanoid@npm:^3.3.1, nanoid@npm:^3.3.11":
version: 3.3.12
resolution: "nanoid@npm:3.3.12"
@@ -15519,6 +16506,15 @@ __metadata:
languageName: node
linkType: hard
+"ncp@npm:~2.0.0":
+ version: 2.0.0
+ resolution: "ncp@npm:2.0.0"
+ bin:
+ ncp: ./bin/ncp
+ checksum: 10c0/d515babf9d3205ab9252e7d640af7c3e1a880317016d41f2fce2e6b9c8f60eb8bb6afde30e8c4f8e1e3fa551465f094433c3f364b25a85d6a28ec52c1ad6e067
+ languageName: node
+ linkType: hard
+
"negotiator@npm:0.6.3":
version: 0.6.3
resolution: "negotiator@npm:0.6.3"
@@ -15584,6 +16580,15 @@ __metadata:
languageName: node
linkType: hard
+"node-addon-api@npm:^7.0.0":
+ version: 7.1.1
+ resolution: "node-addon-api@npm:7.1.1"
+ dependencies:
+ node-gyp: "npm:latest"
+ checksum: 10c0/fb32a206276d608037fa1bcd7e9921e177fe992fc610d098aa3128baca3c0050fc1e014fa007e9b3874cf865ddb4f5bd9f43ccb7cbbbe4efaff6a83e920b17e9
+ languageName: node
+ linkType: hard
+
"node-emoji@npm:^2.2.0":
version: 2.2.0
resolution: "node-emoji@npm:2.2.0"
@@ -15656,6 +16661,17 @@ __metadata:
languageName: node
linkType: hard
+"node-ipc@npm:9.2.1":
+ version: 9.2.1
+ resolution: "node-ipc@npm:9.2.1"
+ dependencies:
+ event-pubsub: "npm:4.3.0"
+ js-message: "npm:1.0.7"
+ js-queue: "npm:2.0.2"
+ checksum: 10c0/ed6448e2c643a831b9795a52de727658417a2cbedd3a59988b9be7654383982577dc507e0ec13211d10ed90377124366e3869a137b05093a424caf647eba0523
+ languageName: node
+ linkType: hard
+
"node-releases@npm:^2.0.36":
version: 2.0.44
resolution: "node-releases@npm:2.0.44"
@@ -16118,6 +17134,15 @@ __metadata:
languageName: node
linkType: hard
+"one-time@npm:^1.0.0":
+ version: 1.0.0
+ resolution: "one-time@npm:1.0.0"
+ dependencies:
+ fn.name: "npm:1.x.x"
+ checksum: 10c0/6e4887b331edbb954f4e915831cbec0a7b9956c36f4feb5f6de98c448ac02ff881fd8d9b55a6b1b55030af184c6b648f340a76eb211812f4ad8c9b4b8692fdaa
+ languageName: node
+ linkType: hard
+
"onetime@npm:^2.0.0":
version: 2.0.1
resolution: "onetime@npm:2.0.1"
@@ -16721,6 +17746,13 @@ __metadata:
languageName: node
linkType: hard
+"pngjs@npm:^7.0.0":
+ version: 7.0.0
+ resolution: "pngjs@npm:7.0.0"
+ checksum: 10c0/0d4c7a0fd476a9c33df7d0a2a73e1d56537628a668841f6995c2bca070cf30819f9254a64363266bc14ef2fee47659dd3b4f2b18eec7ab65143015139f497b38
+ languageName: node
+ linkType: hard
+
"possible-typed-array-names@npm:^1.0.0":
version: 1.0.0
resolution: "possible-typed-array-names@npm:1.0.0"
@@ -16815,6 +17847,18 @@ __metadata:
languageName: node
linkType: hard
+"pretty-format@npm:30.5.1":
+ version: 30.5.1
+ resolution: "pretty-format@npm:30.5.1"
+ dependencies:
+ "@jest/react-is-18": "npm:react-is@^18.3.1"
+ "@jest/react-is-19": "npm:react-is@^19.2.5"
+ "@jest/schemas": "npm:30.5.0"
+ ansi-styles: "npm:^5.2.0"
+ checksum: 10c0/bdef1790b9c93d1aa329c93885b0d1b8b6a3273bb680377531c70151b2e1b0a465210cae100661fc19b0bf35f507587f78b1a2f1afcf08791b9fe5630c23c076
+ languageName: node
+ linkType: hard
+
"pretty-format@npm:^29.0.3, pretty-format@npm:^29.7.0":
version: 29.7.0
resolution: "pretty-format@npm:29.7.0"
@@ -16933,6 +17977,13 @@ __metadata:
languageName: node
linkType: hard
+"promisify-child-process@npm:^4.1.2":
+ version: 4.1.2
+ resolution: "promisify-child-process@npm:4.1.2"
+ checksum: 10c0/e5347137f1c333af4b7b4d2272a091d31007b30320af4b852f30f41776db378e22b3db1e67e40026a18301c243d9c9780e41c211cfce76ba26aedba17e3a385c
+ languageName: node
+ linkType: hard
+
"prompts@npm:^2.3.2, prompts@npm:^2.4.2":
version: 2.4.2
resolution: "prompts@npm:2.4.2"
@@ -16963,6 +18014,17 @@ __metadata:
languageName: node
linkType: hard
+"proper-lockfile@npm:^3.0.2":
+ version: 3.2.0
+ resolution: "proper-lockfile@npm:3.2.0"
+ dependencies:
+ graceful-fs: "npm:^4.1.11"
+ retry: "npm:^0.12.0"
+ signal-exit: "npm:^3.0.2"
+ checksum: 10c0/b3a0bbf87f1b842ab57d91bee2e8c73d6cb2e90558aad259e83561cf5467f20885a1eb127343ecd9db214c78aaa196867ef8edb76e3681dc07ee43a489fd53bd
+ languageName: node
+ linkType: hard
+
"property-information@npm:^7.0.0":
version: 7.2.0
resolution: "property-information@npm:7.2.0"
@@ -17155,13 +18217,6 @@ __metadata:
languageName: node
linkType: hard
-"react-is-18@npm:react-is@^18.3.1, react-is@npm:^18.0.0":
- version: 18.3.1
- resolution: "react-is@npm:18.3.1"
- checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072
- languageName: node
- linkType: hard
-
"react-is-19@npm:react-is@^19.2.5, react-is@npm:^19.1.0":
version: 19.2.6
resolution: "react-is@npm:19.2.6"
@@ -17317,6 +18372,16 @@ __metadata:
languageName: node
linkType: hard
+"react-native-launch-arguments@npm:^4.1.1":
+ version: 4.1.1
+ resolution: "react-native-launch-arguments@npm:4.1.1"
+ peerDependencies:
+ react: ">=16.8.1"
+ react-native: ">=0.60.0-rc.0 <1.0.x"
+ checksum: 10c0/94a88b3c6ba583b20108200c794f2c132a5b94383f0eb22797ff21194d140904bd62510ee9a01ea464ba831ddd69b3c49fb9b2799728d1d7fbaebf0ed683edd2
+ languageName: node
+ linkType: hard
+
"react-native-lightbox@npm:^0.7.0":
version: 0.7.0
resolution: "react-native-lightbox@npm:0.7.0"
@@ -17758,7 +18823,7 @@ __metadata:
languageName: node
linkType: hard
-"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0":
+"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.2":
version: 3.6.2
resolution: "readable-stream@npm:3.6.2"
dependencies:
@@ -18084,6 +19149,17 @@ __metadata:
languageName: node
linkType: hard
+"rimraf@npm:~2.4.0":
+ version: 2.4.5
+ resolution: "rimraf@npm:2.4.5"
+ dependencies:
+ glob: "npm:^6.0.1"
+ bin:
+ rimraf: ./bin.js
+ checksum: 10c0/5251a36053165d23248efec5077f9addc13ad7f742a02dcd9ac7adda9e208cbf7523901e96a9ca6c33059bd0b573b97eab3334cf1d9976cc5ddc8b3c24d9ddd7
+ languageName: node
+ linkType: hard
+
"root@workspace:.":
version: 0.0.0-use.local
resolution: "root@workspace:."
@@ -18177,6 +19253,13 @@ __metadata:
languageName: node
linkType: hard
+"safe-json-stringify@npm:~1":
+ version: 1.2.0
+ resolution: "safe-json-stringify@npm:1.2.0"
+ checksum: 10c0/9c21c7b63a35a9e52d248eea2ad7bc9e790dde5aa418f0d4eed3c0b4c866e15337425b0d973173d30dd70a9e422271619f17e13574e0c8371d0c240cf72b871f
+ languageName: node
+ linkType: hard
+
"safe-push-apply@npm:^1.0.0":
version: 1.0.0
resolution: "safe-push-apply@npm:1.0.0"
@@ -18198,6 +19281,13 @@ __metadata:
languageName: node
linkType: hard
+"safe-stable-stringify@npm:^2.3.1":
+ version: 2.5.0
+ resolution: "safe-stable-stringify@npm:2.5.0"
+ checksum: 10c0/baea14971858cadd65df23894a40588ed791769db21bafb7fd7608397dbdce9c5aac60748abae9995e0fc37e15f2061980501e012cd48859740796bea2987f49
+ languageName: node
+ linkType: hard
+
"safer-buffer@npm:>= 2.1.2 < 3.0.0":
version: 2.1.2
resolution: "safer-buffer@npm:2.1.2"
@@ -18262,7 +19352,7 @@ __metadata:
react-native-teleport: "npm:^1.2.2"
react-native-video: "npm:^6.19.3"
react-native-worklets: "npm:^0.13.0"
- stream-chat: "npm:^9.54.1"
+ stream-chat: "npm:^9.54.2"
stream-chat-react-native: "workspace:^"
stream-chat-react-native-core: "workspace:^"
typescript: "npm:6.0.3"
@@ -18278,6 +19368,15 @@ __metadata:
languageName: node
linkType: hard
+"sanitize-filename@npm:^1.6.1":
+ version: 1.6.4
+ resolution: "sanitize-filename@npm:1.6.4"
+ dependencies:
+ truncate-utf8-bytes: "npm:^1.0.0"
+ checksum: 10c0/b56415a95e4f90dc992cd126b5f45c7b39d178662fbd0dc48f03203e35c58ab8e9eb3f5cebfaabc46f1438093d65a3a3cc96875e2b036a1474e19593bee9a540
+ languageName: node
+ linkType: hard
+
"sax@npm:>=0.6.0":
version: 1.6.0
resolution: "sax@npm:1.6.0"
@@ -18353,6 +19452,15 @@ __metadata:
languageName: node
linkType: hard
+"semver@npm:^7.0.0":
+ version: 7.8.5
+ resolution: "semver@npm:7.8.5"
+ bin:
+ semver: bin/semver.js
+ checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c
+ languageName: node
+ linkType: hard
+
"semver@npm:^7.1.1, semver@npm:^7.1.2, semver@npm:^7.1.3, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:^7.7.4":
version: 7.8.1
resolution: "semver@npm:7.8.1"
@@ -18390,6 +19498,15 @@ __metadata:
languageName: node
linkType: hard
+"serialize-error@npm:^8.0.1":
+ version: 8.1.0
+ resolution: "serialize-error@npm:8.1.0"
+ dependencies:
+ type-fest: "npm:^0.20.2"
+ checksum: 10c0/8cfd89f43ca93e283c5f1d16178a536bdfac9bc6029f4a9df988610cc399bc4f2478d1f10ce40b9dff66b863a5158a19b438fbec929045c96d92174f6bca1e88
+ languageName: node
+ linkType: hard
+
"serve-static@npm:^1.13.1, serve-static@npm:^1.16.2":
version: 1.16.3
resolution: "serve-static@npm:1.16.3"
@@ -18497,6 +19614,13 @@ __metadata:
languageName: node
linkType: hard
+"shell-quote@npm:^1.7.2":
+ version: 1.12.0
+ resolution: "shell-quote@npm:1.12.0"
+ checksum: 10c0/1605a4ed6a758bf13de7cb8197a4184d8015f7a4dbdcd6cd4a717d5b30b3ae7666300cc4ae7f3155cf80ace9739e9e82061ee5aa1a648f53f5fab31a220816d0
+ languageName: node
+ linkType: hard
+
"side-channel-list@npm:^1.0.0, side-channel-list@npm:^1.0.1":
version: 1.0.1
resolution: "side-channel-list@npm:1.0.1"
@@ -18837,6 +19961,13 @@ __metadata:
languageName: node
linkType: hard
+"stack-trace@npm:0.0.x":
+ version: 0.0.10
+ resolution: "stack-trace@npm:0.0.10"
+ checksum: 10c0/9ff3dabfad4049b635a85456f927a075c9d0c210e3ea336412d18220b2a86cbb9b13ec46d6c37b70a302a4ea4d49e30e5d4944dd60ae784073f1cde778ac8f4b
+ languageName: node
+ linkType: hard
+
"stack-utils@npm:^2.0.3, stack-utils@npm:^2.0.6":
version: 2.0.6
resolution: "stack-utils@npm:2.0.6"
@@ -18916,6 +20047,13 @@ __metadata:
languageName: node
linkType: hard
+"stream-chain@npm:^2.2.5":
+ version: 2.2.5
+ resolution: "stream-chain@npm:2.2.5"
+ checksum: 10c0/c512f50190d7c92d688fa64e7af540c51b661f9c2b775fc72bca38ea9bca515c64c22c2197b1be463741daacbaaa2dde8a8ea24ebda46f08391224f15249121a
+ languageName: node
+ linkType: hard
+
"stream-chat-expo@workspace:^, stream-chat-expo@workspace:package/expo-package":
version: 0.0.0-use.local
resolution: "stream-chat-expo@workspace:package/expo-package"
@@ -19035,7 +20173,7 @@ __metadata:
react-syntax-highlighter: "npm:^16.1.0"
react-test-renderer: "npm:19.3.0"
rimraf: "npm:^6.0.1"
- stream-chat: "npm:^9.54.1"
+ stream-chat: "npm:^9.54.2"
typescript: "npm:6.0.3"
use-sync-external-store: "npm:^1.5.0"
uuid: "npm:^11.1.0"
@@ -19123,9 +20261,9 @@ __metadata:
languageName: unknown
linkType: soft
-"stream-chat@npm:^9.54.1":
- version: 9.54.1
- resolution: "stream-chat@npm:9.54.1"
+"stream-chat@npm:^9.54.2":
+ version: 9.54.2
+ resolution: "stream-chat@npm:9.54.2"
dependencies:
"@types/jsonwebtoken": "npm:^9.0.8"
"@types/ws": "npm:^8.18.1"
@@ -19141,7 +20279,7 @@ __metadata:
built: true
husky:
built: true
- checksum: 10c0/1dd6564a5299f3e9d60a29a6ba6aa8841d94b5622975b71247c7e070e42104f09ef066e44283eb245e20058fb3079881c30bc961dc7e4168ebda0d1dcd66e824
+ checksum: 10c0/ebe3835165ad942c1eb7c3c1e4d5b2d24f9638ce6b849918efe945cf4a41a856c3287b12e9f0ab166a128276f36a74f4482da0c505c3e5a339b47f90b800b65f
languageName: node
linkType: hard
@@ -19155,6 +20293,15 @@ __metadata:
languageName: node
linkType: hard
+"stream-json@npm:^1.7.4, stream-json@npm:^1.7.5":
+ version: 1.9.1
+ resolution: "stream-json@npm:1.9.1"
+ dependencies:
+ stream-chain: "npm:^2.2.5"
+ checksum: 10c0/0521e5cb3fb6b0e2561d715975e891bd81fa77d0239c8d0b1756846392bc3c7cdd7f1ddb0fe7ed77e6fdef58daab9e665d3b39f7d677bd0859e65a2bff59b92c
+ languageName: node
+ linkType: hard
+
"string-length@npm:^4.0.2":
version: 4.0.2
resolution: "string-length@npm:4.0.2"
@@ -19536,6 +20683,15 @@ __metadata:
languageName: node
linkType: hard
+"telnet-client@npm:1.2.8":
+ version: 1.2.8
+ resolution: "telnet-client@npm:1.2.8"
+ dependencies:
+ bluebird: "npm:^3.5.4"
+ checksum: 10c0/f6700268a9734305655e305540b21e18f0ab05923d4917fcf8c3192ced596ce4675c61e8b8d6ad12c89bf7eb2d38f2792ead5eb721c7055c4e924f9312913a34
+ languageName: node
+ linkType: hard
+
"temp-dir@npm:^3.0.0":
version: 3.0.0
resolution: "temp-dir@npm:3.0.0"
@@ -19590,6 +20746,24 @@ __metadata:
languageName: node
linkType: hard
+"test-exclude@npm:^7.0.1":
+ version: 7.0.2
+ resolution: "test-exclude@npm:7.0.2"
+ dependencies:
+ "@istanbuljs/schema": "npm:^0.1.2"
+ glob: "npm:^10.4.1"
+ minimatch: "npm:^10.2.2"
+ checksum: 10c0/b79b855af9168c6a362146015ccf40f5e3a25e307304ba9bea930818507f6319d230380d5d7b5baa659c981ccc11f1bd21b6f012f85606353dec07e02dee67c9
+ languageName: node
+ linkType: hard
+
+"text-hex@npm:1.0.x":
+ version: 1.0.0
+ resolution: "text-hex@npm:1.0.0"
+ checksum: 10c0/57d8d320d92c79d7c03ffb8339b825bb9637c2cbccf14304309f51d8950015c44464b6fd1b6820a3d4821241c68825634f09f5a2d9d501e84f7c6fd14376860d
+ languageName: node
+ linkType: hard
+
"text-table@npm:~0.2.0":
version: 0.2.0
resolution: "text-table@npm:0.2.0"
@@ -19665,6 +20839,13 @@ __metadata:
languageName: node
linkType: hard
+"tmp@npm:^0.2.1":
+ version: 0.2.7
+ resolution: "tmp@npm:0.2.7"
+ checksum: 10c0/59eb55584f2f07210d3231b6a1f6b5c2b9794d8a7b509c8ee867ed2acad6d2245ee2448b7937b676ffbff3155a70077edde8a69f9d7cf0f90c86a62e8910c357
+ languageName: node
+ linkType: hard
+
"tmpl@npm:1.0.5":
version: 1.0.5
resolution: "tmpl@npm:1.0.5"
@@ -19702,6 +20883,15 @@ __metadata:
languageName: node
linkType: hard
+"trace-event-lib@npm:^1.3.1":
+ version: 1.4.1
+ resolution: "trace-event-lib@npm:1.4.1"
+ dependencies:
+ browser-process-hrtime: "npm:^1.0.0"
+ checksum: 10c0/301faf760c8d956d9879f1eaf8a949d480126a1af0d88d33299f1580401e32358d48726750ac8903a4e9be1d0c5f68bdc13453e067ca2bc93565ea381123c4bd
+ languageName: node
+ linkType: hard
+
"traverse@npm:0.6.8":
version: 0.6.8
resolution: "traverse@npm:0.6.8"
@@ -19716,6 +20906,22 @@ __metadata:
languageName: node
linkType: hard
+"triple-beam@npm:^1.3.0":
+ version: 1.4.1
+ resolution: "triple-beam@npm:1.4.1"
+ checksum: 10c0/4bf1db71e14fe3ff1c3adbe3c302f1fdb553b74d7591a37323a7badb32dc8e9c290738996cbb64f8b10dc5a3833645b5d8c26221aaaaa12e50d1251c9aba2fea
+ languageName: node
+ linkType: hard
+
+"truncate-utf8-bytes@npm:^1.0.0":
+ version: 1.0.2
+ resolution: "truncate-utf8-bytes@npm:1.0.2"
+ dependencies:
+ utf8-byte-length: "npm:^1.0.1"
+ checksum: 10c0/af2b431fc4314f119b551e5fccfad49d4c0ef82e13ba9ca61be6567801195b08e732ce9643542e8ad1b3df44f3df2d7345b3dd34f723954b6bb43a14584d6b3c
+ languageName: node
+ linkType: hard
+
"ts-api-utils@npm:^2.5.0":
version: 2.5.0
resolution: "ts-api-utils@npm:2.5.0"
@@ -19737,7 +20943,7 @@ __metadata:
languageName: node
linkType: hard
-"tslib@npm:^2.1.0, tslib@npm:^2.4.0":
+"tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.5.3":
version: 2.8.1
resolution: "tslib@npm:2.8.1"
checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62
@@ -19787,6 +20993,13 @@ __metadata:
languageName: node
linkType: hard
+"type-fest@npm:^0.20.2":
+ version: 0.20.2
+ resolution: "type-fest@npm:0.20.2"
+ checksum: 10c0/dea9df45ea1f0aaa4e2d3bed3f9a0bfe9e5b2592bddb92eb1bf06e50bcf98dbb78189668cd8bc31a0511d3fc25539b4cd5c704497e53e93e2d40ca764b10bfc3
+ languageName: node
+ linkType: hard
+
"type-fest@npm:^0.21.3":
version: 0.21.3
resolution: "type-fest@npm:0.21.3"
@@ -20235,6 +21448,13 @@ __metadata:
languageName: node
linkType: hard
+"utf8-byte-length@npm:^1.0.1":
+ version: 1.0.5
+ resolution: "utf8-byte-length@npm:1.0.5"
+ checksum: 10c0/e69bda3299608f4cc75976da9fb74ac94801a58b9ca29fdad03a20ec952e7477d7f226c12716b5f36bd4cff8151d1d152d02ee1df3752f017d4b2c725ce3e47a
+ languageName: node
+ linkType: hard
+
"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1":
version: 1.0.2
resolution: "util-deprecate@npm:1.0.2"
@@ -20532,6 +21752,17 @@ __metadata:
languageName: node
linkType: hard
+"which@npm:^1.3.1":
+ version: 1.3.1
+ resolution: "which@npm:1.3.1"
+ dependencies:
+ isexe: "npm:^2.0.0"
+ bin:
+ which: ./bin/which
+ checksum: 10c0/e945a8b6bbf6821aaaef7f6e0c309d4b615ef35699576d5489b4261da9539f70393c6b2ce700ee4321c18f914ebe5644bc4631b15466ffbaad37d83151f6af59
+ languageName: node
+ linkType: hard
+
"which@npm:^2.0.1, which@npm:^2.0.2":
version: 2.0.2
resolution: "which@npm:2.0.2"
@@ -20554,6 +21785,36 @@ __metadata:
languageName: node
linkType: hard
+"winston-transport@npm:^4.9.0":
+ version: 4.9.0
+ resolution: "winston-transport@npm:4.9.0"
+ dependencies:
+ logform: "npm:^2.7.0"
+ readable-stream: "npm:^3.6.2"
+ triple-beam: "npm:^1.3.0"
+ checksum: 10c0/e2990a172e754dbf27e7823772214a22dc8312f7ec9cfba831e5ef30a5d5528792e5ea8f083c7387ccfc5b2af20e3691f64738546c8869086110a26f98671095
+ languageName: node
+ linkType: hard
+
+"winston@npm:^3.17.0":
+ version: 3.19.0
+ resolution: "winston@npm:3.19.0"
+ dependencies:
+ "@colors/colors": "npm:^1.6.0"
+ "@dabh/diagnostics": "npm:^2.0.8"
+ async: "npm:^3.2.3"
+ is-stream: "npm:^2.0.0"
+ logform: "npm:^2.7.0"
+ one-time: "npm:^1.0.0"
+ readable-stream: "npm:^3.4.0"
+ safe-stable-stringify: "npm:^2.3.1"
+ stack-trace: "npm:0.0.x"
+ triple-beam: "npm:^1.3.0"
+ winston-transport: "npm:^4.9.0"
+ checksum: 10c0/341a8ccfb726120209d34e2466040e2ca72cadb1a3402c4fc90425facad002b81275675b4ab9b4432a624311bc47ef7c9fb7652c86fca454d2be2f2ee1882226
+ languageName: node
+ linkType: hard
+
"word-wrap@npm:^1.2.5":
version: 1.2.5
resolution: "word-wrap@npm:1.2.5"
@@ -20673,6 +21934,21 @@ __metadata:
languageName: node
linkType: hard
+"ws@npm:^7.0.0":
+ version: 7.5.13
+ resolution: "ws@npm:7.5.13"
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: ^5.0.2
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+ checksum: 10c0/e223726bbda5768ed58ec9252d04eda7018ef380ea122bb94e595a4d7a02b5baeb423933936576fddb8fa51b642375f7ae814bf0f6f5d9a3ce290566578a8c5a
+ languageName: node
+ linkType: hard
+
"ws@npm:^8.12.1, ws@npm:^8.20.1":
version: 8.21.0
resolution: "ws@npm:8.21.0"
@@ -20797,7 +22073,7 @@ __metadata:
languageName: node
linkType: hard
-"yargs-parser@npm:^21.1.1":
+"yargs-parser@npm:^21.0.0, yargs-parser@npm:^21.1.1":
version: 21.1.1
resolution: "yargs-parser@npm:21.1.1"
checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2
@@ -20811,6 +22087,18 @@ __metadata:
languageName: node
linkType: hard
+"yargs-unparser@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "yargs-unparser@npm:2.0.0"
+ dependencies:
+ camelcase: "npm:^6.0.0"
+ decamelize: "npm:^4.0.0"
+ flat: "npm:^5.0.2"
+ is-plain-obj: "npm:^2.1.0"
+ checksum: 10c0/a5a7d6dc157efa95122e16780c019f40ed91d4af6d2bac066db8194ed0ec5c330abb115daa5a79ff07a9b80b8ea80c925baacf354c4c12edd878c0529927ff03
+ languageName: node
+ linkType: hard
+
"yargs@npm:^15.1.0":
version: 15.4.1
resolution: "yargs@npm:15.4.1"
@@ -20845,6 +22133,21 @@ __metadata:
languageName: node
linkType: hard
+"yargs@npm:^17.0.0":
+ version: 17.7.3
+ resolution: "yargs@npm:17.7.3"
+ dependencies:
+ cliui: "npm:^8.0.1"
+ escalade: "npm:^3.1.1"
+ get-caller-file: "npm:^2.0.5"
+ require-directory: "npm:^2.1.1"
+ string-width: "npm:^4.2.3"
+ y18n: "npm:^5.0.5"
+ yargs-parser: "npm:^21.1.1"
+ checksum: 10c0/7a28572f7e785a57886e34fdbddb9b28756dec552e1453d5f6e7cdd00ad8721a4e8c4321d33683f5e61cacb36ad43258adbb48396b71ec4ed14abee0fc0d0c1f
+ languageName: node
+ linkType: hard
+
"yargs@npm:^17.5.1, yargs@npm:^17.6.2, yargs@npm:^17.7.2":
version: 17.7.2
resolution: "yargs@npm:17.7.2"