diff --git a/.agents/skills/requesting-mobile-releases/SKILL.md b/.agents/skills/requesting-mobile-releases/SKILL.md new file mode 100644 index 000000000000..27e2d15322b7 --- /dev/null +++ b/.agents/skills/requesting-mobile-releases/SKILL.md @@ -0,0 +1,108 @@ +--- +name: requesting-mobile-releases +description: > + Decides whether a PostHog mobile app change can ship as an over-the-air (OTA) update or needs a native + store release, then creates the release request file under products/mobile/releases/requests/. Use + before creating or editing a mobile release request, when asked to ship, release, publish or push an + update of the mobile app, and when a change under products/mobile touches package.json, + pnpm-lock.yaml, app.config.ts, eas.json, plugins/, targets/, modules/ or any expo-* or react-native-* + dependency. Covers the native fingerprint comparison, the version rule, and what the request must say. +--- + +# Requesting mobile releases + +A merged release request is the release trigger. +Mobile Release publishes it from `master` with no human gate. +An `ota` request goes straight to the production channel that installed apps pull from. +A `native` request submits both binaries to TestFlight and Play internal testing. +Decide the kind before you create the file, and say which kind you chose and why in the PR description. + +[RELEASING.md](../../../products/mobile/docs/RELEASING.md) has the commands and the recovery procedure. +[OTA.md](../../../products/mobile/docs/OTA.md) has the runtime rules. +This skill is the decision procedure. + +## 1. Classify the change + +An OTA update replaces JavaScript and assets only. +The installed binary keeps its native code, so anything the native fingerprint covers forces a native release. +A change needs `native` when it touches any of: + +- a dependency with native code: most `expo-*`, `react-native-*` and `@react-native-*` packages, `@shopify/react-native-skia`, and anything that ships `ios/` or `android/` directories in `node_modules`; +- `app.config.ts` fields under `ios`, `android`, `plugins`, `runtimeVersion` or `version`, and icons, splash, entitlements or permissions; +- `plugins/`, `targets/` (widgets), `modules/` (local native modules), or the build profiles in `eas.json`; +- the Expo SDK or React Native version; +- a `pnpm-lock.yaml` change that adds or bumps any of the above, also transitively. + +Changes limited to `src/`, `app/` routes, assets, copy and fixtures are OTA candidates. + +Quick scan from `products/mobile`: + +```sh +git diff origin/master --stat -- package.json pnpm-lock.yaml app.config.ts eas.json plugins targets modules +``` + +An empty result means the change is probably OTA-safe. +A non-empty result does not make it native by itself. Run step 2 either way. + +## 2. Confirm with the fingerprint + +`pnpm release:fingerprint` prints the iOS and Android native fingerprints of the checkout, computed the same way the publisher computes them. +Compare the branch against the commit of the last completed native release: + +```sh +# Find the last completed release and its native baseline commit. +TAG_SHA=$(gh api repos/PostHog/posthog/git/ref/tags/mobile-production -q .object.sha) +LAST_ID=$(gh api "repos/PostHog/posthog/git/tags/$TAG_SHA" -q .message | jq -r .id) +BASE=$(gh release view "mobile-release/$LAST_ID" --repo PostHog/posthog --json body -q .body | jq -r .native.commit) + +# Head: in the branch, from products/mobile. +pnpm install --frozen-lockfile && pnpm release:fingerprint > /tmp/fp-head.json + +# Base: a disposable worktree at the baseline commit. +git worktree add --detach /tmp/mobile-fp-base "$BASE" +(cd /tmp/mobile-fp-base/products/mobile && pnpm install --frozen-lockfile && pnpm release:fingerprint > /tmp/fp-base.json) +git worktree remove --force /tmp/mobile-fp-base + +diff /tmp/fp-base.json /tmp/fp-head.json +``` + +Before the first hosted native release there is no `mobile-production` tag. Compare against `origin/master` instead, and remember that the first hosted release must be `native`. + +- Identical fingerprints: the change can ship as `ota`. +- Any difference: request `native` and raise `version` in `app.config.ts`. + +Compute both sides on the same machine. +Locally neither side has the EAS production environment (`GOOGLE_SERVICES_JSON`, `APPLE_TEAM_ID`), so a local fingerprint never matches the one in a receipt, but a local base-to-head comparison is valid. +The publisher repeats the comparison with the real environment after the merge and refuses an incompatible OTA. Catch it here, before the PR merges. + +## 3. Apply the version rule + +- `ota`: keep `version` in `app.config.ts` equal to the runtime of the last native release. +- `native`: raise `version` above it. Build numbers come from EAS (`autoIncrement`); do not set them by hand. + +`pnpm release:check` enforces this against the PR base in CI. + +## 4. Create the request + +From `products/mobile`, with `LAST_ID` from step 2 as the predecessor: + +```sh +pnpm release:request ota --id --previous "$LAST_ID" --notes "What testers should verify." +pnpm release:request native --id native- --previous "$LAST_ID" --notes "What changed natively." +pnpm release:check +pnpm release:test +``` + +Rules the publisher enforces: + +- One new request per PR. A push that adds two fails. +- Requests are append-only. Never edit, rename or delete a merged request. +- Commit the request with the app change it releases. +- The notes become the body of a public GitHub prerelease. Keep customer names, credentials and internal links out of them. + +Run `/running-mobile-demo-flows` on the same branch before you open the PR. + +## 5. Report + +State the kind, the fingerprint result per platform, the predecessor id, and the app version. +If you could not run the fingerprint comparison, say so and do not create an `ota` request. diff --git a/.agents/skills/running-mobile-demo-flows/SKILL.md b/.agents/skills/running-mobile-demo-flows/SKILL.md new file mode 100644 index 000000000000..aeab10d0ba14 --- /dev/null +++ b/.agents/skills/running-mobile-demo-flows/SKILL.md @@ -0,0 +1,79 @@ +--- +name: running-mobile-demo-flows +description: > + Runs the PostHog mobile app's Argent demo flows (demo-smoke and views-smoke) on a local iOS simulator + against a bundled Release build, and reads their results. Use before reporting a change under + products/mobile that touches a screen, navigation, the demo fixtures, a testID, or a flow file as done, + and when asked to smoke-test the mobile app. CI does not run these flows, so the local run is the only + UI check a change gets. +--- + +# Running mobile demo flows + +The flows live in `products/mobile/.argent/flows/`. +`demo-smoke` walks the inbox, a report, the hand-off sheet, decisions and chat in demo mode. +`views-smoke` covers the Views tab. +Both start and end on the sign-in screen with demo mode off, so a run can repeat right away. +[TESTING.md](../../../products/mobile/docs/TESTING.md#demo-walkthrough-on-the-ios-simulator) lists each step. + +## When to run them + +- A change under `products/mobile/app/`, `src/features/`, `src/ui/`, navigation, or the demo fixtures. +- A change to a flow YAML or to a `testID` the flows use. +- Before you create a release request (see `/requesting-mobile-releases`). + +Skip them for a change that only touches docs, tests, backend code, or build configuration. + +## Prerequisites, once per machine + +- Xcode with an iOS simulator runtime, and CocoaPods. The scripts are verified on Xcode 26.6 with iOS 26.5 and on Xcode 27.1 with iOS 27.0. +- Argent 0.30.1 in a private prefix, so it does not collide with an editor's Argent server: + +```sh +DO_NOT_TRACK=1 npm install --prefix /tmp/mobile-argent-tools --no-audit --no-fund @swmansion/argent@0.30.1 +``` + +## Run the bundled flows + +From `products/mobile`: + +```sh +export PATH="/tmp/mobile-argent-tools/node_modules/.bin:$PATH" +export DO_NOT_TRACK=1 +pnpm install --frozen-lockfile +bash scripts/ci-argent-build.sh /tmp/mobile-argent-build /tmp/mobile-argent-artifacts +bash scripts/ci-argent.sh \ + /tmp/mobile-argent-build/Build/Products/Release-iphonesimulator/PostHog.app \ + /tmp/mobile-argent-artifacts +``` + +Budget about 8 minutes for a fresh native build and about 3 minutes for both flows. +The build regenerates the ignored `ios/` directory, so use a disposable worktree when the checkout has manual native edits. +The flow script creates its own temporary simulator, so other booted simulators do not interfere. +Set `ARGENT_IOS_RUNTIME` to a runtime id from `xcrun simctl list runtimes` to pick another installed runtime. + +## Read the result + +- Exit code 0 means both flows passed. +- On failure, open `/tmp/mobile-argent-artifacts/`: the JSON report has one entry per step, failed steps have a screenshot, and `prebuild.log`, `pods.log` and `build.log` explain a native build failure. +- Both flows run even when the first fails. Report each one. +- An `idle` step can warn that a small part of the screen was still changing, such as the text caret. The next step waits for a stable element, so this warning is expected. + +## Iterate on a flow with the dev client + +The bundled build is slow to rebuild. While you edit a flow or a screen, use Metro instead: + +1. `pnpm ios --port 8181` builds the dev client, boots a simulator and starts Metro. +2. In the dev menu, turn off **Tools button** and **Fast refresh**. The floating gear swallows taps on the inbox More button, and the refresh banner covers Back and Close. +3. Make sure demo mode is off (Settings, **Exit demo**). +4. `argent flow run demo-smoke --platform ios` from `products/mobile`. Add `--device ` when more than one simulator is booted. + +Known traps: a system "Open in PostHog?" alert after a deep link is invisible to Argent selectors, so dismiss it with a raw tap. +If every selector step times out with a ViewInspector RPC error, drive by normalized coordinates and screenshots instead. +[TESTING.md](../../../products/mobile/docs/TESTING.md#troubleshooting) has the rest. + +## Report + +State which flows ran, the Xcode and runtime versions, and pass or fail per flow. +Name the failed step and its screenshot path when one failed. +Do not commit `.argent/recordings/` or the artifact directory. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 259ff2056a99..77a93220f6ce 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -91,3 +91,8 @@ products/isolation_baseline.txt @PostHog/team-devex /CLAUDE.md @PostHog/team-devex .github/pull_request_template.md @PostHog/team-devex .agents/skills/writing-pr-descriptions/ @PostHog/team-devex + +# The mobile owner reviews app changes and the workflows that build and release it. +products/mobile/** @cvolzer3 +products/desktop/apps/mobile/** @cvolzer3 +.github/workflows/*mobile*.yml @cvolzer3 diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml index 38c96772b9d3..063323e45209 100644 --- a/.github/workflows/ci-python.yml +++ b/.github/workflows/ci-python.yml @@ -140,12 +140,16 @@ jobs: - 'products/desktop/packages/core/src/inbox/objectKinds.generated.ts' - 'packages/agent/packages/agent-contracts/src/objectTagKinds.generated.ts' - 'frontend/src/lib/components/AgentObjectTags/objectKinds.generated.ts' + - 'products/mobile/src/features/chat/objectKinds.generated.ts' - 'products/tasks/frontend/modelCatalog.generated.ts' - 'packages/agent/packages/agent-contracts/src/model-catalog.generated.ts' - 'services/mcp/src/lib/oauth-scopes.generated.ts' - 'frontend/src/lib/oauthScopes.generated.ts' - 'products/commands/commands.generated.json' - 'products/commands/COMMANDS.generated.md' + # Formatter inputs can change the generated mobile projection. + - 'products/mobile/biome.json' + - 'products/mobile/pnpm-lock.yaml' # hogli CLI changes need validation - 'tools/hogli/**' - 'tools/hogli-commands/hogli_commands/**' diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml new file mode 100644 index 000000000000..bb9e20077397 --- /dev/null +++ b/.github/workflows/mobile-build.yml @@ -0,0 +1,115 @@ +name: Mobile Build + +# Queues internal development/preview builds. Production binaries and OTA updates +# are published only by Mobile Release after a release request merges to master. +# Release steps: products/mobile/docs/RELEASING.md. +# +# Store credentials stay on EAS, set once with `eas credentials`, so this workflow +# never handles an Apple or Google key. + +on: + # Manual only. A pull request run executes code its author controls, so it must + # never reach the Expo token or the stores. + workflow_dispatch: + inputs: + profile: + description: 'EAS build profile' + type: choice + default: preview + options: + - development + - preview + platform: + description: 'Platform' + type: choice + default: all + options: + - all + - ios + - android + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.profile }}-${{ inputs.platform }} + # Let an explicit build dispatch finish uploading and submitting to EAS. + # This only serializes GitHub runs in this group. With --no-wait, EAS builds + # continue independently and may overlap. + cancel-in-progress: false + +permissions: + contents: read + +jobs: + build: + if: github.repository == 'PostHog/posthog' && github.ref == 'refs/heads/master' + environment: mobile-build + name: EAS build (${{ inputs.profile }}, ${{ inputs.platform }}) + runs-on: depot-ubuntu-24.04 + timeout-minutes: 20 + defaults: + run: + working-directory: products/mobile + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + persist-credentials: false + sparse-checkout: products/mobile + sparse-checkout-cone-mode: false + + - name: Setup pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: products/mobile/package.json + env: + # See mobile-ci.yml: a stalled npm audit or fetch otherwise waits 300s. + NPM_CONFIG_FETCH_TIMEOUT: 45000 + NPM_CONFIG_FETCH_RETRIES: 2 + NPM_CONFIG_AUDIT: 'false' + NPM_CONFIG_FUND: 'false' + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: products/mobile/.node-version + token: ${{ github.token }} + + - name: Get pnpm store path + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + # Same key as mobile-ci.yml, which writes it on master. Restore only. + - name: Restore pnpm store + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: mobile-pnpm-${{ runner.os }}-${{ hashFiles('products/mobile/pnpm-lock.yaml') }} + restore-keys: mobile-pnpm-${{ runner.os }}- + + # EAS CLI evaluates app.config.ts and its config plugins, which need the + # app's dependencies on disk. + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Setup EAS + uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0 + with: + # Pinned so a new eas-cli release cannot change a release build unannounced. + eas-version: 24.8.0 + token: ${{ secrets.MOBILE_BUILD_EXPO_TOKEN }} + + - name: EAS internal build + env: + PLATFORM: ${{ inputs.platform }} + PROFILE: ${{ inputs.profile }} + run: | + if [[ "$PROFILE" != "development" && "$PROFILE" != "preview" ]]; then + echo "::error::Use a merged mobile release request for production builds." + exit 1 + fi + sh scripts/eas-build.sh \ + --non-interactive \ + --no-wait \ + --platform "$PLATFORM" \ + --profile "$PROFILE" \ + --message "${GITHUB_REF_NAME}@${GITHUB_SHA::12}" diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml new file mode 100644 index 000000000000..63dc81d3b9ff --- /dev/null +++ b/.github/workflows/mobile-ci.yml @@ -0,0 +1,199 @@ +name: Mobile CI + +# Typecheck, lint, unit tests and release metadata checks for the mobile app. +# The app is its own pnpm workspace with its own lockfile, so this workflow never +# installs the repo root workspace. +# +# "Mobile CI Pass" reports on every pull request so repository rules can require it. +# PR housekeeping calls this workflow on each PR event, and the changes job turns +# the run into a no-op when the diff does not touch the app. A trigger-level +# paths filter would leave unrelated PRs waiting for a status that never arrives. + +on: + workflow_call: + secrets: + GH_APP_POSTHOG_PATHS_FILTER_PRIVATE_KEY: + required: true + # Master pushes refresh the pnpm store cache that PR runs restore. + push: + branches: [master] + paths: + - 'products/mobile/**' + - '!products/mobile/**/*.md' + - 'products/signals/frontend/generated/api.schemas.ts' + - 'products/error_tracking/frontend/generated/api.schemas.ts' + - '.github/workflows/mobile-ci.yml' + +concurrency: + # github.workflow is the caller's name, so the release caller and the PR + # caller never cancel or wait on each other. + group: mobile-ci-${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.head_ref || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + changes: + name: Detect mobile changes + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + # A push has no PR diff to filter: a master push already matched the + # trigger paths, and the release caller validates its exact commit. + mobile: ${{ steps.filter.outputs.mobile || 'true' }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: .github/actions/paths-filter + sparse-checkout-cone-mode: false + - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + id: app-token + if: github.repository == 'PostHog/posthog' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' + with: + client-id: ${{ vars.GH_APP_POSTHOG_PATHS_FILTER_APP_ID }} + private-key: ${{ secrets.GH_APP_POSTHOG_PATHS_FILTER_PRIVATE_KEY }} + permission-pull-requests: read + - uses: ./.github/actions/paths-filter + id: filter + if: github.event_name != 'push' + with: + token: ${{ steps.app-token.outputs.token || github.token }} + filters: | + mobile: + - 'products/mobile/**' + - '!products/mobile/**/*.md' + - 'products/signals/frontend/generated/api.schemas.ts' + - 'products/error_tracking/frontend/generated/api.schemas.ts' + - '.github/workflows/mobile-ci.yml' + + check: + name: Typecheck, lint and test + needs: changes + if: needs.changes.outputs.mobile == 'true' + runs-on: depot-ubuntu-24.04 + timeout-minutes: 15 + env: + # Reusable workflows keep the caller's name. Only the direct master + # trigger warms the cache; release callers must validate their commit. + MOBILE_RUN_CHECKS: ${{ github.event_name != 'push' || github.workflow != 'Mobile CI' }} + defaults: + run: + working-directory: products/mobile + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: | + products/mobile + products/signals/frontend/generated/api.schemas.ts + products/error_tracking/frontend/generated/api.schemas.ts + sparse-checkout-cone-mode: false + + # Reads packageManager from the app's package.json, not the repo root's, + # so the pnpm that wrote products/mobile/pnpm-lock.yaml also installs it. + - name: Setup pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: products/mobile/package.json + env: + # The action bootstraps pnpm with `npm ci`. A stalled npm audit or fetch + # waits for npm's 300s default, so cap each attempt and skip the audit. + # See .github/actions/setup-pnpm for the measured failure. + NPM_CONFIG_FETCH_TIMEOUT: 45000 + NPM_CONFIG_FETCH_RETRIES: 2 + NPM_CONFIG_AUDIT: 'false' + NPM_CONFIG_FUND: 'false' + + # The app pins its own Node in .node-version, which EAS builds read too. + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: products/mobile/.node-version + token: ${{ github.token }} + + - name: Get pnpm store path + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + # Explicit restore and save instead of setup-node's `cache: pnpm`, which + # saves on every run, PRs included. Only master pushes write the cache. + - name: Restore pnpm store + id: pnpm-cache + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: mobile-pnpm-${{ runner.os }}-${{ hashFiles('products/mobile/pnpm-lock.yaml') }} + restore-keys: mobile-pnpm-${{ runner.os }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Save pnpm store + if: github.ref == 'refs/heads/master' && steps.pnpm-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: mobile-pnpm-${{ runner.os }}-${{ hashFiles('products/mobile/pnpm-lock.yaml') }} + + - name: Typecheck + if: env.MOBILE_RUN_CHECKS == 'true' + run: pnpm typecheck + + - name: Lint + if: env.MOBILE_RUN_CHECKS == 'true' + run: pnpm lint + + - name: Test + if: env.MOBILE_RUN_CHECKS == 'true' + run: pnpm test --ci + + # Workflow changes also reach branches that have not rebased onto the + # release tooling yet. Those branches still run the existing checks. + - name: Fetch release validation base + if: env.MOBILE_RUN_CHECKS == 'true' && hashFiles('products/mobile/scripts/releases/release.mjs') != '' && (github.event.pull_request.base.sha != '' || github.event.before != '') + env: + MOBILE_RELEASE_BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + run: git fetch --no-tags --depth=1 --filter=blob:none origin "$MOBILE_RELEASE_BASE" + + - name: Validate release requests + if: env.MOBILE_RUN_CHECKS == 'true' && hashFiles('products/mobile/scripts/releases/release.mjs') != '' + env: + MOBILE_RELEASE_BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + run: pnpm release:check + + - name: Test release policy + if: env.MOBILE_RUN_CHECKS == 'true' && hashFiles('products/mobile/scripts/releases/release.mjs') != '' + run: pnpm release:test + + mobile_checks: + name: Mobile CI Pass + needs: [changes, check] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + if: ${{ !cancelled() }} + steps: + - name: Check all mobile jobs + run: | + # Fail if change detection failed or was cancelled (outputs are empty on cancel, so a skipped check would report green). + if [[ "${{ needs.changes.result }}" != "success" && "${{ needs.changes.result }}" != "skipped" ]]; then + echo "Change detection did not succeed (result: ${{ needs.changes.result }})." + exit 1 + fi + + if [[ "${{ needs.changes.outputs.mobile }}" != "true" ]]; then + echo "Mobile checks were skipped (no relevant changes)" + exit 0 + fi + + if [[ "${{ needs.check.result }}" != "success" && "${{ needs.check.result }}" != "skipped" ]]; then + echo "Mobile typecheck, lint, test or release checks failed." + exit 1 + fi + + echo "All mobile checks passed." diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml new file mode 100644 index 000000000000..3c70e9bd9893 --- /dev/null +++ b/.github/workflows/mobile-release.yml @@ -0,0 +1,125 @@ +name: Mobile Release + +on: + push: + branches: [master] + paths: + - 'products/mobile/releases/requests/*.json' + +concurrency: + group: ${{ github.workflow }}-${{ github.sha }} + cancel-in-progress: false + +permissions: + contents: read + pull-requests: read + +jobs: + plan: + if: github.repository == 'PostHog/posthog' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + present: ${{ steps.plan.outputs.present }} + kind: ${{ steps.plan.outputs.kind }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + persist-credentials: false + sparse-checkout: products/mobile + sparse-checkout-cone-mode: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: products/mobile/.node-version + - name: Fetch the exact push base + env: + RELEASE_BASE: ${{ github.event.before }} + run: git fetch --no-tags --depth=1000 --filter=blob:none origin "$RELEASE_BASE" + - name: Validate the newly merged request + id: plan + env: + RELEASE_BASE: ${{ github.event.before }} + RELEASE_HEAD: ${{ github.sha }} + run: node products/mobile/scripts/releases/release.mjs plan --base "$RELEASE_BASE" --head "$RELEASE_HEAD" + + checks: + needs: plan + if: needs.plan.outputs.present == 'true' + uses: ./.github/workflows/mobile-ci.yml + secrets: + GH_APP_POSTHOG_PATHS_FILTER_PRIVATE_KEY: ${{ secrets.GH_APP_POSTHOG_PATHS_FILTER_PRIVATE_KEY }} + + publish: + needs: [plan, checks] + if: needs.plan.outputs.present == 'true' + runs-on: depot-ubuntu-24.04 + timeout-minutes: 180 + environment: mobile-production + # GitHub retains one pending job. A replaced predecessor is detected by + # previousRelease and fails closed; rerun that original commit first. + concurrency: + group: mobile-production-publish + cancel-in-progress: false + permissions: + contents: write + deployments: write + defaults: + run: + working-directory: products/mobile + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + persist-credentials: false + fetch-depth: 1000 + filter: blob:none + sparse-checkout: products/mobile + sparse-checkout-cone-mode: false + - name: Fetch the exact push base + env: + RELEASE_BASE: ${{ github.event.before }} + run: git fetch --no-tags --depth=1000 --filter=blob:none origin "$RELEASE_BASE" + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: products/mobile/package.json + env: + NPM_CONFIG_FETCH_TIMEOUT: 45000 + NPM_CONFIG_FETCH_RETRIES: 2 + NPM_CONFIG_AUDIT: 'false' + NPM_CONFIG_FUND: 'false' + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: products/mobile/.node-version + - name: Install dependencies + run: pnpm install --frozen-lockfile + - uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0 + with: + eas-version: 24.8.0 + token: ${{ secrets.MOBILE_PRODUCTION_EXPO_TOKEN }} + - name: Stage and publish the exact tested source + env: + GH_TOKEN: ${{ github.token }} + MOBILE_RELEASE_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + APP_VARIANT: production + EXPO_PUBLIC_SIGNUP_LINK: '0' + MOBILE_OTA_SIGNING_KEY: ${{ needs.plan.outputs.kind == 'ota' && secrets.MOBILE_OTA_SIGNING_KEY || '' }} + run: | + # Keep the OTA key outside the uploaded project, and unavailable to + # dependency installation. Native builds never receive this key. + if [[ -n "$MOBILE_OTA_SIGNING_KEY" ]]; then + umask 077 + MOBILE_OTA_SIGNING_KEY_PATH=$(mktemp "$RUNNER_TEMP/mobile-ota-private-key.XXXXXX") + export MOBILE_OTA_SIGNING_KEY_PATH + printf '%s' "$MOBILE_OTA_SIGNING_KEY" > "$MOBILE_OTA_SIGNING_KEY_PATH" + trap 'rm -f "$MOBILE_OTA_SIGNING_KEY_PATH"' EXIT + fi + unset MOBILE_OTA_SIGNING_KEY + node scripts/releases/release.mjs publish + - name: Preserve the release receipt + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: mobile-release-receipt-${{ github.sha }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/mobile-release-receipt.json + if-no-files-found: ignore diff --git a/.github/workflows/pr-housekeeping.yml b/.github/workflows/pr-housekeeping.yml index 5fed5e497c59..2bf7aeec9866 100644 --- a/.github/workflows/pr-housekeeping.yml +++ b/.github/workflows/pr-housekeeping.yml @@ -36,6 +36,14 @@ jobs: if: contains(fromJSON('["opened", "synchronize", "reopened"]'), github.event.action) uses: ./.github/workflows/ci-turbo.yml + mobile-ci: + # Dispatch on every PR so repository rules can require "Mobile CI Pass". + # Its own changes job skips the work when the diff does not touch the app. + if: contains(fromJSON('["opened", "synchronize", "reopened"]'), github.event.action) + uses: ./.github/workflows/mobile-ci.yml + secrets: + GH_APP_POSTHOG_PATHS_FILTER_PRIVATE_KEY: ${{ secrets.GH_APP_POSTHOG_PATHS_FILTER_PRIVATE_KEY }} + trunk-impacted-targets: # Also on ready_for_review: Trunk keys the upload to a head SHA, and a PR # can sit in draft long enough for the queue to have forgotten it. diff --git a/.gitignore b/.gitignore index 7cc77d6078c7..8d579516f9e7 100644 --- a/.gitignore +++ b/.gitignore @@ -248,3 +248,6 @@ corpora/ /.context/ # Rendered by the image build with `manage.py render_wizard_tool_catalog` /products/wizard/tool-catalog.json + +# Argent (simulator driver) writes recordings wherever its tool server starts. +.argent/recordings/ diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 2852f9e36798..4201c6b93d13 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -109,6 +109,16 @@ "packages/agent/**/*.mdx", "packages/agent/pnpm-lock.yaml", "products/desktop/apps/code/snapshots.yml", + "products/mobile/**/*.ts", + "products/mobile/**/*.tsx", + "products/mobile/**/*.js", + "products/mobile/**/*.jsx", + "products/mobile/**/*.mjs", + "products/mobile/**/*.cjs", + "products/mobile/**/*.mts", + "products/mobile/**/*.json", + "products/mobile/**/*.jsonc", + "products/mobile/**/*.css", "frontend/snapshots.yml", "venv", "env", diff --git a/.oxlintrc.json b/.oxlintrc.json index 87bec18697d4..298e862e816e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -6,6 +6,7 @@ }, "ignorePatterns": [ "products/desktop", + "products/mobile", "packages/agent", "node_modules", "dist", diff --git a/docs/internal/mobile-release-checks.md b/docs/internal/mobile-release-checks.md new file mode 100644 index 000000000000..f0bfb7175e30 --- /dev/null +++ b/docs/internal/mobile-release-checks.md @@ -0,0 +1,77 @@ +# Mobile app release checks + +Use this checklist before sharing a build or publishing an update of the Expo + React Native app in +`products/mobile` for iOS and Android. Production native builds, store submissions and OTA updates use +[Mobile Release](../../products/mobile/docs/CI.md#mobile-release) after a release request merges to `master`. +Development and preview builds use [Mobile Build](../../products/mobile/docs/CI.md#mobile-build). Detailed instructions are in +[Releasing](../../products/mobile/docs/RELEASING.md). + +## Build and update checks + +- Confirm Mobile CI passes for the source commit. For production, include one native or OTA request in + the app PR and verify the completed Mobile Release receipt identifies the tested commit and exact + builds or update group. Native requests submit both platforms to TestFlight and Play internal testing; + public promotion is manual. For development or preview, select the profile and platforms in Mobile Build + and verify the resulting EAS build records that commit. The Argent demo flows run locally only and do not + replace the manual checks below. + +- Confirm the app identity and update channel. Development and preview identities have `.dev` and + `.preview` suffixes and install alongside production. + + | Production identity | Value | + | ------------------- | ------------------------- | + | iOS bundle ID | `com.posthog.code.mobile` | + | Android package | `com.posthog.mobile` | + | EAS project | `@posthog/posthog` | + + iOS updates the existing store app. Android uses a new Play record and installs separately from the + older `com.posthog.code.mobile` app. Stop the old iOS build workflow before distributing the replacement. + +- For native dependencies, config plugins or native configuration changes, raise `version` in + `app.config.ts` and add a `native` release request. OTA requests keep the installed runtime version and + require a completed hosted native baseline. The runtime follows the app version; the hosted publisher + also checks native fingerprints and production environment inputs. See + [OTA updates](../../products/mobile/docs/OTA.md) for compatibility and validation checks. +- Production OTA promotion reaches every compatible production-channel build, including TestFlight and + public-store builds; there is no separate TestFlight OTA audience or tester approval gate. Verify the + change before merge, then check the receipt and compatible-device behavior after publication. +- Confirm the matching backend APIs and OAuth scopes are available on the target host. Check + [known gaps](../../products/mobile/docs/GAPS.md) before treating an unavailable feature as a regression. +- Run the [demo walkthrough](../../products/mobile/docs/TESTING.md#demo-walkthrough-on-the-ios-simulator), + then test the actual build with a real account on each platform being shared. A web preview or passing + typecheck does not verify native sign-in, push delivery or the widget. + +## Manual smoke checks + +- **Sign-in and account boundaries.** Sign in, restart and confirm the session restores. Switch projects + and verify reports, chats and Views change together. Sign out and use another account; previous data, + drafts and selections must not appear. Check canceled sign-in and an expired session. +- **Inbox and reports.** Refresh and load older reports, open one and check its evidence and read state. + Resolve, dismiss and undo an action; confirm the result on the web. Start PostHog work and answer an + agent question. Check external handoff and Copy prompt; preparing a prompt must leave report state unchanged. +- **Chats.** Start a chat, reopen it from Chats, and continue a report discussion. Send text and an + attachment, check streamed replies and an agent question, and open a PR link. Queue a message while the + agent works. Reopen an unsent draft after restarting; a failed send must keep it. In a long conversation, + scrolling back and opening the keyboard must leave messages and the composer usable. +- **Views.** Open a dashboard, saved insight and web analytics. Change a date range and pull to refresh. + Pin and unpin a view, then confirm its star on the web. Check loading, empty, unavailable and retry states. +- **Notifications.** Use a physical device with working APNs or FCM credentials. Start or actively continue + a task from mobile, background the app, and check its notification opens the correct chat. Test a report + push and a tap after a cold start. Ordinary task alerts use the title "PostHog"; report alerts can have + report-specific titles. Check the master and category controls in Settings, foreground suppression for + the open chat, and a local snooze reminder. Report pushes also need `posthog-code-mobile-push`. + See [Push notifications](../../products/mobile/docs/PUSH.md) for setup and routing checks. +- **Recovery and appearance.** Disconnect during refresh or send, then reconnect. Show a retryable error, + preserve the draft and avoid duplicate sends. Check light and dark appearance, a narrow supported phone, + larger text and screen-reader labels on the main actions. +- **Hedgehog widget.** Install a fresh native build. Check small and medium iOS widgets and Android resizing: + the garden fills the widget, the hedgehog stays visible through walks and jumps, and taps change character + without resetting its position. Confirm the selection survives an app launch and refresh. Check iOS + Reduce Motion and the fallback before the app's first launch. + See the [widget guide](../../products/mobile/modules/hedgehog-widget/README.md). + +## Record the result + +Record the commit, app version and build number or update ID, platforms/devices checked, and any failures +or checks not run in the release notes or PR. Before store release, repeat the core sign-in, report, chat +and notification checks on the TestFlight and Play internal builds. diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index 6cc30fcbf8e3..4169cc6b9916 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -204,6 +204,7 @@ const config: Config = { '/products/[^/]+/frontend/e2e/', '/products/visual_review/cli/', '/products/desktop/', + '/products/mobile/', ], transform: { diff --git a/frontend/src/generated/core/api.ts b/frontend/src/generated/core/api.ts index f5a52cb18024..506f944d6d3e 100644 --- a/frontend/src/generated/core/api.ts +++ b/frontend/src/generated/core/api.ts @@ -3629,7 +3629,7 @@ export const getUsersPushTokensCreateUrl = (uuid: string) => { } /** - * Idempotent upsert: if the (user, token) pair already exists, `platform` and `last_seen_at` are refreshed. Otherwise a new row is created. + * Register the device for the authenticated user, removing registrations for the same token belonging to other users on this instance. Re-registration refreshes `platform` and `last_seen_at`. * @summary Register a push notification token */ export const usersPushTokensCreate = async ( diff --git a/frontend/src/generated/core/api.zod.ts b/frontend/src/generated/core/api.zod.ts index c7a106bb05e3..2447131fe4ff 100644 --- a/frontend/src/generated/core/api.zod.ts +++ b/frontend/src/generated/core/api.zod.ts @@ -1732,7 +1732,7 @@ export const UsersProductIntroSeenPartialUpdateBody = /* @__PURE__ */ zod .describe('Request body for PATCH \/api\/users\/@me\/product_intro_seen.') /** - * Idempotent upsert: if the (user, token) pair already exists, `platform` and `last_seen_at` are refreshed. Otherwise a new row is created. + * Register the device for the authenticated user, removing registrations for the same token belonging to other users on this instance. Re-registration refreshes `platform` and `last_seen_at`. * @summary Register a push notification token */ export const usersPushTokensCreateBodyTokenMax = 512 diff --git a/package.json b/package.json index f0b748c5e643..3640f73d1a87 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ ], "{playwright,frontend,products,common,ee,services,docs,hog}/**/*.{js,jsx,mjs,mts,ts,tsx}": "bin/hogli format:js", "products/desktop/**/*.{js,mjs,ts,tsx,json,jsonc,css}": "pnpm --dir products/desktop exec biome check --write --no-errors-on-unmatched --files-ignore-unknown=true", + "products/mobile/**/*.{js,mjs,ts,tsx,json,jsonc}": "sh -c 'cd products/mobile && pnpm exec biome check --write --no-errors-on-unmatched --files-ignore-unknown=true \"$@\"' _", "packages/agent/**/*.{js,mjs,mts,ts,tsx,json,jsonc}": "pnpm --dir packages/agent exec biome check --write --no-errors-on-unmatched --files-ignore-unknown=true", "nodejs/**/*.{js,jsx,mjs,mts,ts,tsx}": "bin/hogli format:nodejs", "funnel-udf/**/*.rs": "bin/hogli format:rust", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6773f955e8b1..6a0d168185ac 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,6 +21,9 @@ packages: # products/desktop is a nested standalone workspace (own lockfile, catalog, # overrides, Node version); the root install must not absorb it. - '!products/desktop' + # products/mobile is the standalone Expo app: its own lockfile and React 19, + # so the root install must not absorb it either. + - '!products/mobile' - docs/onboarding - services/agent-proxy - services/credentials-service diff --git a/posthog/api/test/test_user_push_token.py b/posthog/api/test/test_user_push_token.py index 141e9dc3ca81..d851e3c64e51 100644 --- a/posthog/api/test/test_user_push_token.py +++ b/posthog/api/test/test_user_push_token.py @@ -1,5 +1,5 @@ from posthog.test.base import APIBaseTest, BaseTest -from unittest.mock import patch +from unittest.mock import MagicMock, patch from django.db import connection from django.test.utils import CaptureQueriesContext @@ -93,7 +93,8 @@ def test_unregister_does_not_touch_other_users_tokens(self): self.assertFalse(UserPushToken.objects.filter(user=self.user, token="ExponentPushToken[shared]").exists()) self.assertTrue(UserPushToken.objects.filter(user=other_user, token="ExponentPushToken[shared]").exists()) - def test_register_does_not_leak_across_users(self): + @patch("posthog.push_notifications.requests.post") + def test_register_transfers_device_to_current_user(self, mock_post: MagicMock) -> None: other_user = self._create_user("other@example.com") UserPushToken.objects.create(user=other_user, token="ExponentPushToken[abc]", platform="ios") @@ -103,8 +104,19 @@ def test_register_does_not_leak_across_users(self): format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) - # Both users now own a row for that token (unique together is per-user). - self.assertEqual(UserPushToken.objects.filter(token="ExponentPushToken[abc]").count(), 2) + self.assertEqual(UserPushToken.objects.filter(token="ExponentPushToken[abc]").count(), 1) + self.assertFalse(UserPushToken.objects.filter(user=other_user, token="ExponentPushToken[abc]").exists()) + self.assertEqual(send_push_to_user(other_user, title="Old account", body="Example update"), 0) + mock_post.assert_not_called() + + self.client.force_login(other_user) + response = self.client.post( + "/api/users/@me/push_tokens/unregister/", + {"token": "ExponentPushToken[abc]"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertTrue(UserPushToken.objects.filter(user=self.user, token="ExponentPushToken[abc]").exists()) def test_unauthenticated_requests_are_rejected(self): self.client.logout() diff --git a/posthog/api/user_push_token.py b/posthog/api/user_push_token.py index 99ad8fecb915..f64c0adb0957 100644 --- a/posthog/api/user_push_token.py +++ b/posthog/api/user_push_token.py @@ -12,8 +12,6 @@ from typing import cast -from django.utils import timezone as django_timezone - import structlog from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework import exceptions, serializers, status, viewsets @@ -112,8 +110,8 @@ def _get_user(self) -> User: }, summary="Register a push notification token", description=( - "Idempotent upsert: if the (user, token) pair already exists, `platform` and " - "`last_seen_at` are refreshed. Otherwise a new row is created." + "Register the device for the authenticated user, removing registrations for the same token " + "belonging to other users on this instance. Re-registration refreshes `platform` and `last_seen_at`." ), ) def create(self, request: Request, **_kwargs) -> Response: @@ -122,18 +120,8 @@ def create(self, request: Request, **_kwargs) -> Response: token: str = data["token"] platform: str = data["platform"] - # `last_seen_at` is explicit in defaults (rather than relying on the - # model's `auto_now=True`) because `update_or_create` passes - # `defaults.keys()` as `update_fields` on the update path — fields not - # listed there won't be written even though `pre_save` ran for them. - push_token, created = UserPushToken.objects.update_or_create( - user=user, - token=token, - defaults={"platform": platform, "last_seen_at": django_timezone.now()}, - ) - - if created: - self._enforce_per_user_cap(user) + push_token = UserPushToken.register(user=user, token=token, platform=platform) + self._enforce_per_user_cap(user) return Response(UserPushTokenItemSerializer(push_token).data) diff --git a/posthog/models/user_push_token.py b/posthog/models/user_push_token.py index bb3a9ac6a324..07ae24d1b937 100644 --- a/posthog/models/user_push_token.py +++ b/posthog/models/user_push_token.py @@ -1,7 +1,13 @@ -from django.db import models +from typing import TYPE_CHECKING + +from django.db import connection, models, transaction +from django.utils import timezone from posthog.models.utils import UUIDModel +if TYPE_CHECKING: + from posthog.models.user import User + class UserPushToken(UUIDModel): """Per-user push notification token for a mobile device. @@ -39,3 +45,18 @@ class Platform(models.TextChoices): class Meta: db_table = "posthog_user_push_token" unique_together = [("user", "token")] + + @classmethod + def register(cls, *, user: "User", token: str, platform: str) -> "UserPushToken": + with transaction.atomic(): + # A row lock cannot serialize the first registration of a token. Lock its identity instead. + with connection.cursor() as cursor: + cursor.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", [f"user-push-token:{token}"]) + cls.objects.filter(token=token).exclude(user=user).delete() + # Include last_seen_at in defaults so update_or_create persists it on the update path too. + push_token, _created = cls.objects.update_or_create( + user=user, + token=token, + defaults={"platform": platform, "last_seen_at": timezone.now()}, + ) + return push_token diff --git a/posthog/object_tags/projection.py b/posthog/object_tags/projection.py index 45f2f70419b8..83c8b3e8fa65 100755 --- a/posthog/object_tags/projection.py +++ b/posthog/object_tags/projection.py @@ -113,4 +113,5 @@ def render() -> dict[str, str]: "products/desktop/packages/core/src/inbox/objectKinds.generated.ts": registry, "packages/agent/packages/agent-contracts/src/objectTagKinds.generated.ts": render_prompt_kinds_ts(), "frontend/src/lib/components/AgentObjectTags/objectKinds.generated.ts": registry, + "products/mobile/src/features/chat/objectKinds.generated.ts": registry, } diff --git a/products/mobile/.argent/flows/demo-smoke.yaml b/products/mobile/.argent/flows/demo-smoke.yaml new file mode 100644 index 000000000000..a2d1b2b34749 --- /dev/null +++ b/products/mobile/.argent/flows/demo-smoke.yaml @@ -0,0 +1,130 @@ +# Demo walkthrough. Starts and ends on the sign-in screen with demo mode off, so it can run +# again right away. How to run it: products/mobile/docs/TESTING.md. +steps: + - echo: Restart PostHog. Expect the sign-in screen with demo mode off + - launch: com.posthog.code.mobile + - await: { visible: { id: try-demo }, timeout: 25000 } + - await: { idle: true } + + - echo: On sign-in. Tap Try the demo. Expect the inbox + - tap: { id: try-demo } + - await: { visible: { id: inbox-screen } } + - await: { idle: true } + + - echo: On the inbox. Open Report load time. Expect its report screen + - tap: { id: inbox-row-demo-report-load-time } + - await: { visible: { id: handoff-picker-button } } + - await: { idle: true } + + - echo: On Report load time. Open the hand-off chooser. Expect the hand-off sheet + - tap: { id: handoff-picker-button } + - await: { visible: { id: handoff-agent-posthog } } + - await: { idle: true } + + - echo: Hand-off sheet open. Drag its header down to close it. Expect the sheet to go + - swipe: { from: { text: Choose an agent }, direction: down } + - await: { hidden: { id: handoff-agent-posthog } } + + - echo: Back on the report. Open Resolve. Expect the resolve sheet + - tap: { id: action-resolve } + - await: { visible: { id: resolve-sheet-reason-fixed_outside_posthog } } + - await: { idle: true } + + - echo: Resolve sheet open. Pick Fixed outside PostHog and confirm. Expect the inbox without the row + - tap: { id: resolve-sheet-reason-fixed_outside_posthog } + - tap: { id: resolve-sheet-confirm } + - await: { hidden: { id: resolve-sheet-confirm } } + - await: { visible: { id: inbox-screen } } + - await: { idle: true } + - await: { hidden: { id: inbox-row-demo-report-load-time } } + + - echo: On the inbox. Open the 3,721 errors report. Expect its pull request and answer action + - tap: { id: inbox-row-demo-report-ui-errors } + - await: { visible: { id: pr-ready-card } } + - tap: { id: decision-request } + - await: { visible: { id: decision-option-guard } } + - await: { idle: true } + + - echo: Decision card open. Pick Guard the undefined label. Expect the decided card with Undo + - tap: { id: decision-option-guard } + - await: { visible: { id: decision-undo } } + - await: { idle: true } + + - echo: Decided card shows the answer. Tap Undo. Expect the decision card again + - tap: { id: decision-undo } + - await: { visible: { id: decision-request } } + - tap: { id: decision-request } + - await: { visible: { id: decision-option-guard } } + - await: { hidden: { id: decision-undo } } + + - echo: Question sheet is open. Close it and open Chat. Expect the chat sheet + - tap: { id: decision-sheet-back } + - await: { hidden: { id: decision-sheet } } + - tap: { id: action-chat } + - await: { visible: { id: chat-input } } + - await: { idle: true } + + - echo: Chat sheet open. Type a question and send it with the return key. Expect the demo reply + - type: { into: { id: chat-input }, text: Which deploy caused this? } + - await: { visible: { text: This is the demo } } + - await: { visible: { text: Which deploy caused this? } } + + - echo: Reply shown. Close the chat. Expect the report again + - tap: { id: chat-close } + - await: { hidden: { id: chat-close } } + - await: { visible: { id: decision-request } } + - await: { idle: true } + + - echo: On the report. Go back. Expect the inbox + - tap: { id: report-back } + - await: { visible: { id: inbox-screen } } + - await: { idle: true } + + - echo: On the inbox. Open Chats from the bottom bar. Expect the chats list + - tap: { id: inbox-nav-chat } + - await: { visible: { id: chats-screen } } + - await: { idle: true } + - assert: { text: { in: { id: chat-row-session-demo-session-flaky-test }, contains: Fix the flaky checkout test } } + + - echo: On Chats. Open the session Fix the flaky checkout test. Expect the chat sheet with its title and thread + - tap: { id: chat-row-session-demo-session-flaky-test } + - await: { visible: { id: session-thread } } + - await: { idle: true } + - assert: { text: { in: { id: chat-header }, contains: Fix the flaky checkout test } } + - assert: { visible: { id: session-assistant } } + + - echo: Session open. Close it. Expect the chats list again + - tap: { id: chat-close } + - await: { visible: { id: chats-screen } } + - await: { idle: true } + + - echo: On Chats. Open the session Add dark mode to settings. Expect its decision card + - tap: { id: chat-row-session-demo-session-dark-mode } + - await: { visible: { id: session-decision } } + - await: { idle: true } + + - echo: Decision card open. Close the session. Expect the chats list again + - tap: { id: chat-close } + - await: { visible: { id: chats-screen } } + - await: { idle: true } + + - echo: On Chats. Go to Home from the bottom bar. Expect the inbox + - tap: { id: inbox-nav-home } + - await: { visible: { id: inbox-screen } } + - await: { idle: true } + + - echo: On the inbox. Open the menu. Expect Settings in it + - tap: { id: tabs-more } + - await: { visible: { id: nav-settings } } + - await: { idle: true } + + - echo: Menu open. Open Settings. Expect Exit demo + - tap: { id: nav-settings } + - scroll-to: { target: { id: settings-exit-demo } } + - await: { visible: { id: settings-exit-demo } } + - await: { idle: true } + + - echo: On Settings. Tap Exit demo. Expect the sign-in screen + - tap: { id: settings-exit-demo } + - await: { visible: { id: try-demo }, timeout: 10000 } + - await: { hidden: { id: settings-exit-demo } } diff --git a/products/mobile/.argent/flows/views-smoke.yaml b/products/mobile/.argent/flows/views-smoke.yaml new file mode 100644 index 000000000000..c7246efe6c05 --- /dev/null +++ b/products/mobile/.argent/flows/views-smoke.yaml @@ -0,0 +1,172 @@ +# Views walkthrough in the demo. Starts and ends on the sign-in screen with demo mode off, so it can run +# again right away. How to run it: products/mobile/docs/TESTING.md, with views-smoke in place of demo-smoke. +steps: + - echo: Restart PostHog. Expect the sign-in screen with demo mode off + - launch: com.posthog.code.mobile + - await: { visible: { id: try-demo }, timeout: 25000 } + - await: { idle: true } + + - echo: On sign-in. Tap Try the demo. Expect the inbox + - tap: { id: try-demo } + - await: { visible: { id: inbox-screen } } + - await: { idle: true } + + - echo: On the inbox. Open Views from the bottom bar. Expect the pinned list + - tap: { id: inbox-nav-views } + - await: { visible: { id: views-screen } } + - await: { idle: true } + - assert: { text: { in: { id: views-pin-dashboard-demo-dash-kpis }, contains: Product KPIs } } + + - echo: On Views. Open Edit pins and show Signups as a number. Expect the big value + - tap: { id: tabs-more } + - tap: { id: views-edit-pins } + - await: { visible: { id: edit-pins-sheet } } + - tap: { id: edit-pin-mode-number-insight-demo-ins-signups } + - tap: { id: edit-pins-done } + - await: { visible: { id: views-pin-value-insight-demo-ins-signups } } + - await: { idle: true } + + - echo: On Views. Open Product KPIs. Expect the dashboard on its saved range + - tap: { id: views-pin-dashboard-demo-dash-kpis } + - await: { visible: { id: view-range } } + - await: { idle: true } + - assert: { text: { in: { id: view-range }, contains: Last 14 days } } + + - echo: On the dashboard. Pick 7 days. Expect Last 7 days without a save-status indicator or period buttons + - tap: { id: view-range } + - await: { visible: { id: range-sheet } } + - tap: { id: range-chip-7d } + - await: { idle: true } + - tap: { id: sheet-done } + - await: { hidden: { id: range-sheet } } + - assert: { text: { in: { id: view-range }, contains: Last 7 days } } + - await: { hidden: { id: view-range-not-saved } } + - await: { hidden: { id: range-step-prev } } + - await: { hidden: { id: range-step-next } } + + - echo: Reset to the saved range. Expect Last 14 days + - tap: { id: view-range } + - await: { visible: { id: range-sheet } } + - tool: gesture-swipe + args: { fromX: 0.5, fromY: 0.85, toX: 0.5, toY: 0.35, durationMs: 300 } + - tap: { id: range-reset } + - await: { hidden: { id: range-reset } } + - tap: { id: sheet-done } + - await: { hidden: { id: range-sheet } } + - assert: { text: { in: { id: view-range }, contains: Last 14 days } } + - await: { hidden: { id: view-range-not-saved } } + + - echo: Open the Daily active users tile. Expect the insight on the dashboard's range + - tap: { id: view-tile-open-1 } + - await: { visible: { id: insight-scroll } } + - await: { idle: true } + - assert: { text: { in: { id: view-range }, contains: Last 14 days } } + + - echo: On the insight. Turn on compare. Expect the previous period in the legend + - tap: { id: view-range } + - await: { visible: { id: range-sheet } } + - tool: gesture-swipe + args: { fromX: 0.5, fromY: 0.85, toX: 0.5, toY: 0.35, durationMs: 300 } + - tap: { id: range-compare } + - await: { idle: true } + - tap: { id: sheet-done } + - await: { hidden: { id: range-sheet } } + - await: { visible: { text: Previous period } } + + - echo: Tap the chart. Expect a pinned tooltip. Tap it. Expect the point sheet + - tap: { id: chart-demo-ins-dau } + - await: { idle: true } + - await: { visible: { id: chart-tooltip } } + - tap: { id: chart-tooltip } + - await: { visible: { id: point-sheet } } + - await: { idle: true } + + - echo: Close the point sheet. Open the chart full screen. Expect the compare series in the legend + - tap: { id: sheet-done } + - await: { hidden: { id: point-sheet } } + - tap: { id: view-fullscreen } + - await: { visible: { id: chart-fullscreen-chart } } + - await: { visible: { text: Previous period, within: { id: chart-fullscreen-legend } } } + - await: { idle: true } + + - echo: Close full screen and go back. Expect the dashboard + - tap: { id: chart-fullscreen-back } + - await: { visible: { id: insight-scroll } } + - tap: { id: view-back } + - await: { visible: { id: dashboard-tiles } } + - await: { idle: true } + + - echo: On the dashboard. Tap the first chart. Expect a pinned tooltip + - tap: { id: chart-demo-tile-1 } + - await: { visible: { id: chart-tooltip } } + - await: { idle: true } + + - echo: Scroll to the bar list. Tap the second row. Expect its share of the total + - scroll-to: { target: { id: chart-demo-tile-7-row-1 } } + - tap: { id: chart-demo-tile-7-row-1 } + - await: { visible: { text: of the total } } + - await: { idle: true } + + - echo: Scroll to the funnel. Tap step 2. Expect the step to stay selected + - scroll-to: { target: { id: view-tile-fullscreen-8 } } + - scroll-to: { target: { id: chart-demo-tile-8-step-1 } } + - tap: { id: chart-demo-tile-8-step-1 } + - await: { visible: { text: Median time from the previous step } } + - await: { idle: true } + + - echo: Open the funnel full screen. Expect the full-screen funnel. Go back + - tap: { id: view-tile-fullscreen-8 } + - await: { visible: { id: chart-fullscreen-funnel } } + - await: { idle: true } + - tap: { id: chart-fullscreen-back } + - await: { visible: { id: view-tile-fullscreen-8 } } + - await: { idle: true } + + # A drag that starts outside a selected chart clears its selection, so scroll before the tap, not after it. + - echo: Scroll to the card under the retention grid. Tap a cell. Expect the callout under the grid + - scroll-to: { target: { id: view-tile-6 } } + - scroll-to: { target: { id: chart-demo-tile-9-cell-3-2 } } + - tap: { id: chart-demo-tile-9-cell-3-2 } + - await: { visible: { id: chart-demo-tile-9-callout } } + - assert: { text: { in: { id: chart-demo-tile-9-callout }, contains: 'Week 2: 36%, 163 of 458 people' } } + - await: { idle: true } + + - echo: Go back. Expect Views + - tap: { id: view-back } + - await: { visible: { id: views-screen } } + - await: { idle: true } + + - echo: On Views. Scroll to Browse and open Dashboards. Expect the dashboard list + - tool: gesture-swipe + args: { fromX: 0.5, fromY: 0.8, toX: 0.5, toY: 0.25, durationMs: 300 } + - await: { visible: { id: views-browse-dashboards } } + - tap: { id: views-browse-dashboards } + - await: { visible: { id: views-browse-dashboards-screen } } + - await: { visible: { text: Product KPIs } } + - await: { idle: true } + + - echo: On the dashboard list. Go back. Expect Views + - tap: { id: views-browse-back } + - await: { visible: { id: views-screen } } + - await: { idle: true } + + - echo: On Views. Go to Home from the bottom bar. Expect the inbox + - tap: { id: inbox-nav-home } + - await: { visible: { id: inbox-screen } } + - await: { idle: true } + + - echo: On the inbox. Open the menu. Expect Settings in it + - tap: { id: tabs-more } + - await: { visible: { id: nav-settings } } + - await: { idle: true } + + - echo: Menu open. Open Settings. Expect Exit demo + - tap: { id: nav-settings } + - scroll-to: { target: { id: settings-exit-demo } } + - await: { visible: { id: settings-exit-demo } } + - await: { idle: true } + + - echo: On Settings. Tap Exit demo. Expect the sign-in screen + - tap: { id: settings-exit-demo } + - await: { visible: { id: try-demo }, timeout: 10000 } + - await: { hidden: { id: settings-exit-demo } } diff --git a/products/mobile/.easignore b/products/mobile/.easignore new file mode 100644 index 000000000000..85d125e1ad4e --- /dev/null +++ b/products/mobile/.easignore @@ -0,0 +1,21 @@ +# EAS reads this file instead of .gitignore, so it repeats every entry there. +# Builds run prebuild from app.config.ts. A local ios/ or android/ folder in the +# archive would make EAS skip prebuild and build that stale native project. +# Anchored: modules/hedgehog-widget/ios and /android are source and must upload. +node_modules/ +.expo/ +/ios/ +/android/ +modules/*/android/build/ +dist/ +web-build/ +coverage/ +.turbo/ +*.tsbuildinfo +expo-env.d.ts +.env +.env.local +google-services.json +.argent/* +!.argent/flows/ +public/canvaskit.wasm diff --git a/products/mobile/.gitignore b/products/mobile/.gitignore new file mode 100644 index 000000000000..63b5b86555d1 --- /dev/null +++ b/products/mobile/.gitignore @@ -0,0 +1,26 @@ +node_modules/ +.expo/ +# Anchored: modules/hedgehog-widget/ios and /android are source, only the prebuild output at the root is generated. +/ios/ +/android/ +modules/*/android/build/ +dist/ +web-build/ +coverage/ +.turbo/ +*.tsbuildinfo +expo-env.d.ts +.env +.env.local + +# Firebase config for Android push. EAS builds get it from the GOOGLE_SERVICES_JSON file variable, see docs/PUSH.md. +google-services.json + +# Argent flows are committed; recordings and local state are not. +.argent/* +!.argent/flows/ + +# Copied from canvaskit-wasm by `pnpm web` (setup-skia-web); Expo web serves it from the site root. +public/canvaskit.wasm + +!/releases/ota-certificate.pem diff --git a/products/mobile/.node-version b/products/mobile/.node-version new file mode 100644 index 000000000000..3fe3b1570a51 --- /dev/null +++ b/products/mobile/.node-version @@ -0,0 +1 @@ +24.13.0 diff --git a/products/mobile/.npmrc b/products/mobile/.npmrc new file mode 100644 index 000000000000..c67455a2cc8b --- /dev/null +++ b/products/mobile/.npmrc @@ -0,0 +1,2 @@ +node-linker=hoisted +min-release-age=7 diff --git a/products/mobile/AGENTS.md b/products/mobile/AGENTS.md new file mode 100644 index 000000000000..6053ebc7a836 --- /dev/null +++ b/products/mobile/AGENTS.md @@ -0,0 +1,9 @@ +# PostHog mobile + +The app is its own pnpm workspace. Run every command from `products/mobile`. +[README.md](README.md) has setup and the docs index; read it before your first change here. + +## Skills + +- Invoke `/requesting-mobile-releases` before you create or edit a file under `releases/requests/`, and when a change touches `package.json`, `pnpm-lock.yaml`, `app.config.ts`, `eas.json`, `plugins/`, `targets/`, `modules/` or any `expo-*` or `react-native-*` dependency. It decides whether the change can ship over the air or needs a native release. A merged request publishes without a human gate. +- Invoke `/running-mobile-demo-flows` before you report a change to a screen, navigation, the demo fixtures or a flow as done. CI does not run the simulator flows, so the local run is the only UI check. diff --git a/products/mobile/CLAUDE.md b/products/mobile/CLAUDE.md new file mode 120000 index 000000000000..47dc3e3d863c --- /dev/null +++ b/products/mobile/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/products/mobile/README.md b/products/mobile/README.md new file mode 100644 index 000000000000..ae076a18374a --- /dev/null +++ b/products/mobile/README.md @@ -0,0 +1,145 @@ +# PostHog mobile + +A standalone Expo + React Native app for iOS and Android, named **PostHog** on the stores. +It is a quick-triage companion to the PostHog home page: +open your latest reports, hand them to the PostHog agent, answer its questions, +review the PR it opens, reassign, resolve, dismiss, mark read, or chat with PostHog AI about a report. + +Demo mode renders the whole design from fixture data with no network. +Signed in, the same screens read the PostHog API. + +This directory is its own pnpm workspace with its own lockfile. +The repo root workspace excludes it, so run everything from here. + +## Run it + +```sh +cd products/mobile +pnpm install # Node 24.13.0 (see .node-version), pnpm 10 +pnpm ios # build the dev client, open it in the iOS simulator and start Metro +pnpm web # Expo web in the browser, used for render checks; copies CanvasKit to public/ first +pnpm android # build the dev client and open it in an Android emulator +pnpm start # Metro only, for a dev client that is already installed +``` + +`pnpm ios` needs Xcode with an iOS simulator runtime, and CocoaPods. +The first build takes about 10 minutes. Later builds reuse the Xcode cache. +If another Metro already uses port 8081, run `pnpm ios --port 8181`. +If the app says it cannot load the project from your network address, see [Troubleshooting](docs/TESTING.md#troubleshooting). + +### Demo mode + +Demo mode shows fixture reports and needs no PostHog account or network. +It is available in every build, including preview and store builds, so app reviewers can try the app without an account. +To start it, tap **Try the demo** on the sign-in screen. This is the only way to start demo mode in a release build. +To leave it, tap **Exit demo** in Settings. +The app saves demo mode on the device, so a demo session continues after the app relaunches. + +Development bundles (`__DEV__`), including local Metro sessions, also have a demo mode toggle in Settings. +To start a development bundle in demo mode, set `EXPO_PUBLIC_DEMO_MODE=1` when you start Metro: + +```sh +EXPO_PUBLIC_DEMO_MODE=1 pnpm ios +EXPO_PUBLIC_DEMO_MODE=1 pnpm web +``` + +The flag sets only the first value. The app saves demo mode on the device, so after you leave demo mode in Settings the flag has no effect until you reinstall the app. +Metro inlines `EXPO_PUBLIC_*` values when it bundles, so restart Metro with `--clear` after you change one. + +Deep links open screens directly, for example in the simulator: + +```sh +xcrun simctl openurl booted "posthog://inbox" +xcrun simctl openurl booted "posthog://report/demo-report-load-time" +xcrun simctl openurl booted "posthog://report/demo-report-load-time/details" +``` + +A build made with `APP_VARIANT=development` or `preview` uses the `posthog-dev://` or `posthog-preview://` scheme instead. + +### Sign in to the local backend + +Until the backend setup changes merge, run the PostHog stack from the paired `codex/mobile-dev-environment` branch and the mobile app from `codex/mobile-app`. +The backend branch contains the setup commands and shared seed data; it does not contain this mobile workspace yet. +Use the normal [local development setup](../../docs/published/handbook/engineering/developing-locally.md), wait for migrations, then run from that backend checkout: + +```sh +python manage.py setup_local_dev --json +python manage.py check_local_dev --json +``` + +The setup creates the usual `test@posthog.com` / `12345678` account when absent, registers the local OAuth callbacks, and adds personal Inbox reports and a completed Chat with saved messages. +It preserves an existing account and selected project. +It stops with instructions if migrations are missing or an existing account has different sign-in details. +The JSON output contains the project and seeded object IDs. + +From this mobile workspace, build the development app: + +```sh +APP_VARIANT=development pnpm ios +``` + +Leave demo mode, choose **Self-hosted**, and enter `http://localhost:8010`. +Keep the prefilled local Client ID, then sign in as the usual test account and approve access. +The browser returns through `posthog-dev://callback`. +A production-identity local build uses `posthog://callback`; a preview build uses `posthog-preview://callback`. +All three callbacks are registered by `setup_tasks_oauth`. + +The iOS simulator can reach the Mac's loopback backend. +For an Android emulator, run `adb reverse tcp:8010 tcp:8010` before using the same localhost URL. +A physical device's localhost refers to the device; use a reachable HTTPS backend and register its OAuth configuration instead. +The web preview supports render checks; native OAuth sign-in needs iOS or Android. +See [Authenticated local checks](docs/TESTING.md#authenticated-local-checks) for the backend and device checklist. + +### Environment variables + +| Variable | Use | +| ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `EXPO_PUBLIC_POSTHOG_KEY` | Project API key for the app's own usage analytics. Analytics are off when it is not set. | +| `EXPO_PUBLIC_POSTHOG_HOST` | Host for those analytics, for example `https://us.i.posthog.com`. | +| `EXPO_PUBLIC_DEMO_MODE` | `1` starts a development bundle in demo mode. Release bundles ignore it, so **Try the demo** is the only way into demo mode there. | +| `EXPO_PUBLIC_SIGNUP_LINK` | `0` hides the "Create an account" link on the sign-in screen, for store builds where a link out to sign-up is not allowed. Production builds and OTA bundles set it to `0`. | +| `EXPO_PUBLIC_OAUTH_US_CLIENT_ID`, `EXPO_PUBLIC_OAUTH_EU_CLIENT_ID` | Separate non-first-party Cloud OAuth application IDs for native development and preview builds. Required for Cloud sign-in in these builds; see [AUTH.md](docs/AUTH.md#redirect-uris). | +| `APP_VARIANT` | Build-time only: `development`, `preview` or `production` (the default). It sets the app name, bundle id, URL scheme, App Group and icon, so the variants install side by side. `eas.json` sets it per profile. See [RELEASING.md](docs/RELEASING.md#build-variants). | +| `GOOGLE_SERVICES_JSON` | Build-time only: path to the Firebase `google-services.json` that Android push needs. On EAS it is a file variable; locally, a `google-services.json` in this directory is used instead. See [PUSH.md](docs/PUSH.md#credentials). | + +For local runs, put them in `.env.local` (git ignores it). Expo loads it when Metro starts. +A local run without `APP_VARIANT` builds the store identity, but the app treats any Metro bundle as a development build: it shows the environment ribbon and the Settings Environment group, and accepts `http://localhost`. +EAS builds and updates read them from the EAS environment of their profile, see [RELEASING.md](docs/RELEASING.md#one-time-setup). + +## Check + +```sh +pnpm check:expo # compare installed Expo SDK compatibility metadata +pnpm typecheck # Expo compatibility check, then tsc --noEmit +pnpm lint # biome check . (pnpm fix to apply fixes) +pnpm test # jest (jest-expo preset) +npx expo-doctor +``` + +`pnpm check:expo` compares dependencies with the installed Expo SDK's bundled compatibility metadata. +It does not compare newer patch metadata from a remote service. +`pnpm typecheck` runs this check automatically before TypeScript. + +## Assets + +The app icon, adaptive icon, favicon and the iOS 26 `app.icon` bundle are copies of the +PostHog Desktop mobile host's logomark assets. +`splash-icon.png` is the gradient logomark from [brand.posthog.com](https://brand.posthog.com/logo), +rendered from `frontend/src/assets/posthog-icon.svg` with `rsvg-convert -w 1040`. +The in-app logomark and landscape lockup (`src/ui/icons/PostHogLogomark.tsx`, `PostHogLogo.tsx`) +are the same brand geometry ported to `react-native-svg`. +`icon-dev.png`, `icon-preview.png` and their `adaptive-icon-*` copies add a DEV or PREVIEW band to the icon. +Regenerate them after an icon change with `uv run --with pillow python scripts/make_variant_icons.py`. + +## Docs + +- [Architecture](docs/ARCHITECTURE.md): directory map, data flow, module boundaries, hook surface +- [Design](docs/DESIGN.md): tokens, component inventory, how handoff CSS values map to React Native +- [Decisions](docs/DECISIONS.md): choices the build contract left open +- [Gaps](docs/GAPS.md): known gaps and local-only behavior +- [API](docs/API.md), [Push](docs/PUSH.md) +- [Releasing](docs/RELEASING.md): workflow builds, TestFlight and the Play internal track +- [Release checks](../../docs/internal/mobile-release-checks.md): build checks and manual smoke checks before sharing a release +- [OTA updates](docs/OTA.md): update channels, runtime version, rollbacks +- [CI](docs/CI.md): the Mobile CI and Mobile Build workflows +- [Testing](docs/TESTING.md): authenticated local checks and demo walkthroughs on the iOS simulator with Argent diff --git a/products/mobile/app.config.ts b/products/mobile/app.config.ts new file mode 100644 index 000000000000..1d1612359e7c --- /dev/null +++ b/products/mobile/app.config.ts @@ -0,0 +1,201 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +import type { ConfigContext, ExpoConfig } from 'expo/config' + +import variantTable from './src/lib/appVariants.json' + +const EAS_PROJECT_ID = '4eb57b2f-79f6-4ab0-ae2c-bbd82af2cef9' + +type AppVariant = keyof typeof variantTable.variants + +function isAppVariant(value: string): value is AppVariant { + return Object.hasOwn(variantTable.variants, value) +} + +// eas.json sets APP_VARIANT per build profile, and the update scripts in package.json set it per channel. +// A plain local run has none and gets the store identity; the app still treats a Metro bundle as development. +function readVariant(): AppVariant { + const value = process.env.APP_VARIANT + if (value === undefined || value === '') { + return 'production' + } + if (!isAppVariant(value)) { + throw new Error(`APP_VARIANT must be development, preview or production, not "${value}".`) + } + return value +} + +// On EAS, GOOGLE_SERVICES_JSON is a file environment variable: the build gets the path of the uploaded file. +// Locally, a google-services.json next to this file is used when it exists. git and EAS both ignore it. +function googleServicesFile(projectRoot: string): string | undefined { + if (process.env.GOOGLE_SERVICES_JSON) { + return process.env.GOOGLE_SERVICES_JSON + } + return existsSync(join(projectRoot, 'google-services.json')) ? './google-services.json' : undefined +} + +const ICONS: Record = { + development: { png: './assets/icon-dev.png', adaptive: './assets/adaptive-icon-dev.png' }, + preview: { png: './assets/icon-preview.png', adaptive: './assets/adaptive-icon-preview.png' }, + production: { png: './assets/icon.png', adaptive: './assets/adaptive-icon.png' }, +} + +export default ({ config, projectRoot }: ConfigContext): ExpoConfig => { + const variant = readVariant() + const { name, idSuffix, scheme } = variantTable.variants[variant] + const appId = `${variantTable.productionAppIds.ios}${idSuffix}` + const androidPackage = `${variantTable.productionAppIds.android}${idSuffix}` + // The HedgehogWidget target and both Swift readers of the shared store derive the same name from the bundle id. + const appGroup = `group.${appId}` + const icons = ICONS[variant] + const otaCertificate = './releases/ota-certificate.pem' + // Local builds work before key provisioning; the hosted publisher refuses this unsigned configuration. + const hasOtaCertificate = existsSync(join(projectRoot, otaCertificate)) + + return { + ...config, + name, + slug: 'posthog', + owner: 'posthog', + scheme, + version: '1.4.0', + orientation: 'portrait', + userInterfaceStyle: 'automatic', + icon: icons.png, + ios: { + // The Icon Composer bundle draws the iOS 26 layered icon and has no badged copy, so the internal + // variants use their PNG, which keeps their band visible on the home screen. + icon: variant === 'production' ? './assets/app.icon' : icons.png, + bundleIdentifier: appId, + // Local device builds sign automatically with this team; EAS builds carry their own credentials. + appleTeamId: process.env.APPLE_TEAM_ID, + supportsTablet: false, + entitlements: { + // Shared with the HedgehogWidget extension, which reads the choreography the app writes. + 'com.apple.security.application-groups': [appGroup], + }, + infoPlist: { + ITSAppUsesNonExemptEncryption: false, + }, + // Expo merges these into the template's manifest, which already declares the reasons React Native needs. + privacyManifests: { + NSPrivacyTracking: false, + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults', + // 1C8F.1 covers the App Group suite that the widget sync writes. + NSPrivacyAccessedAPITypeReasons: ['CA92.1', '1C8F.1'], + }, + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryFileTimestamp', + NSPrivacyAccessedAPITypeReasons: ['C617.1'], + }, + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategorySystemBootTime', + NSPrivacyAccessedAPITypeReasons: ['35F9.1'], + }, + ], + NSPrivacyCollectedDataTypes: [ + collected('NSPrivacyCollectedDataTypeUserID', ['AppFunctionality', 'Analytics']), + collected('NSPrivacyCollectedDataTypeDeviceID', ['AppFunctionality', 'Analytics']), + collected('NSPrivacyCollectedDataTypeProductInteraction', ['Analytics']), + collected('NSPrivacyCollectedDataTypeEmailAddress', ['AppFunctionality']), + collected('NSPrivacyCollectedDataTypeName', ['AppFunctionality']), + // PostHog derives a coarse location from the request IP of each analytics event. + collected('NSPrivacyCollectedDataTypeCoarseLocation', ['Analytics']), + // Chat messages the user sends to PostHog AI. + collected('NSPrivacyCollectedDataTypeOtherUserContent', ['AppFunctionality']), + // Photos the user attaches to a PostHog AI chat. + collected('NSPrivacyCollectedDataTypePhotosorVideos', ['AppFunctionality']), + ], + }, + }, + android: { + package: androidPackage, + googleServicesFile: googleServicesFile(projectRoot), + adaptiveIcon: { + foregroundImage: icons.adaptive, + backgroundColor: '#FFFFFF', + }, + }, + web: { + favicon: './assets/favicon.png', + bundler: 'metro', + }, + plugins: [ + 'expo-router', + 'expo-secure-store', + 'expo-notifications', + 'expo-web-browser', + [ + 'expo-splash-screen', + { + image: './assets/splash-icon.png', + imageWidth: 120, + resizeMode: 'contain', + backgroundColor: '#F6F6F3', + dark: { + image: './assets/splash-icon-dark.png', + backgroundColor: '#1D1F27', + }, + }, + ], + 'expo-font', + 'expo-updates', + [ + 'expo-image-picker', + { + photosPermission: 'PostHog opens your photo library so you can attach images to PostHog AI chats.', + // The composer attaches from the library only. + cameraPermission: false, + microphonePermission: false, + }, + ], + ['./plugins/withAndroidLoopbackNetworking', { allowLoopback: variant !== 'production' }], + './plugins/withAndroidAdaptiveIconInsets', + './plugins/withIosSceneLifecycle', + '@bacons/apple-targets', + ], + updates: { + url: `https://u.expo.dev/${EAS_PROJECT_ID}`, + ...(variant === 'production' + ? hasOtaCertificate + ? { + codeSigningCertificate: otaCertificate, + codeSigningMetadata: { keyid: 'main', alg: 'rsa-v1_5-sha256' }, + } + : { enabled: false } + : {}), + }, + runtimeVersion: { + policy: 'appVersion', + }, + extra: { + router: {}, + appVariant: variant, + eas: { + projectId: EAS_PROJECT_ID, + }, + }, + } +} + +type CollectionPurpose = 'AppFunctionality' | 'Analytics' + +function collected( + type: string, + purposes: CollectionPurpose[] +): { + NSPrivacyCollectedDataType: string + NSPrivacyCollectedDataTypeLinked: boolean + NSPrivacyCollectedDataTypeTracking: boolean + NSPrivacyCollectedDataTypePurposes: string[] +} { + return { + NSPrivacyCollectedDataType: type, + NSPrivacyCollectedDataTypeLinked: true, + NSPrivacyCollectedDataTypeTracking: false, + NSPrivacyCollectedDataTypePurposes: purposes.map((purpose) => `NSPrivacyCollectedDataTypePurpose${purpose}`), + } +} diff --git a/products/mobile/app/(tabs)/_layout.tsx b/products/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 000000000000..4522be39007a --- /dev/null +++ b/products/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,7 @@ +import type { ReactElement } from 'react' + +import { TabsLayout } from '@/features/tabs/TabsLayout' + +export default function TabsRoute(): ReactElement { + return +} diff --git a/products/mobile/app/(tabs)/chats.tsx b/products/mobile/app/(tabs)/chats.tsx new file mode 100644 index 000000000000..58acc4c3c655 --- /dev/null +++ b/products/mobile/app/(tabs)/chats.tsx @@ -0,0 +1,7 @@ +import type { ReactElement } from 'react' + +import { ChatsScreen } from '@/features/chats/ChatsScreen' + +export default function ChatsRoute(): ReactElement { + return +} diff --git a/products/mobile/app/(tabs)/errors.tsx b/products/mobile/app/(tabs)/errors.tsx new file mode 100644 index 000000000000..f38cd2b9ac39 --- /dev/null +++ b/products/mobile/app/(tabs)/errors.tsx @@ -0,0 +1,14 @@ +import type { ReactElement } from 'react' + +import { ErrorsListScreen } from '@/features/errors/ErrorsListScreen' + +import { MobileProductGate } from '@/features/tabs/MobileProductGate' +import { FEATURE_FLAGS } from '@/lib/featureFlags' + +export default function ErrorsTabRoute(): ReactElement { + return ( + + + + ) +} diff --git a/products/mobile/app/(tabs)/inbox.tsx b/products/mobile/app/(tabs)/inbox.tsx new file mode 100644 index 000000000000..728c26370070 --- /dev/null +++ b/products/mobile/app/(tabs)/inbox.tsx @@ -0,0 +1,7 @@ +import type { ReactElement } from 'react' + +import { InboxScreen } from '@/features/inbox/InboxScreen' + +export default function InboxRoute(): ReactElement { + return +} diff --git a/products/mobile/app/(tabs)/replay.tsx b/products/mobile/app/(tabs)/replay.tsx new file mode 100644 index 000000000000..ed6678101975 --- /dev/null +++ b/products/mobile/app/(tabs)/replay.tsx @@ -0,0 +1,14 @@ +import type { ReactElement } from 'react' + +import { ReplayListScreen } from '@/features/replay/ReplayListScreen' + +import { MobileProductGate } from '@/features/tabs/MobileProductGate' +import { FEATURE_FLAGS } from '@/lib/featureFlags' + +export default function ReplayTabRoute(): ReactElement { + return ( + + + + ) +} diff --git a/products/mobile/app/(tabs)/views.tsx b/products/mobile/app/(tabs)/views.tsx new file mode 100644 index 000000000000..2d0618b284a4 --- /dev/null +++ b/products/mobile/app/(tabs)/views.tsx @@ -0,0 +1,7 @@ +import type { ReactElement } from 'react' + +import { ViewsScreen } from '@/features/views/list/ViewsScreen' + +export default function ViewsRoute(): ReactElement { + return +} diff --git a/products/mobile/app/+native-intent.tsx b/products/mobile/app/+native-intent.tsx new file mode 100644 index 000000000000..69a74028c837 --- /dev/null +++ b/products/mobile/app/+native-intent.tsx @@ -0,0 +1,20 @@ +import { trackWhenReady } from '@/lib/analytics' +import { deepLinkKind, webLinkTarget } from '@/lib/deepLinkKind' + +// Internal builds register posthog-dev:// or posthog-preview://, see src/lib/appVariants.json. +const AUTH_CALLBACK = /^(?:posthog(?:-dev|-preview)?:\/\/|\/)callback(?:[/?#]|$)/ + +// On Android the OAuth redirect reaches the app as a deep link while the browser session also receives it. Without +// this filter Expo Router would open an unmatched `/callback` screen on top of sign in. iOS never gets here. +export function redirectSystemPath({ path, initial }: { path: string; initial: boolean }): string | null { + const kind = deepLinkKind(path) + if (kind) { + // On a cold start Expo Router can resolve the initial URL before the root layout module runs `initAnalytics()`. + trackWhenReady('mobile_deep_link_opened', { path_kind: kind, initial }) + } + if (!AUTH_CALLBACK.test(path)) { + return webLinkTarget(path) ?? path + } + // A cold start from a callback has no sign-in waiting for it, so start the app normally. + return initial ? '/' : null +} diff --git a/products/mobile/app/_layout.tsx b/products/mobile/app/_layout.tsx new file mode 100644 index 000000000000..cd0aa17cac2b --- /dev/null +++ b/products/mobile/app/_layout.tsx @@ -0,0 +1,133 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { Stack, useSegments } from 'expo-router' +import * as SplashScreen from 'expo-splash-screen' +import { StatusBar } from 'expo-status-bar' +import { type ReactElement, useEffect, useState } from 'react' +import { StyleSheet, View } from 'react-native' +import { GestureHandlerRootView } from 'react-native-gesture-handler' +import { KeyboardProvider } from 'react-native-keyboard-controller' +import { SafeAreaProvider } from 'react-native-safe-area-context' + +import { queryClient } from '@/api/queryClient' +import { installAuthProvider, useAuthStore } from '@/auth/authStore' +import { BootSplash } from '@/auth/BootSplash' +import { SPLASH_TIMEOUT_MS, shouldHideSplash, useBootGate } from '@/auth/bootGate' +import { useAuthRedirect } from '@/auth/useAuthRedirect' +import { useDemoHydrated } from '@/demo/demoMode' +import '@/features/chat/draftPersistence' +import { AiApprovalDialog } from '@/features/ai/AiApprovalDialog' +import { useHedgehogWidgetSync } from '@/features/hedgehog/useHedgehogWidgetSync' +import { configureNotifications } from '@/features/notifications/pushRegistration' +import { useNotificationRouting } from '@/features/notifications/useNotificationRouting' +import { usePushRegistration } from '@/features/notifications/usePushRegistration' +import { useAutomaticUpdates } from '@/features/updates/useAutomaticUpdates' +import { track } from '@/lib/analytics' +import { initAnalytics } from '@/lib/posthogClient' +import { useScreenTracking } from '@/lib/useScreenTracking' +import { useAppearanceHydrated } from '@/ui/appearanceStore' +import { RouteErrorBoundary } from '@/ui/RouteErrorBoundary' +import { createStyles, useTheme } from '@/ui/theme' + +export { RouteErrorBoundary as ErrorBoundary } + +SplashScreen.preventAutoHideAsync().catch(() => undefined) +SplashScreen.setOptions({ fade: true, duration: 250 }) +installAuthProvider() +configureNotifications() +initAnalytics() +const bootStartedAt = Date.now() + +function hideNativeSplash(): void { + SplashScreen.hideAsync().catch(() => undefined) +} + +export default function RootLayout(): ReactElement { + const demoHydrated = useDemoHydrated() + const appearanceHydrated = useAppearanceHydrated() + const authStatus = useAuthStore((state) => state.status) + // Screens wait for the saved appearance too, so a Light or Dark override never paints a frame in the device scheme. + const ready = demoHydrated && appearanceHydrated && authStatus !== 'loading' + const firstScreenReady = useBootGate((state) => state.firstScreenReady) + const segments = useSegments() + const { colors, scheme } = useTheme() + const styles = useStyles() + const [timedOut, setTimedOut] = useState(false) + const [splashHidden, setSplashHidden] = useState(false) + + useEffect(() => { + void useAuthStore.getState().restore() + }, []) + + useEffect(() => { + if (!ready) { + return + } + const timer = setTimeout(() => setTimedOut(true), SPLASH_TIMEOUT_MS) + return () => clearTimeout(timer) + }, [ready]) + + useEffect(() => { + if (splashHidden) { + return + } + if (shouldHideSplash({ sessionReady: ready, firstScreenReady, segments, timedOut })) { + setSplashHidden(true) + hideNativeSplash() + track('mobile_splash_hidden', { + reason: firstScreenReady ? 'first_screen' : timedOut ? 'timeout' : 'not_held', + wait_ms: Date.now() - bootStartedAt, + }) + } + }, [ready, firstScreenReady, segments, timedOut, splashHidden]) + + useAuthRedirect() + useScreenTracking(ready) + usePushRegistration() + useHedgehogWidgetSync() + useNotificationRouting() + useAutomaticUpdates(ready && segments[0] === '(tabs)') + + return ( + + + + + + {/* Screens mount only after the session loads, so no query runs without a token. */} + {ready ? ( + + 'chat'} + options={{ + presentation: 'transparentModal', + animation: 'none', + contentStyle: { backgroundColor: 'transparent' }, + }} + /> + + ) : null} + {!splashHidden && ( + + + + )} + + + + + + ) +} + +const useStyles = createStyles(({ colors }) => ({ + root: { + flex: 1, + backgroundColor: colors.background, + }, +})) diff --git a/products/mobile/app/chat.tsx b/products/mobile/app/chat.tsx new file mode 100644 index 000000000000..777443395aff --- /dev/null +++ b/products/mobile/app/chat.tsx @@ -0,0 +1,41 @@ +import { useLocalSearchParams, useRouter } from 'expo-router' +import { type ReactElement, useCallback } from 'react' + +import { useDataMode } from '@/demo/demoMode' +import { ChatSheet } from '@/features/chat/ChatSheet' +import { chatSheetSource } from '@/features/chat/chatSheetEvents' +import { objectContextFromParams } from '@/features/chat/objectContext' +import { currentDefaultPageHref } from '@/features/settings/defaultPageStore' + +export default function ChatRoute(): ReactElement { + const router = useRouter() + const dataMode = useDataMode() + const { reportId, taskId, runId, entry, origin, objectType, objectId, objectLabel } = useLocalSearchParams<{ + reportId?: string + taskId?: string + runId?: string + entry?: string + origin?: string + objectType?: string + objectId?: string + objectLabel?: string + }>() + const close = useCallback(() => { + if (router.canGoBack()) { + router.back() + } else { + router.replace(currentDefaultPageHref()) + } + }, [router]) + return ( + + ) +} diff --git a/products/mobile/app/errors/[id].tsx b/products/mobile/app/errors/[id].tsx new file mode 100644 index 000000000000..7fc00b73aa92 --- /dev/null +++ b/products/mobile/app/errors/[id].tsx @@ -0,0 +1,18 @@ +import { useLocalSearchParams } from 'expo-router' +import type { ReactElement } from 'react' + +import { issueOpenSourceParam } from '@/features/errors/analytics' +import { IssueScreen } from '@/features/errors/IssueScreen' +import { windowParam } from '@/features/errors/issueModel' + +import { MobileProductGate } from '@/features/tabs/MobileProductGate' +import { FEATURE_FLAGS } from '@/lib/featureFlags' + +export default function IssueRoute(): ReactElement { + const { id, from, window } = useLocalSearchParams<{ id: string; from?: string; window?: string }>() + return ( + + + + ) +} diff --git a/products/mobile/app/errors/index.tsx b/products/mobile/app/errors/index.tsx new file mode 100644 index 000000000000..bc6be6534807 --- /dev/null +++ b/products/mobile/app/errors/index.tsx @@ -0,0 +1,14 @@ +import type { ReactElement } from 'react' + +import { ErrorsListScreen } from '@/features/errors/ErrorsListScreen' + +import { MobileProductGate } from '@/features/tabs/MobileProductGate' +import { FEATURE_FLAGS } from '@/lib/featureFlags' + +export default function ErrorsRoute(): ReactElement { + return ( + + + + ) +} diff --git a/products/mobile/app/index.tsx b/products/mobile/app/index.tsx new file mode 100644 index 000000000000..05a891dcc813 --- /dev/null +++ b/products/mobile/app/index.tsx @@ -0,0 +1,40 @@ +import { Redirect } from 'expo-router' +import { type ReactElement, useEffect, useRef } from 'react' + +import { useAuthStore } from '@/auth/authStore' +import { BootSplash } from '@/auth/BootSplash' +import { useDataMode } from '@/demo/demoMode' +import { + defaultPageHref, + resolveStartPage, + sessionOrgAiStatus, + settleDefaultPage, + useDefaultPageHydrated, + useDefaultPageStore, +} from '@/features/settings/defaultPageStore' + +export default function Index(): ReactElement { + const mode = useDataMode() + const status = useAuthStore((state) => state.status) + const orgStatus = useAuthStore((state) => (mode === 'live' ? sessionOrgAiStatus(state) : null)) + const pageHydrated = useDefaultPageHydrated() + const page = useDefaultPageStore((state) => state.page) + const ready = mode !== 'loading' && status !== 'loading' && pageHydrated + const settles = ready && mode === 'live' && status === 'signed_in' + const settled = useRef(false) + + useEffect(() => { + if (settles && !settled.current) { + settled.current = true + settleDefaultPage() + } + }, [settles]) + + if (!ready) { + return + } + if (mode !== 'demo' && status !== 'signed_in') { + return + } + return +} diff --git a/products/mobile/app/replay/[sessionId].tsx b/products/mobile/app/replay/[sessionId].tsx new file mode 100644 index 000000000000..858179985543 --- /dev/null +++ b/products/mobile/app/replay/[sessionId].tsx @@ -0,0 +1,22 @@ +import { useLocalSearchParams } from 'expo-router' +import type { ReactElement } from 'react' + +import { parseSeekSeconds, sessionOpenSource } from '@/features/replay/replayLinks' +import { SessionScreen } from '@/features/replay/SessionScreen' + +import { MobileProductGate } from '@/features/tabs/MobileProductGate' +import { FEATURE_FLAGS } from '@/lib/featureFlags' + +export default function SessionRoute(): ReactElement { + const { sessionId, t, source } = useLocalSearchParams<{ sessionId: string; t?: string; source?: string }>() + return ( + + + + ) +} diff --git a/products/mobile/app/replay/index.tsx b/products/mobile/app/replay/index.tsx new file mode 100644 index 000000000000..a2805281be65 --- /dev/null +++ b/products/mobile/app/replay/index.tsx @@ -0,0 +1,14 @@ +import type { ReactElement } from 'react' + +import { ReplayListScreen } from '@/features/replay/ReplayListScreen' + +import { MobileProductGate } from '@/features/tabs/MobileProductGate' +import { FEATURE_FLAGS } from '@/lib/featureFlags' + +export default function ReplayListRoute(): ReactElement { + return ( + + + + ) +} diff --git a/products/mobile/app/report/[id]/details.tsx b/products/mobile/app/report/[id]/details.tsx new file mode 100644 index 000000000000..214f9e888870 --- /dev/null +++ b/products/mobile/app/report/[id]/details.tsx @@ -0,0 +1,9 @@ +import { useLocalSearchParams } from 'expo-router' +import type { ReactElement } from 'react' + +import { ReportDetailsScreen } from '@/features/report/details/ReportDetailsScreen' + +export default function ReportDetailsRoute(): ReactElement { + const { id } = useLocalSearchParams<{ id: string }>() + return +} diff --git a/products/mobile/app/report/[id]/index.tsx b/products/mobile/app/report/[id]/index.tsx new file mode 100644 index 000000000000..c2e6f3c54128 --- /dev/null +++ b/products/mobile/app/report/[id]/index.tsx @@ -0,0 +1,10 @@ +import { useLocalSearchParams } from 'expo-router' +import type { ReactElement } from 'react' + +import { ReportScreen } from '@/features/report/ReportScreen' +import { reportOpenSourceParam } from '@/features/report/reportAnalytics' + +export default function ReportRoute(): ReactElement { + const { id, from } = useLocalSearchParams<{ id: string; from?: string }>() + return +} diff --git a/products/mobile/app/settings.tsx b/products/mobile/app/settings.tsx new file mode 100644 index 000000000000..3d82411affdd --- /dev/null +++ b/products/mobile/app/settings.tsx @@ -0,0 +1,7 @@ +import type { ReactElement } from 'react' + +import { SettingsScreen } from '@/features/settings/SettingsScreen' + +export default function SettingsRoute(): ReactElement { + return +} diff --git a/products/mobile/app/sign-in.tsx b/products/mobile/app/sign-in.tsx new file mode 100644 index 000000000000..5f627b486176 --- /dev/null +++ b/products/mobile/app/sign-in.tsx @@ -0,0 +1,7 @@ +import type { ReactElement } from 'react' + +import { SignInScreen } from '@/auth/SignInScreen' + +export default function SignInRoute(): ReactElement { + return +} diff --git a/products/mobile/app/views/browse/dashboards.tsx b/products/mobile/app/views/browse/dashboards.tsx new file mode 100644 index 000000000000..48cd21315e7e --- /dev/null +++ b/products/mobile/app/views/browse/dashboards.tsx @@ -0,0 +1,7 @@ +import type { ReactElement } from 'react' + +import { BrowseListScreen } from '@/features/views/browse/BrowseListScreen' + +export default function BrowseDashboardsRoute(): ReactElement { + return +} diff --git a/products/mobile/app/views/browse/insights.tsx b/products/mobile/app/views/browse/insights.tsx new file mode 100644 index 000000000000..5951c2949679 --- /dev/null +++ b/products/mobile/app/views/browse/insights.tsx @@ -0,0 +1,7 @@ +import type { ReactElement } from 'react' + +import { BrowseListScreen } from '@/features/views/browse/BrowseListScreen' + +export default function BrowseInsightsRoute(): ReactElement { + return +} diff --git a/products/mobile/app/views/chart/[shortId].tsx b/products/mobile/app/views/chart/[shortId].tsx new file mode 100644 index 000000000000..e2f06935d9d7 --- /dev/null +++ b/products/mobile/app/views/chart/[shortId].tsx @@ -0,0 +1,10 @@ +import { useLocalSearchParams } from 'expo-router' +import type { ReactElement } from 'react' + +import { FullScreenChart } from '@/features/views/fullScreen/FullScreenChart' +import { queryKeyParam } from '@/features/views/viewRoutes' + +export default function ChartRoute(): ReactElement { + const { shortId, key } = useLocalSearchParams<{ shortId: string; key?: string }>() + return +} diff --git a/products/mobile/app/views/dashboard/[id].tsx b/products/mobile/app/views/dashboard/[id].tsx new file mode 100644 index 000000000000..d95a5e1ca7b9 --- /dev/null +++ b/products/mobile/app/views/dashboard/[id].tsx @@ -0,0 +1,10 @@ +import { useLocalSearchParams } from 'expo-router' +import type { ReactElement } from 'react' + +import { DashboardScreen } from '@/features/views/dashboard/DashboardScreen' +import { displayModeParam, viewEntryParam } from '@/features/views/viewRoutes' + +export default function DashboardRoute(): ReactElement { + const { id, from, mode } = useLocalSearchParams<{ id: string; from?: string; mode?: string }>() + return +} diff --git a/products/mobile/app/views/insight/[shortId].tsx b/products/mobile/app/views/insight/[shortId].tsx new file mode 100644 index 000000000000..bf2eabece573 --- /dev/null +++ b/products/mobile/app/views/insight/[shortId].tsx @@ -0,0 +1,24 @@ +import { useLocalSearchParams } from 'expo-router' +import type { ReactElement } from 'react' + +import { InsightScreen } from '@/features/views/insight/InsightScreen' +import { displayModeParam, numberParam, viewEntryParam } from '@/features/views/viewRoutes' + +export default function InsightRoute(): ReactElement { + const { shortId, fromDashboard, insightId, from, mode } = useLocalSearchParams<{ + shortId: string + fromDashboard?: string + insightId?: string + from?: string + mode?: string + }>() + return ( + + ) +} diff --git a/products/mobile/app/views/web-analytics.tsx b/products/mobile/app/views/web-analytics.tsx new file mode 100644 index 000000000000..4dc1c7bfd656 --- /dev/null +++ b/products/mobile/app/views/web-analytics.tsx @@ -0,0 +1,10 @@ +import { useLocalSearchParams } from 'expo-router' +import type { ReactElement } from 'react' + +import { viewEntryParam } from '@/features/views/viewRoutes' +import { WebAnalyticsScreen } from '@/features/views/webAnalytics/WebAnalyticsScreen' + +export default function WebAnalyticsRoute(): ReactElement { + const { from } = useLocalSearchParams<{ from?: string }>() + return +} diff --git a/products/mobile/assets/adaptive-icon-dev.png b/products/mobile/assets/adaptive-icon-dev.png new file mode 100644 index 000000000000..88c9a8e99f5f Binary files /dev/null and b/products/mobile/assets/adaptive-icon-dev.png differ diff --git a/products/mobile/assets/adaptive-icon-preview.png b/products/mobile/assets/adaptive-icon-preview.png new file mode 100644 index 000000000000..ac264e2a0f9a Binary files /dev/null and b/products/mobile/assets/adaptive-icon-preview.png differ diff --git a/products/mobile/assets/adaptive-icon.png b/products/mobile/assets/adaptive-icon.png new file mode 100644 index 000000000000..c44991ba0846 Binary files /dev/null and b/products/mobile/assets/adaptive-icon.png differ diff --git a/products/mobile/assets/app.icon/Assets/logo.png b/products/mobile/assets/app.icon/Assets/logo.png new file mode 100644 index 000000000000..c44991ba0846 Binary files /dev/null and b/products/mobile/assets/app.icon/Assets/logo.png differ diff --git a/products/mobile/assets/app.icon/icon.json b/products/mobile/assets/app.icon/icon.json new file mode 100644 index 000000000000..a869a295eaa1 --- /dev/null +++ b/products/mobile/assets/app.icon/icon.json @@ -0,0 +1,31 @@ +{ + "fill": { + "solid": "display-p3:0.97647,0.93333,0.88235,1.00000" + }, + "groups": [ + { + "blur-material": null, + "layers": [ + { + "glass": false, + "image-name": "logo.png", + "name": "logo" + } + ], + "lighting": "individual", + "shadow": { + "kind": "neutral", + "opacity": 0.5 + }, + "specular": true, + "translucency": { + "enabled": true, + "value": 0.5 + } + } + ], + "supported-platforms": { + "circles": ["watchOS"], + "squares": "shared" + } +} diff --git a/products/mobile/assets/favicon.png b/products/mobile/assets/favicon.png new file mode 100644 index 000000000000..d37491d1692e Binary files /dev/null and b/products/mobile/assets/favicon.png differ diff --git a/products/mobile/assets/icon-dev.png b/products/mobile/assets/icon-dev.png new file mode 100644 index 000000000000..a73fb12b4014 Binary files /dev/null and b/products/mobile/assets/icon-dev.png differ diff --git a/products/mobile/assets/icon-preview.png b/products/mobile/assets/icon-preview.png new file mode 100644 index 000000000000..3439ab128dbe Binary files /dev/null and b/products/mobile/assets/icon-preview.png differ diff --git a/products/mobile/assets/icon.png b/products/mobile/assets/icon.png new file mode 100644 index 000000000000..c44991ba0846 Binary files /dev/null and b/products/mobile/assets/icon.png differ diff --git a/products/mobile/assets/splash-icon-dark.png b/products/mobile/assets/splash-icon-dark.png new file mode 100644 index 000000000000..1e2694b0f6a6 Binary files /dev/null and b/products/mobile/assets/splash-icon-dark.png differ diff --git a/products/mobile/assets/splash-icon.png b/products/mobile/assets/splash-icon.png new file mode 100644 index 000000000000..b3cfe744d149 Binary files /dev/null and b/products/mobile/assets/splash-icon.png differ diff --git a/products/mobile/babel.config.js b/products/mobile/babel.config.js new file mode 100644 index 000000000000..b61adf3cc831 --- /dev/null +++ b/products/mobile/babel.config.js @@ -0,0 +1,6 @@ +module.exports = (api) => { + api.cache(true) + return { + presets: ['babel-preset-expo'], + } +} diff --git a/products/mobile/biome.json b/products/mobile/biome.json new file mode 100644 index 000000000000..ea241cf3e00d --- /dev/null +++ b/products/mobile/biome.json @@ -0,0 +1,49 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "files": { + "includes": [ + "**", + "!**/node_modules", + "!**/.expo", + "!**/ios", + "!**/android", + "!**/dist", + "!**/web-build", + "!**/coverage", + "!**/src/api/generated", + "!**/objectKinds.generated.ts" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 4, + "lineWidth": 120 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "jsxQuoteStyle": "double", + "semicolons": "asNeeded", + "trailingCommas": "es5" + } + }, + "json": { + "formatter": { + "indentWidth": 4 + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/products/mobile/docs/API.md b/products/mobile/docs/API.md new file mode 100644 index 000000000000..457c382b6f25 --- /dev/null +++ b/products/mobile/docs/API.md @@ -0,0 +1,110 @@ +# API + +Every request goes through `src/api/client.ts`: it prefixes the host, adds the bearer token, +sets `X-PostHog-Client-Platform: mobile`, refreshes the token once on a 401, and throws +`ApiError` with the server's `code` and `detail` on any other failure. +`projectPath('signals/reports/')` builds `/api/projects/{projectId}/signals/reports/`. +Streams use `apiStream` (expo's fetch, so the body streams on native) and `src/api/sse.ts`. + +Most existing types are written by hand per module and name the Django serializer they mirror. +PR checks use `PullRequestChecksResponseApi` from the Signals generated schemas. New API +types should come from the generated schemas; update the Django serializer and run +`hogli build:openapi` when those contracts change. Mobile CI includes the Signals schema +file for typechecking, and its type-only import adds nothing to the native bundle. + +| Domain | Module | Endpoints | Notes | +| -------------------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| Account | `src/api/users.ts` | `GET /api/users/@me/`, `GET /api/organizations/{id}/` | [AUTH.md](AUTH.md) | +| OAuth | `src/auth/oauth.ts` | `/oauth/authorize`, `/oauth/token`, `/oauth/revoke` | PKCE public client | +| Inbox and reports | `src/api/reports.ts` | `signals/reports/?view=for_you&sort=priority`, `.../{id}/`, `.../signals/`, `.../artefacts/`, `read_state/`, `.../state/`, `.../reviewers/`, `available_reviewers/` | [DATA.md](DATA.md) | +| Today | `src/api/today.ts` | `today/reports/{id}/page/` | 403 or 404 means the project lacks Today; the report screen shows signals instead | +| Tasks | `src/api/tasks.ts` | `tasks/`, `tasks/{id}/` (`PATCH { archived }` archives a chat), `tasks/{id}/run/` (a chat's resume adds `resume_from_run_id` and `pending_user_message`; `pending_user_artifact_ids` carries staged files), `.../runs/{runId}/`, `.../logs/`, `.../stream/`, `.../command/` (`user_message`, with `artifact_ids` for files; `set_config_option`, `permission_response`, `cancel`) | [HANDOFF.md](HANDOFF.md), [CHAT.md](CHAT.md) | +| Chat attachments | `src/api/tasks.ts`, `src/features/chat/attachments/uploadAttachments.ts` | `tasks/{id}/staged_artifacts/prepare_upload/`, `tasks/{id}/staged_artifacts/finalize_upload/`, `.../runs/{runId}/artifacts/prepare_upload/`, `.../runs/{runId}/artifacts/finalize_upload/`, all `task:write`; then a presigned S3 `POST` with no PostHog auth | [CHAT.md](CHAT.md#attachments) | +| Push | `src/api/pushTokens.ts` | `/api/users/@me/push_tokens/`, `.../unregister/` | [PUSH.md](PUSH.md) | +| Views pins | `src/api/shortcuts.ts` | `file_system_shortcut/`, `.../{id}/` | [VIEWS.md](VIEWS.md) | +| Views dashboards | `src/api/dashboards.ts` | `dashboards/`, `dashboards/{id}/?refresh=force_cache` | [VIEWS.md](VIEWS.md) | +| Views insights | `src/api/insights.ts` | `insights/`, `insights/{id}/`, `insights/viewed/` | [VIEWS.md](VIEWS.md) | +| Web analytics, people at a point | `src/api/webAnalytics.ts`, `src/api/actors.ts` | `query/` (`WebOverviewQuery`, `ActorsQuery`) | [VIEWS.md](VIEWS.md) | +| Session replay | `src/api/recordings.ts`, `src/api/replayVision.ts` | `session_recordings/`, `query/` (`HogQLQuery`), `vision/observations/`, `vision/scanners/watch_feed/`, `vision/scanners/inline_scan/` | [Session replay](#session-replay) | + +`send()` also keeps the server clock offset from each response's `Date` header (`src/api/serverClock.ts`) and spends a token from the request's budget family (`src/api/budget.ts`); an empty bucket throws `BudgetExceededError` without sending. + +`runQuery()` applies one deadline to the initial `POST query/` and every status poll. If the server does not answer in +time, the request is canceled and the caller gets a timeout error. Reviewer edits load artefact pages until the latest +reviewer assignment is found before sending the replacement list, so GitHub-only reviewers remain assigned. + +OAuth scopes the app requests: `OAUTH_SCOPES` in `src/auth/oauth.ts`, listed in [AUTH.md](AUTH.md#scopes). + +### Error tracking and replay scopes + +The app asks for five more scopes for the Errors and Sessions screens. Views, the inbox and chats never need them. + +| Scope | Why | +| ------------------------ | -------------------------------------------------------------------------------------------------------------- | +| `error_tracking:read` | The issues list, one issue, its events and spikes (`error_tracking/query/...`, `error_tracking/issues/{id}/`). | +| `error_tracking:write` | Resolve, suppress, reopen and assign an issue. It also covers the read. | +| `session_recording:read` | The sessions list and one recording's metadata and events (`session_recordings/`). | +| `replay_scanner:read` | The AI summary and chapters of a recording (`vision/observations/`). | +| `replay_scanner:write` | Asking for a summary from the phone (`vision/scanners/inline_scan/`). | + +`session_recording:write` is never requested: the phone does not change or delete recordings. +`ERRORS_SCOPES` and `REPLAY_SCOPES` in `src/api/scopeErrors.ts` hold the same lists, and `useFeatureAccess('errors' | 'replay')` reads them. +A server that drops a scope (the OAuth application's ceiling, see [AUTH.md](AUTH.md#server-side-ceiling)) gets the feature's "isn't available yet" state, not a broken screen. + +## Error tracking + +`src/api/errorTracking.ts` imports the generated issue-list request and response types. Other reads name the serializers in +`products/error_tracking/backend/presentation/views/` (`query_serializers.py`, `issues.py`, `spike_events.py`). +Mobile CI checks out the Signals and Error Tracking generated schemas. The compact `error_tracking/query/` endpoints are the ones the +MCP tools and agents use: they wrap `ErrorTrackingQuery`, `EventsQuery` and `ErrorTrackingBreakdownsQuery` and answer +typed, small payloads, so the phone does not post raw HogQL queries to `query/`. + +| Call | Endpoint | Scope | Payload the app sends or reads | +| ------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Issues list | `POST error_tracking/query/issues/` | `error_tracking:read` | `{ dateRange: { date_from: '-24h' \| '-7d' }, status: 'active', orderBy: 'users', orderDirection: 'DESC', limit: 15, offset: 0, volumeResolution: 0, assignee?: { type: 'user', id } }`; rows carry `id`, `name`, `description` (300 characters at most), `status`, `first_seen`, `last_seen`, `library`, `assignee { id, type }`, `aggregations { users, occurrences, sessions }`. Load more follows `nextOffset` while `hasMore` is true | +| Spikes | `GET error_tracking/spike_events/?issue_ids=a,b&date_from=&limit=100` | `error_tracking:read` | `results[].issue.id`, `detected_at`; the web's issue list uses the same read for its spike marks | +| One issue | `GET error_tracking/issues/{id}/` | `error_tracking:read` | `id`, `status`, `name`, `description`, `first_seen`, `assignee` | +| Impact and chart | `POST error_tracking/query/issue/` | `error_tracking:read` | `{ issueId, dateRange, includeSparkline: true, volumeResolution: 24 \| 28 }`; reads `impact`, `sparkline`, `trend.direction`, `trend.change_percent`, `last_seen`, `library`, `latest_release`, `first_release` | +| Where it breaks | The same endpoint with `includeBreakdown: true` | `error_tracking:read` | `breakdown.occurrences` and `breakdown.top_values` (`path`, `url`, `screen`, `browser`, `os`, `app_version`). Its own request, because the server fails the whole response when the breakdown fails | +| Frames and sessions | `POST error_tracking/query/issue_events/` | `error_tracking:read` | `{ issueId, dateRange, limit: 10, include: ['exception', 'stacktrace', 'environment', 'correlation'], onlyAppFrames: true }`; reads `$exception_list[].stacktrace.frames` (already symbolicated at ingestion: `resolved_name`, `source`, `line`), `$session_id`, `$browser`, `$os`, `timestamp` | +| Linked report | `GET signals/reports/?source_product=error_tracking&source_id={id}&limit=1&include_source_metadata=false` | `task:read` | Error tracking signals carry the issue id as `source_id` | +| Status | `PATCH error_tracking/issues/{id}/` `{ status: 'active' \| 'resolved' \| 'suppressed' }` | `error_tracking:write` | Answers the issue resource | +| Assign | `PATCH error_tracking/issues/{id}/assign/` `{ assignee: { type: 'user', id } \| null }` | `error_tracking:write` | `id` is the numeric user id from `@me` | + +None of these paths is in a `src/api/budget.ts` family, and none returns a `next_allowed_client_refresh` hint, so the +screens rely on TanStack staleness (60 seconds) and pull to refresh. +Pull to refresh discards later pages and reloads the first page. Spike reads are scoped to each loaded page, so +loading another page does not repeat the spike lookup for previous pages. New and Spiking filter the loaded rows; +an empty partial result offers Load more instead of claiming there are no matching issues in the project. +The Spiking filter waits for pending spike lookups and offers a retry when they fail. List sparklines are omitted; +the issue detail still requests its chart. +A 403 that names a missing scope goes to `reportScopeError(error, 'errors')`, and the screen shows `ViewsReauthState`. +`error_tracking/stack_frames/batch_get/` is not called: the frames in `$exception_list` already carry resolved names, and +the phone shows no code context. + +## Session replay + +`src/api/recordings.ts` and `src/api/replayVision.ts`. Types are written by hand and name the serializers they mirror: +`SessionRecordingSerializer` in `posthog/session_recordings/session_recording_api.py`, and `ReplayObservationSerializer`, +`InlineScanResponseSerializer` and `WatchFeedResponseSerializer` in `products/replay_vision/backend/api/`. The summary's +`model_output` follows `SummarizerOutput` in `products/replay_vision/backend/temporal/scanners/summarizer.py`. + +| Call | Endpoint | Scope | Payload the app sends or reads | +| -------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Sessions list | `GET session_recordings/?limit=10&date_from=-7d` | `session_recording:read` | Newest first (the server's default `start_time` order). Reads `id`, `distinct_id`, `recording_duration`, `start_time`, `end_time`, `click_count`, `console_error_count`, `start_url`, `person { name, distinct_ids, properties }`, `ongoing`, `activity_score`. Load more sends `next_cursor` as `after` while `has_next` is true | +| Worth watching | `GET vision/scanners/watch_feed/?limit=5&date_from=-7d` | `replay_scanner:read`, `session_recording:read` | `results[].observation` and `results[].reason`. Starts after recordings load and never blocks their display. Enriches existing rows in place; other recommendations appear below the recordings without reordering them | +| One recording | `GET session_recordings/{id}/` | `session_recording:read` | The same fields. A 404 means the recording expired or never existed | +| Events timeline | Two `POST query/` with a `HogQLQuery`, on the `replay` budget in `src/api/budget.ts` | `query:read` | One for `$pageview`, `$screen` and `$autocapture` clicks (`LIMIT 300`), one for `$rageclick` and `$exception` (`LIMIT 100`), each where `properties.$session_id = {session_id}`, between the recording's start and end padded by a minute, `ORDER BY timestamp`. The session id and the window are `values`, never query text. Reads the timestamp, the event, `$current_url`, `$pathname`, `$screen_name`, `elements_chain_texts`, `$exception_issue_id`, `$exception_types`, `$exception_values`, `$browser`, `$os`, `$geoip_city_name` | +| Summary and chapters | `GET vision/observations/?session_id={id}` | `replay_scanner:read`, `session_recording:read` | Every scanner's observations of the session, newest first. The app uses the newest `succeeded` summarizer: `model_output.title`, `summary`, `chapters[] { kind, start_ms, end_ms, title }`, `key_moment_ms`, `notability`, `notability_reason`, plus `media[] { kind, position }` and `completed_at`. A `pending` or `running` summarizer means a summary is on its way | +| Frame | `GET vision/observations/{id}/thumbnail/?chapter={n}` | `replay_scanner:read`, `session_recording:read` | A 302 to a signed object-storage URL. The app sends `redirect: 'manual'` and reads `Location`, so the bearer token never reaches the storage host; the image loads from the signed URL. Without `chapter` it is the observation's thumbnail | +| Summarize | `POST vision/scanners/inline_scan/` `{ built_in: 'summarize_recording', session_ids: [id] }` | `replay_scanner:write`, `session_recording:read` | The web's Summarize button sends the same body, so both share one result. Answers 202 with `results[].scan_outcome`: `started`, `already_running`, `already_scanned`, `skipped_limit`, `skipped_quota`, `skipped_scanner_limit` or `failed`. A 400 means the organization has not approved AI data processing | + +Session pagination deduplicates recording IDs. Pull to refresh reloads the first page and the optional feed. +A failed next page preserves the loaded rows and offers Try again. + +After `started` or `already_running` the screen reloads the observations every 5 seconds while it is focused and the +app is in front, for at most 2 minutes. The app never sends `PATCH session_recordings/{id}/` (`viewed`), because that +needs `session_recording:write`, so opening a session on the phone does not mark it watched on the web. +A 403 that names a missing scope goes to `reportScopeError(error, 'replay')`, and the screen shows `ViewsReauthState`. +The timeline's queries spend the `replay` budget family (`runQuery({ budgetFamily: 'replay' })`, the same buckets as +`query`), not the Views `query` family, so Views refreshes and a session screen never block each other. diff --git a/products/mobile/docs/ARCHITECTURE.md b/products/mobile/docs/ARCHITECTURE.md new file mode 100644 index 000000000000..13790cb47b80 --- /dev/null +++ b/products/mobile/docs/ARCHITECTURE.md @@ -0,0 +1,211 @@ +# Architecture + +## Directory map + +```text +products/mobile/ + app/ Expo Router routes. Thin: read params, mount a screen. + _layout.tsx Providers (gesture handler, safe area, keyboard, TanStack Query), auth gate, + push registration, notification routing, analytics, error boundary + +native-intent.tsx Drops the Android OAuth callback deep link so it never opens a screen, and opens a web + link to one issue or recording at /errors/ or /replay/ (webLinkTarget) + index.tsx Redirect to /inbox (demo mode or signed in) or /sign-in + sign-in.tsx -> src/auth/SignInScreen + (tabs)/ Inbox and Chats share one tab navigator, so the bottom bar stays put when the tab changes + _layout.tsx -> src/features/tabs/TabsLayout + inbox.tsx -> src/features/inbox/InboxScreen + chats.tsx -> src/features/chats/ChatsScreen + errors.tsx -> src/features/errors/ErrorsListScreen chrome="tab" (a slot only while pinned) + replay.tsx -> src/features/replay/ReplayListScreen chrome="tab" (a slot only while pinned) + report/[id]/index.tsx -> src/features/report/ReportScreen (`/report/`, as deep links and notifications open it) + report/[id]/details.tsx -> src/features/report/details/ReportDetailsScreen (the full report) + errors/index.tsx -> src/features/errors/ErrorsListScreen chrome="stack" (pushed from the Views Browse row) + errors/[id].tsx -> src/features/errors/IssueScreen (`/errors/`, `from` names the source for analytics) + replay/index.tsx -> src/features/replay/ReplayListScreen chrome="stack" (pushed from the Views Browse row) + replay/[sessionId].tsx -> src/features/replay/SessionScreen (`/replay/?t=&source=`) + chat.tsx -> src/features/chat/ChatSheet (transparent modal, draws its own sheet; + no params starts a new chat, `taskId` and `runId` open one, `reportId` attaches a report) + settings.tsx -> src/features/settings/SettingsScreen + src/ + api/ client.ts (fetch wrapper: base URL, bearer, refresh on 401, mobile header), + sse.ts (event-stream parser and reader), queryClient.ts, and one typed + module per backend domain: users, reports, today, tasks, + pushTokens. Each module declares the response fields it reads and names + the serializer it mirrors. + errorTracking.ts holds the error tracking reads and writes ([API.md](API.md#error-tracking)). + recordings.ts and replayVision.ts hold the session replay reads and Summarize + ([API.md](API.md#session-replay)). + auth/ oauth.ts (PKCE flow, refresh, revoke), tokens.ts (secure store), + account.ts (@me and projects), regions.ts (hosts and client ids), + authStore.ts (zustand), useViewer, SignInScreen + model/ types.ts (the view-model contract), sources.ts, agent.ts, queryStates.ts, + adapters/ (Today and signals responses -> view models) + demo/ briefings.ts (raw prototype data), fixtures.ts (view models), derive.ts, + demoMode.ts (zustand store), hooks.ts (demo query hooks), + sessionThreads.ts (demo session run logs) + features/ + tabs/ TabsLayout (Inbox, Chats and Views tab navigator), BottomBar and TabsHeader (each + rendered once), tabHeaderStore (each tab's header state), NavMenu (the More menu), + navPinStore (Errors and Sessions pinned to the nav pill, a device preference), + navPinRoutes (openNavTarget: the tab when pinned, a pushed screen when not), + navPinActions (pin change with toast and Undo), NavPinMenu (the Views row menu) + inbox/ InboxScreen, InboxRow, InboxListFooter, hooks, + prefetchPlan (prefetch window and queue), usePrefetchReports + report/ ReportScreen, TopChrome, Headline, HeadlineTitle, ReportMeta, ReportByline, TextLinkRow, + Evidence*, ReplayRow, Facts, Activity, + hooks, queries (query options shared by the screen and the inbox prefetch) + metrics/ ObservationCard, ImpactMetrics, ReportCharts, OpenInsightLink, query and value hooks + details/ ReportDetailsScreen (the full report) and its sections, ReportMarkdown, chartRefs + (markdown-it plugin), openDetailsLink + handoff/ ActionBar and its cards, HandoffSheet, actionBarState, kickoff, prompt, + runStream (reducer), toolPolicy, permissionRequest, commands, useTaskRun, + useReportWithLiveRun, handoffStore, hooks + snooze/ SnoozeSheet, CustomSnoozePicker (.web.tsx for web), snoozeTimes, + snoozeStore, snoozeEntries, reminders, hooks + verdict/ VerdictSheet (Resolve and Dismiss), verdictCopy, hooks; reasons in model/verdictReasons + reviewers/ AssigneeSheet, hooks + priority/ PrioritySheet, hooks + chat/ ChatSheet, Composer, ContextChip, MentionPopover, ChatEmptyState, ChatOptionsSheet, + chatRunOptions (model and mode), ChatMarkdown, ChatErrorBubble, ChatProgressLine, + UserBubble, contextPrompt, chatErrors, webLinks (reply links), + objectContext and ObjectContextChip (an issue or recording as chat context) + session/ useSession (create, follow, send, resume, stop, answer), sessionRun (request bodies + and the set_config_option plan), sessionReducer, sessionDisplay, SessionThread and its + rows, SessionDecisionPanel + errors/ ErrorsListScreen, IssueListRow, IssueScreen, IssueSections, IssueActionBar, IssueAssigneeSheet, + SuppressSheet, IssueBadges, AssigneeAvatar, + hooks (react-query reads, optimistic status and assign), issueModel (pure adapters: rows, + badges, frames, sessions, breakdowns, web links, empty copy), analytics + replay/ ReplayListScreen, SessionListRow, SessionScreen, SessionChrome (Back, Open full replay, More), + SummaryCard, ChapterList, FrameSheet, FrameImage (signed frame URLs), + SessionTimelineSection, SessionSkeleton, hooks (recording, timeline, summary flow with + Summarize and polling, frames, list), sessionAdapters (pure: person labels, timeline, + summary, list rows), replayModel (view types), replayLinks (web URLs, `t`), replayAnalytics + views/ The Views tab and its screens, see VIEWS.md + screen/ Shared screen chrome: ViewChrome, ViewScreenFrame, ViewTitle, HeaderMenu (the More menu + of a pushed screen: a view, an issue, a session), ViewMoreSheet, ViewErrorState, ViewSkeleton + chats/ ChatsScreen, ChatRow, ChatRowMenu (long-press Archive), + chatGroups (day labels, grouping, search), sessionStatus, hooks + notifications/ pushSupport, pushStore, pushRegistration, usePushRegistration, + notificationRouting, taskLookup, useNotificationRouting + settings/ SettingsScreen, ProjectSwitcher, PushStatusGroup, SignOutSection, appInfo, + EnvironmentGroup (internal builds), LegalGroup, SettingsLinkRow, SettingsRadioRow, + DefaultPageGroup, defaultPageStore (the tab the app opens on) + lib/ analytics, posthogClient, dates, errors, haptics, markdownIt (the shared safe parser), + deepLinkKind (link kind for analytics, webLinkTarget for issue and recording links), + richText, useAsyncAction, useNow, + environment (build variant, host kind, update id), appVariants (+ .json), legalLinks + ui/ tokens.ts, primitives (SearchField, StateView, ...), icons/ + plugins/ Expo config plugins (withIosSceneLifecycle: UIScene life cycle for Xcode 27) + .argent/flows/ Argent flows that drive the app on a simulator (see TESTING.md) + assets/ app icon (shared with PostHog Desktop), adaptive icon, splash, favicon, badged dev and + preview icons + scripts/ eas-build.sh, make_variant_icons.py +``` + +## Data flow + +```text +route (app/) -> feature screen -> feature hooks -> useDataMode() + ├─ 'loading' -> loading QueryState + ├─ 'demo' -> src/demo (fixtures + useDemoMode store) + └─ 'live' -> src/api fetchers + TanStack Query + -> src/model/adapters -> view models +``` + +Screens render only the types in `src/model/types.ts`. +Every data hook returns `QueryState`, which TanStack Query's `UseQueryResult` satisfies. +`useDataMode()` waits for the persisted demo flag to load, so a cold start never flashes an error. + +The report screen reads `useReportWithLiveRun(id)`, which takes `useReport(id)` and overlays the +live task run (status, stage, pending question, PR URL) and the hand-off prompt. +The footer reads independent execution, attention and PR state from the pure +`actionBarStateFor(report, local)` in `features/handoff/actionBarState.ts`. The focused report +polls lightweight report/artefact/task snapshots in the foreground; stream changes trigger +throttled invalidations without refetching on message chunks. +The full report reads `useReport(id)` without polling. It shares the report screen's cache, +so it renders at once and refreshes once when it opens. + +Mutations go through `useAsyncAction`, which exposes a `pending` flag and drops a second call +while one is in flight. Buttons read the flag to disable and show progress. +Hand-off and decision actions resolve on failure and surface the error through +`useHandoffNotice`, so the footer shows one line instead of an alert. + +## Live data sources + +| Need | Source | Fallback | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Inbox rows | `GET signals/reports/?view=for_you&sort=priority&limit=20&offset=` one page at a time | Loads more while the response has a `next` link | +| Report page | `GET signals/reports/{id}/`, `/signals/`, `/artefacts/`, `GET today/reports/{id}/page/` | Signals only when the Today page is unavailable | +| Selected PR checks | `GET signals/reports/{id}/pr_checks/?pull_request_id={canonicalPrId}` | No id or failed/missing checks stay unavailable; focused foreground polling at 30 seconds | +| Current run | `GET tasks/{taskId}/` then `GET .../runs/{runId}/stream/` (SSE) | Bounded reconnects and focused foreground task polling, including later resumes | +| New chat | `POST tasks/` then `POST tasks/{id}/run/` (`mode: interactive`) | A refused run deletes the new task | +| Follow-up | `POST tasks/{id}/runs/{run}/command/` (`set_config_option` for changed options, then `user_message`) | `POST tasks/{id}/run/` with `resume_from_run_id` when the run ended | +| Chats list | `GET tasks/?created_by={user id}&ordering=-last_activity_at&basic=true&limit=30` | A 403 or 404 means no sessions | +| Chat thread | `GET tasks/{id}/` then `GET tasks/{id}/runs/{run}/logs/` for an ended run, or `GET tasks/{id}/runs/{run}/stream/` for a live one | A failed load shows Try again; demo sessions come from `src/demo/sessionThreads.ts` | +| Reviewers | `GET signals/reports/available_reviewers/`, `PUT signals/reports/{id}/reviewers/` | none | +| Priority | `POST signals/reports/{id}/artefacts/` (`priority_judgment`) | none | +| Snooze | `POST signals/reports/{id}/state/` for "until more signals"; on-device store for times | none | +| Read state | `POST signals/reports/read_state/` | none | +| Push | `POST /api/users/@me/push_tokens/` | unavailable on web and Expo Go | + +Details per domain: [DATA.md](DATA.md), [HANDOFF.md](HANDOFF.md), [CHAT.md](CHAT.md), +[AUTH.md](AUTH.md), [PUSH.md](PUSH.md). + +## Hook surface + +| Hook | File | Returns | +| ------------------------------------------------------------------------ | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `useInboxItems()` | `features/inbox/hooks.ts` | `InboxQueryState`: `QueryState` plus `hasMore`, `isLoadingMore`, `loadMoreFailed`, `loadMore` | +| `useMarkReadAction(id)`, `useMarkReadOnOpen(id)` | `features/inbox/hooks.ts` | `AsyncAction<[]>`, `void` | +| `usePrefetchReports(items)` | `features/inbox/usePrefetchReports.ts` | `{ onViewableItemsChanged, viewabilityConfig }` for the inbox `FlatList` | +| `useReport(id)` | `features/report/hooks.ts` | `QueryState` | +| `useReportWithLiveRun(id)` | `features/handoff/useReportWithLiveRun.ts` | `QueryState` with the live run merged in | +| `useReportActions(id)` | `features/report/hooks.ts` | `ReportActions` (`handOff`, `answerDecision`, `undoDecision`, `snooze`, `setReviewers`, `setPriority`, `markRead`, `pending`) | +| `useReportNeighbors(inbox, id)` | `features/report/hooks.ts` | `{ previousId, nextId }`, wrapping only when every page is loaded; loads the next page on the last loaded report | +| `useMetricSeries(data)`, `useMetricAggregate(data)` | `features/report/metrics/hooks.ts` | `QueryState`, `QueryState` from the query endpoint or fixture data | +| `useMetricValue(metric)` | `features/report/metrics/useMetricValue.ts` | `MetricValueState`: the live value, else the snapshot with its measured time | +| `useChartPoints(data, fallback?)` | `features/report/metrics/useChartPoints.ts` | `{ points, isLoading, isError }` for `MetricChart`, from live buckets or the snapshot series | +| `useHandoffActions(id)`, `useDecisionAnswer(id)`, `useHandoffNotice(id)` | `features/handoff/hooks.ts` | actions, the device-side answer, the footer error line | +| `usePullRequestChecks(reportId?, pullRequestId?, enabled?)` | `features/handoff/usePullRequestChecks.ts` | status, local `observedAt`, `isRefreshing` | +| `useTaskRun(reportId, target, enabled, onRefresh?, onTaskSnapshot?)` | `features/handoff/useTaskRun.ts` | `void`; follows the current run into the report store, with scoped connection state | +| `useSnoozeAction(id)`, `useSnoozeEntries()` | `features/snooze/hooks.ts` | `AsyncAction<[SnoozeChoice]>`, active snoozes | +| `useAvailableReviewers(query)` | `features/reviewers/hooks.ts` | `QueryState` | +| `useSetReviewersAction(id)` | `features/reviewers/hooks.ts` | `AsyncAction<[string[]]>` (full replace) | +| `useSetPriorityAction(id)` | `features/priority/hooks.ts` | `AsyncAction<[ReportPriority]>` | +| `useSession({ taskId?, runId? })` | `features/chat/session/useSession.ts` | `Session`: `taskId`, `runId`, `title`, `items`, `invocations`, `phase`, `history`, `decision`, `runConfig`, `notice`, `unsentText`, `isSending`, `isStopping`, `start(text, context, options?)`, `send(text, context, options?)`, `stop`, `answer`, `reset`, `reloadHistory` | +| `useChatList()` | `features/chats/hooks.ts` | `QueryState` | +| `useArchiveChat()` | `features/chats/hooks.ts` | `ChatArchiveActions` (`archive`, `restore`), both optimistic on the cached list | +| `usePushRegistration()`, `useNotificationRouting()` | `features/notifications/` | registers the device, routes taps | +| `useViewer()` | `auth/useViewer.ts` | `{ name, projectName }` | + +## Stores + +- `useAuthStore` (`src/auth/authStore.ts`, zustand, non-secret state persisted with AsyncStorage, + tokens in the secure store): `status`, `signedIn`, `region`, `host`, `clientId`, `method`, + `user`, `organizations`, `projects`, `projectId`, `projectName`, `signedOutReason`; + actions `restore`, `signIn`, `signInWithPersonalApiKey` (dev builds), `selectProject`, + `reloadAccount`, `signOut`. It installs the auth provider that `src/api/client.ts` uses for + the base URL, bearer token and refresh. +- `useDemoMode` (`src/demo/demoMode.ts`, persists only `enabled`): in-memory demo state for + hand-offs, decisions, snoozes, read flags, reviewers and the activity log. +- `useSnoozeStore` (`src/features/snooze/snoozeStore.ts`, persisted): time and signal-count + snoozes keyed by host, project and report, with expiry. +- `useHandoffStore` (`src/features/handoff/handoffStore.ts`): the task and run a hand-off + started on this device, pending answers and the footer notice. +- `usePushStore` (`src/features/notifications/pushStore.ts`, persisted): permission state and + the registered token. + +## Module boundaries + +- `app/` imports screens only. +- `src/ui` holds generic primitives and imports nothing from `features`, `demo`, `auth` or `api`. + It imports the `Accent` type from `src/model`. +- `src/model/types.ts` imports nothing. `src/model/adapters` imports the API types in `src/api` + and helpers in `src/lib`, and nothing from `features`. +- `src/api` imports nothing from `features`, `demo` or `auth` (the auth provider is injected). +- `src/features/*` may import `src/model`, `src/ui`, `src/lib`, `src/api` and other features' + hooks. The app's shared screen chrome is the exception: `src/features/views/screen/*`, `ViewsToast` and + `ViewsReauthState` may be imported by other features. Fixtures reach screens only through hooks. Screens import the `useDemoMode` store + directly only to turn demo mode on or off. +- `src/demo` imports `src/model` and nothing from `features`. diff --git a/products/mobile/docs/AUTH.md b/products/mobile/docs/AUTH.md new file mode 100644 index 000000000000..6011f76b43eb --- /dev/null +++ b/products/mobile/docs/AUTH.md @@ -0,0 +1,258 @@ +# Auth + +The app signs in with OAuth 2.0 authorization code + PKCE as a public client. +Code: `src/auth/` (flow, store, tokens), `src/api/users.ts` (bootstrap), `src/api/client.ts` (bearer + refresh on 401). + +## Flow + +```text +Sign in ──► AuthRequest (PKCE S256, state) ──► system browser: {host}/oauth/authorize + │ user signs in (first-party apps skip consent) + {scheme}://callback?code&state ◄──────┘ + ──► state check (expo-auth-session) ──► POST {host}/oauth/token (JSON, code + code_verifier) + ──► GET /api/users/@me/ ──► GET /api/organizations/{id}/ for each other organization + ──► tokens to the secure store, account to AsyncStorage ──► status signed_in ──► /inbox +``` + +- The authorize page opens in the system browser (`openAuthSessionAsync`), so it shares cookies with the user's browser. + A user already signed in to PostHog there only confirms. +- The grant is not narrowed to one project (no `required_access_level`), so the project switcher lists every project. +- `restore()` runs once at boot from `app/_layout.tsx`. + Screens mount only after it finishes, so no query runs without a token. + It refreshes first when the access token has less than a minute left. +- Any request that gets a 401 refreshes once and retries (`src/api/client.ts`). + Concurrent 401s share one refresh, because each refresh rotates the token. +- A refresh rejected with 400, 401 or 403 ends the session: tokens and the query cache are cleared and every screen goes to sign in, which says the session ended. Push cleanup needs valid credentials, so expiry cannot unregister the old token. The next successful registration on the same instance transfers that device token to the signed-in user; see [Push](PUSH.md#token-lifecycle). + A network error or 5xx keeps the session; the request fails and the next one tries again. +- Sign out waits for the push registration queue, unregisters the device token, revokes the refresh token (`POST {host}/oauth/revoke`, form body, `client_id`), then clears tokens, the query cache and the analytics identity. + If push unregistration fails, sign out keeps the session and shows a retry action. OAuth revocation failures are ignored after push cleanup succeeds. +- OAuth and personal-key sign-in load the candidate account with its own host and credential. The active host, token and account change together only after that succeeds. A canceled or failed attempt leaves the previous session in place. +- On Android the callback also arrives as a deep link. `app/+native-intent.tsx` drops it so Expo Router does not open an unmatched `/callback` screen. + +## Hosts and client ids + +| Region | Host | Client id | +| ----------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------- | +| US | `https://us.posthog.com` | Production: `a5TY7w9IjFYfes6dkPgZe6envclWw3bm2UD8ZTlm`; internal: `EXPO_PUBLIC_OAUTH_US_CLIENT_ID` | +| EU | `https://eu.posthog.com` | Production: `1A7vO138Fh5sYmJislicN4F5HnttI6urmFttxPDU`; internal: `EXPO_PUBLIC_OAUTH_EU_CLIENT_ID` | +| Self-hosted (`custom` in the store) | typed by the user | internal builds prefill `DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ` (the local dev app); store builds start empty. Editable. | + +### Redirect URIs + +The redirect URI is `{scheme}://callback` (`makeRedirectUri({ scheme: appScheme, path: 'callback' })`). +The scheme follows the variant the native build was made as (see [RELEASING.md](RELEASING.md#build-variants)): + +| Build | Redirect URI | +| ------------------------------------------------------ | ---------------------------- | +| Store build, and any local build without `APP_VARIANT` | `posthog://callback` | +| `APP_VARIANT=preview` | `posthog-preview://callback` | +| `APP_VARIANT=development` | `posthog-dev://callback` | + +The scheme comes from the bundle id, not from Metro, so a Metro session in a store-identity build still uses `posthog://callback`. +Every redirect URI a build uses must be registered on the OAuth application it signs in to, or the authorize page rejects the request: + +- The production US and EU mobile applications register only `posthog://callback`. Remove `posthog-dev://callback` and `posthog-preview://callback` from those first-party applications if they were added. +- For Cloud development and preview builds, create a separate public OAuth application in each region, with **Is first party off**, and register `posthog-dev://callback` and `posthog-preview://callback` there. These builds require the consent screen. Put the generated IDs in `EXPO_PUBLIC_OAUTH_US_CLIENT_ID` and `EXPO_PUBLIC_OAUTH_EU_CLIENT_ID` in both EAS internal environments and local `.env.local`. They are public client identifiers, not secrets. Missing IDs leave Cloud sign-in unconfigured; there is no production fallback. +- Client selection follows the native build variant, matching the callback scheme. A Metro bundle in a store-identity binary still uses the production registration. +- The local dev app from `setup_tasks_oauth` registers all three callbacks, so an EAS development or preview build signs in to a local stack without a manual edit. A stack set up before that change needs `python manage.py setup_tasks_oauth` run again, which appends the missing URIs. + +Expo Go produces an `exp://` redirect that no app registers, so sign in needs a development build. + +Self-hosted hosts must be https. Internal builds (dev, preview, or any Metro session) accept plain http only for `localhost`, `127.0.0.1` and `[::1]`. These are also loopback hosts accepted by the server's OAuth redirect policy. Other `127.*` addresses, localhost subdomains and the Android emulator's `10.0.2.2` alias require https. +A store build rejects plain http, including loopback hosts. + +Android release binaries built with the development or preview variant allow cleartext only for those three exact loopback hosts. Production release binaries deny cleartext for all hosts; loading a Metro bundle does not change that native policy. Local Android debug builds, including `pnpm android` with the default store identity, retain Expo's cleartext allowance so Metro can load over the local network. The app's token host validation still accepts HTTP only on the three loopback hosts. + +To reach a local stack from an Android device or emulator, run `adb reverse tcp:8010 tcp:8010` and enter `http://localhost:8010` (substitute the stack's port). OAuth's browser must be able to reach the same host. + +## AI approval roles + +The selected organization's current server membership determines the approval controls: admins and owners can open the existing web settings; members are asked to contact an admin or owner. Unknown roles never get an approval control. The web's automatic Request access notification is not available through the current OAuth API, so mobile does not offer that action. See [CHAT.md](CHAT.md#ai-consent) for the shared approval flow. + +## Scopes + +The app requests exactly these scopes (`OAUTH_SCOPES` in `src/auth/oauth.ts`): + +`openid profile email user:read user:write organization:read project:read task:read task:write conversation:write today:read query:read dashboard:read insight:read file_system_shortcut:write error_tracking:read error_tracking:write session_recording:read replay_scanner:read replay_scanner:write` + +| Scope | Used by | +| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `user:read` | `GET /api/users/@me/` | +| `user:write` | push token register and unregister | +| `organization:read` | `GET /api/organizations/{id}/` for the project list | +| `project:read` | project-scoped reads | +| `task:read`, `task:write` | reports, hand-off, runs, notification routing | +| `conversation:write` | chat | +| `today:read` | `GET today/briefing/` and `GET today/reports/{id}/page/`. Without it both return 403 and the inbox always falls back to the signals report list. | +| `query:read` | `POST query/` for the live report metrics and charts, and in Views for web analytics, compare on an insight, and the people and recordings at a chart point. Without it the query returns 403, and the report shows each metric's saved value and series instead. | +| `dashboard:read` | Views: dashboard detail, the dashboard list, search, and the dashboards in Recent | +| `insight:read` | Views: insight reads in every `refresh` mode, the insight list, search, and `POST insights/viewed/`. Chat: cached insight names and descriptions in insight cards. Without it the chat card keeps its browser link and shows that the summary could not load. | +| `file_system_shortcut:write` | Views: pins, which are the web's starred items. It also grants `file_system_shortcut:read` for the pin list. | +| `error_tracking:read`, `error_tracking:write` | Errors: the issues list and issue screen, and resolve, suppress, reopen and assign. See [API.md](API.md#error-tracking-and-replay-scopes). | +| `session_recording:read`, `replay_scanner:read`, `replay_scanner:write` | Sessions: the sessions list, one recording, and its AI summary. | + +### Granted scopes and signing in again + +The token response's `scope` is stored with the tokens as `grantedScopes` (`src/auth/tokens.ts`). +A refresh response without `scope` keeps the previous value, because a refresh never widens a grant. +Sessions stored before the app recorded scopes, and personal API keys, have `grantedScopes: null`. +The sign-in also stores the scopes it asked for as `requestedScopes`, and a refresh keeps them. + +The Views scopes arrived after the first release, so a user signed in before then has a grant without them. +Nothing signs that user out: the inbox and chats keep working. +`useViewsAccess()` in `src/features/views/useViewsAccess.ts` decides what Views shows: + +- `reauth` when the OAuth grant lacks a Views scope, or when a Views request answered 403 with "API key missing required scope". Views then asks the user to sign in again once. +- `unavailable` when the last sign-in asked for that scope and the grant still lacks it. The ceiling below dropped it, so a new sign-in gets the same grant. Views says it isn't available yet and offers no sign-in. +- `personal-key-missing-scopes` for the same cases with a personal API key, which needs the scopes added to the key. +- `ok` otherwise. A `:write` scope counts as its `:read`. + +`missingScope(error)` in `src/api/scopeErrors.ts` reads the scope name from that 403, so a 403 for a dashboard the user cannot view stays a row error. + +Errors and Sessions use the same states through `useFeatureAccess('errors' | 'replay')` and ``. +A 403 is reported per feature (`reportScopeError(error, feature)`), so a refused issue read does not lock Views. + +### Server-side ceiling + +Each OAuth application has a scope ceiling: `OAuthApplication.scopes` plus `optional_scopes` (`posthog/models/oauth.py`). +An empty ceiling, or one containing `@default`, allows every unprivileged scope; any other ceiling is an exact allow-list. +The production app rows are not visible from this repository, so check them in Django admin when the app adds a scope. + +- `/authorize` normally does not reject an out-of-ceiling scope. It drops it and issues the rest, so the usual symptom is a 403 on the feature that needs it, for example the inbox never showing the Today briefing. +- When the server does answer `invalid_scope` (on the authorize redirect or the token endpoint), the sign-in screen says "This app's OAuth registration is missing a permission. Ask an admin to update it." + +Either way the fix is the same: add the scope to the mobile OAuth application's `scopes` in Django admin (or set the ceiling to `@default`), then sign in again. + +The US and EU client ids are also used by the mobile apps in `products/desktop/apps/mobile` and `products/desktop/apps/mobilehog`, so a ceiling change applies to them too. +On an application that is not first party, every entry in `scopes` is required for every client, so add a new scope to `optional_scopes` there instead. + +## Token lifetimes + +From `OAUTH2_PROVIDER` in `posthog/settings/web.py` and `OAuthValidator` in `posthog/api/oauth/views.py`: + +- Access token: 7 days for first-party and dynamically registered clients, 1 hour for every other client. +- Refresh token: 30 days, rotated on every refresh. Reusing an old refresh token after the 2 minute grace period revokes the whole token family. +- Authorization code: 5 minutes. + +## Storage + +| What | Where | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| Access token, refresh token, expiry, host, client id, auth method, user UUID | `expo-secure-store` key `posthog_mobile_session` | +| Region, host, client id, user, organizations, projects, current project | AsyncStorage key `posthog-mobile-auth` (zustand `persist`) | +| Web build | localStorage in dev builds, memory in release builds (a reload signs out) | + +The secure record is a single versioned session envelope. Restore takes the credential destination and identity only from that envelope; cached account and project metadata is used only when it matches. Older token-only records require signing in again. + +Region, host and client id stay after sign out so the sign-in form starts where the user left it. +If AsyncStorage cannot be read on launch, the auth routing gate settles with its default signed-out state so the splash screen can close. + +## Store shape + +`useAuthStore` (`src/auth/authStore.ts`): + +| Field | Meaning | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `status` | `loading` until `restore()` finishes, then `signed_out` or `signed_in` | +| `signedIn` | `status === 'signed_in'` | +| `region`, `host`, `clientId`, `method` | the session's instance and how it signed in (`oauth` or `personal_api_key`) | +| `user` | `{ uuid, distinctId, email, name }` | +| `organizations` | `{ id, name, aiDataProcessingApproved?, membershipLevel? }[]`; membership level is the requesting user's server-provided role | +| `projects` | `{ id, name, organizationId, organizationName }[]`, current organization first | +| `projectId`, `projectName` | the current project; `projectPath()` reads `projectId` | +| `accountLoading`, `accountError` | the last account reload | +| `signedOutReason` | `expired` when the server ended the session | + +Actions: `restore()`, `signIn(host, clientId)`, `signInWithPersonalApiKey(host, key)`, `selectProject(id, from)`, `reloadAccount()`, `signOut()`. +`selectProject` resets every TanStack query, so no screen shows the previous project's data. +`addSignOutTask(task)` registers work that needs the token during sign out (push unregistration uses it). + +## Sign-in screen + +The screen shows a data region dropdown, Log in, a self-hosted link, and a separate Try the demo button. +On first use, the device's region selects EU for European countries and US elsewhere. +If the device has no region setting, a European time zone selects EU; otherwise the default is US. +Returning users keep the region from their last sign-in. The dropdown can always override the default. +"Use a self-hosted instance" sits directly below Log in and shows the host and OAuth client ID fields. +Returning to PostHog Cloud restores the selected cloud region. Try the demo stays below the divider. +The sign-in screen uses OAuth in every build and has no personal API key option. +Every build shows "Try the demo", including preview and store builds, so app reviewers can try the app without an account. +In a release build it is the only way to start demo mode. Settings there can only leave demo mode, with "Exit demo". +Development bundles (`__DEV__`) also have the Settings demo toggle and start in demo mode with `EXPO_PUBLIC_DEMO_MODE=1`. +Every build restores a saved demo session when the app relaunches. +Log in always opens the system browser, where the user's password, Google, GitHub, GitLab or SSO login happens. +The app never asks for an email or password itself. + +The app does not create accounts. The footer link "Create an account" opens `{host}/signup` in the browser for the selected cloud region. +Store review may not allow a link out of the app for sign-up, so a build can hide the link with `EXPO_PUBLIC_SIGNUP_LINK=0`. +Production builds and OTA bundles set it to `0`, so store builds hide the link. +Under the card, "Privacy policy" and "Terms" open posthog.com in the browser. + +Internal builds show the environment ribbon above the logo, for example "Dev build · US cloud". +It names the selected region, or the self-hosted address once it is valid. +A store build shows it only for a self-hosted address, in red. +The link is also hidden while "Use a self-hosted instance" is selected. + +## Analytics + +The sign-in, session and settings events never carry the host, the error text or anything typed: + +- `mobile_sign_in_started` with `method` (`oauth` or `personal_api_key`), `region` and `after_expiry` (the screen showed "Your session ended"). +- `mobile_sign_in_failed` with `method`, `region`, `retryable` and `reason`: an `OAuthErrorKind` (`cancelled`, `denied`, `network`, ...), `invalid_input`, `invalid_key`, `account_rejected`, `account_forbidden`, `account_load` or `unknown` (`signInFailureReason()` in `src/auth/signInErrors.ts`). +- `mobile_signed_in` with `method` and `region`. +- `mobile_sign_in_region_changed` with `region`, from the region dropdown and "Use a self-hosted instance". +- `mobile_create_account_opened` with `region`. +- `mobile_session_restored` with `method`, once per cold start that opens signed in. +- `mobile_signed_out` with `reason` (`user`, or `expired` when the server ended the session) and `method`. It fires before the analytics identity resets. +- `mobile_account_reload_failed` with `reason` (`forbidden`, `api_error`, `network` or `unknown`). +- `mobile_project_picker_opened`, and `mobile_project_switched` with `from` (`nav_menu`, `settings` or `push`). +- `mobile_settings_opened` with `from` (`nav_menu`). +- `mobile_default_page_changed` with `page` and `previous_page` (`inbox`, `chats` or `views`), when Start page in Settings changes. +- `mobile_default_page_defaulted` with `page` (`inbox` or `views`) and `org_ai_approved`, when the app saves the start page once for a person who never chose one. +- `mobile_sign_out_confirm_shown` and `mobile_sign_out_cancelled` with `from` (`settings` or `nav_menu`). +- `mobile_demo_started` with `from` (`sign_in` or `settings`), and `mobile_demo_ended` with `from` (`settings`, `sign_out`, `sign_in` or `push`). +- `mobile_deep_link_opened` with `path_kind` (the first path segment as an enum, `deepLinkKind()` in `src/lib/deepLinkKind.ts`) and `initial`. `app/+native-intent.tsx` sends it. `deepLinkKind()` returns null for the app's root URL, which Expo Router also passes on a cold start without a link, so that start sends nothing. + +## Development sign in + +- **Local stack.** Run `python manage.py setup_tasks_oauth` in the main checkout. + It creates the dev app `DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ` with `posthog://callback` among its redirect URIs. + That covers `pnpm ios` and other local builds without `APP_VARIANT`. The command also registers development and preview callbacks; see [Redirect URIs](#redirect-uris). + In the app tap "Use a self-hosted instance", enter `http://localhost:8010`, keep the default client id. + The dev app is not first-party, so the consent screen shows and access tokens last 1 hour, which exercises refresh often. + - iOS simulator: `localhost` reaches the Mac. + - Android emulator: run `adb reverse tcp:8010 tcp:8010` first, so `localhost` on the emulator reaches the Mac. +- The auth store retains support for existing development sessions that use personal API keys. + The sign-in screen no longer starts these sessions. Keys never refresh; a 401 ends the session. +- **Demo** (every build). "Try the demo" needs no account. "Exit demo" in Settings returns to the sign-in screen. + +## Register a new OAuth app + +For a self-hosted instance or a new environment, create the app in Django admin (`/admin/posthog/oauthapplication/add/`): + +| Field | Value | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| Name | PostHog mobile | +| Client type | Public | +| Authorization grant type | Authorization code | +| Redirect URIs | Production app: `posthog://callback`. Separate internal app: `posthog-dev://callback posthog-preview://callback` | +| Algorithm | RS256 | +| Is first party | Off for internal apps. Only the production app may enable it; it skips consent and gives 7 day access tokens. | +| Scopes | Leave empty or `@default`, or list every scope above as the ceiling | + +For a non-first-party internal app, put feature scopes in `optional_scopes` so callers can request them without forcing every client to require them. Never attach an internal callback scheme to a first-party registration. + +Copy the generated client id into the Client ID field that "Use a self-hosted instance" shows on the sign-in screen. + +## Manual test + +A real OAuth sign in cannot run in CI. Before a release, on a development build: + +1. Sign in on US with a PostHog account. Check that the system browser opens, returns to the app and lands on the inbox. +2. Settings shows the host, your email and every project grouped by organization. Switch project, in Settings and from the project name at the top of the inbox menu, and check the inbox reloads. +3. Kill and reopen the app. It opens on the inbox without signing in again. +4. Cancel the browser during sign in. The sign-in screen says it was canceled and offers Try again. +5. Turn on airplane mode and reopen the app. The app stays signed in and shows load errors with Try again. +6. Against a local stack, delete the session's refresh and access token rows in Django admin (OAuth refresh tokens, OAuth access tokens), then pull to refresh. The app returns to sign in and says the session ended. First-party apps do not appear under Connected apps, so this needs admin. +7. Sign out from Settings. Confirm, then check that sign in comes back and a second launch stays signed out. +8. Repeat 1 on EU, and once against a local stack (see above). diff --git a/products/mobile/docs/CHAT.md b/products/mobile/docs/CHAT.md new file mode 100644 index 000000000000..cc4ed232e03a --- /dev/null +++ b/products/mobile/docs/CHAT.md @@ -0,0 +1,447 @@ +# Chat + +The chat sheet talks to PostHog AI on the task runtime, as the web app's side-panel chat does. +Every chat is a task with interactive runs in a sandbox. +A new chat creates the task on its first message; the Chats list reopens one by its task id. +The protocol below follows the web's `taskTrackerSceneLogic.ts`, `runInteractionLogic.ts` and `runStreamLogic.ts` in `products/posthog_ai/frontend`. + +## Files + +| File | Role | +| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/features/chat/ChatSheet.tsx` | The sheet: header, thread or empty state, composer, the options reducer, draft restore | +| `src/features/chat/chatSheetEvents.ts` | The sheet's `entry` from the route params, and the opened and closed events | +| `src/features/chat/ChatAccessGate.tsx` | The organization AI approval and the personal consent before the composer (see [AI consent](#ai-consent)) | +| `src/features/chat/session/useSession.ts` | `useSession({ taskId?, runId? })`: create, follow, send, sync options, resume, stop, answer, reset, demo replies | +| `src/features/chat/session/sessionRun.ts` | Pure request bodies: the create shapes, run fields, the `set_config_option` plan, the tracked run config | +| `src/features/chat/session/sessionReducer.ts` | Folds run log lines and stream frames into the thread, as the web's `foldLogFromCheckpoint` does | +| `src/features/chat/session/sessionDisplay.ts` | Groups thoughts and tool calls into activity rows, error copy | +| `src/features/chat/session/sessionTurn.ts` | `SessionTurnTracker`: one `mobile_session_finished` per turn the person started | +| `src/features/chat/session/SessionThread.tsx` | The thread, its rows, the PR card and the status line | +| `src/features/chat/session/SessionDecisionPanel.tsx` | The notice line and a pending question, plan approval or permission | +| `src/features/chat/messageQueue.ts` | The queue reducer, the hold, and the queue, drain and Send now rules | +| `src/features/chat/QueuedMessages.tsx` | The queue banner above the composer: count, rows, remove and Send now | +| `src/features/chat/chatRunOptions.ts` | `ChatRunOptions` (model and permission mode), its reducer, the chip label, seeding from a run | +| `src/features/chat/ChatOptionsSheet.tsx` | The Model and Mode pages in a sheet over the chat | +| `src/features/chat/contextPrompt.ts` | The leading context blocks, `bareQuestion`, `stripInjectedContext` | +| `src/features/chat/objectContext.ts` | The issue and recording item types, `objectContextFromParams`, their trusted lines (see [Object context](#object-context)) | +| `src/features/chat/attachments/` | Files in the composer: the limits and content types (`attachmentRules.ts`), the pickers, the three-step upload (`uploadAttachments.ts`), `useComposerAttachments`, the Attach sheet and the file chip (see [Attachments](#attachments)) | +| `src/features/chat/chatErrors.ts` | Maps HTTP and network failures to copy, including the PostHog Desktop gate | +| `src/features/chat/*.tsx` | `Composer`, `ContextChip`, `MentionPopover`, `ChatEmptyState`, `ChatMarkdown`, `ChatErrorBubble`, `ChatProgressLine`, `UserBubble` | +| `src/features/chats/` | The Chats screen: list, search, day groups and `useChatList()` (see [DESIGN.md](DESIGN.md)) | +| `src/demo/sessionThreads.ts` | Demo run logs keyed by the demo task ids in `src/demo/chats.ts`, and the demo replies | + +The transport is `src/api/tasks.ts`, shared with the hand-off (see [HANDOFF.md](HANDOFF.md)). +Every request carries `X-PostHog-Client-Platform: mobile`, so `run/` never reuses a warm run a web composer booted. + +## AI consent + +The same approval rules cover Chat, Create PR/task handoff, and replay summarization. The chat composer and decision panel remain hidden until approval is known. + +- **Organization approval.** The selected project's organization must have `is_ai_data_processing_approved: true`. The app reads the requesting user's `membership_level` from `OrganizationSerializer` in `/api/users/@me/` and `/api/organizations/{id}/`: 8 is an administrator and 15 is an owner. Only those confirmed roles see Open settings; other members and unknown roles see instructions to ask an admin or owner. The settings link opens the organization's existing web approval control; mobile never changes that setting itself. +- **Personal consent.** The shared sheet explains that messages, reports, attached files and photos, selected recordings, and project data used by the agent can go to PostHog AI and third-party model providers. It links to the privacy policy. Continue records the disclosure on this device for this host and user; Not now sends nothing. The broader disclosure invalidates consent saved by older versions of the chat-only sheet. Sign-out and session expiry clear consent. +- **Fresh checks.** Every submitted message (including queue delivery), handoff, decision answer, and replay summary first reads the organization's current approval. The chat also rechecks on foreground before showing role-specific controls. Unknown approval, a failed read, and removed project access stop the action. Changing host, user, project or session, or leaving the originating screen while the sheet is open, cancels its pending action. +- Demo mode skips approval and sends no live requests. + +These client checks supplement existing server scopes, resource RBAC and product checks. They do not grant an organization role or replace server authorization. Revocation after a check must still be enforced by the backend; mobile cannot stop an agent that is already running on the server. + +## Composer drafts + +The message input wraps long text onto multiple lines and grows up to 120 points tall. +Longer drafts scroll vertically within the input. The keyboard's Send action sends the message +and keeps the keyboard open for a follow-up. + +Text and attached report IDs are saved separately for each task, each report discussion, and +the new-chat composer. Native drafts use encrypted device storage, scoped to the account, +host and project, with at most 50 drafts per project. Changes save after 400 ms of idle time +and flush when the composer closes or the app backgrounds. Opening a saved chat restores its +draft after storage loads. +`mobile_chat_draft_restored` is captured with `text_length` and `report_count` when the restored draft has text. + +A successful send clears the submitted text. A failed send keeps it, and a delayed completion +does not replace text typed for a later message. Sign-out or session expiry clears the account's +drafts. Demo drafts stay in the current composer; the web preview stores drafts in memory only. +Picked files are not part of a draft. They stay in memory and go when the sheet closes (see [Attachments](#attachments)). + +## Rich replies + +PostHog object tags render as inline links to the selected host and project. Standalone +`display="block"` insight tags show the saved name and description; SQL tags show a three-line +preview with Show all. Insight summaries use `refresh=force_cache`, so opening a reply does not +run its insight queries. A reply renders at most ten cards, then uses inline links. Unknown +object kinds keep their labels, tags inside code stay literal, and unfinished tags stay hidden +while the reply streams. The object registry is generated from `posthog/object_tags/kinds.py` +into this app so standalone builds do not depend on the desktop app. + +Closed Mermaid fences render offline in a native WebView. The bundled renderer uses strict +mode and a policy that blocks network requests; navigation is disabled. Wide diagrams scroll +horizontally. Incomplete, invalid, or remote-image diagrams stay as code. The web preview also +uses the code fallback. Adding this renderer requires a new native build with +`react-native-webview` before distributing an update that uses it. +`mobile_chat_diagram_failed` is captured once per diagram with `reason` (`timeout`, `render_error`, `load_error`, `process_gone` or `open_window`). + +A tap on an object link, an insight card, a SQL card or a markdown link opens it in the browser. +`mobile_chat_link_opened` is captured after the system opens it, with `source` (`object_chip`, `insight_card`, `query_card` or `markdown`), +`object_kind` (the registry kind, null for a markdown link) and `app_link` (a markdown link that was an app path). + +Insight summaries need the `insight:read` OAuth scope. Existing sessions need a new sign-in, +and the server's mobile OAuth application must allow the scope; see [AUTH.md](AUTH.md). + +## Starting a chat + +The first send creates a task, then starts an interactive run on it. +Both calls go through `withWarmRetry`, which retries a 503 `warm_run_activation_unavailable` with the server's retry token. +The bubble shows at once and is taken back if the start fails. + +Two create shapes (`chatStartRequests` in `sessionRun.ts`): + +- **PostHog AI chat** (no report, or more than one report attached). `POST tasks/`: + + ```json + { + "title": "", + "description": "", + "origin_product": "posthog_ai", + "initial_permission_mode": "auto", + "pending_user_message": "\n\n" + } + ``` + + The server generates the title from `description` in the create request, so the response already carries it. + The app sends no `branch` and no `repository`, so the create never claims a warm run. + +- **Inbox "Discuss"** (exactly one attached report). `POST tasks/`: + + ```json + { + "title": "", + "description": "", + "origin_product": "signal_report", + "signal_report": "", + "signal_report_task_relationship": "discussion", + "signal_report_discussion_question": "", + "pending_user_message": "" + } + ``` + + The server exempts a repo-less report discussion from the PostHog Desktop gate. + `signal_report_discussion_question` is the trimmed question as typed, without the prompt's tag escaping, cut at 4000 code points. + Every later fresh run of a task with a `signal_report` carries `run_source: "signal_report"` and `signal_report_id`, also when the Chats list opens it. + It counts toward the report's discussion task limit (see [Errors](#errors)). + +Then `POST tasks/{t}/run/`, unless the create answered with a `latest_run`: + +```json +{ "mode": "interactive", "pending_user_message": "\n\n", "initial_permission_mode": "auto" } +``` + +- `mode: "interactive"` is required: only interactive runs relay approvals and questions to the client. +- A picked model adds `runtime_adapter`, `model` and `reasoning_effort` (left out for a model without effort control), and maps the mode onto that harness. + An untouched model sends only the mode, so the server applies the stored default. +- The app follows `run.id` from the response (`latest_run` on older servers). +- A refused run (any 4xx, or a 503) deletes the task it just created with `DELETE tasks/{t}/`, so no empty row stays in the Chats list. + A 5xx other than 503 or a lost connection may still have started a run, so the task stays, and the next message first looks for that run (see [Follow-ups and resume](#follow-ups-and-resume)). +- Closing the sheet or tapping New chat does not abort the create or the run start, so the app always learns the task id and whether a run started. + When the create finishes after the chat was dropped, the app deletes the new task and starts no run. + +## Options + +The composer has a Model chip and a Mode chip after `@`. +They open `ChatOptionsSheet` with the hand-off's `ModelPage` and `ModePage`. +`mobile_chat_options_opened` is captured with `page` (`model` or `mode`) when a chip opens the sheet. +`mobile_chat_option_changed` is captured with `option` (`model`, `effort` or `mode`) only. + +The resolved default model is selected when no model is pinned: its row has a tinted background, +bold model and Default labels, and a checkmark. A known default reasoning effort uses the same +filled pill as an explicit choice. Picking another model or resetting to default moves these +selection indicators to the active options. + +- **Seeding.** When an existing chat opens, the sheet seeds the options from `session.runConfig`: + the run's model and effort as a pinned model, and its permission mode. + A new chat keeps "Default" after sending, even when the run resolves a model and effort. + Its model changes only when the person picks a model or effort. + A run with no known harness or model seeds "Default"; an unknown mode seeds Auto. + Seeding stops once the person picks anything in this sheet, and New chat starts seeding again. + While seeding, the Mode chip follows the mode the stream reports (`current_mode_update`), also for a run the app started. + After a pick, a new mode from the stream still replaces the picked mode, as the web drops its mode override: the agent can leave Plan on its own when a plan is approved. +- **Enabled** while the agent is idle, the history has loaded, no send is in flight and no decision waits, also on an open chat. +- **One harness per run.** A live run cannot switch between Claude Code and Codex. + On an open chat the Model page lists only the models of the run's harness, and an untouched model keeps that harness. + The hook still refuses a cross-harness pick with a notice. +- **The mode that is sent.** The Mode chip shows the mode mapped onto the harness the next message runs on (`chatPermissionMode`), and the sheet sends that mode. + +### Live changes: `set_config_option` + +A pick alone sends nothing. +On the next message to a live run, `useSession` first sends one command per changed value, in the web's order (model, effort, mode): + +```json +{ "jsonrpc": "2.0", "method": "set_config_option", "params": { "configId": "model", "value": "claude-opus-5-5" } } +``` + +- "Changed" means different from what the run last had: what it started with, then each change the app applied (`TrackedRunConfig`). + A mode the app set wins over the mode the stream reported before it, until the stream reports a mode again; then the stream's mode wins, as the web clears `sentMode` on `setCurrentMode`. +- The starting mode is the run's `state.initial_permission_mode` from the `run/` response, which can differ from the mode sent. +- Each change is recorded as it succeeds, so a later failure resends only what did not go out. +- 503 `sandbox_not_ready` retries for up to 10 seconds, honoring `Retry-After`. +- 409 means the run ended. The message then starts a resume run that carries the options. +- A 400 (such as a model outside the run's gateway pin), a 502, or a JSON-RPC `error` in the body stops the send before its `user_message`, shows the notice and restores the draft. + +## Follow-ups and resume + +- **Live run.** `POST tasks/{t}/runs/{r}/command/` with `user_message` and the context-wrapped text. + Success is `result.queued`. The bubble moves to where the turn starts when the agent echoes it. +- **No run known.** After a first run that failed with a 5xx or a lost connection, or on a task opened without a run, `GET tasks/{t}/` comes first. + A live `latest_run` gets the message and is followed; an ended one is resumed. Only a task with no run gets a fresh run. +- **Ended run, or a 409 on `user_message`.** `POST tasks/{t}/run/` with `mode: "interactive"`, `resume_from_run_id`, `pending_user_message`, and the run fields. + An untouched model sends no triple, so the server keeps the previous run's selection; the mode maps onto the previous run's harness. + No `branch`: a resumed run keeps its base branch. +- **Queue.** A message sent while the agent works, while a send or an answer is in flight, while a decision waits, or behind earlier queued messages goes into a queue above the composer instead (`messageQueue.ts`, `QueuedMessages.tsx`), as on the web. + The banner shows "N queued" and one row per message, each with a remove control. + The queue lives in memory only. + While the agent works and the input has text, Send shows beside Stop, which turns into an outline button. +- **Automatic drain.** When the turn ends and nothing else is in flight or waiting, the queued rows go out as one message, joined by blank lines. + A run that has ended takes the queue too: the send resumes it, as for any follow-up. +- **Send now.** Sends the queue into the live turn at once with `params.steer: true` on `user_message`, when the session reports `canSteer`; otherwise it goes as an ordinary follow-up. + It is disabled with "Answer the agent first" while a decision waits, and with "Wait for the agent to start" while the turn runs but the stream has not shown the agent generating yet. + The reason shows under the banner, because a phone has no tooltip. +- **Hold.** Stop holds the queue, so it does not go out when the stopped turn ends; the banner then says "N queued, not sent yet". + A new message lifts that hold. + A failed delivery puts its rows back in front of anything queued since and holds them; only Send now sends them again, because the failed send may have reached the agent. +- Closing the sheet, an Android back, or New chat puts the queued messages back in front of the composer text, so the draft keeps them. +- **Files.** A queued message takes the composer's files with it, as on the web, and the row shows "1 file" or "N files". + The drained message, or Send now, carries the files of every row in queue order. The 10-file cap applies per composer message, not to the join. + A failed delivery puts the rows back with their files. Removing a row drops its files. + Closing the sheet or New chat drops the queued files, as closing drops unsent composer files. The queued text folds into the chat's own draft, so its files must not follow into another chat. + Attach stays enabled while messages queue. Files of a direct send still in flight stay with that send and never join the queue. +- `mobile_session_queued` is captured with `queue_length` when a message joins the queue, `mobile_session_queue_removed` with `queue_length` after a row is removed, + and `mobile_session_queue_sent_now` with `queued_count` and `steer` (the session could steer at the tap). +- `mobile_session_sent` is captured with `new_task`, `resumed_run`, `report_count`, `attachment_count`, `model`, `reasoning_effort`, `permission_mode`, + `delivery` (`steer` when the agent accepted a steered message, `queue` for a drained queue, else `direct`) and `queued_count`. + Model and mode changes still go out as `set_config_option` before a steered message, as on the web. +- `mobile_session_send_failed` is captured with `code` and `new_task` when a send shows a notice. + `code` is `upload_failed`, `config_rejected`, `command_failed`, `run_not_started`, `warm_run_unavailable`, or the `ChatError` kind from `chatErrors.ts`. +- `mobile_session_finished` is captured once per turn the person started, with `status`, `duration_s` (from the accepted send) and `had_decision`. + A steered message joins the turn it steers, so it adds no event of its own. + `status` is `completed` (the turn ended), `failed` (the run failed, or the turn ended with a new run error), `cancelled` (the run was cancelled without Stop), + `stopped` (the turn or run ended after Stop) or `abandoned` (the sheet closed or New chat dropped the chat first). + +## Attachments + +The Attach button opens a sheet with Photo library and Choose a file. +Photo library uses `expo-image-picker` (images only, never the camera); Choose a file uses `expo-document-picker` (any type). +Both allow several files. The keyboard drops before the sheet opens and comes back after the pick. +The limits and content types are the web's (`products/posthog_ai/frontend/utils/attachments.ts`): + +- Any file type. At most 30 MiB per file, 10 MiB for a `.pdf`. An empty file is refused. +- At most 10 files per message. At 10 the Attach button is dimmed, and its accessibility hint says "Up to 10 files fit in one message"; no visible text says it. + Files picked past the cap are left off, and one line says how many. +- The content type comes from the extension, never from the picker. An unknown extension is `application/octet-stream`; a file is an image when its type starts with `image/`. + iOS gives a converted photo the name of its original (`IMG_0001.HEIC` for a JPEG), so the extension of the file the picker wrote wins. +- A refused file shows in the notice line above the composer, such as "notes.txt is empty." or "slides.pdf is over the 10 MB limit for PDFs." + +Picked files show as chips after the report chips: an image with a small thumbnail, any other file with a document icon, each with a remove ×. +They live in memory only, never in the draft store. +Text stays required: a send with files and no text does not go, as on the web. + +Files upload when the message sends, in three steps per batch, as the web's `artifactUpload.ts` does: + +1. `POST .../prepare_upload/` with `{ "artifacts": [{ "name", "type": "user_attachment", "source": "posthog_ai", "size", "content_type" }] }`. + The answer keeps the request's order, and each entry has an `id`, a `storage_path` and a `presigned_post`. +2. A `multipart/form-data` `POST` to each `presigned_post.url`, all files at once. + Every `presigned_post.fields` entry goes first, then the file as `file`. It is a plain `fetch`, so the PostHog token never goes to S3. +3. `POST .../finalize_upload/` with each `id`, `name`, `type`, `source`, `storage_path` and the extension's `content_type`. + +One failed file fails the send, so a message never arrives with fewer files than the person attached. +The order of a send: + +- **New chat.** `POST tasks/` (no `branch`, so it never claims a warm run and never answers with a run), then the staged upload against the new task (`tasks/{t}/staged_artifacts/...`), + then `POST tasks/{t}/run/` with `pending_user_artifact_ids`. A failed upload deletes the new task, as a refused run does. + If a create ever answers with a run while the send has files, the send fails the same way, because that run would never get the files. +- **Live run.** The changed options, then the run-scoped upload against the current run (`tasks/{t}/runs/{r}/artifacts/...`), + then `user_message` with `params.artifact_ids`. +- **Ended run, or a 409.** Run-scoped ids belong to that run only, so the files upload again, staged against the task, and the resume `run/` carries `pending_user_artifact_ids`. +- **Demo.** Nothing uploads. The chips and the bubble show the files, and the canned reply plays. + +While a send uploads, its chips show a spinner instead of ×, and Attach is dimmed. A successful send clears its chips. +A message that goes into the queue takes the composer's files with it (see [Follow-ups and resume](#follow-ups-and-resume)). +A failed send keeps them, puts the text back, and the notice says "Your files didn't upload. Try again." +Every failure while the files upload (prepare, the S3 post, finalize) shows this copy and counts as `upload_failed`, also when the server sent its own copy. +The first failed S3 post stops the other posts of the batch. +Closing the sheet or New chat aborts an upload in flight. + +The bubble shows a picked image as a thumbnail from the file on the device and any other file as a chip with its name. +The echo names each file by a `resource_link` (or `image`) block whose URI ends in `.posthog/attachments///`. +The reducer reads the ids from that path, keeps one entry per artifact, and fills the first entry of the same name that has no id yet, so the optimistic bubble keeps its thumbnail. +It also keeps each artifact's device file, so a resume, which draws the thread again from the log, keeps the thumbnails of files sent from this sheet. +A message with files and no text, which another client can send, has no bubble of its own: its files join the previous bubble, as on the web. +A file from history has no local copy and shows by name only (see [GAPS.md](GAPS.md#chat)). + +- `mobile_chat_attach_menu_opened` when the sheet opens. +- `mobile_chat_file_attached` with `source` (`photos` or `files`), `attached_count` (files in the composer after the pick) and `image_count` (images in the pick). +- `mobile_chat_file_rejected` with `reason` (`empty`, `too_large`, `unreadable` or `too_many`), once per refused file and once per pick that went past the cap. +- `mobile_chat_file_removed` with `attached_count` when a chip's × removes a file. +- `mobile_chat_file_upload_failed` with `status` (the HTTP status of prepare, finalize or the S3 post, when there was one) and `new_task`. +- No event carries a file name or its content. + +## Reading a run + +- `GET tasks/{t}/` gives the title and the latest run. With a `runId` the sheet follows that run through `GET tasks/{t}/runs/{r}/`. +- **Ended run.** `GET tasks/{t}/runs/{r}/logs/` returns the whole resume chain as JSON lines, folded into the thread. +- **Live run.** `GET tasks/{t}/runs/{r}/stream/` without a cursor replays the stored log, then streams. + The app buffers catch-up frames and shows the history together, then streams new replies. + Catch-up ends on a keepalive, stream completion, or 250 ms of silence after a Redis frame; silence during durable-log replay never ends it. + Opening a thread scrolls straight to the latest message without animation. + Reconnects follow the hand-off rules (see [HANDOFF.md](HANDOFF.md)). A reconnect keeps the cursor and buffered history + through a transport rotation, and buffers each new connection's backlog before showing it. A replay after a lost + cursor rebuilds the thread with a fresh event deduplication set, so previously displayed or buffered frames remain in the full replay. +- `mobile_session_resumed` is captured with the run status. +- `mobile_session_load_failed` is captured with `code` (the `ChatError` kind) each time the thread shows "Couldn't load this session". + `mobile_session_load_retried` is captured when the person taps Try again there. + +## Stop + +Stop sends the `cancel` command (`{ "jsonrpc": "2.0", "method": "cancel" }`), which interrupts the turn and keeps the run alive. +The app sends it only after the stream showed the agent generating, because a cancel before that drops the run's first message; until then the notice says "The agent hasn't started yet." +A JSON-RPC `error` in the answer shows "Couldn't stop this run. Try again." +`mobile_session_stopped` is captured when the cancel goes out. The turn's `mobile_session_finished` then has `status: stopped`. + +## Decisions + +Permission requests go through the hand-off tool policy (`toolPolicy.ts`, see [HANDOFF.md](HANDOFF.md#tool-policy)): +the app allows built-in tools and PostHog `exec` calls on the person's behalf and asks about the rest. +A question, plan approval or permission shows in `SessionDecisionPanel` and replaces the composer while it waits. +The answer goes as a `permission_response` command and shows in the thread as the person's bubble. +`mobile_decision_shown` is captured once per request with `kind` (`question`, `plan` or `permission`), `surface: chat` and `replayed` (the request was in the stored log). +`mobile_decision_answered` follows with `task_id`, `kind`, `picked_option` and `surface: chat`. + +## Errors + +- A send, answer or stop that fails shows one notice line above the composer. + A failed send also sets `unsentText`; the sheet puts it back into an empty input once. +- **PostHog Desktop gate.** `run/` and `user_message` answer 403 `code_access_required` for a user the gate refuses. + The message is under `error`, and the app shows it as is, because it names the reason (Startup plan, prepaid credits). + A 503 `desktop_access_unavailable` says "We couldn't verify PostHog Desktop access. Try again." + A 429 `usage_limit_exceeded` or `billing_limit` shows the server copy. + `cancel` and `set_config_option` are not gated. Only the Inbox "Discuss" shape is exempt. +- **Report discussion limit.** A one-report chat counts toward the report's discussion task limit. + `POST tasks/` and `run/` answer 429 `signal_report_task_cap` with the copy under `error`; the app shows it, else "This report has reached its chat limit. Try again later." +- A 400 shows the server copy (`detail` or `error`, such as "Invalid resume_from_run_id" or a model the account cannot use), else a generic line. +- A 502 "Failed to queue" says the message did not reach the agent. +- The run's own failure (`error_message`, `_posthog/error`) shows inline in the thread. + +## Context block + +`chatPrompt(question, context)` ports the web's `wrapWithPosthogContext`. +The web thread strips only leading blocks, so the blocks lead and the question follows. + +```text + +- A user sent the message after this block about the PostHog Inbox report at {report url}. If it is a question, answer it; if it asks for action, carry the action out and summarize what you did. +- {REPORT_DISCUSSION_STATE_INSTRUCTIONS} +- {NO_CHECKOUT_INSTRUCTIONS} + + +{the web's hardening header} +- signal_report {report id} ("Report: {title}") +{the web's reminder} + + +{question} +``` + +- The trusted block goes only with attached reports. Its first line names the report by the web URL the app builds from the signed-in host, as the web's `discussReportInstructions` does. + When the report cannot be acted on, the first line becomes "Answer this question about the PostHog Inbox report at {report url}. The question is the message after this block." and the state instructions drop out. +- The web has no framing for several reports. The app names them all ("these PostHog Inbox reports: {url}, {url}") and adds "The instructions below speak of one report. Apply them to each of these reports on its own." +- With no report attached, the first message of a new chat lists up to 20 inbox rows as `signal_report` lines in the untrusted block. + Follow-ups and opened chats send no inbox list. +- The agent reads report details through the inbox MCP tools, so the prompt carries no summaries. +- `defang` escapes context tags and line breaks in every item field; `bareQuestion` escapes context tags in the question. +- `description` and the bubble get the bare question. `stripInjectedContext` removes leading blocks from echoes and logs. +- The blocks go with every message while a chip stays attached, because the chip says it is attached. + +### Object context + +The issue and session screens open the sheet with `entry` (`issue` or `replay`), `objectType`, `objectId` and `objectLabel`. +`objectContextFromParams` in `objectContext.ts` keeps only the two types the web uses, `error_tracking_issue` and `session_recording`. +The message carries one untrusted item line, `- {type} {id} ("{label}")`, and one static trusted line that names the investigating skill and the fetch tool (`query-error-tracking-issue`, `session-recording-get`). +The object shows as a removable chip with the issue or recording icon. While it is attached, @-mentions are off and report chips stay hidden, because a message frames one kind of context. +New chat drops it. + +## Rendering + +- Assistant text renders as markdown with `react-native-markdown-display`, styled from `src/ui/tokens.ts`. + `html: false`; images are dropped. Relative links open on the signed-in host; only http(s) links open. +- The status line shows the current stage or tool, "Thinking…", or "Reconnecting…". +- Thoughts and tool calls fold into activity rows. Every row starts closed, also while it runs. `mobile_chat_activity_expanded` is captured with `running` and `tool_count` when the person opens one. +- Known PRs remain available while execution is active, idle or terminal. Their state is separate + from execution; a discovered URL starts unknown until the report supplies authoritative state. +- Report task status opens that implementation task, including idle and completed runs that can + resume. The Chat button beside the report action pill starts a separate report discussion. A completed turn + alone does not create an attention request; the task composer provides continuation. + +## New chat + +The compose button in the header shows once a chat has a task or a message. +It drops the open task (or the one the first send created), clears the text and attached reports, and resets the options to Default and Auto. +The header shows the task title, else "New chat". +The empty state with suggested prompts shows while there is no task and no message. +`mobile_chat_suggestion_picked` is captured with `source` (`report` for the report's own prompts, else `default`), `index` and `report_count`; never the prompt. + +`@` or the @ button opens the report list. `mobile_chat_mention_opened` is captured with `trigger` (`typed` or `button`), +`mobile_chat_report_attached` with `attached_count`, and `mobile_chat_report_removed` (a chip's ×) with `attached_count` and `initial` (the report the sheet opened with). + +## Sheet lifecycle + +The navigator keeps one chat screen. Opening another chat reuses that screen and loads fresh +conversation state. A notification for the chat already on screen leaves its session, draft and +scroll position intact, including a chat started from the composer. A notification for a different +chat replaces the open chat, so one Close returns to the screen underneath. Starting New chat +does not prevent a later notification from reopening the previous conversation. + +Changing the selected account, host or project closes an open chat and aborts its scoped work. Callbacks captured in the old scope cannot send to the newly selected project. +A send waiting for encrypted draft storage is cancelled by New chat or closing the sheet, and its text remains recoverable in the original draft. + +The sheet opens from the bottom bar's compose button, a report's Chat button, a report's task status, an inbox row's menu and the Chats tab. +The route takes an optional `entry` param; `chatSheetSource` falls back on `report` with a `reportId`, `task` with only a `taskId`, else `compose`. + +- `mobile_chat_sheet_opened` is captured once per open with `entry` (`chats_list`, `chats_search`, `chats_empty`, `compose`, `report`, `report_task`, `inbox_menu` or `task`), + `origin` (the Chats row's origin, else null), `has_context` (a report is attached) and `resumed` (an existing task opened). +- `mobile_chat_sheet_closed` is captured on unmount, also after an Android back, with `entry`, `messages_sent`, `new_chats` (New chat taps), `had_run` and `dwell_s`. + +## Chats list + +`useChatList()` (`src/features/chats/hooks.ts`) lists the person's sandbox sessions: +`GET tasks/?created_by={user id}&ordering=-last_activity_at&basic=true&limit=30`. + +- `created_by` takes the numeric user id from `GET /api/users/@me/`. A session restored from before the app stored it has none: + the hook calls `reloadAccount()` once and shows loading until it settles, then the list, the account error with Try again, or an empty list. +- `chatListFromSessions` (`src/model/adapters/chats.ts`) maps `origin_product` to the row's origin: `posthog_ai` is a chat, `signal_report` a report, anything else a session. + The server already leaves out archived tasks. +- A row opens the sheet with `{ taskId, entry, origin }`: `entry` is `chats_search` while the search field has text, else `chats_list`. + `mobile_chat_sheet_opened` carries both; the list sends no event of its own for a row. +- `mobile_chats_searched` is captured with `query_length` and `result_count` once per query, after typing pauses. +- `mobile_chats_refreshed` is captured with `trigger` (`pull` or `retry`). +- `mobile_chats_load_failed` is captured with `code` (the `ChatError` kind) each time the list shows "Couldn't load your chats". +- A 403 or 404 from tasks means the user has no sessions and shows the empty state. +- `chatGroups.ts` labels rows by local calendar day and filters titles on the device. +- A long press (or the screen reader's "Show actions") opens the row menu (`ChatRowMenu`) with Archive. + `useArchiveChat()` hides the row at once and sends `PATCH tasks/{id}/` with `{ "archived": true }`; the server stamps `archived_at` + and leaves the task out of every later list. The toast offers Undo, which puts the row back and sends `{ "archived": false }` after any pending archive request finishes. + Repeated archive submissions share the pending request. A failed archive puts the row back and shows "Couldn't archive this chat. Try again." + A failed Undo hides the row again and shows "Couldn't restore this chat. Try again." + There is no archived list in the app: the web app and PostHog Desktop show archived tasks. +- `mobile_chat_row_menu_opened`, `mobile_chat_archived` and `mobile_chat_archive_undone` carry the row's `origin` and `status`; + `mobile_chat_archive_failed` carries `action` (`archive` or `restore`) and `code` (the `ChatError` kind). +- Regaining focus (closing the sheet) invalidates the list, so a new chat appears with its title. +- Legacy LangGraph conversations are not listed. The web's new side panel does not list them either. + +## Demo mode + +No request goes out, and attached files do not upload. +A new chat gets a demo task id, echoes the message and plays `DEMO_REPLY` through the session reducer: streamed chunks, the final message, then a turn end. +Demo sessions replay the run logs in `src/demo/sessionThreads.ts`: +"Fix the flaky checkout test" ended with a PR, "Add dark mode to settings" waits on a question, +and "Migrate billing webhooks" failed. A send plays `DEMO_REPLY`, an answer plays `DEMO_ANSWER_REPLY`, and Stop cuts the reply short. +The model list is the static catalogue, and the options change nothing about the reply. +Archiving a demo chat hides it in the demo store until the demo refreshes or resets. diff --git a/products/mobile/docs/CI.md b/products/mobile/docs/CI.md new file mode 100644 index 000000000000..a7ff0287c22f --- /dev/null +++ b/products/mobile/docs/CI.md @@ -0,0 +1,90 @@ +# CI + +Three GitHub Actions workflows cover the app. They install the independent `products/mobile` workspace. + +## Mobile CI + +`.github/workflows/mobile-ci.yml` is a reusable workflow. `pr-housekeeping.yml` calls it on every pull request, and `mobile-release.yml` calls it for each release commit. +Direct pushes to `master` that match the workflow's paths only install dependencies and warm the pnpm store cache. They do not repeat validation. + +A `changes` job reads the pull request diff. When nothing under `products/mobile/**` (Markdown excluded), the generated Signals API types, or the workflow changed, the check job is skipped and the run ends in seconds. +The `Mobile CI Pass` job reports a verdict on every run, so it can be a required branch-protection check. +A trigger-level `paths:` filter cannot do this: a required check that never dispatches leaves the pull request waiting for a status that never arrives. See `/authoring-ci-workflows`. + +The check job runs these steps for pull requests and release commits, using the same commands you run locally: + +```sh +pnpm install --frozen-lockfile +pnpm typecheck +pnpm lint +pnpm test --ci +pnpm release:check +pnpm release:test +``` + +Write `pnpm test --ci`, not `pnpm test -- --ci`. pnpm forwards the `--`, and Jest then reads `--ci` as a test path pattern. + +### Formatting + +Mobile uses Biome for JavaScript, TypeScript, JSON and CSS, following the desktop and agent workspaces. `pnpm lint` checks formatting and lint rules; `pnpm fix` applies fixes. The root `.oxfmtrc.json` excludes these mobile file types so the two formatters do not rewrite the same files. + +Markdown, MDX, SCSS and YAML use the repository's Oxfmt configuration. The shared `pnpm-lock.yaml` ignore pattern already excludes the mobile lockfile. From the repository root, run `bin/hogli format:markdown products/mobile/docs/CI.md` to format a mobile Markdown file. + +### CI configuration + +Details: + +- The checkout includes the mobile app and its existing generated Signals API types. Keep new runtime dependencies inside the mobile workspace. +- pnpm comes from `packageManager` in `products/mobile/package.json`, not from the repo root. +- Node comes from `products/mobile/.node-version`. Keep it equal to the repo's `.nvmrc` and to `node` in `eas.json`. +- The pnpm store cache is keyed on `products/mobile/pnpm-lock.yaml`. Pull requests only restore it. Pushes to `master` save it. +- Change detection uses the existing paths-filter GitHub App. Both reusable-workflow callers pass `GH_APP_POSTHOG_PATHS_FILTER_PRIVATE_KEY` by name. The workflow creates a token with pull-request read access only for same-repository PRs; fork and Dependabot PRs use `GITHUB_TOKEN` because they do not receive the app secret. No credential value belongs in the repository. +- A newer push to the same pull request cancels the older run. +- Push runs use a separate concurrency group for each commit, so a later release cannot replace an earlier pending validation run. Publication stays serialized; see [release ordering and retries](RELEASING.md#receipts-ordering-and-retries). +- After this workflow reaches `master`, ask a repository administrator through `@PostHog/team-devex` to add `mobile-ci / Mobile CI Pass` to the `master` ruleset's required status checks with GitHub Actions as its source. Keep the other required checks. YAML comments do not configure repository rules. +- The release workflow calls Mobile CI on the merged commit. A push has no pull request diff to filter, so the checks always run there. + +`@cvolzer3` owns both mobile app trees and all mobile workflow files in `CODEOWNERS`, including release requests, release tooling and the public OTA certificate. + +The simulator demo flows do not run in CI. Run them locally before you merge a change to a screen. See [TESTING.md](TESTING.md#demo-flows-before-you-merge) and the `/running-mobile-demo-flows` skill. + +## Mobile Build + +`.github/workflows/mobile-build.yml` queues an EAS build. It runs only when someone starts it from the Actions tab on `master`; other refs are skipped before checkout or credentials. The `mobile-build` environment must also allow only `master`. + +| Input | Values | Default | +| ---------- | ------------------------ | --------- | +| `profile` | `development`, `preview` | `preview` | +| `platform` | `all`, `ios`, `android` | `all` | + +- The job runs `scripts/eas-build.sh` with `--no-wait`, so it ends once EAS has the upload. Follow the build on the EAS project page. +- This workflow makes internal builds only. Store submission is owned by Mobile Release. +- The build message is `@`, because EAS cannot read git when it uploads only this directory. +- eas-cli is pinned in the workflow. Raise it on purpose, after reading its changelog. +- It needs a dedicated `MOBILE_BUILD_EXPO_TOKEN` secret only in the `mobile-build` environment. Store keys stay on EAS. See the protected environment setup in [RELEASING.md](RELEASING.md#one-time-hosted-release-setup). + +## Mobile Release + +`.github/workflows/mobile-release.yml` starts when a push to `master` adds a release request under `products/mobile/releases/requests/`. A normal app merge without a request does not publish anything. PR runs never publish. + +The request selects `native` or `ota`, names the app/runtime version, describes the changes, and identifies the preceding completed release. Requests are append-only; each release push adds exactly one. Use `pnpm release:request` to prepare the file, then include it in the app PR. See [RELEASING.md](RELEASING.md) for commands and first-release setup. + +After the required checks pass and an authorized `mobile-production` reviewer approves the deployment, the hosted publish job uses the exact tested commit: + +- **Native:** verify a higher app/runtime version, build both platforms, wait for successful builds, and submit those exact build IDs to TestFlight and Play internal testing. Public-store promotion remains manual. +- **OTA:** verify the runtime and native fingerprints against the last completed native release, sign and upload to an unlinked staging branch, and sign the promotion of that exact update group to the production channel. The native receipt pins the signing certificate as well as native compatibility. + +The `mobile-production` environment must require reviewer Chris Volzer (`@cvolzer3`), allow self-review, disable administrator bypass and allow only `master`; these settings are configured by an administrator outside the workflow. It holds `MOBILE_PRODUCTION_EXPO_TOKEN` and `MOBILE_OTA_SIGNING_KEY`, with no repository/organization secret fallback. The private OTA key is available only to the OTA publish step, after dependency installation; native builds need only the checked-in public certificate. A durable release receipt prevents a completed request from publishing twice. Interrupted publication requires reconciliation rather than blindly retrying; the request's predecessor prevents a later release from silently overtaking a missing one. See the release guide for recovery. + +Production build/submit/OTA package commands now direct developers to this request flow. No active release request is included with the automation itself. + +Complete the [mobile app release checks](../../../docs/internal/mobile-release-checks.md) before sharing builds or promoting an internal build publicly. Hosted checks and the local demo flows do not verify real sign-in, push delivery or physical-device behavior. + +## Check workflow changes + +From the main checkout: + +```sh +hogli lint:workflows +actionlint -config-file .github/actionlint.yaml .github/workflows/mobile-ci.yml .github/workflows/mobile-build.yml .github/workflows/mobile-release.yml +``` diff --git a/products/mobile/docs/DATA.md b/products/mobile/docs/DATA.md new file mode 100644 index 000000000000..7785d32621a9 --- /dev/null +++ b/products/mobile/docs/DATA.md @@ -0,0 +1,211 @@ +# Inbox and report data + +How the inbox and the report screen get their data in live mode, and how the adapters in `src/model/adapters/` turn API responses into the view models in `src/model/types.ts`. +Demo mode never reaches any of this: every hook keeps its demo branch. + +## Endpoints + +All paths are project scoped (`/api/projects/{project_id}/...`) and go through `apiJson` in `src/api/client.ts`. +Fetchers live in `src/api/today.ts`, `src/api/reports.ts` and `src/api/query.ts`, typed by hand with only the fields the app reads. +Each interface names the serializer it mirrors. + +| Use | Request | Serializer | Scope | +| ----------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------- | +| Inbox list | `GET signals/reports/?view=for_you&sort=priority&limit=20&offset=` | Paginated `SignalReportListSerializer` | `task:read` | +| Read states of the list | `POST signals/reports/read_state/` `{report_ids}` (one call per page) | `ReportReadStateResponseSerializer` | `task:read` | +| Report | `GET signals/reports/{id}/` | `SignalReportSerializer` | `task:read` | +| Work log | `GET signals/reports/{id}/artefacts/?limit=100` (newest first) | `SignalReportArtefactSerializer` | `task:read` | +| Signals | `GET signals/reports/{id}/signals/` | `ReportSignalsResponseSerializer` | `task:read` | +| Today page | `GET today/reports/{id}/page/` | `ReportPageSerializer` | `today:read` + `task:read` | +| Current task | `GET tasks/{task_id}/` (`getTask` in `src/api/tasks.ts`) | `TaskSerializer` | `task:read` | +| Mark read | `POST signals/reports/read_state/` `{report_ids: [id], read: true}` | same | `task:read` | +| Signal snooze | `POST signals/reports/{id}/state/` `{state: 'potential', snooze_for: 5}` | `SignalReportSerializer` | `task:write` | +| Set reviewers | `PUT signals/reports/{id}/reviewers/` `{content: [{user_uuid} or {github_login}]}` | `SignalReportSuggestedReviewersArtefactSerializer` | `task:write` | +| Set priority | `POST signals/reports/{id}/artefacts/` `{artefact_type: 'priority_judgment', content: {priority, explanation}}` | `SignalReportArtefactLogCreateSerializer`, content `PriorityAssessment` | `task:write` | +| Reviewer search | `GET signals/reports/available_reviewers/?query=` | `{uuid: {name, email}}` | `task:read` | +| Metric and chart data | `POST query/` `{query: , client_query_id, refresh: 'blocking'}` | `TrendsQueryResponse` | `query:read` | +| Running query | `GET query/{query_id}/` | `QueryStatusResponse` | `query:read` | + +Query keys (`reportKeys` in `src/api/reports.ts`) carry the host and project id, so a project switch never shows another project's cache. +`useProjectScope()` reads both from the auth store and returns null until the session is signed in. + +## The inbox list + +`useInboxItems()` reads `signals/reports/?view=for_you&sort=priority` one page of 20 reports at a time, with `useInfiniteQuery`. +The `for_you` view holds only the open, actionable reports that wait for the user's input, that the user claimed, or that name the user as a reviewer. It excludes unowned P0 reports. +The priority sort matches the Today briefing: priority first, then status, then most recently updated. + +The list asks for the next page when the user scrolls within half a screen of its end, and while the response has a `next` link. +Each page orders the reports again, so a report that moved can come back on a later page; `reportsFromPages()` keeps its first place. +A next page that fails shows "Couldn't load more reports." and "Try again" under the list. +`mobile_inbox_page_requested` carries the page number of each next-page request. +`mobile_inbox_error_shown` carries `during` (`load` for the full-screen error, `next_page` for the footer) and, for `load`, `kind` (`scope`, `auth`, `forbidden`, `not_found`, `server`, `network`, `unknown`). +`mobile_inbox_empty_shown` is sent each time the list shows "Nothing needs you right now". + +**Filter and sort.** +`useInboxItems({ filters })` filters and sorts on the device over the reports returned by the `for_you` view. +`applyInboxFilters()` (`src/features/inbox/filters.ts`) keeps the reports created inside the window and the reports with any source in the selected groups, then sorts. +Source groups follow the mobile source vocabulary in `src/model/sources.ts`: `error` holds every error source, `support` every feedback source, `session_replay` also holds `replay_vision`, and `report` holds `signals_scout`, `signals_check`, unknown sources and reports with no source. +`recommended` keeps the server ranking. `model_score` sorts by `ranking.scores.pr_merged`, highest first, with unscored reports last; the server sends `ranking` to staff only, and the option shows only behind the `mobile-inbox-model-score-sort` flag. +While any filter or sort is not the default, the hook requests the next page until the server sends no `next`, so a sort covers the whole set and a filter does not hide matches on pages not yet loaded. The footer spinner shows until the last page arrives. A failed page stops this until "Try again". +The choice persists on the device (`inboxFiltersStore.ts`). `matchedCount` and `totalCount` tell "No reports match these filters" from an empty inbox. +`mobile_inbox_filters_opened` is sent when the sheet opens. `mobile_inbox_sort_changed` carries `sort`, `mobile_inbox_created_window_changed` carries `window`, and `mobile_inbox_source_filter_changed` carries `source`, `selected` and `count` (the selected groups after the change). `mobile_inbox_filters_cleared` is sent for "Clear" and "Clear filters", and `mobile_inbox_filtered_empty_shown` each time the list shows "No reports match these filters". + +Read states are fetched once per page, so a new page does not ask again for the pages above it. + +Pull to refresh is the only manual refresh. It keeps only the first page and refetches it and the read states. +Refetching every loaded page would rank the whole list once per page. +`mobile_inbox_refreshed` carries `trigger`: `pull` or `retry` (the error screen's "Try again"). +A change made on the report screen (resolve, dismiss, reviewers, priority, hand-off) refetches every loaded page in the background. + +## Inbox rows + +`inboxFromReports(reports)`: + +- `needsInput` is `status === 'pending_input'`; `agent` is the PostHog badge when `assignee.kind` is `task` or `agent`; +- `state` is `done` for resolved, `dismissed` for suppressed or deleted, else `open`; +- `sourceProduct` is the first source that is not `signals_scout` or `signals_check`, else the first; +- `sources` and `createdAt` carry `source_products` and `created_at` for the filters, and `modelScore` is `ranking.scores.pr_merged`, or null. + +Accent: `resolved` when the report is resolved, suppressed or deleted; `alert` when any source is an error source or any source is not feedback; `opportunity` when every source is feedback (support tickets, feedback boards, app reviews) or the report is scout-only. + +The hook then sets `read` from the read-state map and applies local snoozes (below). + +## Report view + +`useReport(id)` runs the report, work log, Today page and signals requests in parallel, then `GET tasks/{id}/` once it knows the task. +The screen waits for the report and the work log, for the Today page to answer, and for the signals only when there is no page. +The task fills in when it arrives. +A failed background refetch keeps the cached report on screen. + +**Prefetch and refresh.** +The inbox prefetches the report detail, work log and Today page of the first 40 rows (the first two pages, as they load), and of the rows that scroll into view plus 5 ahead. +These are the requests the report screen waits for before its first render; the task and the metric queries load after it opens. +It also prefetches the signals of a report when the Today page answers 404 or 403, because the signals are the evidence then. +It works on two reports at a time and skips done and dismissed rows. +It uses a 5 minute stale time, so a refresh or a scroll does not fetch a report again within 5 minutes of its prefetch. +A report the cache dropped while the app was idle is fetched again when its row comes into view. +The screen and the prefetcher share the query factories in `src/features/report/queries.ts`, so they read and write the same cache entries. +The report screen renders the cached copy at once and refetches it on every open, and on Prev/Next. +A failed background refetch keeps the cached copy. +`mobile_report_opened` carries `from_cache`, true when the report detail was in the cache when the screen opened. +It also carries `from`, read from the route's `from` param: `inbox` (a row tap), `push` (a notification), `adjacent` (Prev/Next) or `deep_link` (no param or an unknown one). +`mobile_report_load_failed` is sent when the screen shows "Couldn't load this report", with `report_id` and `kind` as for the inbox error, or with `kind: invalid_id` and no id for "Couldn't find this report". +`mobile_report_read` is sent once when the report body unmounts (Back or Prev/Next), with `report_id`, `dwell_s` (whole seconds the screen had focus with the app in the foreground) and `scrolled_to_end` (the deepest scroll came within 160 px of the content end, or the content fit on screen). +`mobile_report_replay_opened` carries `report_id` and `replay_count` when "See the moment" opens the session screen (the recording on the web in demo mode). + +**Full report.** "Read the full report" under the lede opens `/report/{id}/details` when the report has a summary or at least one signal. +The screen reads `useReport(id)`, so it shares the report screen's cache and does not poll. +The signals can arrive after the first render when a Today page exists, because the report screen does not wait for them then; the Signals section appears when they land. +`mobile_report_details_opened` is sent once per open, with `report_id`, `has_summary` and `signal_count`. +`mobile_report_details_link_opened` carries `report_id` and `target`: `web` (Open in PostHog), `replay` (a signal's Watch session) or `link` (a link in the summary or a signal). +`mobile_report_details_read` has the same properties as `mobile_report_read` and is sent when the full report unmounts. +The report screen's dwell time pauses while the full report is on top, because that screen loses focus. + +`reportView(...)` rules: + +- **Headline.** The title, split when it starts with a number token (`3,721`, `+2.3s`, `0 of 860`, `$184k`, `12%`). When the title has no number, the first sentence of the Today `lead` is used if it starts with one and is at most 120 characters. Otherwise the whole title is `rest`. +- **Lede.** The Today `impact_sentence`, else `summary_lead`, else the first sentence of the summary, as plain text. A lede that starts with the headline sentence loses that sentence. +- **Accent and source.** As for inbox rows, from `source_products` and `status`. +- **Evidence from the Today page.** The first error signal is a `trace`: `error` is its `headline`, frames come from the quiet `preview.block` lines (`file.ts:12 fn`), at most 4, and `footer` is its `meta` when set. Feedback signals become up to 3 `quotes` (`lead`, attributed "{source} · {meta}"). The first signal with a `recording` is the `replay`: meta "at {offset} · {headline}", url `{host}/project/{id}/replay/{session_id}?t={seek_seconds}`, count the larger of the recordings on the page and the distinct `session_id`s in the signals. +- **Evidence from signals** (no page). The first error signal is a `trace` with its first line and the frames of its fenced stack (`fn in path line N`, closest to the error first). Feedback signals become quotes ("Ticket #N" when `extra.ticket_number` is set). The first signal with `extra.session_id` is the `replay`, without a seek time. +- **Signals.** Every signal from the signals endpoint, newest first as the API returns them, also when the Today page supplies the evidence. Each has its source (unknown sources map to `other`), the trimmed `content`, `timestamp`, the `extra.session_id` with its replay URL (`{host}/project/{id}/replay/{session_id}`, no seek time), and `extra.ticket_number` when it is a number. The full report shows them all, untruncated. +- **Since.** `created_at` as activity time ("Mon 7:21 AM", "Today 9:04 AM", "Just now"). The byline under the headline reads it as "Since …". +- **Owner.** `{ kind: 'user', name }` for a user claim; `{ kind: 'agent', name }` with "PostHog" for a task or system claim, or the MCP client ("Claude Code") for an external agent claim; `{ kind: 'none' }` when unclaimed. The owner chip under the byline shows it. +- **Facts**, the rows the byline and chips do not show. The adapter emits only Impact, dropped when it has no source: the primary metric's value and title ("612 affected users", or "612 users" with the title under it when the metric has its own unit) and its caption; else the first Today impact number. + The byline takes the source from `sourceProduct` and `sourceLabel`, so the facts carry no Source row. +- **Observation, supporting metrics and charts.** See [Metrics and charts](#metrics-and-charts). +- **Activity**, oldest first, starting with "PostHog created the report" at `created_at`: + - `work_claim`: "{name} handed off to **PostHog**" for a task claim when the task's creator is known, else "PostHog claimed the report"; "{actor} claimed the report" for users and agents. + - `task_run` of type `implementation`: "PostHog started working". Other run types are left out. + - `pull_request`: "PostHog opened **PR #N**", or "{actor} linked **PR #N**" when a person or agent attached it. + - `suggested_reviewers`: "{actor} changed reviewers to **A, B**" or "{actor} removed the reviewers"; "PostHog suggested **A, B** as reviewers" for pipeline writes. + - `dismissal`, `note`, `title_change`, `summary_change`: one sentence each. Every other artefact type is left out. + - The actor is the `created_by` user, the MCP client name for agent writes, else "PostHog". Agent writes carry `via PostHog MCP`. +- **Work.** `work_state`, the assignee, and the pull request the server calls the implementation PR (else the first). A PR in state `unknown` counts as open, as the server does. +- **Task.** The claim's task when a task owns the report, else the newest `implementation` task run. `runId`, `status` and `stage` come from the task's `latest_run`. The hand-off feature streams it from there. +- **Reviewers.** The newest `suggested_reviewers` artefact, entries with a PostHog user only. +- **Other fields.** `status` and `priority` when they are known values, `summary`, `webUrl` (`reportWebUrl()`: `{host}/project/{id}/inbox/reports/{report_id}`, also used for chat context), `canAct` (the web's `isActionCapableReport`: `ready` or `pending_input`, not already addressed, not `not_actionable`, no pull request), up to 3 `suggestedPrompts`, and the fixed `contextNote`. `useReport` adds `prompt`, the hand-off feature's implementation prompt for `webUrl`. + +## Metrics and charts + +The report detail carries `metrics` (`ReportMetricSerializer`) and `charts` (`ReportChartSerializer`). +The rules port the web inbox (`products/signals/frontend/inbox/utils/reportMetrics.ts`, `reportChartQuery.ts`, `chartOpenTarget.ts`). + +A metric has a saved snapshot (`value`, `value_at`, up to 14 undated `series` points) and a live `query`, an `InsightVizNode` with a `TrendsQuery` source. +`value: null` means no snapshot for this viewer, never zero. +`query` is absent on list responses and null when access rules redact it. + +`metricView(metric, scope)` in `src/model/adapters/metric.ts` builds a `MetricView`: + +- **Role.** The single `primary` metric becomes `ReportView.observation`. Every other metric is a `supportingMetrics` tile, in the order the report lists them. +- **Chart kind.** `count` is a bar chart. `currency` is a bar chart only when every series has `math: 'sum'`. Everything else is a line, because bars invite the reader to add up a rate. +- **Data.** With a trends query, `data` is `{kind: 'query', seriesQuery, aggregateQuery}`. Both are copies of the `TrendsQuery` source. `seriesQuery` sets `trendsFilter.display` to `ActionsBar` or `ActionsLineGraph`, turns `showPercentStackView` off and drops `hiddenLegendIndexes`. `aggregateQuery` sets `display: 'BoldNumber'`, so the server totals the window: summing buckets would count a returning user once per bucket. Without a query, `data` is `{kind: 'none'}` and the snapshot is all there is. +- **Window label.** From the relative `dateRange.date_from`, as the web words it: `-14d` is "Last 14 days", `-24h` "Last 24 hours", `-1m` "Last month". Anything else is "Current window". +- **Filter count.** The property filters on every series plus the query's own `properties`. Groups count their leaf filters. +- **Interval.** `source.interval` (`hour`, `day`, `week`, `month`), `day` when unset. +- **Open link.** `{host}/project/{id}/insights/new#q=` with the embed flags (`full`, `embedded`, `show*`) removed. A link over 8,000 characters is dropped, because nginx refuses a longer request line. + +`reportChartView(chart, scope)` builds a `ReportChartView`. +The app draws a chart only when it is an `InsightVizNode` with a `TrendsQuery` source, one series, no breakdown, and a display that draws buckets over time (`ActionsLineGraph`, `ActionsLineGraphCumulative`, `ActionsAreaGraph`, the three bar displays, or none). +Such a chart gets `data` (its own display kept, the same clean-up as a metric) and a format from `aggregationAxisFormat`. +Any other chart (SQL, saved insight, pie, number, table, breakdown) has no `data` and stays in the list with its title and open link: +`/sql?open_query=` for a `DataVisualizationNode` with a `HogQLQuery` source, `/insights/{shortId}` for a `SavedInsightNode`. + +### Running a query + +`runQuery(query)` in `src/api/query.ts` posts the `TrendsQuery` to the plain `query/` route. +The server also routes `query/{Kind}/`, which only checks that the path matches the body. +With `refresh: 'blocking'` the server usually answers inline. +When it answers with a `query_status` that is not complete, the app polls `GET query/{id}/` every second for at most 30 seconds, and stops when the request is aborted. + +`useMetricSeries(data, enabled?)` and `useMetricAggregate(data, enabled?)` in `src/features/report/metrics/hooks.ts` return a `QueryState`: + +- `static` (demo fixtures) succeeds at once with no request; `none` or an undefined `data` succeeds with `data` undefined. +- `query` runs in live mode only. The cache key is the host, the project and the query with its keys sorted. A result stays fresh for 15 minutes, as the web treats a snapshot. A failure is retried once, except a 403 or 404. +- The series is the first result's `days` zipped with its `data`, cut to the shorter list, with `NaN` for a bucket that has no number. The aggregate is the first result's `aggregated_value`. A response with neither succeeds with `data` undefined, and the screen falls back to the snapshot. +- `enabled: false` leaves a query idle: not loading, no data, no error. + +The query endpoint needs the `query:read` scope. Without it the request answers 403 and the screen shows the snapshot. + +### Showing a metric + +`metricValueState` in `src/features/report/metrics/metricValue.ts` picks the value an observation or a tile shows: + +- A live aggregate wins, also while it refetches. +- Without one, the snapshot value shows with "Measured {time ago}" from `value_at`. This covers a redacted metric (`data: none`), a query that returned no value, and a query still loading. +- When the live query failed, the snapshot shows with "Couldn't refresh this metric. Showing the latest saved value." A tile says "Couldn't refresh". +- With no snapshot, a loading query shows a spinner, and a settled one shows "Not available". After a failure the observation offers Try again. + +`useChartPoints` in `src/features/report/metrics/useChartPoints.ts` feeds the chart: + +- Live buckets win. A bucket label without an offset (`2026-10-13`, `2026-10-13 14:00:00`) is read as local wall-clock time, because `Date.parse` reads a bare date as UTC midnight, which is the day before west of UTC. +- Without live buckets, the observation draws the snapshot's undated series. The points are one interval apart and the last one sits at `value_at`, or at the current time when that is unknown. +- While the live query loads, nothing is drawn, so the chart does not draw the snapshot and then redraw. +- With no points at all, the observation leaves the chart out. A drawable chart in the Charts section shows a spinner while it loads, then "No data in this window." or an error line. + +## Actions + +- **Sheets.** `mobile_report_sheet_opened` carries `sheet` (`handoff`, `resolve`, `dismiss`, `assignee`, `priority`, `decision`, `pull_request_picker`), `from` (`action_bar`, `chip` for the owner and priority chips, `inbox_menu`) and `report_id`. `mobile_report_sheet_dismissed` carries the same properties and is sent only when the sheet closes without its completion: a successful resolve, dismiss, priority or reviewer save, a hand-off attempt, an external agent opened or a prompt copied, a picked pull request, or an answer sent. The completion events below carry `report_id`. +- **Outcomes.** `mobile_report_resolved` and `mobile_report_dismissed` carry `reason` and `has_note`, never the note. `mobile_report_priority_changed` carries `priority`. `mobile_reviewers_changed` carries `count`. `mobile_inbox_report_menu_action` stays the record of which row menu choice was picked; sheets opened from it also send `mobile_report_sheet_opened` with `from: inbox_menu`. +- **Read state.** Only a fresh, successful server response of `false` marks a row unread. Pending, missing and failed lookups stay quiet. The inbox refreshes read state every 30 seconds while focused in the foreground, and when it becomes active again. Rows show an unread dot; the menu counts unread reports in the loaded pages. +- **Mark read.** `useMarkReadOnOpen` posts `read: true` after the report loads, including on Prev/Next. Separate reports can post concurrently. The cached state updates at once; a failed write restores that report's previous state without replacing other reports' updates. In-flight lookups cannot overwrite a pending read, and account or project changes invalidate the pending operation. + `mobile_report_marked_read` and `mobile_report_marked_unread` are sent after the server accepts the write, with `trigger`: `open` for this automatic mark, `manual` for a swipe or a row menu choice. +- **Reviewers.** The sheet sends the full list of uuids. Reviewers that exist only as a GitHub login on the newest artefact are sent back as `{github_login}`, because the PUT replaces the whole list. The PUT takes at most 10 entries, so the sheet stops adding people when the selection plus the GitHub-only reviewers reaches 10. The hook waits for the work log to refetch so the sheet closes on the new list. A failed save keeps the sheet open with the error. +- **Reviewer search.** Debounced by 250 ms, cached for 5 minutes, and the previous results stay on screen while the next search loads. + +## Snooze + +Two kinds, both remembered in `useSnoozeStore` (zustand, persisted in AsyncStorage as `posthog-mobile-snoozes`), keyed by `{host}|{project id}|{report id}`: + +- **Until more signals arrive** posts `state: potential` with `snooze_for: 5`. The server hides the report from the inbox list until its `signal_count` reaches `signals_at_run`. The device stores the threshold and labels the row "Snoozed until more signals" until the list refetches without the report, the row's signal count reaches the threshold, or 14 days pass. +- **Time snoozes** stay on the device. The row reads "Snoozed until 3:31 PM", "Snoozed until tomorrow" or "Snoozed until Oct 16", computed against the current time, and the entry ends by itself at its time. A local notification is scheduled for that time (`expo-notifications`, title the report title, body "Snoozed report is back", data `{type: 'report', report_id, project_id}`). The reminder is scheduled only when notifications are already allowed; the app does not ask for permission here, and without it the snooze still works. Web schedules no reminder. + +Snoozed rows stay in the list, muted, in their place. Rows that are done or dismissed keep their state. +Opening a report ends its time snooze and cancels the reminder. A new snooze on the same report replaces the old one and its reminder. + +Limits: + +- Time snoozes do not sync. Another device or the web app still shows the report as open. +- Reinstalling the app or clearing its storage forgets every snooze. Scheduled reminders survive only as long as the OS keeps them. +- Signing out, or a session that expires, clears every snooze on the device and cancels the reminders. diff --git a/products/mobile/docs/DECISIONS.md b/products/mobile/docs/DECISIONS.md new file mode 100644 index 000000000000..f5a1a163ca97 --- /dev/null +++ b/products/mobile/docs/DECISIONS.md @@ -0,0 +1,413 @@ +# Decisions + +Choices the build contract left open, with the reason for each. + +## Toolchain and versions + +- **Expo SDK patch level.** `expo ~57.0.8` resolved to 57.0.26, the newest release older than + 7 days. Its `bundledNativeModules.json` expects React Native 0.86.3, Reanimated 4.5.1 and + Worklets 0.10.1, so those are pinned instead of desktop's 0.86.0 / 4.5.0 / 0.10.0. + `react-native-gesture-handler` follows Expo's `~2.32.0`, not 3.x. +- **React 19.2.6** matches desktop and sits in `expo.install.exclude` with `react-dom` and + `typescript`, the same way desktop does it. +- **Release age.** `pnpm-workspace.yaml` sets `minimumReleaseAge: 10080` (7 days), the same as + the repo root. This is why `posthog-react-native` is `^4.78.0` (desktop's `^4.80.0` is newer + than 7 days) and why `expo-doctor` still lists patch releases from 2026-10-06. Bump them with + `npx expo install --fix` once they are a week old. +- **Extra packages.** `@react-native-community/datetimepicker` (Expo's 9.1.0) for "Custom…" + snooze; `@react-native/jest-preset` and `@react-native/metro-config` 0.86.3 to settle peer + dependencies; `jest` 29 and `@types/jest` for `jest-expo` 57. +- **No `metro.config.js`.** This directory is the workspace root, so Expo's default config + resolves everything. No monorepo watch folders are needed. +- **pnpm workspace.** `packages: ['.']` makes this a single-package workspace so pnpm never walks + up to the repo root, which excludes `products/mobile`. +- **Typed routes off.** They need `expo start` to generate types before `tsc` passes. Expo removed + the generated-type entries from `tsconfig.json#include` on first start for the same reason. +- **EAS** uses Node 24.13.0, the repo's version. +- **Charts.** `victory-native` 42.0.1 on `@shopify/react-native-skia` 2.6.2, the Skia version Expo SDK 57 pins. + It is the only candidate with line and bar charts and scrubbing on the UI thread (`useChartPressState`). + The runner-up, `react-native-gifted-charts`, was rejected because it scrubs on the JS thread. + Skia's install script links its prebuilt binaries, so `pnpm-workspace.yaml` allows it in `onlyBuiltDependencies`. + On web, `pnpm web` copies CanvasKit to `public/`, and only screens with a chart load it, lazily. + Tick labels are React Native `Text`, so the app ships no font file for Skia. + +## Architecture + +- **Hooks per feature.** `useReportActions` composes `useHandoffActions`, `useSnoozeAction`, + `useSetReviewersAction` and `useMarkReadAction`, so each data source lives in one `hooks.ts`. +- **Live branches return error states** for reads instead of throwing during render, so a missing + piece shows the error screen with Try again. Actions throw `NotImplementedError`. +- **`useDataMode()`** waits for the persisted demo flag, so a cold start shows loading, never an error. +- **Overlays on the report screen are components, not routes.** The chat sheet is a route + (`presentation: 'transparentModal'`, no stack animation) that draws its own scrim and 94% sheet, + so it looks the same on iOS, Android and web. +- **Inbox and Chats are tabs** (`app/(tabs)/`, `Tabs` from `expo-router/js-tabs` with a custom `tabBar`), so the + bottom bar renders once and stays put when the tab changes. `backBehavior="firstRoute"`: Android back from Chats + returns to Inbox, and back from Inbox leaves the app. +- **Menus and sheets draw through `OverlayPortal`.** The tab navigator draws its bar after the scenes, so an overlay + inside a tab scene would draw under the bar. `FloatingCard` and `BottomSheet` render into the `OverlayHost` in + `TabsLayout`, above the bar. Screens outside the tabs have no host, so their overlays render in place. +- **Animations** use Reanimated shared values through `usePresence`, not `@gorhom/bottom-sheet` + (one fewer dependency) and not Reanimated layout animations (custom ones are unreliable on web). +- **Prev/Next** uses `router.setParams`, so switching reports does not push a screen. It wraps + through the whole inbox list, like the prototype's `nextAny`. +- **Pull to refresh in demo mode** resets every demo change and re-anchors fixture times, with a 450ms + refreshing state. +- **Error boundary.** `app/_layout.tsx` exports a branded boundary with Try again. + +## Visual + +- **Status bar** is `dark` (dark content on the light background). The contract's "light status + bar" read as "the light theme". +- **CSS box sizing.** Sizes follow what the prototype renders, not the raw CSS numbers. See + [DESIGN.md](DESIGN.md#reading-handoff-values). +- **Web preview insets.** The web build uses the prototype's 54pt status bar and 34pt home + indicator as insets, so it previews like a phone. +- **Floating chrome blurs what scrolls under it.** The inbox header, bottom tab bar, report top chrome and Settings chrome sit on + `BlurEdge` (`expo-blur` masked by the `FadeEdge` gradient through `@react-native-masked-view/masked-view`) + under a 72% background tint, so rows soften and dissolve under the chrome instead of vanishing behind + a solid block. Android uses the experimental `dimezisBlurView` method; without it the view is only a + tint. The web build has no masked view, so it masks the blur with CSS `mask-image`. + Top chrome fades down into the content; the bottom tab bar fades up, with the same blur intensity and tint. +- **Shadows.** Native uses `shadow*` + `elevation`; web uses the exact CSS `boxShadow`, because + react-native-web renders `shadowRadius` as a blur half the handoff's size. +- **Text wrapping.** `lineBreakStrategyIOS="standard"` on iOS and CSS `text-wrap: pretty` on web + match the prototype's wrapping of headlines, ledes, questions, PR titles and quotes. +- **Focus ring.** Web text fields drop the browser outline; the field container is the affordance. +- **Disabled chat tools.** Expand renders at 40% opacity because it does nothing yet. +- **Nav pill** shows the handoff's three slots as Home, Chat and Products. The app has no Spaces or + Views, so the chat-bubbles slot opens the chat sheet and the grid slot opens the web app's product + library (`/project/{id}/library`, the web rail's Products pane), which has no product screen in the app. +- **Bottom padding floor.** `bottomOffset()` never returns less than 16pt. The handoff's bottom paddings include the + 34pt home indicator, so on a device with a home button (inset 0) several came out between 0 and 14pt. + +## Content and behavior + +- **Sources.** Error → `error_tracking`, GitHub → `github`, pganalyze → `pganalyze`, + Product Analytics → `analytics`, Report → `signals_scout`. Dashboards has no source product, so + the model adds `dashboard` (Today's `dashboard` group) and `other` (unknown values). + Default labels are sentence case ("Product analytics"); fixtures keep the prototype's + "Product Analytics". +- **Fixture people.** Every fixture person is invented and uses an `example.com` address. + The demo user is "Robin Calloway" ("RC"). Trace footers and "Asking Sana in the thread" name fixture teammates only. +- **Fixture times** are computed from the moment demo mode starts. A weekday such as "Mon 7:21 AM" + is the most recent Monday one to seven days back, so the timeline prints the prototype's text. +- **Fixture 3** (report load time) starts `unclaimed` so its footer is idle, as in the reference + screenshot, even though its owner chip says Claude Code. +- **One agent.** New demo activity says "PostHog". Historic fixture entries keep Codex and Claude + Code verbatim. All agent marks are `ph`. +- **Inbox rows** show `{source} • {time}` only, like the prototype template. A snoozed row shows + "Snoozed until …" in place of the time (README). `progressLabel` and `agent` stay in the model + for later. +- **Hand-off sheet** keeps the selected-row styling for its single PostHog row. Its meta line reads + "PostHog agent · opens a PR in your repo", which says what handing off does, because the app offers one agent. +- **Pull to refresh replaces the prototype's Reset.** Pull to refresh is the only manual refresh: it refetches the + inbox, and in demo mode it also resets the demo, which is what Reset did, so one gesture covers both modes. The + headers have no refresh button, because every list already supports pull to refresh. +- **Decided card meta** comes from the answer: the prototype's text rules in demo mode, the run's + `stage` from the run. +- **PR card.** "Review PR" calls `Linking.openURL`, which opens the GitHub app when installed, + where the user is signed in. An in-app browser would not share that session. Only `https://` URLs open + (`safeHttpsUrl` in `src/lib/urls.ts`), because the URL comes from the run's output. +- **Assignee sheet** is multi-select because reviewers are a list. A dark "Done" pill appears next + to Close once the selection changes; Close and the scrim discard changes. The activity entry + reads "changed assignee to **A, B**", or "removed the assignee". +- **Snooze sheet** adds "Until more signals arrive" with "Hide until 5 more signals" under the label + (too long for the right column). Day presets wake at 9:00 AM local time; "In a month" is 30 days, + as in the prototype. "Custom…" shows wheels inside the sheet on iOS, the date and then time + dialogs on Android, and a typed `YYYY-MM-DD HH:MM` field on web. +- **Mention popover** eyebrow reads "ADD A REPORT", not "ADD A BRIEFING", to match "add a report as + context" in the chat subtitle. +- **Chat** keeps the "New chat" title after the first message. The demo answers with a canned + reply after 600ms. +- **Mark read** runs when a report opens and again on Prev/Next. Read state is not styled. +- **Settings** shows the version to everyone. Internal builds (dev, preview, or any Metro session) add an + Environment group: build variant, update channel, update id and time from `expo-updates`, and the API host kind. + Store builds show nothing about environments unless the session's host is not PostHog Cloud. + +## Verification + +- `hogli product:lint --all` (the CI gate) only lints product directories with `__init__.py`, so it + skips `products/mobile`, the same as `products/desktop`. Running `hogli product:lint mobile` + by name reports a missing `__init__.py`, a missing tach entry and Python test files inside + `node_modules`, exactly as `hogli product:lint desktop` does. The fix belongs in the linter. + +## Chat + +- **One runtime: tasks.** Every chat is a task with interactive runs, as on the web's side panel. + The tasks endpoints accept `task:read` and `task:write`, so the LangGraph path below is gone from the app, + and the bullets about `conversations/`, its context block, reattach and reconnect describe the removed path. + [CHAT.md](CHAT.md) has the current behavior. +- **LangGraph endpoint (superseded).** Chat posts to `conversations/`, the LangGraph runtime. The sandbox runtime + opens through `conversations/:id/open/`, which declares no OAuth scopes, so an app token gets 403. + `frontend/src/scenes/max/AGENTS.md` freezes the LangGraph path; the app only consumes it. + Move to the sandbox runtime once its endpoints accept `conversation:write`. +- **Context is text in `content`.** `MaxUIContext` has no report key and rejects unknown keys, so the + app sends the Today page's `` block, ported with the two kickoff constants verbatim. + Each report's summary is inlined (first 1500 characters) because this agent has no report tools. +- **Several reports.** One bullet group per report under a plural heading. The action framing and + `REPORT_DISCUSSION_STATE_INSTRUCTIONS` go out only when every attached report can be acted on. + At most 5 reports attach, to keep the prompt small. +- **Global chat sends the inbox.** With no report attached, up to 20 inbox rows go in a + `#### Context from my PostHog inbox` block, so "What needs me today?" has something to answer from. +- **Context on every message** while a chip stays attached, because the chip says it is attached. +- **Files upload at send time**, as on the web. A new chat has no task until it sends, and staged files expire, so an upload at pick time would need a task the person may never use. +- **Text stays required with files.** The web never sends files alone, and the echo pairs the bubble by its text. +- **Picked files stay in memory.** The draft store keeps text and report ids only. A file URI points into the picker's cache, which the system can clear, so a restored draft could point at nothing. +- **The Attach menu is a bottom sheet**, as the Model and Mode pages are. The keyboard drops before it opens and moves the composer, so a menu anchored to the button would open where the button was. +- **A new chat never claims a warm run.** The create sends no `branch`, so the server cold-creates the task, and the files always stage on it for `run/`. + The web uploads to a warm run before the create; the app has no warm run to upload to. +- **A resume uploads the files again.** Ids from a run-scoped upload are valid only on that run, so a 409 on `user_message` stages the files against the task for the new run. +- **`send(text, context)`** instead of `send(text, reportIds)`. Each context chip loads its report with + `useReport`, so the sheet holds the views and builds the context. ARCHITECTURE.md's hook table still + says `reportIds`. +- **Suggested prompts fill the composer**, as in the prototype, instead of sending at once as the web + inbox does. The server's `suggested_prompts` replace the defaults only for one attached report the + agent may act on, matching the web. +- **Header title** shows the title the server generates, replacing the earlier "keeps New chat" choice. + A compose button in the right slot starts a new chat once the thread has messages. +- **Stop replaces Send** while a reply streams. A 409 after Stop takes the new bubble back, + reattaches to show the end of the stopped turn, then sends the message. +- **Reconnect only after the stream opened.** Before that the server may not have the message, so + the error entry offers Try again instead of resending on its own. +- **Markdown** uses `react-native-markdown-display` with `html: false`. Images are dropped, relative + links open on the signed-in host, and a trailing list marker is hidden while a snapshot streams. +- **Demo replies stream** through the same reducer (a tool step, snapshots, the final message), so + Stop, the progress line and markdown all show in demo mode. +- **Sessions use the tasks API.** The sandbox conversation endpoint (`conversations/:id/open/`) declares + no OAuth scopes, so an app token gets 403 there. The tasks endpoints accept `task:read` and `task:write`, + which the app already has for the hand-off, and the web's `TaskRunChat` reads the same runs. +- **Logs for an ended run, the stream for a live one.** An ended run has nothing left to stream, so one + `logs/` request reads the whole log without holding a connection open. A live run's stream replays the stored log + before the new frames, so one connection gives both the history and the live turn without a gap between them. +- **A successor run rebuilds the thread.** The first replayed frame of a resume run starts the thread + again, because the server replays the whole resume chain. Appending would draw every earlier turn twice. +- **Answers echo as a user bubble.** The agent never echoes a `permission_response`, so without a local + bubble the thread would show no trace of what the person picked. +- **One sheet, two modes.** The sheet mounts `useConversation` and `useSession` and switches with a + `mode` state, so the header, composer, @-mentions and drag dismissal stay one implementation. +- **Object context uses the web's item types.** An issue goes in as `error_tracking_issue` (the web's issue scene and + context picker) and a recording as `session_recording` (the web's replay player), so the agent reads the same line + from either client. A static trusted line per type names the skill and the MCP tool that fetch the object, adapted + from the web's replay-player instruction; the id and the label stay in the untrusted block. +- **One kind of context per message.** An attached issue or recording wins over reports, so @-mentions are off and a + restored draft's report chips stay hidden while the object chip is there. + +## Inbox and report data + +See [DATA.md](DATA.md) for the endpoint map and every adapter rule. + +- **Hand-written API types.** `src/api/today.ts` and `src/api/reports.ts` declare only the fields the adapters read, each naming its serializer. Adapters in `src/model/adapters/` do all mapping, so a response change touches one adapter. +- **Model boundary.** `src/model/adapters/` imports API types (type-only) from `src/api`, date formatting from `src/lib/dates` and the record guards from `src/lib/records`. The rest of `src/model` still imports only `src/ui/icons`. +- **The full report is a route** (`report/[id]/details`), not an overlay, so Back and the Android back gesture leave it like any pushed screen. `report/[id].tsx` moved to `report/[id]/index.tsx` for it; the `$screen` name stays `report/[id]`. +- **One markdown parser setup.** `lib/markdownIt.ts` holds the `html: false`, `linkify: false` and linear autolink configuration. The chat and the full report each add their own plugins and render rules to their own instance. +- **The hand-off prompt is not built by the adapter.** `reportView()` returns the view without `prompt`, because the prompt text lives in `features/handoff/prompt.ts` and adapters import nothing from features. `useReport` adds it from the report's `webUrl`. +- **The inbox lists every report for the user.** It pages through `signals/reports/?view=for_you&sort=priority`, which orders reports the way the Today briefing does, while the list holds every report for the user beyond the briefing's top five. + It needs no Today: the list is a signals endpoint, so a Today outage or a token without `today:read` does not change it. +- **Only the user's reports.** The `for_you` view excludes unowned P0 reports. That report belongs to the project. +- **Pages of 20.** The next page loads when the user scrolls within half a screen of the end. +- **A refresh keeps only the first page.** Refetching every loaded page would rank the whole list once per page. +- **Prev/Next wrap only over the whole list.** While more pages exist, Next on the last loaded report waits for the next page instead of going back to the top. +- **Report screen waits** for the report, work log and Today page, and for signals only when there is no page. The task loads after and fills in; ownership from a claim does not prove that execution is running. +- **Report prefetch is a window with a concurrency cap.** The inbox prefetches the first two pages (40 reports) and the rows near the visible ones, two reports at a time, not the whole list at once. + Each report is 3 to 4 requests, so a full-list prefetch would load the phone's radio and the server for reports nobody opens. + It fetches only what the report screen waits for before its first render: the report, the work log, the Today page, and the signals when there is no page. + The report screen still refetches on every open, because a fresh report matters more than a cache hit. + The cache only removes the loading screen. +- **Headline number** comes from the title first, then from the Today lead's first sentence (at most 120 characters). A report with neither shows the title with no number. +- **Hot bars** start at the first bar more than 15% of the spread away from the median of the first quarter, and a tail of two or more bars back at the baseline is cold. It matches the prototype's bars except one edge bar. +- **Owner for an external agent claim** names the MCP client ("Claude Code") instead of "PostHog", because that agent is not the PostHog agent. Task and system claims say "PostHog". +- **Reviewer activity** reads "changed reviewers to", as the server calls them. Pipeline writes read "PostHog suggested **A** as reviewers". +- **GitHub-only reviewers** are sent back unchanged when the sheet saves, because the PUT replaces the whole list and the sheet cannot show them. +- **Signal snoozes are remembered on the device** with the server's `signals_at_run` threshold, so the row reads "Snoozed until more signals" until the list refetches without it. +- **Opening a report ends its time snooze** and cancels the reminder. Prev/Next counts as opening. +- **Snooze reminders never ask for permission.** A time snooze schedules its reminder only when notifications are + already allowed. Otherwise the snooze applies without a reminder, because later permission prompts come only from + the Settings button. +- **Snooze and reviewer errors stay in the sheet.** A failed snooze or reviewer change keeps its sheet open with one + orange line under the title, from the server's `detail` or `error` when it sent one. +- **At most 10 reviewers**, the PUT's `MAX_ENTRIES`, counting the GitHub-only reviewers the sheet cannot show. The sheet + stops adding at the limit and says why. + +## Auth and push + +- **Unscoped grant.** The authorize request omits `required_access_level`, so a first-party token covers every organization and the project switcher can list all projects. + The project list comes from `@me.organization.teams` plus `GET /api/organizations/{id}/` for the other organizations, not from the token's `scoped_teams`. +- **Projects are teams.** The switcher lists team ids, which is what `/api/projects/{id}/` paths take. + The default project is the last one picked on this device, then the user's current web team, then the first one. +- **Five scopes for Errors and Sessions.** The sign-in asks for `error_tracking:read`, `error_tracking:write`, `session_recording:read`, `replay_scanner:read` and `replay_scanner:write`, never `session_recording:write`. Each feature checks its own scopes (`useFeatureAccess`) and records its own refused 403, so a grant without them leaves Views, the inbox and chats working and shows the feature's sign-in or "isn't available yet" state. +- **Own token exchange.** The code exchange and refresh post JSON with `fetch` instead of `exchangeCodeAsync`, so the app controls error handling and reads the raw response. +- **Refresh failure policy.** 400, 401 and 403 from `/oauth/token` end the session. Network errors and 5xx keep it, so a user is never signed out by being offline. +- **Single-flight refresh.** Concurrent 401s share one refresh. Two parallel refreshes would race the rotation and could trip reuse protection, which revokes the whole token family. +- **Screens wait for the session.** `app/_layout.tsx` renders the stack only after `restore()`; until then the native splash stays up (web shows `BootSplash`). Feature hooks never see a missing token at launch. + The native splash then also waits for the inbox's first load, data or error, so the logo hands over to a filled inbox with no spinner. Any other first screen releases it on mount. An 8 second cap (`SPLASH_TIMEOUT_MS` in `src/auth/bootGate.ts`) releases it if the load hangs. +- **One redirect to sign in.** `useAuthRedirect()` sends every screen to `/sign-in` when the session ends and demo mode is off, including a refresh failure in the middle of a session. +- **In-place confirmation.** Sign out confirms inside Settings, and inside the inbox menu, instead of `Alert.alert`, which does nothing on react-native-web. + Both use `SignOutConfirm` and `useSignOutAction`. A demo session without an account leaves the demo with no confirmation. +- **OAuth sign in is native only.** The web build has no registered redirect, so Sign in on the web says to use the apps or the demo. The sign-in screen does not offer personal API keys, including in development builds. +- **Sign-out tasks.** `addSignOutTask()` lets the notifications feature unregister its token without the auth store importing a feature. + `addSessionEndTask()` runs device cleanup on every session end, an expired session too: the snooze feature clears its store and cancels the reminders, and the hand-off feature clears its store. +- **Push prompt.** The app asks for notification permission once, right after the first sign in, never on the sign-in screen. Later prompts come only from the Settings button. +- **Foreground pushes** show a banner without sound or badge only when they need attention outside the report + currently on screen. Report pushes and task pushes associated through cached task/report data are suppressed + for the viewed report or an active local time snooze. The handler never fetches data: unknown associations and + legacy pushes without a project remain visible. Signal snooze labels do not mute pushes because the server + decides when those reports wake. +- **Foreground duplicate protection** remembers up to 256 event/request keys for 24 hours in memory, scoped to + the account and project. Repeated terminal outcomes for one run, first-visible report events, message events, + and the same OS notification are suppressed. Different turns, questions and PR-ready events are preserved: + their payloads do not yet identify a distinct occurrence reliably. This does not change OS delivery while + backgrounded or after termination, and the memory resets when the process restarts. +- **Notification taps wait for sign-in.** A cold-start response stays pending until navigation and the account + are ready. Receiving the same response through both startup and the listener opens it once. +- **Task pushes name their project.** Routing looks the task up in that project only. A push without `projectId` (sent before the server added it) looks in the current project first, then the others, up to 8 lookups, and opens the inbox when none has it. +- **Analytics.** `src/lib/posthogClient.ts` creates the `posthog-react-native` client only when `EXPO_PUBLIC_POSTHOG_KEY` is set (host `EXPO_PUBLIC_POSTHOG_HOST`, default `https://us.i.posthog.com`). It identifies by `distinct_id` after sign in and on restore, and resets on sign out. Lifecycle events remain enabled, but the SDK's `Application Opened` event omits the launch URL before it enters the analytics queue; deep-link analytics carry only `path_kind` and `initial`. Feature flag evaluation includes the signed-in email only when the account explicitly has `anonymize_data: false`. Anonymized accounts and older persisted accounts whose preference is unknown send `email: null` for flag evaluation, overriding any stored email used for targeting; other flag rules still apply. Changing the preference clears cached flag person properties and re-evaluates flags. `track()` stays the only capture call site, with `trackScreen()` for `$screen` and `captureError()` for `$exception` beside it in `src/lib/analytics.ts`. Code that can run before `initAnalytics()` (`app/+native-intent.tsx`) uses `trackWhenReady()`, which holds up to five events until the client exists. `useScreenTracking()` in the root layout sends one `$screen` per route, named by the route pattern without groups (`inbox`, `report/[id]`), with `report_id` on reports. `RouteErrorBoundary` sends `mobile_screen_error_shown` (`screen`, `error_name`) and one `captureError()` per error, and `mobile_screen_error_retried`. The root layout sends `mobile_splash_hidden` with `reason` (`first_screen`, `timeout` or `not_held`) and `wait_ms` once per cold start. + +## Hand-off and task runs + +- **The prompt reads the report fresh.** `handOff` only has the report id (it is composed into `useReportActions`), so the kickoff fetches `GET signals/reports/{id}/` for the title and summary. It is the same data the web prompt uses, and it avoids a side channel from the screen to the action. +- **One run follows per report screen.** The current claim's task wins over local starts and historical artefacts. A user or external claim suppresses historical execution. The run is `GET tasks/{id}/`'s `latest_run`, because an artefact's run id can be stale. +- **Live state lives in a zustand store** (`handoffStore.ts`), keyed by report, so the answer action can read the pending request and mark it handled while the stream keeps writing. Screens subscribe with an equality check (`sameVisibleEntry`) so message chunks do not re-render the screen many times a second. +- **The overlay hook is `useReportWithLiveRun` in its own file**, not in `handoff/hooks.ts`, because `report/hooks.ts` imports `useHandoffActions` from there and the overlay imports `useReport` from `report/hooks.ts`. +- **Every relayed approval stays manual**, including built-in tools, PostHog operations, external tools and unrecognized tool names in every mode. The server decides which calls need approval. Replayed requests are never answered automatically. Each approval shows the original request details as selectable plain text. +- **Replayed requests show as decisions.** On first connect the durable log replays. A request still unresolved at the end is something the agent waits on, so the card shows it, like the web. +- **Another client's answer clears a live card** when the asked-about tool call completes or fails, or the turn or run ends, because resolution frames only appear in the replay. +- **A completed turn is idle, not an input request.** Only a structured outstanding question, plan or permission produces the Needs your answer row and decision sheet. Continue the implementation in its task chat; report discussion remains a separate conversation. +- **Several questions in one request show one at a time.** Earlier picks stay on the device and all answers go out together, because the agent expects one reply with every answer. +- **Typed text never approves.** A typed reply to a permission or plan goes with the option that takes text, else a reject option. Only a question's typed answer uses `option_0`, as the web does. +- **Plan approval offers the web's two modes**, "Approve the plan" (`auto`) and "Approve in full auto" (`bypassPermissions`), with the web's mode descriptions shortened. +- **The decided card outlives delivery.** After the agent confirms an answer the card stays until the run shows new activity (a tool call, a step, a message). Live answers have no Undo: once dispatched, a response may already have authorized the action. Demo answers remain reversible. +- **Errors show in the footer.** Hand-off and answer actions resolve and put one orange line above the cards, because every caller fires them without awaiting. This differs from the `ReportActions` doc comment ("rejects with an Error"). +- **Execution, attention and PR state are independent.** Known PRs stay available through working, idle, unknown, failed and canceled execution. Claims do not imply execution; reconnecting and unavailable streams do not imply the run stopped. PR URL discovery does not imply review readiness, and missing CI stays unknown. +- **Tool titles read as progress.** Paths become file names and Read, Edit, Write, Find, Fetch and Search become "Reading", "Editing" and so on, to match the design's "Reading ChartLegend.tsx". +- **PR cards preserve known state.** Draft, open, merged, closed and unknown PRs remain discoverable. Several PRs use a compact selector. A URL from `output.pr_url` starts unknown until the report confirms its state; absent diff stats or CI are not invented. +- **Foreground freshness.** Only the focused report polls detail, artefacts and task every 15 seconds. Foreground return refreshes them, and meaningful PR/status/turn stream changes refresh them at most once per two seconds. Cached PRs remain visible during loading. Today pages and signals are outside that interval. +- **Stream cursors stay in memory per run**, so coming back to a report resumes after the last event instead of replaying the log. +- **Abort errors are plain `Error`s named `AbortError`** (`src/lib/abort.ts`), used by `sleep` and `readSse`. Expo installs `DOMException` and `TextDecoder` on native, so this and the `TextDecoder` check in `readSse` only matter in a runtime without Expo's globals. + +## Delivery + +- **EAS uploads only `products/mobile`.** `scripts/eas-build.sh` sets `EAS_NO_VCS=1` and an absolute `EAS_PROJECT_ROOT`. With git, EAS archives the whole repository and installs the root pnpm workspace, which excludes this app. eas-cli 24 still asks git for the root when only `EAS_NO_VCS` is set, so both variables are needed. `.easignore` replaces `.gitignore` for the archive and keeps local `ios/` and `android/` folders out, so EAS always runs prebuild. +- **Each build profile names its EAS environment** (`development`, `preview`, `production`), and the update scripts pass the same `--environment`. Expo SDK 55 and later require `--environment` for `eas update`, and it keeps `EXPO_PUBLIC_*` values equal between a build and its updates. +- **`pnpm` is pinned in `eas.json`** (`10.29.3`, the `packageManager` version), next to `node`. +- **Shared OTA publishing needs a repository workflow.** The channel and environment requirements are in [OTA.md](OTA.md). +- **Submissions only from production builds on `master`.** Preview builds use ad hoc signing, which App Store Connect does not take, so TestFlight gets production builds. The Mobile Build workflow uses `--auto-submit` with `--no-wait`, so EAS submits the build it just made, not the previous one. +- **eas-cli 24.8.0 is pinned in CI** instead of `latest`, so a new release does not change a release build without a review. +- **Mobile CI reads Node from `.node-version` and pnpm from this `package.json`**, not from the repo root, so CI, EAS and local runs use the same versions. The check is not required, so it can use trigger paths. +- **iOS 27 SDK scene life cycle.** Apps built with Xcode 27 do not launch without the UIScene life cycle, and the Expo SDK 57 template predates it. `plugins/withIosSceneLifecycle.js` applies the SDK 58 template's change (scene manifest, `SceneDelegate` on `ExpoAppSceneDelegate`, an AppDelegate that provides the React Native factory). It fails the prebuild if the template changes shape. Remove it when the app moves to SDK 58. +- **`EXPO_PUBLIC_DEMO_MODE=1` sets the first value of the demo flag.** The saved value wins after that, so leaving demo mode in Settings sticks. +- **The demo walkthrough is an Argent flow** (`.argent/flows/demo-smoke.yaml`) that ends by tapping Exit demo, so it can run twice in a row. It uses `testID`s except for the hand-off scrim, which it taps by its "Close" label. + +## Hedgehog widget + +- **The hedgehog lives in a home-screen widget, not in the app.** `modules/hedgehog-widget/README.md` holds the contract. Widgets cannot run JavaScript or animate on their own, so `src/features/hedgehog/choreography.ts` plans a seeded script for the day and the app hands it to the widget once per UTC day through the local Expo module (App Group on iOS, SharedPreferences on Android). +- **iOS uses two-second keyframes; Android plays a closed animation loop.** iOS saves the scheduled timeline and resumes it across taps and refreshes. Each hop or flip completes in one animation, and every saved entry rests upright on the ground so delayed updates cannot strand it mid-flip. Walking uses uninterrupted two-second position animations, isolated from button content changes; reversing does not insert a stationary beat. Android keeps one loop per placed widget, interpolates the standard script at 30 ms intervals, and reuses stable frame views on Android 12+, preserving the visible frame across updates. Older versions restore the tapped frame index on character changes. +- **`@bacons/apple-targets` owns the widget extension target** (`targets/HedgehogWidget/`), so the target survives `expo prebuild`. Its pod is named `PostHogHedgehogWidget`, because the appex already owns the Swift module name `HedgehogWidget`. +- **The widget target needs iOS 17**, for `Button(intent:)` and animated entry transitions, so iOS 16 users see no widget. The app's own minimum is unchanged. +- **A tap on the hog cycles its character and keeps it selected.** Selection persists per iOS widget family or Android widget ID, independently of daily choreography. Taps change the artwork without interrupting the route; background taps open the app. Both widgets bundle the grass layer of PostHog’s Keyboard Garden wallpaper. Dialogue is hidden, including in legacy scripts. +- **Sprites are committed PNGs** cut from the `@posthog/hedgehog-mode` atlas by `scripts/extract_hedgehog_sprites.py` at 2x nearest-neighbour. Android also gets mirrored `_l` copies, because `RemoteViews` cannot flip an `ImageView`. +- **`.gitignore` anchors `/ios/` and `/android/`**, so the module's native folders are tracked and only the prebuild output at the root is ignored. + +## Navigation pins + +- **Errors and Sessions are Views rows, not fixed tabs.** The Browse group lists Dashboards, Insights, Web analytics, Sessions, Errors. Sessions replaces the old Recordings row, which opened the web. +- **A pin adds one slot after Views.** Long-press the Views row, or use the list's More menu, for "Pin to navigation". Both changes confirm with the Views toast and Undo. +- **One user slot.** Five 44pt slots and the labelled Chat button do not fit a 375pt or 393pt phone: about 199pt and 217pt are left for 220pt of slots. Four fit everywhere, so the pill has Home, Chats, Views and one pin. Pinning the other list replaces the pin, the toast says "Pinned, replacing Errors", short enough for one line beside Undo on a 393pt phone, and Undo puts the old pin back. The store keeps a list (`MAX_NAV_PINS`), so a larger cap needs no change to the saved format. +- **A device preference.** `navPinStore` keeps the pins in AsyncStorage for every project, and sign-out keeps them, like the pin hint: they hold no account data. +- **Both tab routes are always registered.** `app/(tabs)/errors.tsx` and `app/(tabs)/replay.tsx` exist whether pinned or not; the bar draws a slot only for a pin. A list unpinned from its own tab keeps its slot until the person leaves it, so Undo has a place to return to. +- **`/errors` is the pushed screen.** The tab and the stack screen share the path, and Expo Router resolves a path without a group to the stack screen. `openNavTarget` reaches the tab as `/(tabs)/errors`. `navPinRoutes.test.tsx` checks both. +- **The lists count their own opens.** The bar sends `mobile_inbox_opened` and `mobile_chats_opened` only; `mobile_errors_opened` and `mobile_replay_opened` come from the list screens with `chrome`, because the same screens also open pushed from Views. +- **Rows are disabled in the demo**, as Recordings was, because the demo has no issues or recordings. + +## Errors + +- **The compact `error_tracking/query/` endpoints, not raw `query/`.** They are the agent and MCP surface: typed + request serializers, small answers, impact, trend, releases and breakdowns already computed. Every read then needs + only `error_tracking:read`, the scope the Errors gate checks, and none spends the Views query budget. +- **Most users first, active only.** The list is `status: 'active'`, `orderBy: 'users'`, 15 rows per page, 24h by default + with 7d one tap away. Load more follows the server's next offset. Resolved and suppressed issues stay on the web. +- **New, Spiking and Mine filter the list, one at a time.** New and Spiking narrow loaded pages on the device. + A partial empty result offers Load more. Mine is a server filter (`assignee: { type: 'user', id }`). +- **New is first seen inside the window. Spiking is the server's spike detection.** The badge reads + `error_tracking/spike_events/`, the read the web's issue list uses, so the phone and the web call the same issues + spiking. New wins over Spiking for the displayed badge, but an issue can match either filter. + Spike reads cover each loaded page, and a failed read offers a retry in the Spiking filter. +- **Counts on the list, charts on the issue.** The list requests `volumeResolution: 0` to reduce the initial response + and time-bucket work. The issue detail still requests its volume chart. +- **No Reopened badge.** Nothing the app can read says an issue came back after a resolve: the issue resource has no + history field, and the activity log needs `activity_log:read`. +- **Statuses and wording match the web.** `active`, `resolved`, `suppressed` with the labels of the web's + `ISSUE_STATUS_CONFIG`. Resolve and Reopen are one tap with a toast and Undo; Suppress confirms with the web's + "New occurrences of this issue are ignored." first. The status changes on the screen at once and rolls back when the + `PATCH` fails, and the pill shows progress and takes no second tap while it is in flight. +- **Assign is "Assign to me" only.** `assign/` takes a numeric user id, and the only one the app knows is the viewer's + (`@me`). The member list needs `organization_member:read`, which the app does not request, so another person's + assignment shows as "A teammate" and passing an issue on opens the web. +- **Frames come from the newest event, without `stack_frames/batch_get/`.** Frames in `$exception_list` are symbolicated + at ingestion, so the resolved name, file and line are already there. The card shows the innermost in-app frame and + expands to three; code context stays on the web. +- **Breakdowns are their own request.** `includeBreakdown` adds an aggregate query, and the server answers an error for + the whole call when that query fails, so the impact numbers load without waiting on it and a failed breakdown only + hides the card. +- **Sample sessions are the newest exception events with a `$session_id`**, up to three, deduplicated. Rows open + `/replay/{sessionId}` without `t`: an event timestamp alone does not give the offset into the recording. +- **The linked report is a cheap read.** `signals/reports/?source_product=error_tracking&source_id={issue id}` names + the Inbox report built from the issue's signals. A failed read hides the row. +- **Chat is the only agent verb.** The round Chat button opens the chat sheet with the issue attached + (`ISSUE_OBJECT_TYPE`). It uses the compose glyph of the report's Chat button. +- **No header refresh button.** The prototype shows one, but pull to refresh is the app's only manual refresh (see + the tabs header in DESIGN.md), so both errors screens use it instead. +- **Demo mode shows an empty state.** The demo has no issues, and the Views row that opens the list is disabled there. + +## Session replay + +- **The REST recordings list, not `RecordingsQuery`.** `GET session_recordings/` checks `session_recording:read`, the + scope the Sessions gate checks. The web's `RecordingsQuery` through `query/` would need only `query:read`, so a token + without the replay scope could list recordings it cannot open. +- **Newest first, recommendations afterward.** The list loads 10 recordings per page from the last 7 days and + follows the server's continuation cursor. It displays recordings without waiting for `vision/scanners/watch_feed/`. + The optional feed starts afterward with a limit of 5. It enriches loaded recordings in place and puts other picks + below the recordings, so a late response never changes their order. +- **No filters on the phone.** The prototype's Today, 7d, Errors and Rage clicks chips are out: the build contract keeps + filters on the web. The window is a fixed 7 days, the feed's own default. +- **The timeline comes from events, not from the recording.** Two HogQL queries bounded by the recording's start and + end, so ClickHouse stays on its days: page views and clicks (first 300), and errors and rage clicks (first 100) on + their own, so a session with hundreds of clicks still shows an error near its end. The adapter merges them by time. + Errors and rage clicks always stay, then page views, then up to 20 clicks spread over the session, 60 rows at most + (`sessionTimeline`, tested). A capped first query shows "301+ events". Rows group by minute, the first 25 show, and + "Show N more" opens the rest. The row nearest a link's `t` is highlighted and always shown. +- **The timeline has its own query budget.** It posts to `query/` like Views, but spends the `replay` buckets, so a + heavy Views refresh never turns into "Couldn't load this session's events". +- **An exception row opens the issue** when the event carries `$exception_issue_id`, with `from: 'replay'`. Other rows + do nothing: the phone has no player to seek. +- **The headline is the scan's title.** Without a summary, a title from the events ("4 min on /files with 1 error + and 3 rage clicks"), as the prototype shows. The person label leads the meta line and names the session in the chat + chip and the share sheet. +- **Only summarizer scans count as a summary.** A monitor or classifier answers one question, not what happened. A + finished summary wins over a re-scan still running. +- **A failed or ineligible scan is final.** The server answers `already_scanned` to another Summarize, so the card + says "The summary didn't work for this session" (with the ineligible reason when there is one), offers no + Summarize, and points at Open full replay. The wait and the polling stop at any terminal status. +- **Summarize sends the web button's own scan** (`built_in: 'summarize_recording'`), so a summary made on the phone + shows on the web and the other way round. It is offered when the project's organization approved AI data processing, + or when the app does not know yet (the server then answers 400). Each `scan_outcome` has its own line: the credit + limit, too many scans running, or a summary this user cannot read. +- **Polling, not push.** After Summarize, and whenever a summary is already running, the screen reloads the + observations every 5 seconds while it is focused and the app is active, for 2 minutes. Then it says "Still working. + Check back in a minute." with Check again. +- **Frames load from the signed URL, never through a header.** The thumbnail endpoint redirects to object storage. A + request with `redirect: 'manual'` reads the `Location`, so the bearer token stays on the PostHog host, and + `` loads the signed URL. Cached for 4 minutes, because signed URLs expire. +- **"Open full replay" appears once**, in the top-right pill. More holds "Share link at m:ss" (the native share sheet + with the web URL at the linked `t`, else the key moment, else the start) and "Copy session ID". Open full replay + opens at the same moment: a `t` from a report or link is what the person came to see. +- **The Chat button matches the report's.** A round ink button with the compose glyph, bottom right, the one agent + verb on the screen. It opens the chat with `RECORDING_OBJECT_TYPE` and the person label plus the date. +- **No "Play from" in the frame sheet.** A play button would be a second way to the web player, and the phone has no + clip player yet. The sheet shows the frame, the chapter, Previous and Next. +- **Opening a session does not mark it viewed.** The web PATCHes `viewed`, which needs `session_recording:write`. +- **"See the moment" replaces "Watch a session"** on the report's replay row, which now opens the session screen at the + signal's `seek_seconds`. The demo keeps opening its `example.com` URL, because demo sessions exist in no project. +- **Events use `mobile_replay_*`.** `mobile_session_*` already names chat sessions (`mobile_session_sent`), so the + session screen's events are `mobile_replay_session_opened`, `mobile_replay_full_replay_opened` and so on. The + reauth state reuses `mobile_views_reauth_shown` with `feature: 'replay'`. diff --git a/products/mobile/docs/DESIGN.md b/products/mobile/docs/DESIGN.md new file mode 100644 index 000000000000..d5a3e9e559c7 --- /dev/null +++ b/products/mobile/docs/DESIGN.md @@ -0,0 +1,283 @@ +# Design + +The design handoff (README, prototype and reference screenshots) is the spec. +This file records how it maps to React Native. + +## App icon + +Android adaptive icon layers use a 108dp canvas, with a central 72dp viewport and a 66dp safe circle. +`plugins/withAndroidAdaptiveIconInsets.js` adds a proportional 18dp inset on every side during +prebuild, so the hedgehog and the DEV or PREVIEW label fit inside launcher masks. The inset +applies to both regular and round adaptive icons for all three build variants. + +Keep the 1024px source images at their existing scale; the plugin supplies Android's extra +spacing. Icon changes need a new native build and cannot ship through an over-the-air update. +See [Android's adaptive icon guidelines](https://developer.android.com/develop/ui/compose/system/icon_design_adaptive). + +## Tokens + +The app follows the device's light or dark setting unless the Appearance setting in Settings picks Light or Dark; `useColorSchemeName` combines that override (`appearanceStore`) with the device scheme. +`app.config.ts` sets `userInterfaceStyle: 'automatic'` and a dark splash, which take effect only after a native rebuild. +Scheme-independent values live in `src/ui/tokens.ts`. Colors and everything derived from them live in `src/ui/theme.ts`. + +- `lightColors`, `darkColors`: every color in the handoff, named by role (`ink`, `muted`, `secondary`, `tertiary`, + `border`, `hairline`, `rowDivider`, `sunken`, `pressed`, `background`, `orange`, `blue`, ...), + plus the scrims and the overlays used on the accent button. Both palettes have the same keys. + The dark palette follows the web app's dark theme: cool dark neutrals, light text, amber accent. +- Roles that were all white in the light handoff have their own tokens, because they differ in dark: + `surface` for cards, pills, sheets, floating cards, text fields and menus; + `onInk` for text and icons on an `ink` fill; `onAccent` for text and icons on an `accent` fill. + `white` is literal white in both schemes, only for text on a fixed color, such as avatar initials. + The logomark's head and the wordmark use `ink`, so they stay visible on a dark background. +- `accent` (`accentHover` when pressed) fills the primary call to action: Sign in, Hand off, Send a decision. + `orange` stays for warnings, failures, destructive actions, the P0/P1 priority and the report `alert` accent. +- Some roles shared `border` or `ink` in light but need their own value in dark: + `switchOn`/`switchOff` for switch tracks (the thumb is literal `white`), `control` for unselected radio and checkbox + rings, `disabledFill`/`onDisabled` for a disabled primary button and its label, `barNeutral` for single-series bars + and `terminalBorder` for the code card outline (transparent in light). +- `useTheme()` returns the current `Theme`: `colors`, `shadows`, `avatarPalette`, `accentColor` (keyed by the report + `Accent`: `alert` orange, `opportunity` blue, `resolved` ink), `swipeColors`, `chartPalette` and + `previousPeriodColor`. `useColors()` returns only `colors`. `useColorSchemeName()` reads the device setting; + an unknown setting counts as light. +- `createStyles(({ colors, shadows }) => ({ ... }))` replaces `StyleSheet.create` for any sheet that uses a color or a + shadow. Call it at module level; it returns a `useStyles` hook that builds the sheet once per scheme. The factory + must be pure. Code outside a component (a helper, a pure function) takes `ThemeColors` or the `Theme` as a + parameter, or a sheet typed `ReturnType`, and the component passes it in. +- Inline colors (`color`, `tintColor`, `stroke`, `placeholderTextColor`) come from `useTheme()` in the component. + `BlurEdge` tints, `keyboardAppearance`, `StatusBar`, `ActivityIndicator` and `RefreshControl` follow the scheme. +- `environmentRibbonColors` (in `tokens.ts`) are brand bands and stay the same in both schemes. +- `type`: the type scale. React Native letter spacing is in points, so `letterSpacing(size, em)` + converts the handoff's em values per size. Text without an explicit line height uses the + prototype's root line height of 1.4 (`lineHeight(size)`). +- `fonts.mono`: Menlo on iOS, `monospace` on Android, the handoff's CSS stack on web. + Everything else uses the system font. +- `iconSizes`: 24pt for navigation and list tiles, 20pt for action buttons, search fields and + selection marks, 16pt for inline status glyphs, 14pt for compact chip accessories, and 28pt + for empty-state illustrations. Use the role's token at the call site instead of a glyph's default size. +- `space` (keyed by value, `space[14]`), `radii`, `layout` (gutters, 44pt tap target, and the + prototype frame's 54pt status bar and 34pt home indicator). +- `shadows` (on the `Theme`): `shadow*` props plus Android `elevation` on native (radius is half the CSS blur); + the exact CSS `boxShadow` on web. Dark shadows are black and stronger. +- `motion`: 180ms fades, 200ms sheets and chevron, 220ms floating cards, with the handoff's + bezier curves. `usePresence` sets every duration to 0 when reduce motion is on. +- `inputReset`, `prettyWrap`: web-only fixes, see below. + +## Proportions + +Body text, picker labels, menu rows and text inputs use 16pt. Sheet titles use 18pt, including +the Assignee and Project floating cards. Screen titles (28pt), report headlines (22pt), +empty-state headings (20pt), secondary text (13–15pt) and chart values keep their hierarchy. +The same action uses the same icon size across menus and report controls. List tiles and +navigation use larger glyphs; inline metadata and chip accessories use smaller ones. +The unassigned avatar occupies the same 32pt column as a person's avatar in the Assignee card. +Sizing a glyph does not change its button's tap target, which stays at least 44pt including hit slop. + +Views, Chats and browse lists use the shared `SearchField`. +Its 16pt input has an explicit 20pt line height, centered vertically without Android font padding, so placeholder and entered text keep the same alignment across screens. + +## Reading handoff values + +- **Box sizing.** The prototype's `div`s use CSS `content-box`, so a `height` or `min-height` + excludes padding and border. React Native is always border-box. Where the two differ, the code + uses the rendered size and says so in a comment: inbox rows render 71pt (44pt content), icon + tiles 40pt, the bottom nav pill 64pt, the Chat button 56pt, the Back chip and Prev/Next pill + 46pt, the action pill 54pt, the hand-off agent row 78pt, + @-mention rows 56pt, the mention popover 274pt max, and the nav menu 75% of the width plus 22pt. + `button` elements in the prototype are border-box, so their sizes carry over unchanged. +- **Safe areas.** Top and bottom paddings in the handoff include the prototype's status bar + (54pt) and home indicator (34pt). `topOffset(insets, designTop)` and + `bottomOffset(insets, designBottom)` swap those for the device's insets. The web build has no + insets, so `useScreenInsets` uses the prototype's values there. +- **Page titles.** Pushed pages share a 46pt navigation row and a 20pt gap before their title block. + Their blur fade ends 10pt before the content. `screenLayout` supplies these offsets to every page. + As the title scrolls under navigation, `ScreenChrome` fades in a smaller copy centered between the controls. + The title truncates before either control and stays out of the accessibility tree; the content title remains the heading. + This pattern covers Settings, browse lists, dashboards, insights, web analytics, scrollable full-screen charts, + Errors, issues, Sessions, recordings, reports and full reports. Tab titles stay in their shared navigation row. +- **Text wrapping.** The prototype sets `text-wrap: pretty`. iOS gets the same behavior from + `lineBreakStrategyIOS="standard"`, and web from the CSS property (`prettyWrap`). + Android wraps greedily. + +## Component inventory + +| Component | File | Notes | +| ------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Pressable44` | `ui/Pressable44.tsx` | Default 6pt hit slop so 34pt visuals reach 44pt; pressed and hover background; optional haptic | +| `PillControl` | `ui/PillControl.tsx` | White bordered pill for icon buttons | +| `SegmentedPill` | `ui/SegmentedPill.tsx` | Equal segments with inset dividers; `raised` adds the chip shadow; `selected` state | +| `IconTile` | `ui/IconTile.tsx` | Source icon tile, muted for resolved, dismissed, or snoozed reports | +| `Avatar` | `ui/Avatar.tsx` | Initials on a palette color picked from a seed | +| `MonoText`, `Eyebrow`, `Dot` | `ui/` | Mono text, uppercase label with optional dot, colored dot | +| `Scrim` | `ui/Scrim.tsx` | Fades with the overlay; optional tap to close | +| `BottomSheet` | `ui/BottomSheet.tsx` | Grabber, radius 22, slides up 200ms, closes on scrim tap or a downward drag. An optional `scrollGesture` makes a drag that starts on an inner list scroll it instead | +| `HandoffOptionRows` | `features/handoff/HandoffOptionRows.tsx` | White bordered group of 48pt rows in the hand-off sheet: muted label, right-aligned bold value with an optional tertiary second line, chevron | +| `SheetPageHeader` | `features/handoff/SheetPageHeader.tsx` | 44pt round back button and sheet title for a page that replaces the sheet's content | +| `ModelPage`, `ModePage`, `BranchPage` | `features/handoff/` | Hand-off option pages; the chat's options sheet hosts Model and Mode too, so they take the options and pick callbacks as props. Model: "Reasoning" effort pills (ink fill for the picked or known default effort), then models under "Claude Code" and "Codex" eyebrows with the cost multiplier; the selected model, including an untouched default, has a tinted row, bold label and check. The list is capped at 42% of the window and scrolls without closing the sheet, followed by "Reset to default". Mode: label and description rows. Branch: bordered text field that lifts with the keyboard, one note and a Done button | +| `Composer` chips | `features/chat/Composer.tsx` | After `@`: a Model chip ("Default", or the pinned model without its "Claude" prefix and the effort, such as "Opus 5.5 · High") and a Mode chip ("Auto" by default). 32pt bordered pills with a chevron that reach 44pt through the hit slop, shrink and truncate to one line on a narrow phone, and dim while the agent works, the history loads, a send is in flight or a decision waits. On an open chat they start from the run's own model and mode, and the Model page lists only the run's harness | +| `ChatOptionsSheet` | `features/chat/ChatOptionsSheet.tsx` | `BottomSheet` over the chat that hosts `ModelPage` or `ModePage` for the chat's own options. The back button closes it, and a mode pick closes it. It draws inline, because the chat route is a native modal outside the overlay host | +| `FloatingCard` | `ui/FloatingCard.tsx` | Radius 28 card, scales from 0.92 and drops 6pt, 220ms emphasized curve | +| `HeaderMenu` | `features/views/screen/HeaderMenu.tsx` | The More menu of a pushed screen (Sessions and Errors lists, issue, session): a `FloatingCard` under the chrome's More pill with `MenuItem` rows and a trailing glyph | +| `MetricChart` | `ui/chart/MetricChart.tsx` | Line or bar chart on Skia (victory-native). The y axis floats for lines and starts at zero for bars. Hairline gridlines, 11pt mono ticks. Tap or drag to scrub, with a selection haptic and a tooltip that stays after release. 144pt high by default | +| `ObservationCard` | `features/report/metrics/ObservationCard.tsx` | White card: "Observation" eyebrow, title, caption, 28pt mono value, chart, then the window and filter count with Open insight | +| `ImpactMetrics` | `features/report/metrics/ImpactMetrics.tsx` | "Impact" eyebrow over a two-column grid of tiles: 20pt mono value, title, and the window or measured time. A tile with a link opens the insight | +| `ReportCharts` | `features/report/metrics/ReportCharts.tsx` | "Charts" eyebrow over one card per chart: title, caption, the chart when the app can draw it, and Open insight | +| `ReportContextMenu` | `features/inbox/ReportContextMenu.tsx` | Inbox row long-press menu on `FloatingCard`; nav menu width; opens 8pt below the row, or above it when the bottom bar is in the way (`contextMenuPlacement.ts`) | +| `ReportMeta` | `features/report/ReportMeta.tsx` | `ReportByline` under the headline, above the lede, then the Owner and Priority chips: 32pt bordered pills with a chevron that open the Assignee card and the Priority sheet. No owner shows a dashed orange circle and "Assign"; P0 and P1 color their code orange | +| `ReportByline`, `HeadlineTitle` | `features/report/` | The byline (source glyph and label · "Since …") and the 22pt headline with its number in the accent color, shared by the report screen and the full report | +| `TextLinkRow` | `features/report/TextLinkRow.tsx` | Quiet 15pt ink link with a 14pt secondary chevron, or the external-link mark when it opens the browser. 32pt tall, so the hit slop reaches 44pt. No button chrome | +| `ReportMarkdown` | `features/report/details/ReportMarkdown.tsx` | The full report's markdown, on the shared parser in `lib/markdownIt.ts`. `summary` is 16pt ink with 17pt headings (`type.sectionHeader`); `signal` is 15pt. Fenced blocks are `MonoBlock`s, images are dropped, and `[label](chart:id)` reads as its label (`chartRefs.ts`) | +| `MonoBlock`, `SignalCard` | `features/report/details/` | A fenced block on the trace's dark card (`type.mono`); a white bordered card per signal: source glyph, label, time and ticket number, then the content and Watch session | +| `Sparkline` | `ui/Sparkline.tsx` | 14 bars; height `max(2, round(v × 32 / max))`, capped at 32 (`sparklineMath.ts`) | +| `TimelineItem`, `RichText` | `ui/` | Activity entry with node, connector, `**bold**` text, via chip and time | +| `FadeEdge` | `ui/FadeEdge.tsx` | SVG gradient behind floating chrome (no gradient dependency) | +| `BlurEdge` | `ui/BlurEdge.tsx` | Masked blur of the content under floating top chrome | +| `BackChip` | `ui/BackChip.tsx` | Report and Settings back button | +| `EnvironmentRibbon` | `ui/EnvironmentRibbon.tsx` | 28pt band above the sign-in logo and under the Settings title: amber for dev builds, purple for preview builds, red for a store build on a host that is not PostHog Cloud. Hidden in a store build on Cloud. Colors in `environmentRibbonColors`, text from `environmentBadge()` | +| `StateView`, `LoadingView` | `ui/StateView.tsx` | Empty and error screens with a next action; loading spinner | +| `PostHogLogomark`, `PostHogLogo` | `ui/icons/PostHogLogomark.tsx`, `ui/icons/PostHogLogo.tsx` | Brand logomark and landscape lockup as SVG, sized by `height` | +| Icons | `ui/icons/*.tsx` | One component per glyph, ported from the prototype's SVG paths | + +## Screens and states + +- **Splash**: the centered gradient logomark hops in a ripple, with the head first and the three colored spikes following. + It uses a gentle 720ms hop every 1.4 seconds, with 45ms between parts and a smooth start and landing. + All four parts share one animation clock, and the logo stays still with reduced motion enabled. + The native launch screen fades into this animation once the animated splash lays out. + The splash stays above the app until the session and first screen are ready, with the existing eight-second inbox timeout. +- **Tabs header** (`features/tabs/TabsHeader.tsx`): Inbox, Chats and Views share one header that the + tabs layout draws once, like the bottom bar. The logomark and the tab's title sit at the same spot on + every tab. The pill on the right holds More on every tab, and a filter button left of it on a tab that + publishes one (the Inbox); Views in Edit pins swaps the pill for Done. Pull to refresh is the only manual + refresh, so the header has no refresh button. A tab may add a status line under the title (Views does); it + and the refresh hint are absolutely positioned, so they never move the title row. Each screen publishes its + refresh hint, status line, filter button and menu rows to its own slot in `tabHeaderStore` with `useTabHeader`, and the header reads the focused tab's slot. + The logomark plays the splash's hop while the focused Inbox, Chats or Views page refreshes, including pull-to-refresh and automatic refreshes. + It finishes its current hop when refreshing ends and stays still with reduced motion enabled. +- **Inbox**: rows (source · `#123` PR tag when the report has a PR, green when open, purple when merged, gray otherwise · time), + pull to refresh, floating nav pill (Home selected, + Chats, Products) and the Chat button. Chats in the pill opens the Chats screen; the Chat button + opens a new chat sheet. Products opens the project's product library in the web app, and is + dimmed in demo mode or when the session has no project. A long press on a row (or the screen + reader's "Show report actions") opens a menu with Hand off to the saved agent (PostHog, + Claude Code or Codex), Reassign, Resolve, Dismiss, Chat and Mark as read (for an unread report) or Mark as unread. + A full swipe on a row changes its read state: swipe right past 30% of the row marks the report read (blue), + swipe left marks it unread (ink). Both directions always play, and a swipe toward the state the row already has + only plays the animation. Screen readers get the same change as a "Mark as read" or "Mark as unread" row action. + Unread reports have a bold title and an 8pt blue dot at the right. Read reports use a regular-weight title + without the dot. Resolved, dismissed, and snoozed reports keep their muted styling. + Hand off uses the same eligibility rules and saved destination as the report's hand-off button; + it waits for report details, blocks repeat taps while pending and keeps failures in the menu for retry. + A report with a pull request shows View PR (with an external-link mark, it opens the browser) in + place of Hand off, from the first frame: the inbox list payload carries the PR URL, so the menu + does not wait for report details. + The other actions work like the report's action pill, without leaving the inbox. + The header's filter button opens the Filter and sort sheet (`InboxFilterSheet`). Sort by is one outlined white card of 56pt rows with a leading icon and hairline dividers; the selected row has a gray fill and a check mark. + Created is a `SegmentedPill` (Any, 24h, 3d, 7d, 14d), and Sources are wrapping chips with a checkbox, outlined in ink when on. The list fades out at the bottom of the sheet. + A choice applies at once, Clear resets them all, and a blue dot on the button shows that a filter or sort is on. + Loading, empty, "No reports match these filters" (with Clear filters) and error states are separate screens. +- **Chats**: the mobile port of the web app's Chats pane. A search field above the list filters titles on the device. Rows are grouped + under day labels (Today, Yesterday, a weekday, a date, Earlier) and use the inbox row geometry: + a chat bubble tile for a PostHog AI chat, a branch tile for a report hand-off or another session, + and an origin label, a status dot and the time under the title. A row opens the chat sheet on that + task: the agent's thread, its PR card, and a pending decision above the composer. The nav pill + shows Chats selected. Loading, error, empty and "No matches" are separate screens. +- **Nav menu** (More in the tabs header): project name (opens the Project card), then on Views a section with + Edit pins, then Settings and Sign out. +- **Report**: top chrome, headline, the byline with the Owner and Priority chips (`ReportMeta`), lede, + "Read the full report" (only when there is a summary or a signal), evidence (trace, quotes, replay), facts, + the observation card, impact tiles, charts, activity, and the action bar. +- **Full report** (`report/[id]/details`): top chrome with Back only and the headline as its scroll title, + a "Full report" eyebrow, the headline and the byline, the whole summary, then "Signals" with a count and one card per + signal, newest first, and "Open in PostHog" at the end (hidden in demo mode). + Charts stay on the report screen, so the summary's chart references read as their labels and a paragraph of references only is dropped. + Loading, "Couldn't load this report" with Try again, and "This report has no more detail yet." are separate states. +- **Action bar** (`actionBarStateFor`): execution, attention and PR availability are independent. + Execution distinguishes unclaimed, starting, working, idle, reconnecting, unavailable, failed, + canceled, completed and unknown. A claim names the owner without claiming they are running. + Only a structured pending input produces the compact Needs your answer row. Read question + opens a bounded decision sheet with the full question, choices and a typed-answer field; + turn completion is idle. + A known PR stays visible during every execution state, with its draft/open/merged/closed/unknown + state. View PR opens GitHub and View task activity opens the implementation chat. Several PRs + use a compact selector opening the Pull requests sheet. Selected-PR checks distinguish checking, + passing, failing, pending, no checks and unavailable, with the local fetch time. A dot beside the + check label is green for passing, red for failing, amber for pending or unavailable, blue while + checking, and gray when no checks are reported. The label stays visible alongside the color. + Task status opens the + implementation chat. The Reassign / Resolve / Dismiss pill and the round ink Chat button to its right + show in every state; Chat starts report discussion. + Its compose icon matches the tabs' Chat button. + Resolve and Dismiss open a sheet with the web + inbox's reasons and an optional note, and after a confirmed verdict the screen goes back to the inbox. + A 409 from the server (the report's status cannot take that verdict) stays in the sheet as an error. Undo is a separate action while an answer is in flight. Transient tool text + clears when a turn is idle or terminal and stays on one truncated line while active. +- **Assignee card**: loads the latest complete reviewer assignment, including when it is older than the first page of activity. On the report screen, opening it shows loading until that assignment is known, or an error with a retry action if the read fails. Search and unsaved selections stay in place while the report refreshes. Closing and reopening starts from the current assignment. +- **Overlays**: hand-off sheet, Resolve and Dismiss sheets (`VerdictSheet`), Assignee card, Priority sheet (P0 to P4), + Project card (search field, projects grouped by organization, check on the current one), + chat sheet with @-mention popover, the Model and Mode chips, and the session thread. The chat sheet shares the sheets' grabber + and drag-down dismissal (`useSheetDrag`), on its header only, so a drag never competes with the thread's scroll. +- **Sign-in**: the logo, form and account link are vertically centered above the legal footer. Privacy policy and Terms sit at the bottom safe area. The page scrolls when the form needs more room, including with the keyboard open. +- **Settings**: minimal, built from the same tokens. The large title scrolls with the content. + A centered title fades into the navigation row as the large title scrolls out, using the shared page-title pattern. + Groups run Start page, Project, Notifications, Appearance, Demo (dev builds), App, Account, Environment (internal builds), Legal, then Sign out. + Start page, Project and Appearance use the same radio rows (`SettingsRadioRow`). + The project list puts the current project and its organization first, with the other projects alphabetical + within each organization. It initially shows five projects in total. See more expands the full list, + and See less collapses it again. Organization labels stay visible for accounts with multiple organizations. + Appearance is Match system, Light or Dark, saved on the device; screens wait for it to load so they never paint in the wrong scheme. + Start page picks the tab the app opens on (Inbox, Chats or Views) and is saved on the device (`defaultPageStore`). + Sign in also lands there, and so do Back on Settings or a view and closing the chat when there is no screen to go back to. + Until the person picks one, the app saves a start page once, on the first launch that knows whether the selected project's organization approved AI data processing: Views if it did not, Inbox if it did. + While that status is unknown, the app opens the inbox and saves nothing. +- **Errors list** (`features/errors/ErrorsListScreen.tsx`): one body in two chromes. Pushed from the Views Browse + row, it draws the Back chip and More over a 28pt "Errors" title and the "Most users first · updated 5m ago" line; as + a pinned tab, the tabs header draws the title and that line. Under them sit the 24h and 7d `SegmentedPill` and the New, + Spiking and Mine chips (ink fill when on; tapping the chip that is on clears it). Rows follow the inbox geometry: + the exception type on one line with a New (orange outline) or Spiking (orange fill, up arrow) badge, the exception + value in secondary text, then the owner's avatar, users and first seen in tertiary text. The list loads 15 issues + per page with a Load more footer; charts stay on the issue detail. Pull to refresh reloads the first page. + Loading (row-shaped blocks), "Couldn't load your + errors" with Try again, the empty state per filter with its next step ("Show the last 7 days", "Show all active + issues"), the Views sign-in state for missing scopes, and a demo state are separate screens. New and Spiking show + "No matches in loaded issues" while more pages remain. Pending spike reads show "Checking for spikes…"; failed + reads offer Try again. More (pushed only) + holds Pin to navigation or Unpin from navigation, with the Views toast and Undo, and Open on web. +- **Issue** (`features/errors/IssueScreen.tsx`): Back and More on the floating chrome. "Error · project" eyebrow, + the status chip (the web's labels and colors: amber Active, green Resolved, red Suppressed) and the New or Spiking + badge; the 22pt headline with the exception type in orange; a clock byline with first and last seen; the owner chip + (dashed orange "Assign" when nobody owns it). Impact: the 24h and 7d segment, three tiles (users, sessions, + occurrences with a mono "last 24h"), and a card with the window's volume line and the trend caption ("Up 45%", + orange while rising). Release: version, short commit, branch and library, with "First seen in this release" when the + first and latest releases match. Where it breaks: the dark trace card from the report (exception, innermost in-app + frame, "In-app frame 1 of 4" and Show all, up to three frames), then path, browser, OS and app version shares as text + and a stacked bar. Sessions: up to three rows ("Chrome on macOS", "Error 12m ago") that open the session screen. + Inbox report: the report built from the issue, when there is one. The bottom bar is the report's layout: a pill with + Resolve and Suppress, or Reopen when the issue is not active, and the round ink Chat button. Resolve and Reopen show + the Views toast with Undo; Suppress opens a confirmation sheet first. More holds Assign, Open on web and Copy link. + The Assign sheet offers Assign to me (or Unassign me) and Assign a teammate on the web. +- **Sessions list** (`features/replay/ReplayListScreen.tsx`): one body in two chromes, like the Errors list. Pushed + from Views, Back and More over a 28pt "Sessions" title and "Newest first · last 7 days"; + as a pinned tab, the tabs header draws both. Rows: an 80 × 50 frame (the scan's + thumbnail, or a muted recording tile), the scan's title, or "Session · 14 clicks · 1 error" in regular weight for + an unsummarized one, then person, length, "key moment 2:14" and the time ago (never the raw score), with an + orange dot when the recording logged console errors. A row opens the session at its key moment. The list loads + 10 recordings per page with a Load more footer. Up to 5 optional recommendations load afterward under Worth watching, + below the recordings; recommendations matching loaded recordings enrich those rows without moving them. + Pull to refresh reloads the first page and the recommendations. + Loading (row-shaped blocks), "Couldn't load your sessions" with Try again, "No recordings yet", the sign-in state and + the demo state are separate screens. More (pushed only) holds Pin to navigation or Unpin from navigation, with the + Views toast and Undo, and Open on web. +- **Session** (`features/replay/SessionScreen.tsx`): Back on the left; on the right one pill with the "Open full replay" icon + (the only way to the web player) and More (Share link at m:ss, Copy session ID). "Session · project" eyebrow, the 22pt + title (the scan's, else one built from the events), the meta line (person · date and time · length · browser on OS), + then a "Key moment 2:14" chip in orange (it opens that chapter's frame) or "First error 2:14", and a city chip. + The Summary card holds the summary text, the worth-watching bars with "Worth watching" and the reason (no raw score), and "Replay Vision · + scanned …" in mono. Its other states: a spinner while loading, "No summary yet" with Summarize this session and + "Uses Replay Vision credits", "Summarizing" with a spinner, "Still working" with Check again, "AI summaries are off for + this organization" with a lock, and "Couldn't load the summary" with Try again. Chapters: a 96 × 60 frame, the mono + start time and the title per row; the key moment's row has an orange rule and flag. A row opens the frame sheet: the + frame at full width, the chapter title, "Chapter 4 of 5 · 2:14", the Key moment chip, Previous and Next, and "Frames + can show text that the recording did not mask." What happened: the event count, minute labels, and `TimelineItem` + rows with the event name as the chip and the offset as the time; errors and rage clicks draw in orange, and an error + with an issue opens it. The round ink Chat button sits bottom right. Loading is a skeleton of the screen; "Couldn't + find this recording" (expired or deleted) and "Couldn't load this session" are separate states. diff --git a/products/mobile/docs/GAPS.md b/products/mobile/docs/GAPS.md new file mode 100644 index 000000000000..6da392a07833 --- /dev/null +++ b/products/mobile/docs/GAPS.md @@ -0,0 +1,225 @@ +# Gaps + +Known gaps and behavior that stays on the device. + +## Product + +- **Dark mode.** Light theme only (`userInterfaceStyle: 'light'`). Tokens are not split by scheme yet. +- **Dismiss and resolve from the inbox list.** Resolve and dismiss live on the report screen's action pill and the row's context menu; there is no bulk action. +- **Approve PR.** The PR card only opens the PR. Approving happens on GitHub. +- **External hand-off is a prepared prompt.** Claude Code and Codex get a deep link with the prompt, and copy is the fallback. There is no Cursor row, and the app never checks that the agent has the PostHog MCP connected. +- **Time-based snooze is local.** "In an hour", "Tomorrow", "Next week", "In a month" and + "Custom…" hide the report on this device only. Other devices and the web app still show it. + "Until more signals arrive" is the only server-side snooze. +- **Hidden navigation.** Teams, Notifications and Give feedback are not in v1. + The project switcher chevron does nothing yet. The nav pill's Products slot leaves the app for the + web product library, and is dimmed in demo mode because there is no project to open. +- **Agent progress UI.** The prototype's "Step N of M" segments are not shown; the run API has a + free-text stage only. +- **Impact avatars.** The prototype data has avatar stacks on Impact facts that its template never + draws; the app does not draw them either. +- **Chat** has no expanded composer; Expand is dimmed. + +## Platform + +- **Android text wrapping** is greedy, so a headline can end on a single word. iOS and web avoid it. +- **Hover states** only show on web. +- **`expo-localization` is unused.** Only the Today briefing request used it, for the timezone. Removing it needs a native rebuild. + +## Verification limits + +- No live PostHog was reachable while building. Every live path is covered by unit tests on + fixtures shaped from the serializers and by browser runs against intercepted responses. + The first real sign-in, hand-off, stream and chat need a device and an account. +- The web render check cannot show the iOS keyboard that some reference screenshots include. + +## Chat + +- **The suggested prompts stay visible behind the organization AI notice.** When the organization has not allowed AI processing, the empty state's prompt chips still render above the notice, and a tap on one has no composer to fill. +- **Legacy conversations are not listed.** The Chats list shows task sessions only. + LangGraph chats made before the move stay in the web app's legacy view and `/ai?chat=` links. +- **No message queueing while the agent works.** Stop replaces Send until the turn ends. + The web queues messages typed meanwhile and joins them into one `user_message` after `turn_complete`. +- **No PostHog Desktop access pre-check.** A refused user learns it from the 403 on the first send. + `GET /api/code/invites/check-access/` evaluates the server-side current team, which can differ from the app's project, + and the project-scoped `desktop/access/` needs `llm_gateway:read`, which the app does not request. +- **Context items are re-sent with every message** while a chip stays attached. The web sends a report item once per task resume chain. +- **Report discussion limit.** A chat with exactly one attached report is an Inbox discussion task and counts toward the report's task limit. + At the limit, `POST tasks/` or `run/` answers 429 `signal_report_task_cap`; the app shows the server copy and takes the message back. Attach a second report, or none, to chat without the limit. +- **One-report chats show as "Report" in Chats.** The `basic=true` list carries `origin_product` but not `signal_report_task_relationship`, + so a discussion started on mobile gets the Report label and branch icon of a Create PR hand-off. +- **A push for a chat opens the Inbox.** Task push routing (`src/features/notifications/notificationRouting.ts`) opens the task's report, and a `posthog_ai` task has none, so it falls back to the Inbox instead of the chat sheet. +- **A replayed mode change does not replace a mode the app set.** Only a live `current_mode_update` does. If the agent changed mode while the stream was replaying after a lost cursor, the Mode chip keeps the app's mode until the agent reports one live. +- **No harness switch on a live run.** A chat started on Claude Code stays on it, and one on Codex stays on Codex. Start a new chat to switch. +- **`set_config_option` result shape is unverified.** The app treats a 2xx without a JSON-RPC `error` as applied. + The agent server source is not in this repo; its built dist answers `{ configOptions }`. +- **In-progress reopen relies on the server backlog.** A live run is followed without a cursor and without reading `logs/`, + which shows the whole thread only on thin-tail runs. The web reads `logs/` and reconciles on every connect. +- **Approvals, forms, charts and notebooks** the agent links show as links to the web app. +- **Files from history show by name only.** A picked image draws from the file on the device. + A file in a stored thread has no local copy, and the app neither downloads it nor draws a thumbnail. + `runs/{r}/artifacts/{id}/download/` answers with a 302 to a presigned S3 URL, and the app's bearer header must not follow the redirect to S3. + `runs/{r}/artifacts/presign/` would avoid the redirect, but it needs the `storage_path` from the run's manifest, which the app does not read. +- **Sessions in the chat sheet.** + - No artifacts or diff view. Only the PR card shows what the agent changed. + - No undo of an answer. + - Stop needs a started agent. Before the stream shows it generating, the sheet says the agent hasn't started yet. + - The connection loop in `useSession.ts` is a copy of `useTaskRun`'s `followRun`. A fix to one needs the same fix in the other. + +## Inbox and report data + +- **`today:read` scope.** The Today page needs `today:read`. Without it in the OAuth request, the page returns 403 and the report screen always uses signal evidence. +- **Inbox API compatibility.** The inbox needs a server that supports `view=for_you` on the reports list. +- **Inbox paging.** Each page orders the list again. A report that moves down while the user scrolls can be skipped until the next refresh. +- **A change on the report screen refetches every loaded page.** Resolve, dismiss, reviewers, priority and hand-off invalidate the inbox list, and the list then refetches each loaded page in turn. +- **The signal snooze store is nearly redundant.** The server excludes a report snoozed until more signals from the `for_you` view, so the device entry only labels the row until the list refetches. The store and its labels could go. +- **Time snoozes do not sync** to other devices or the web, and are lost when the app's storage is cleared. +- **Inbox rows** carry no progress label. +- **Evidence** maps error, feedback and recording signals only. Scout findings, GitHub and analytics signals add no evidence block. +- **Work log** shows one page of 100 artefacts. Older entries of a very long log are not shown. +- **PR details.** The PR view has the URL, number, state and review decision. Checks for the selected canonical PR are fetched through `pr_checks/`, with the client fetch time. Title, diff size and the agent's note remain unavailable in the report API. A PR URL without a canonical id cannot fetch checks. +- **Read-state counts cover loaded reports only.** The menu does not fetch every page to compute a global unread total. Read state refreshes every 30 seconds while the inbox is active. + +## Auth and push + +- **No OAuth sign in on the web.** The web build offers the demo, and a personal API key in dev builds. +- **Web release builds keep tokens in memory**, so a reload signs out. +- **Expo Go cannot sign in or get push.** Its redirect URI and push token belong to Expo Go. Use a development build. +- **An expired session cannot always unregister push.** User-initiated sign out and account switch keep credentials and offer a retry if device unregistration fails. A session that has already expired may lose the credentials needed to remove its server-side token row. +- **Legacy task pushes carry no project id.** Current pushes carry `projectId`; older payloads still need up to 8 project lookups when tapped. Foreground report/snooze suppression needs an explicit project and a cached task-to-report association. +- **Local dev app is not first-party.** On a local stack the consent screen shows and access tokens last 1 hour. +- **No proactive refresh timer.** The app refreshes at launch when the token is nearly expired and on any 401, not on a schedule. +- **Project switcher lists environments as projects** when a project has more than one environment (team), because it lists teams. +- **Production OAuth apps may not allow the Errors and Sessions scopes yet.** `error_tracking:read`, `error_tracking:write`, `session_recording:read`, `replay_scanner:read` and `replay_scanner:write` need a human to add them to the US and EU mobile applications' ceiling in Django admin. Until then the server drops them and both screens show "isn't available yet". +- **Web links do not open the app.** `app.config.ts` declares no iOS associated domains and no Android intent filters, so an `https://` issue or recording link opens the browser. `webLinkTarget()` maps such links once a universal link reaches the app (from a custom-scheme link or a future associated domain). `webLinkTarget()` ignores the link's project id, so a link to another project opens in the current one and its request can fail. +- **Shared screen chrome lives in Views.** Errors and Sessions import `features/views/screen/*`, `ViewsToast` and `ViewsReauthState`. Move them to `src/ui/` (or one shared module) in a follow-up, so features import only other features' hooks again. +- **Only one list can be pinned.** Errors and Sessions share the one user slot in the nav pill (see [DECISIONS.md](DECISIONS.md#navigation-pins)). +- **Views' sign-in state hides Browse.** While Views lacks its own scopes, the Browse group is hidden, so Errors and Sessions are reachable only through a pin. +- **Production OAuth apps may not allow `today:read` yet.** The server drops scopes outside the app's ceiling without an error, so the report screen would always use signal evidence. Check the US and EU mobile apps in Django admin (see [AUTH.md](AUTH.md#server-side-ceiling)). + +## Notification follow-ups + +The app-side policy lives in `src/features/notifications/foregroundNotifications.ts` and +`notificationPresentation.ts`. It only controls notifications handled while the app is active. +The following need shared server policy or additional product work: + +- **One outcome per requested operation.** Combine task-turn, terminal completion and PR-ready events. + Prefer “PR ready to review” when a PR is the outcome; keep internal investigation, summarization and retries + silent. A later user request or new decision can notify again. Keep shared activity-feed updates independent + of mobile push suppression, and preserve desktop/web notification behavior. +- **Durable event identity and delivery.** Include operation, turn/decision/PR and recipient identifiers. + Replace timer-only deduplication with durable per-recipient decisions and a transaction-safe delivery queue. + Handle callback replay, rollback, queue failure and partial delivery without duplicated or missing alerts. + Mobile cannot safely collapse different turn/question/PR events based only on their run or report. +- **Background snooze and presence.** Synchronize mute/snooze state and apply it to every mobile push producer. + Local foreground filtering cannot stop lock-screen pushes while the app is backgrounded or closed. + Validate the registration/device identity needed for presence beacons before adding them. +- **Stale outcomes and reminders.** Recheck cancelled/superseded work, resolved reports and recipient relevance + before sending. Cancel snooze reminders when a report is resolved elsewhere. Avoid a confirmation push for + the user's own cancellation. +- **Notification destinations and controls.** Carry the exact decision, run, message or PR to open; add + category preferences and per-report mutes. Keep important new requests for input distinct. +- **Delivery diagnosis.** Carry a common notification event ID through send decisions and opens, track + suppression reasons, and distinguish Expo acceptance from actual delivery. Add appropriate expiry/grouping + metadata and reconcile stale device registrations. + +## Hand-off and task runs + +- **No following in the background.** The run streams only while its report is open and the app is in the foreground. A tool approval that arrives otherwise waits until a client connects. +- **Undo is best effort.** It aborts the answer in flight; a request the server already received still reaches the agent. +- **No resume of an ended run from the report screen.** A reply there to a run whose workflow ended asks the user to hand off again. The chat sheet starts a resume run (`resume_from_run_id`) instead; see CHAT.md. +- **No feedback on hand-off.** The web's Create PR takes optional feedback; the phone sends the prompt without it. +- **Inbox rows do not know about live questions.** The yellow "needs input" badge comes from the report data; the live pending request is only known while the report is open. +- **No server presence ping.** The app suppresses foreground notifications for the report on screen locally, but does not call `POST tasks/{id}/presence/`. The server still sends them and cannot use mobile presence to suppress other devices. +- **No repository choice.** Report tasks get their repository from the report or the server's cascade; `POST tasks/` answers 400 to a client-set `repository`. A wrong repository needs the web. +- **No branch list.** `integrations/`, `github_repos/` and `github_branches/` need `integration:read`, which the mobile OAuth app does not request, so the Branch page is a text field with no suggestions and the app cannot check that the branch exists before the run starts. +- **Picked options are lost when the task starts its own run.** When `POST tasks/` answers with a `latest_run`, the app skips `POST tasks/{id}/run/`, so the picked model, mode and branch never reach the server. `mobile_report_handed_off` still records them. +- **No mid-run model or mode change.** `POST tasks/{id}/runs/{run}/command/` with `set_config_option` (`model`, `effort`, `mode`) is not wired; options apply only when the run starts. +- **Plan mode default is ignored.** The web starts in Plan when `task_defaults.start_in_plan_mode` is set (behind a flag); the app always starts in Auto. +- **Live-only footer variants were not rendered.** The stopped-run card, the error line, the reply card and the URL-only PR line need a signed-in session; the web build cannot sign in, so only the demo states were checked in a browser. + +## Delivery + +- **Store and Expo accounts.** Builds, submissions, credentials, EAS environment variables and device registration need a person with access to the `posthog` Expo organization, App Store Connect and the Play Console. See [RELEASING.md](RELEASING.md#one-time-setup). +- **No EAS build has run yet.** The `.easignore` archive could not be inspected without an Expo login (`eas build:inspect` needs one). +- **Internal variants need portal setup before their first build.** The dev and preview bundle ids, widget ids, App Groups, push credentials and OAuth redirect URIs do not exist until someone registers them. See [RELEASING.md](RELEASING.md#one-time-setup). +- **Per-variant widget stores were not run on a device.** Both Swift readers derive the App Group from their bundle id. Only the generated entitlements and a Swift typecheck were checked. +- **Shared OTA publishing is pending.** A repository workflow is needed before OTA updates are part of the shared release process. +- **Chat composer under the keyboard (iOS).** On iPhone 17 with iOS 27, the composer's bottom row (Add a report, Send) sits about 30pt behind the keyboard, so Send cannot be tapped while typing. The return key still sends. See the `KeyboardAvoidingView` in `src/features/chat/ChatSheet.tsx`. +- **Sheets do not hide the screen under them from VoiceOver.** The report's controls stay in the accessibility tree under the hand-off, snooze and chat sheets. +- **Dev build overlays.** The Expo dev client's tools button covers the inbox More button, and its fast refresh banner covers Back and Close. Release builds do not have them. [TESTING.md](TESTING.md) says how to turn them off. +- **Android was not run.** Only the iOS simulator was checked. + +## Agent state backend backlog + +The frontend handles known PRs, execution uncertainty, focused foreground refresh, connection +failures and task-chat continuation. The following require backend work and are not implemented +by the mobile footer change: + +- **Normalized CI snapshots.** Persist check summaries consistently for legacy and babysit + execution paths and expose a consistent snapshot through the report PR API. The existing + `pr_checks` endpoint already fetches selected-PR checks on demand; it does not provide the + persisted head/freshness contract. Each PR needs its own head SHA, + observed timestamp and check state. Missing, stale and pending results must remain distinct; + a PR URL or successful tool step does not prove checks passed. +- **Execution versus monitoring.** Expose typed execution and monitoring state separately, with + the current task/run identity, latest observation time, next scheduled check and stop capability. + A claim, an idle turn or a completed run cannot establish whether PR monitoring continues. +- **Stream catchup boundary.** Emit a typed catchup-complete event with the current run identity + and a snapshot/stream watermark after both durable and Redis history have drained. The + frontend can batch durable replay and a quiet Redis tail, but without this boundary it cannot + distinguish arbitrarily slow Redis history from new live events immediately. +- **Authoritative outstanding input.** Expose the current request snapshot, version and request + id, plus resolution events with matching ids/version. Replayed log entries alone cannot prove + a question still needs an answer, especially after another client answered. +- **External agent state.** Optionally accept MCP progress, structured outstanding input and + heartbeat timestamps. Any input shown in the app needs a real answer channel and clear + capabilities; identifying the MCP client or retaining its claim does not establish liveness. +- **Server permission policy.** Move automatic permission decisions out of viewer stream effects + into the server's execution policy, preserving the existing decision rules. This is separate + work so opening a report is not what enables unattended execution. The frontend policy has + not been broadened here. + +## Errors + +- **No push for a new or spiking issue.** Error tracking alerts reach Slack and webhooks only. Backend ask: a mobile + push destination for `ErrorTrackingAlert` (or a HogFunction that calls `send_user_push`) with the issue id, so a tap + opens `/errors/{id}`. Today an issue reaches the phone through an Inbox report or a link. +- **Assign covers the viewer only.** Listing teammates and roles needs `organization_member:read` (and the roles list), + which the app does not request. Another person's assignment shows as "A teammate" or "A role" without a name. +- **No Reopened badge.** No read the app can make says an issue came back after a resolve. Backend ask: a + `reopened_at` (or last status change) on the issue resource. +- **No full stack, code context or source maps.** The trace card shows up to three in-app frames as text. Exception + chains, code lines, variables and symbol-set problems stay on the web. +- **No search, severity, release or library filters**, no sort other than users, and no resolved or suppressed lists. +- **New and Spiking filter loaded pages.** Load more checks another 15 issues. An empty partial page is not a + project-wide empty result. Mine filters on the server, and also loads 15 issues per page. +- **Sample sessions open at the start.** The session screen gets no `t`, because the exception's offset into the + recording needs the recording's start time, which the issue reads do not return. +- **No merge, split, or external issue links** (Linear, GitHub, Jira). +- **Not checked against a live project.** The request and response shapes come from the serializers in this + repository and the tests; the first real run needs an account whose sign-in includes `error_tracking:read` and + `error_tracking:write`. + +## Session replay + +- **No player.** The phone shows the summary, chapter frames and the event timeline. Watching the recording is + "Open full replay", the web player in the browser. No inspector, console or network panes. +- **No clip video.** Replay Vision renders an MP4 with idle time cut, but playing it needs `expo-video`, a native + module the app does not ship. Adding it needs a new native build, not an over-the-air update. +- **No push when a summary is ready.** The screen polls for two minutes while it is open. Backend ask: a hook on scan + completion that sends a mobile push with the session id, so a tap opens `/replay/{sessionId}`. +- **Frames need two requests.** The thumbnail endpoint answers with a redirect to a signed URL, so each frame costs an + API call before the image loads, and the signed URL expires (the app keeps it for 4 minutes). The web build cannot + read a manual redirect, so frames show the empty tile there. +- **Opening a session does not mark it viewed** on the web, because that needs `session_recording:write`. +- **No filters and a fixed window.** The list covers the last 7 days, 10 newest recordings per page, with up to 5 + optional recommendations below the recordings. No search, collections or playlists. +- **Clicks in the timeline are a sample.** At most 20 clicks of a session show, spread over its length. The page view + and click query reads the first 300 events, so a long session loses its later pages and clicks; the count then + reads "301+ events". Errors and rage clicks have their own query and always show (up to 100). +- **Rows from the feed may lack a length.** A feed pick outside the loaded recording pages has no duration or click + count, because the feed carries the observation, not the recording. +- **The full report's "Watch session" opens at the start.** A signal carries the session id but no offset into it. +- **Not checked against a live project.** The shapes come from the serializers in this repository and the tests; the + first real run needs a sign-in with `session_recording:read`, `replay_scanner:read` and `replay_scanner:write`. diff --git a/products/mobile/docs/HANDOFF.md b/products/mobile/docs/HANDOFF.md new file mode 100644 index 000000000000..c2b8793fbefc --- /dev/null +++ b/products/mobile/docs/HANDOFF.md @@ -0,0 +1,324 @@ +# Hand-off and agent runs + +How the report screen hands a report to the PostHog agent, follows the run, relays the agent's +questions, and shows the PR it opens. Code lives in `src/features/handoff/` and `src/api/tasks.ts`. + +## Flow + +### Default agent + +The report's hand-off button uses the last agent selected in the hand-off sheet: PostHog, +Claude Code or Codex. The selection is saved on this device across reports and app +restarts, with PostHog as the initial default. Selecting PostHog again restores that default. +Copy prompt does not change the preference. The preference is client-only; external handoffs +still open a prepared prompt without starting a PostHog run. + +### External agents on mobile + +The hand-off sheet offers two external destinations, independently of the PostHog run options: + +PostHog, Claude Code and Codex each show their existing brand mark beside the option label. +The marks are decorative; screen readers announce the button's label and hint. + +- **Claude Code** opens the [documented mobile composer](https://support.claude.com/en/articles/14898120-open-the-claude-mobile-app-with-a-link) + at `claude://code/new` with an encoded `q` and, when the report's latest `repo_selection` artefact names + a GitHub repository, `repo=owner/repo`. The user reviews and submits the prompt in Claude. Claude Code access + is required, and Claude ignores a repository that is not connected to the user's GitHub account. The + `/code?prompt=...&repositories=...` web route can open the mobile app without applying the prompt or repository. +- **Codex** opens `https://chatgpt.com/?prompt=`. ChatGPT's current + [iOS universal-link configuration](https://chatgpt.com/.well-known/apple-app-site-association) explicitly + admits this route. Prompt prefill was confirmed in device testing, and the web fallback was checked with + harmless sample text: it fills the composer without sending. + This replaces `codex://new?prompt=...&originUrl=...`, which opened ChatGPT but ignored the prompt and repository + in device testing. Repository context stays in the prompt; this route does not select a Codex project or + environment. Use `prompt`, not `q`, to target a draft. OS link preferences or an absent app may open the browser. + +The prompt includes the report URL and ID, a repository hint when known, and instructions to retrieve evidence +through authenticated PostHog MCP tools. It does not make the report public or pass PostHog credentials. +The receiving agent needs its own PostHog connection. Copy prompt uses the same text as both direct links. +The agent claims the report when work starts, retains its `claim_id`, and attaches its PR URLs. +Opening or copying never claims the report, starts a PostHog run, or reports that an external agent +is working. Failed launches show an inline fallback. The web preview supports copying only. +Tracking: `mobile_external_handoff_opened` with `target` (`claude`, `codex`), `from` (`button`, `sheet`, +`inbox_menu`) and `has_repository`, never the prompt. A failed launch sends `mobile_handoff_failed` with +`agent` set to the target and `reason` (`launch_failed`, `unsupported_platform`). Copy prompt sends +`mobile_handoff_prompt_copied`. + +Device acceptance checks (iOS and Android, with receiving apps installed and absent): + +1. Open each direct option; check the destination, complete prompt, repository and explicit send step. +2. In ChatGPT, choose the project manually; the prompt includes repository context but does not select a project. +3. Return from each app and test a failed launch and Copy prompt; report state must stay unchanged. +4. Try a report with no repository; the prompt must ask the agent to confirm one. +5. Try a small screen and larger accessibility text; all destinations, PostHog options and Copy prompt must remain reachable. + +### PostHog runs + +```text +Hand off ──► kickOffReportTask ──► store.started ──► useTaskRun ──► store.live ──► mergeLiveRun ──► ActionBar + (button or sheet) GET report (status card bootstrap + reducer (report + live) (footer state + POST tasks/ shows at once) SSE stream from actionBarStateFor) + POST tasks/{id}/run/ +``` + +1. **Kickoff** (`kickoff.ts`). `GET signals/reports/{id}/` for a fresh title and summary, then the + web's two calls: + `POST tasks/` with `{title, description: , origin_product: 'signal_report', signal_report, signal_report_task_relationship: 'implementation'}` + (no `repository`: the server picks it from the report and rejects one the client sets), and, only + when the response has no `latest_run`, + `POST tasks/{task_id}/run/` with `{run_source: 'signal_report', signal_report_id, mode: 'interactive', pending_user_message: , ...}`. + The run options come from `buildHandoffRunRequest` (see [Run options](#run-options)). + The run comes from `run`, else `latest_run`. Both calls retry a 503 + `warm_run_activation_unavailable` with `X-PostHog-Warm-Retry: `, 250 ms + doubling to 1 s, for up to 20 s. + The created task ID stays in memory if its first run fails; after an app restart the report's current task claim recovers it. Retry fetches that same task, adopts any run the earlier request started, or starts its first run without creating another task. The Hand off button remains available while that task has no run, including after the report shows its claim. An ambiguous failure never deletes a potentially running task. +2. **Started.** `useHandoffActions(id).handOff` stores `{taskId, runId}` in the hand-off store, + tracks `mobile_report_handed_off`, and invalidates the report and inbox queries. While the + calls are in flight `handOffPending` already shows the status card. +3. **Follow** (`useTaskRun.ts`). While the report screen has focus and the app is in the + foreground: `GET tasks/{task_id}/` (its `latest_run` wins over a run id from the report, which can + be stale), falling back to `GET tasks/{task_id}/runs/{run_id}/`, then + `GET tasks/{task_id}/runs/{run_id}/stream/`. A terminal run is not streamed. +4. **Overlay** (`useReportWithLiveRun.ts`, `liveReport.ts`). Lays the live state over B's + `useReport(id)`: `task` (status, stage, pending decision, error), `work.pullRequest` (from + `output.pr_url` when the report does not know that PR yet), and `prompt` (the external-agent + prompt for "Copy prompt"). The footer stays driven by `actionBarStateFor`. +5. **Answer** (`hooks.ts`, `commands.ts`). The decision card answers through + `POST tasks/{task_id}/runs/{run_id}/command/`. + +Demo mode skips all of this: the demo store drives the same cards. + +## Run options + +The hand-off sheet has Model, Mode and Branch rows above the PostHog row. Each row opens a page in +the same sheet with a back control. The quick "Hand off" button uses the same options while they last +(see Reset below), so outside a sheet session it sends the defaults. +Code: `runOptions.ts` (ports of the web's `composerModels.ts` and `composerModes.ts`), +`runOptionsStore.ts`, `useRunOptions.ts`, `ModelPage.tsx`, `ModePage.tsx`, `BranchPage.tsx`. + +| Request | When | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET tasks/models/` (`task:read`) | Sheet open. `{models: [{runtime_adapter, model, display_name, supported_efforts, cost_multiplier}]}`. The app hides the catalogue's retired ids, except a retired stored default, which stays listed so it shows by name (the web's `pickerModels`). An empty list or an error shows "Couldn't load the model list" and the run uses the default model. | +| `GET tasks/@me/config/` (`task:read`) | Sheet open and at hand-off (cached 60 s). The app reads `resolved_ai_run_defaults.{runtime, runtime_adapter, model, reasoning_effort}`. | + +The run body (`buildHandoffRunRequest`): + +| Selection | Fields sent | +| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Model untouched, usable stored default (`runtime: 'acp'` with a model and adapter) | `initial_permission_mode` only, mapped to the default's harness. The server resolves the user's, then the project's, default. | +| Model untouched, no usable stored default (`model` is null, or `runtime` is `pi`) | `runtime_adapter: 'claude'`, `model: 'claude-opus-5-5'`, `reasoning_effort: 'high'`, and the mode mapped to Claude. A run that pins no runtime starts on ACP, and `resolve_ai_run_selection` discards a Pi default there; the web maps it to null too. Matches the web Inbox's Create PR. | +| Model untouched, `@me/config/` failed | `initial_permission_mode` only, so a failed lookup never overrides a stored default. | +| Model picked | `runtime_adapter`, `model`, `reasoning_effort` (left out for a model with no efforts) and the mode mapped to that harness. | +| Branch typed | `branch` (trimmed). Left out when empty: the server then uses the project's Inbox base branch for that repository (`autostart_base_branch_for_repository`), else the repository's GitHub default branch. | + +- **Mode** defaults to Auto. Claude offers Default, Accept edits, Plan and Auto; Codex offers Plan, + Read only and Auto. Full auto (`bypassPermissions`) and Full access (`full-access`) are hidden, as + on the web. A mode from the other harness maps to its nearest equivalent, which can be looser: + Default and Accept edits become Auto on Codex, Read only becomes Plan on Claude, and the hidden + Full auto and Full access map onto each other. +- **Effort.** Picking a model keeps the current effort when the model supports it, else High, else + the model's highest effort. With an untouched model, picking an effort pins the default model. An + untouched picker marks the default's effort only when the stored default names one. +- **Repository** is not a choice: report tasks get their repository from the report's repository + selection or the server's cascade, and the server answers 400 to a client-set one. +- **Reset.** Options are in memory for one sheet session, like the web's per-run picks. They go back + to the defaults when the sheet closes (with or without a hand-off), after each hand-off, on a + project switch and at sign-out. The Branch field writes to the store on every keystroke. +- **Scrolling.** The model list sits in a `Gesture.Native()` that the sheet's drag-to-close pan waits + for, so a drag that starts on the list scrolls it. Drag the grabber or the header to close. +- **Tracking.** `mobile_handoff_option_changed` with `option` (`model`, `effort`, `mode`, `branch`), + never the value. `mobile_report_handed_off` adds `model` (`default` when untouched), + `reasoning_effort` (when pinned), `permission_mode` and `has_branch`. + +The chat sheet (`src/features/chat/session/useSession.ts`, see [CHAT.md](CHAT.md)) reuses `runStream`, `permissionRequest`, `commands` and `toolPolicy` from this feature. + +## Which run a report follows + +The current task claim wins over the run this device started and over historical implementation +artefacts. A claim by a user, an external agent, or a system without a task clears historical task +execution from the report footer. When there is no claim, the adapter can use the newest +implementation `task_run` artefact. Discussion tasks do not become implementation ownership. +Within the current task, `GET tasks/{id}/` supplies its latest run; the bootstrap also updates +the task query so a resumed run can replace the report's older run immediately. + +Tapping the action bar's agent status card opens that task's chat sheet, passing the task id and +the displayed run id when available. It sends `mobile_report_action_pressed` with `action: task_chat`, +`from` (`status_card`, `decided_card`, or `pr_card` for "View task activity") and `run_status`. The round +Chat button sends it with `action: chat`. A claim without a task id cannot open a task chat yet. The +round Chat button beside the Reassign / Resolve / Dismiss pill starts a new conversation with the report attached. +The streamed command beneath the status title stays on one line and truncates at the end. + +## Stream handling + +Frames follow `products/tasks/backend/presentation/views/api.py` (stream view) and the web's +`products/posthog_ai/frontend/logics/runStreamLogic.ts`. The SSE parser is `src/api/sse.ts`. + +| SSE event | Handling | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `keepalive` | Resets the 60 s idle timer and publishes any buffered report history. | +| `end` `{"type":"rotated"}` | Connection cap reached. Reconnect at once with `Last-Event-ID`. | +| `end` `{"type":"resync"}` | Drop the cursor, reconnect, take the full replay. | +| `stream-end` | Stream finished. Publish buffered history, drop the cursor, fetch the run once more, stop. The run snapshot determines whether the run is terminal. | +| `error` | Server closes. Fetch the run; stop if terminal, else reconnect with backoff. | +| content (no `event:`) | Saved as the cursor (`id`), deduped by envelope `event_id`, reduced below. | + +Reconnects back off 2 s doubling to 30 s, 10 attempts per drop and 30 per screen visit, and fetch +the run first so a finished run stops. A 503 at admission waits `Retry-After`. 401, 403 and 404 stop. +The first connection sends no `Last-Event-ID` (and not `start=latest`), so the server replays the +durable log (`log-` ids) and a question asked before the app connected reaches the decision +card. Cursors stay in memory per run, so returning to the report resumes after the last event. + +Opening or reconnecting a report folds the initial stream privately in `RunHydration`, keeping +the cached report and PRs visible. Durable `log-` frames never paint individually. Once Redis +frames arrive, a 250 ms quiet interval coalesces their initial tail into one publication; keepalive +or explicit `stream-end` can also settle it. Subsequent live frames publish immediately. Rotation, +resync, errors and unmarked EOF do not publish incomplete catchup. Rotation keeps the private +state and cursor together; leaving the screen discards both unpublished copies. An authoritative +terminal snapshot can settle accumulated PR metadata while clearing pending input. Cursors commit +with their state and clear at session end, so reopening cannot skip unpublished history. + +All catchup frames, including Redis history, use replay permission policy: they cannot send +automatic approval commands. The final unresolved request remains available to answer. Local +answers and handled request ids written during catchup survive publication. A resumed cursor +can apply work missed while disconnected without replaying its intermediate status changes. + +The server has no explicit catchup-complete event or snapshot watermark. A quiet Redis tail is a +UI batching heuristic: Redis history delivered in arbitrarily slow chunks can still look live +after the quiet interval. An authoritative boundary requires backend work listed in +[GAPS.md](GAPS.md#agent-state-backend-backlog); the client does not use `start=latest` to skip +history because doing so could lose an outstanding question. + +| Content frame | Effect on the live state | +| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `task_run_state` | `status`, `stage`, `error_message`, `output.pr_url` / `pr_urls`. A terminal status clears the pending decision. | +| `session/update` `tool_call` | Latest tool title becomes the stage, shortened: "Read /repo/src/ChartLegend.tsx" reads "Reading ChartLegend.tsx". | +| `session/update` `tool_call_update` | Title updates of the latest call move the stage. A `completed` or `failed` update for the pending request's tool clears it (someone else answered). | +| `session/update` `agent_message_chunk` / `agent_message` | Records activity for the current run. Message text belongs in task chat. | +| `session/update` `current_mode_update`, `session/new` `_meta.permissionMode` | Permission mode for the tool policy. | +| `_posthog/progress` | `label` (else `detail`) overrides the tool title while the step runs; a finished step clears it. | +| `_posthog/turn_complete` | The turn is idle. Clears pending requests and transient progress; it does not create an input request. | +| `_posthog/error` | Keeps `message` as the run error. | +| `permission_request` (live) and `_posthog/permission_request` (replay) | Runs the tool policy: allow it for the user, or surface it as a decision. Ignored when already handled, from an earlier run in the log, or when the run is terminal. | +| `_posthog/permission_resolved` (replay) | Clears the matching request and remembers its id so a replay cannot bring it back. | + +While execution is working, the status card stage is the active step label, else the latest tool +title, else the server's `stage`. Idle and terminal runs clear this transient text. There is no +"step N of M": the API has no step count. A claim identifies ownership, not execution; unknown +execution and missing check data stay unknown. + +## Tool policy + +`toolPolicy.ts` is a port of `products/posthog_ai/frontend/policy/toolPolicy.ts` and the routing in +`runStreamLogic.routePermissionRequest`. An interactive run relays approvals to the connected client +and waits; when the phone is the only client, a request it does not answer stalls the agent. + +- Allowed for the user (with `allow_once`, else another `allow*` option): built-in tools (a + canonical tool name such as `Bash`, `Edit`), and PostHog `exec` calls in the task's project. +- Asked: questions (`AskUserQuestion`), plan approvals (no tool name, `kind: 'switch_mode'`), + other MCP servers (`mcp__*`), connected-project calls (`posthog-connection-call`, + `posthog-connection-forward`), and anything with no tool name. +- In `bypassPermissions` mode everything is allowed except questions, plans and connected-project calls. +- A request from the replayed log is never answered for the user, since the run may have moved on. +- If allowing fails, the request shows as a decision instead. + +## Decisions and answers + +| Request | Card | Answer sent | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Question | `_meta.questions[0].question`; options `option_` with the label as title and the description as meta. Several questions show one at a time and go out together. | `permission_response` with `optionId` (the picked `option_` of the first question, else `option_0`), `answers` keyed by question text, and `customInput` for typed text. | +| Plan approval | "Approve the plan?" with "Approve the plan" (`auto`) and "Approve in full auto" (`bypassPermissions`), or the offered options when neither exists. | The picked mode. Typed text rejects with feedback (`customInput`). | +| Permission | "Allow {tool title}?" with the offered options. | The picked option. Typed text goes with the option that takes text, else a reject option, else the first. | + +Success is `jsonrpc: '2.0'`, no `error`, and `result.resolved === true`. A 503 `sandbox_not_ready` +or `agent_session_not_ready` retries at 250, 500 and 1000 ms for up to 10 s. A 409 +`permission_target_ended` or `run_ended` (or any 409 on `user_message`) means the run is over: the +card closes, a notice says the answer was not sent, and the run and report are fetched again. A +JSON-RPC error "No pending permission request" means someone else answered: the card closes. + +The decided card shows the answer while it is delivered, with Undo, and after the agent confirms, +without Undo, until the run shows new activity. Undo aborts the request in flight and brings the +card back. It is best effort: a request the server already received still reaches the agent, and +answering again then gets "already moved on". Tracking: `mobile_decision_answered` with `kind` +(`question`, `plan`, `permission`) and `picked_option`. "Read question" sends `mobile_report_sheet_opened` +with `sheet: decision` and the same `kind`, so shown and answered form a funnel. A close before an answer +sends `mobile_report_sheet_dismissed`. Undo sends `mobile_decision_undone`. Task chat uses the same card +and sends none of these sheet events. + +## Error copy + +Hand-off and answer errors show as one orange line above the footer cards. The actions resolve +instead of rejecting, because every caller fires them without waiting. + +| Case | Copy | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| 402 `self_driving_free_trial` | Server `detail`, else "During your free trial, Self-driving writes reports but doesn't open pull requests. Contact us to upgrade." | +| Other 402 (PR limit) | Server `detail`, else "Your organization reached its self-driving pull request limit. Ask an org admin to raise it from the Inbox usage widget." | +| 429 `signal_report_task_cap`, `throttled` | Server `error` or `detail`, else "Task limit reached for this report. Try again later." | +| Other 429 | Server copy, else "PostHog is busy. Try again in a few minutes." | +| 503 `warm_run_activation_unavailable` after 20 s | "PostHog couldn't start the agent in time. Try again." | +| 403 | Server copy, else "You don't have access to hand off reports in this project." | +| 404 | "This report is no longer available." | +| No run returned | "PostHog created the task but couldn't start it. Try again." | +| Network | "Couldn't reach PostHog. Check your connection and try again." | +| Anything else | "Couldn't hand off to PostHog. Try again." | + +Tracking: `mobile_handoff_failed` with `agent: posthog` and `reason`, one value per row above +(`free_trial`, `pr_limit`, `task_limit`, `busy`, `start_timeout`, `forbidden`, `not_found`, +`run_not_started`, `network`, `unknown`, plus `not_signed_in`), never the message. `handoffFailureReason` +in `kickoff.ts` picks the reason and `handoffErrorMessage` maps it to the copy, so the two cannot drift. + +Execution, attention and pull requests are independent footer state. A failed or canceled run +keeps its known PR available, alongside its run outcome and task-chat entry point. A completed +turn with no outstanding request is idle; continuation happens in task chat. A connection failure +shows reconnecting or unavailable, rather than claiming work is running or stopped. + +## What is not possible + +- **Undo after delivery.** The agent acts on an answer at once; there is no command to take it back. +- **Step counter.** The run has a free-text stage only, so there is no "Step N of M". +- **Background following.** Nothing streams while the report screen is closed or the app is in the + background, or while another route covers the report. A tool request that arrives then waits until a client connects; push notifications + (`awaiting`) cover the wait. +- **Answers from other clients, live.** Resolutions appear only in the replayed log. Live, a + request clears when its tool finishes, the turn ends, or the run ends. +- **Continuing an ended run.** A message to a run whose workflow ended needs a resume run + (`POST tasks/{id}/run/` with `resume_from_run_id`). Open the implementation task + chat to start that resume run, see [CHAT.md](CHAT.md#follow-ups-and-resume). + +## Pull request checks + +`usePullRequestChecks(reportId, selectedPr.id)` reads +`GET signals/reports/{id}/pr_checks/?pull_request_id={selectedPr.id}`. The canonical PR id +selects only that report-linked PR; a discovered URL without an id cannot fetch checks. +The focused report checks every 30 seconds in live mode while the app is active, and fetches +again on foreground return. Changing the selected PR uses a separate query entry. + +"View PR" sends `mobile_pull_request_opened` with `from` (`report`, `chat`, `inbox_menu`), `state` and +`pr_count`. Choosing between several PRs opens the `pull_request_picker` sheet (see the sheet events in +[DATA.md](DATA.md#actions)). + +Checking, passing, failing, pending, no checks and unavailable are distinct states. Empty checks +mean no checks, while missing or malformed data and request errors mean unavailable. A successful +snapshot records when this client fetched it, including while cached data stays visible during +refresh. This is a local observation time; the endpoint provides no server observation timestamp +or head SHA, so it cannot prove that a cached green result describes the PR's newest commit. + +## Freshness and backend follow-ups + +The visible report polls its detail, artefacts and task snapshot every 15 seconds while the app is +active. It takes a fresh lightweight snapshot on foreground or route return. Today pages and +signals are not part of that interval. Known PRs stay visible during background refetches. Stream +PR/status/turn changes invalidate these lightweight queries, throttled to one refresh per two +seconds; message chunks do not cause network refetches. Task or run changes abort the old follower, +and canceled responses and permission deliveries cannot update its successor. + +A dropped stream uses the bounded retry budget above, showing reconnecting during retries and +unavailable after exhaustion or a fatal admission response. Terminal snapshots clear connection +warnings. A nonterminal `stream-end` keeps an idle turn idle and otherwise makes execution unknown. +Polling the current task continues to discover later resumes after the stream has ended. + +These frontend rules cannot prove that an external agent is active, that monitoring is scheduled, +that a replayed input is still outstanding, or that CI belongs to the latest PR head. The API needs +authoritative snapshots for those states. The concrete backend work is tracked in +[GAPS.md](GAPS.md#agent-state-backend-backlog). Automatic permission routing remains the existing +client policy; moving it to the server is separate backend work, not part of this frontend change. diff --git a/products/mobile/docs/OTA.md b/products/mobile/docs/OTA.md new file mode 100644 index 000000000000..bed82bfa9839 --- /dev/null +++ b/products/mobile/docs/OTA.md @@ -0,0 +1,92 @@ +# Over-the-air updates + +EAS Update ships JavaScript and asset changes to installed builds without a store release. +Native changes still need a new build, see [RELEASING.md](RELEASING.md). + +Production OTA updates use a release request merged to `master` and approval from a required `mobile-production` environment reviewer. See [Releasing](RELEASING.md#request-a-hosted-release) for request creation, prerequisites and recovery. + +## How a device gets an update + +- `expo-updates` checks `https://u.expo.dev/4eb57b2f-79f6-4ab0-ae2c-bbd82af2cef9` each time the app starts. +- The app also checks on return to the foreground and approximately every 15 minutes while open. Checks are at least 15 minutes apart, + including the native startup check, and cannot overlap. Failed checks or downloads retry at a later check. +- Production builds verify each update signature using the public certificate embedded in the native binary before applying it. Unsigned updates or updates signed by another key are rejected. Local production config disables OTA until its certificate is provisioned; hosted releases require it. See [OTA signing setup](RELEASING.md#ota-signing-setup-and-rotation). +- Available updates download silently without blocking startup. Downloads wait for the native startup procedure to finish. +- A downloaded update applies automatically when the app returns after at least five minutes in the background, provided + it is on an Inbox, Chats or Views tab, the keyboard is closed, and no API request, notification navigation or TanStack + mutation is pending. A one-second grace period lets navigation settle before checking these conditions. There is a brief reload; + the user does not need to force-close the app. +- Returning to an open chat, report, settings page or other form defers the reload until a later eligible return. + This keeps active chat sessions and unsaved input intact. Saved chat drafts remain on the device across reloads. +- Downloads that finish during a session wait for that later return, or the next cold start, before applying. +- Development builds load JavaScript from Metro and ignore updates. + +This behavior uses the existing native update library and can itself ship as an OTA. Builds that have not received it +still use the previous startup-only flow: one cold start downloads it and a later cold start runs it. + +## Channels + +Each build profile in `eas.json` sets a channel, and the build listens to that channel for life. +Channels are managed by EAS. Production updates are promoted by the hosted release workflow. + +| Profile | Channel | Distribution | +| ------------- | ------------- | ---------------------------------- | +| `development` | `development` | Metro; OTA is not used. | +| `preview` | `preview` | Internal builds from Mobile Build. | +| `production` | `production` | Hosted `ota` release requests. | + +TestFlight and public-store builds share the production channel. An OTA can reach both when their runtime versions match; TestFlight is not a separate OTA audience. + +The workflow loads the EAS production environment and explicitly sets `APP_VARIANT=production` and `EXPO_PUBLIC_SIGNUP_LINK=0`. The `env` block of an EAS build profile applies only to builds, so OTA publication sets the same values itself. + +Do not run `pnpm update`. That is pnpm's own command, and it upgrades dependencies. + +## Runtime version + +`app.config.ts` sets `runtimeVersion.policy` to `appVersion`, so the runtime version equals `version` (now `1.4.0`). +An update reaches only the builds with the same runtime version. + +The device's `appVersion` policy does not detect native changes. The hosted release workflow additionally compares Expo native fingerprints for both platforms against the last successful native release. You must raise `version` and request a native release when a change needs native code, for example: + +- a new or upgraded package with native code (most `expo-*` and `react-native-*` packages), +- a new or changed config plugin, or a change to `ios`, `android` or `plugins` in `app.config.ts`, +- an Expo SDK upgrade. + +An incompatible OTA request fails before publication. The first hosted release must be native to establish this compatibility baseline; the implementation PR contains no request and publishes nothing. + +## Validate an update + +Complete the [mobile app release checks](../../../docs/internal/mobile-release-checks.md). A preview build can verify behavior before merge, but it has a separate identity and channel. Hosted staging uses the production identity on an unlinked branch; it does not deliver the candidate to preview builds or provide a second manual tester gate after staging. The protected environment approval happens before staging. + +1. Keep `version` unchanged for a JavaScript/assets-only fix. Find the preceding completed request in the latest mobile release receipt. +2. Prepare a request locally; this command only writes a file: + + ```sh + cd products/mobile + pnpm release:request ota --id fix-chat-layout --previous --notes "Fix the chat layout" + ``` + +3. Include the request and fix in a PR. On merge to `master`, Mobile Release runs the required checks, waits for the required deployment review, verifies native compatibility, stages the update on an unlinked EAS branch, and promotes that exact group to production. Local `update:production` and `update:preview` commands no longer publish. +4. Verify the completed receipt and device behavior. Open the app on an Inbox, Chats or Views tab with no keyboard open. Once a check has downloaded the update, + background the app for at least five minutes and return. Settings in an internal build shows the new update id. + Checks are throttled to once per 15 minutes, so a return immediately after publishing may not fetch it yet. + Also verify that returning to an open chat preserves the draft and defers the reload, and that an offline launch + opens normally. To test the first OTA that adds this behavior, use the previous two-cold-start flow once. + +The workflow publishes to the full compatible production audience. Staged percentage rollouts are not exposed by the request schema yet. + +## Roll back + +For a normal rollback, revert the faulty JavaScript change in a new PR and add an `ota` request following the last completed release. Do not edit an old request or move the production pointer manually. The same tests and compatibility checks apply. + +If publication stopped after claiming a request, follow the reconciliation procedure in [RELEASING.md](RELEASING.md) before requesting another release. Do not rerun a local EAS publish command to work around the receipt. + +A rollback follows the same download and safe-return policy as an update. A cold start can also apply a staged rollback. + +## See what a device runs + +Settings shows the app version in every build. +Development and preview builds also show an Environment group: the build variant, the update channel, the update (the first 8 characters of `Updates.updateId` and the time of `Updates.createdAt`, or "Built in" when the app runs the JavaScript inside the build), and the API host kind. +Store builds do not show the channel or update. +The app's own analytics events carry `app_variant`, `update_channel`, `api_host_kind`, `data_mode` (`demo` or `live`, null until the demo flag loads) and `project_id` (null when signed out) as super properties. +On the EAS project page, the Updates tab lists each update group with its runtime version, platforms and commit message. diff --git a/products/mobile/docs/PUSH.md b/products/mobile/docs/PUSH.md new file mode 100644 index 000000000000..dbd6e1383b73 --- /dev/null +++ b/products/mobile/docs/PUSH.md @@ -0,0 +1,148 @@ +# Push notifications + +Code: `src/features/notifications/`, `src/api/pushTokens.ts`. The root layout installs both hooks. + +## Token lifecycle + +| When | What happens | +| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| App start | `configureNotifications()` sets the foreground handler (banner and list, no sound, no badge) and creates the Android channel `default`. | +| After sign in | `usePushRegistration()` asks for permission once (`prompted` is persisted), gets the Expo push token with the EAS project id, and `POST /api/users/@me/push_tokens/` `{ token, platform: 'ios' \| 'android' }`. | +| Every later launch | Registers again without asking. The server upserts on (user, token), so this only refreshes `last_seen_at`. | +| OS rotates the token | `addPushTokenListener` unregisters the old token before registering the new one. A failed delete keeps the old token for retry. | +| App returns to the foreground | Rereads the permission and retries registration after an error, a missing token or a session change. | +| Settings, "Enable notifications" or "Try again" | Asks when the system allows it, retries a failed registration, or opens system settings when permission is blocked. | +| Sign out or account switch | Waits for in-flight registration, then sends `POST /api/users/@me/push_tokens/unregister/` `{ token }` before changing credentials. A failed delete keeps the session and offers a retry. | + +The server keeps at most 20 tokens per user and evicts the oldest. Both endpoints need `user:write`. +A successful registration assigns the token to the current user and removes other users' registrations of that token on the same instance. Concurrent registrations of the same token are serialized, including when no row exists yet. + +An expired session cannot authenticate an unregister request. Until the device successfully registers again on that instance, its previous registration can still receive pushes. Registration on another region or self-hosted instance cannot remove registrations on the old host. Explicit sign-out therefore still requires push cleanup before credentials are discarded. Messages already queued for delivery can also arrive after cleanup. + +The app saves a newly issued token before its registration POST, because a lost response does not prove the server rejected it. Registration and unregistration requests have bounded deadlines. A failed storage read lets the launch gate settle with default push state. + +Push is unavailable, and the app skips it without errors, on the web, in simulators and emulators, and in Expo Go. +Settings shows which one applies. + +## Credentials + +The app sends no push itself. The server posts to the Expo push service, which delivers through APNs on iOS and FCM on Android. +EAS keeps the delivery credentials, and the Android build also needs the Firebase app config. +Someone with access to the Apple Developer team, the Firebase project and the `posthog` Expo organization does each step once. + +| Variant | iOS bundle id | Android package | +| -------------------- | --------------------------------- | ---------------------------- | +| Store (`production`) | `com.posthog.code.mobile` | `com.posthog.mobile` | +| `preview` | `com.posthog.code.mobile.preview` | `com.posthog.mobile.preview` | +| `development` | `com.posthog.code.mobile.dev` | `com.posthog.mobile.dev` | + +**iOS: APNs key.** + +1. In the Apple Developer account, create an APNs authentication key (`.p8`), or reuse the team's existing one. One key serves every bundle id in the team. +2. Run `eas credentials`, choose iOS and each build profile, then Push Notifications, and add or select that key. + EAS stores the key per bundle id, so repeat it for the preview and development bundle ids. + +**Android: Firebase.** + +1. In a Firebase project, add an Android app for each package in the table that should get pushes. + Download `google-services.json`. One file lists every Android app in the project. +2. Upload that file to each EAS environment as a file variable. The Android build reads its path from `GOOGLE_SERVICES_JSON` (see `app.config.ts`): + + ```sh + eas env:create --environment production --name GOOGLE_SERVICES_JSON --type file --value ./google-services.json --visibility sensitive + eas env:create --environment preview --name GOOGLE_SERVICES_JSON --type file --value ./google-services.json --visibility sensitive + eas env:create --environment development --name GOOGLE_SERVICES_JSON --type file --value ./google-services.json --visibility sensitive + ``` + +3. In the Firebase console, open Project settings, Service accounts, and generate a private key (a JSON file). + Run `eas credentials`, choose Android and each package, then Google Service Account, and upload the key for push notifications (FCM V1). +4. For a local Android build, copy the file to `products/mobile/google-services.json`. git and `.easignore` both ignore it. + +Without `google-services.json` the Android build still succeeds, but the app gets no push token and Settings shows "Couldn't turn on". + +## Payloads + +Server pushes are sent by `posthog/tasks/push_notifications.send_user_push` through the Expo push API. +All server pushes respect the account's push notification preferences. Ordinary chat and task pushes do not require a feature flag. Report pushes also require the existing `posthog-code-mobile-push` feature flag. Settings saves these in `users/@me/` `notification_settings`: `mobile_push_enabled` (master), `mobile_push_tasks_enabled` (mobile chats and tasks), `mobile_push_reports_enabled` (new reports), and `mobile_push_report_updates_enabled` (report updates). All default to enabled. The server checks preferences again before delivery, so disabling a category also stops queued pushes. + +Chat and task notifications are eligible for work started in the mobile app, or work you actively continue there. Sending a message, starting or resuming a run, or answering an agent question from mobile enables updates for your account on that task, including chats originally started on the web or Slack. Merely opening a chat, reading its history, or watching its stream does not enable updates. Configuration changes and canceling a run also do not enroll the task. + +Continuing a chat on mobile transfers its agent-update notifications to mobile for that user: automated run/chat @mentions, stream recipient targeting, and lifecycle/PR notifications stop targeting the enrolled user on Slack. Artifact and comment DMs remain unchanged because mobile does not yet offer the corresponding comment reader and deep links. Shared Slack thread content and notifications for other participants remain. Slack may still send its own subscribed-thread alerts for shared replies; PostHog cannot turn off those user-managed Slack subscriptions. Web activity, history, and unread state remain available. Report notifications are independent of where their associated work started: new reports and meaningful report updates have separate controls. + +**Inbox** (`products/signals/backend/inbox_push_notifications.py`): + +```json +{ "type": "report", "report_id": "", "project_id": 2, "notificationKind": "report_visible" } +``` + +`notificationKind` is `report_visible` (a new report for its suggested reviewers), `pull_request_ready`, or `report_completed`, `report_failed`, `report_cancelled`, `report_awaiting`, `report_turn_completed`, `report_pr_merged`, and `report_pr_closed` (with `taskRunId`). Report updates cover meaningful milestones of implementation or research work: needs input, a finished turn, completion, failure, cancellation, or a pull request being merged or closed. They use report links and the report updates preference even when the work began outside mobile. `pull_request_ready` includes `pullRequestId` to deduplicate a single PR without hiding other PRs on the report. A report discussion remains a chat and follows the chat and task updates preference. + +**Tasks** (`products/tasks/backend/push_dispatcher.py`): + +```json +{ "type": "task", "taskId": "", "taskRunId": "", "projectId": 2, "notificationKind": "awaiting" } +``` + +The server records the `X-PostHog-Client-Platform: mobile` creation header on the task; subsequent runs do not change its origin. Active continuation from mobile records a separate user-specific notification enrollment rather than rewriting that origin. Tasks created before the server stored the creation marker stay silent until actively continued in mobile; history alone cannot reliably establish mobile origin, and there is no backfill. + +Kinds: `completed`, `failed`, `cancelled`, `awaiting`, `turn_completed`, `thread_message` (with `messageId`, no run), `handoff` (no run), `pr_merged`, and `pr_closed` (with `pullRequestUrl`). +`projectId` is the task's team id. Pushes from servers that predate it have no project id. + +**Pull request outcomes** come from verified PR close events through `notify_task_pull_request_outcome()`. Ordinary task pushes use title `PostHog` and body `"{task title}" pull request was merged` or `"{task title}" pull request was closed`, with `Untitled task` as the blank-title fallback. Report work uses title `Report update`, the same suffix with the report title (`Untitled report` fallback), and the report updates preference. Both carry `pullRequestUrl` as the stable PR identity. A durable per-recipient, task, PR, and outcome claim prevents retry duplicates independently of Slack announcement history. + +**Snooze reminders** are local notifications. They use the inbox shape with `notificationKind: 'snooze_reminder'`, so the same routing opens the report. + +## Routing + +`useNotificationRouting()` handles taps that start the app (`getLastNotificationResponse`) and taps while it runs (`addNotificationResponseReceivedListener`). +It waits until the navigator is mounted and the session has loaded, and handles each notification id once. +The pure resolver is `resolveNotificationRoute()` in `notificationRouting.ts`. + +Chat taps reuse the single chat screen. If the target task is already open in the same account +and project, the tap leaves its session and draft intact. Otherwise it replaces the open chat +with the target conversation. The comparison uses the live session's task, so it also works for +chats started in the composer and after the person taps New chat. Close returns to the screen +underneath without stepping through older chats. + +| Payload | Route | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `type: 'report'`, project is current or absent | `/report/{report_id}` | +| `type: 'report'`, another project the user has | switch project, then `/report/{report_id}` | +| `type: 'report'`, a project the user does not have | `/inbox` | +| `type: 'task'` with `taskId` and `projectId` | Validate access with `GET /api/projects/{projectId}/tasks/{taskId}/`, switch project if needed, then `/chat?taskId={taskId}`, including report discussions. Missing tasks or inaccessible projects go to `/inbox`. | +| Legacy `taskId` with `projectId`, without `type: 'task'` | `GET /api/projects/{projectId}/tasks/{taskId}/` only, or `/inbox` when the user does not have that project. `signal_report` set: switch project if needed, then `/report/{signal_report}`. Otherwise switch project if needed, then `/chat?taskId={taskId}`. | +| `taskId` without `projectId` | The same lookup in the current project, then the others (at most 8 lookups). | +| anything else | nothing | + +A tap turns demo mode off, because a push is about live data. +A task lookup that finishes after the account changes cannot navigate the new account. +Every tap captures `mobile_push_opened` with `kind` (the payload's `notificationKind`, else `report`, `task` or `unknown`). +A push that arrives while the app is open captures `mobile_push_received` with `kind` and `shown` (false when the user is viewing that task or report, or has time-snoozed the report). Explicit task pushes are suppressed only when that chat is open; viewing its linked report does not hide a discussion reply. Legacy task payloads retain report-association suppression. Foreground delivery never plays sound or sets a badge. + +Time snooze reminders are scheduled on the device after an explicit snooze action. They follow system notification permission and are separate from the account's server push categories. A signal-based snooze does not schedule a timed reminder. Repeated delivery of the same notification is suppressed before receipt analytics in the foreground; terminal task and report-work outcomes are also deduplicated by run. +A permission prompt captures `mobile_push_permission_resolved` with `permission`, `can_ask_again` and `trigger` (`auto` for the first signed-in run, `settings` for the Enable button). +Registration captures `mobile_push_registration_finished` with `ok` and `trigger`, plus `reason` (`token_unavailable`, `network`, `api_error` or `unknown`) on failure. A success counts only a new registration, not the silent one on every launch. +The Settings button captures `mobile_push_enable_pressed` with `action` (`request` or `open_system_settings`). + +## Testing + +1. Install a development build on a physical device and sign in. Allow notifications. +2. Settings, Notifications, shows "On" and the device token (`ExponentPushToken[…]`). +3. Send a test push with the Expo CLI: + + ```sh + npx expo push:send --to 'ExponentPushToken[...]' --title 'New report' --body 'Checkout fails on Safari' \ + --data '{"type":"report","report_id":"","project_id":2}' + ``` + + If your CLI has no `push:send`, post to the Expo push API directly: + + ```sh + curl -s https://exp.host/--/api/v2/push/send -H 'Content-Type: application/json' \ + -d '{"to":"ExponentPushToken[...]","title":"New report","body":"Checkout fails on Safari","data":{"type":"report","report_id":"","project_id":2}}' + ``` + +4. Tap it with the app killed, in the background and in the foreground (the banner shows). Each opens the report. +5. Repeat with `{"taskId":"","notificationKind":"awaiting"}` for a task linked to a report, and for one without a report (opens its chat). +6. For real server pushes, trigger a task run started or actively continued in mobile. To test report pushes, also enable `posthog-code-mobile-push` for your user and trigger a new report or report update. +7. Sign out and check that `POST …/push_tokens/unregister/` ran (server logs or the token row is gone). diff --git a/products/mobile/docs/RELEASING.md b/products/mobile/docs/RELEASING.md new file mode 100644 index 000000000000..3b9d73b8134c --- /dev/null +++ b/products/mobile/docs/RELEASING.md @@ -0,0 +1,323 @@ +# Releasing + +Use **Mobile Release** for native store builds and production OTA updates after a release request merges to `master`. +Native releases stop at TestFlight and the Play internal track; public-store promotion follows testing. +Use **Mobile Build** for development and preview builds. Both workflows are documented in [CI.md](CI.md). +Over-the-air JavaScript updates are in [OTA.md](OTA.md). The CI workflows are in [CI.md](CI.md). + +## Request a hosted release + +Production releases run in GitHub Actions after a release request merges to `master` and an authorized reviewer approves the `mobile-production` deployment. +Add the request to the same PR as the app changes. Do not publish production builds or updates from a local checkout. +The implementation includes no active request, so merging the release tooling alone publishes nothing. + +From `products/mobile`, choose one: + +```sh +# First hosted release: raise version in app.config.ts above 1.4.0 first. +pnpm release:request native --id native-150 --initial --notes "Describe the changes testers should verify." + +# Later native release: raise version again; name the last completed request. +pnpm release:request native --id native-160 --previous native-150 --notes "Describe the native changes." + +# JavaScript/assets only: keep the installed native runtime version. +pnpm release:request ota --id chart-legend-fix --previous native-160 --notes "Fix the chart legend." + +pnpm release:check +pnpm release:test +``` + +Commit the generated `releases/requests/.json` with the source changes. Review its kind, version, predecessor and release notes. +IDs use lowercase letters, digits and hyphens. Requests are append-only: never edit, rename or delete one after merging it. +Only one new request may appear in a master push. `previousRelease` names the last completed request, including OTA requests. A corrective request can explicitly follow that receipt when an earlier request failed before publication; the failed manifest remains historical and inert. +The template at `releases/request.json.example` is inert and may also be copied and filled in manually. +Release notes and receipts are public; keep customer data, credentials and private operational details out of them. + +`native` produces both production binaries, waits for both builds to finish, then submits those exact build IDs to TestFlight and Play internal testing. +It never selects `--latest`. The job waits for EAS submission to finish; Apple can still need processing time before TestFlight makes the build available. +Public App Store review and Play production promotion remain manual after testing. + +`ota` requires a successful hosted native release first. The request version must match the current app version and that native receipt's runtime. +The workflow compares Expo fingerprints for both platforms against the recorded native baseline. +Native build results must also provide matching EAS fingerprints before either binary is submitted. +A new native dependency, native configuration change, widget/plugin change or other fingerprint difference blocks OTA and requires a native request with a higher app version. +Remote EAS variables that affect native configuration must stay stable for a runtime; change them only with a new native release. +The first hosted native release must raise `version` above the existing `1.4.0` baseline in `releases/config.json`. +This deliberately establishes new binaries and a verified baseline instead of guessing compatibility with older manually built apps. + +### What runs after merge + +1. `Mobile Release` checks the exact master push diff. It selects only the newly added request, rather than sweeping historical requests. +2. The reusable `Mobile CI` runs typecheck, lint, Jest, release metadata validation and release policy tests on that exact commit. The simulator demo flows do not run in CI; run them locally before you merge. +3. An authorized reviewer approves the exact commit in the protected `mobile-production` GitHub environment. The publisher then loads the EAS `production` environment, fixes `APP_VARIANT=production` and disables the signup link. +4. Before contacting EAS to build or publish, it creates a durable draft GitHub receipt at `mobile-release/`. This claims the request so an interrupted retry cannot duplicate a release. +5. A `mobile-staging` deployment records the stage. Native requests build production binaries with the pinned public OTA certificate; OTA requests sign and publish to an unlinked `mobile-staging-` EAS branch. +6. Native requests submit the completed binaries. OTA requests sign and republish the exact staged group to the production channel; they do not rebuild the bundle during promotion. +7. The receipt records the source commit, request, staging deployment, build IDs or update groups, and the native runtime/fingerprints and OTA certificate fingerprint. It becomes a GitHub prerelease, and the dedicated annotated `mobile-production` tag points at the completed source. + +The workflow runs only on trusted master pushes in `PostHog/posthog`. Pull requests have no publishing step or Expo token. +Development/preview builds remain manually available from `master` through `Mobile Build`; production is handled by release requests. +The package's old production build, submit and update commands stop with a hosted-release instruction. + +### One-time hosted release setup + +Keep the EAS credentials and store setup below. Also: + +- Protect the `mobile-production` GitHub environment with **required reviewer Chris Volzer (`@cvolzer3`)**, allow self-review so the release owner can explicitly approve their own requests, disable administrator bypass, and restrict deployments to `master`. Review the request and full source diff of the exact deployment commit before approval. The workflow's `environment` field does not configure these server-side protections: a repository administrator must set them before any release request merges. +- Store a dedicated `MOBILE_PRODUCTION_EXPO_TOKEN` only in `mobile-production`. Create a separate `mobile-build` environment restricted to `master`, with a different `MOBILE_BUILD_EXPO_TOKEN` environment secret for internal builds. Do not create repository or organization secrets under these names; that would make them available outside their environment. Neither workflow falls back to the shared `EXPO_TOKEN`. Remove or revoke the inherited shared `EXPO_TOKEN` access to this production EAS project; changing these workflow references does not revoke an organization secret or its Expo authority. Coordinate any other workflows that still use it. Use the narrowest Expo account/project access available for the new tokens. Separate tokens do not impose EAS channel or build-profile authorization: both workflows also require trusted master source, and production OTA has an independent signing key. +- Provision OTA signing as described below before the first hosted native release. The public certificate belongs in the repository; the private key belongs only in the protected `mobile-production` environment. Native build jobs and dependency installation do not receive the private key. +- Permit the workflow's `GITHUB_TOKEN` to create releases/tags and deployment records. The workflow requests only contents and deployments write access in its publishing job. +- Keep production build-time variables readable as Plain text or Sensitive. The publisher audits both project variables and linked account variables before claiming a request; any Secret value blocks the release because it cannot be included in the compatibility check. Native signing/submission keys belong in EAS credentials; the OTA signing key belongs in the protected GitHub environment. Neither belongs in these variables. +- Set `GOOGLE_SERVICES_JSON` to a **Sensitive file** variable. The publisher reads its base64 file content through the authenticated Expo API, writes an ignored file with owner-only permissions, and uses it during fingerprinting and configuration. Neither contents nor other variable values appear in receipts or artifacts. Secret files, unreadable file values and other file-variable names fail closed before an EAS build or OTA publish. Native EAS fingerprint results must match these materialized inputs. +- The native environment is audited and fingerprinted again after staging. A change to variable names, types or contents blocks production publication, including removed values. Snapshot hashes stay in memory. Variables that overwrite GitHub identity, credentials or the runner command environment are rejected before any values are applied; the script imports the audited environment itself. +- Complete the initial Play Console app/signing/internal-tester setup before requesting the first Android submission. Confirm the production iOS App Store Connect record and stored API key. +- Stop using the older Desktop Mobile Build/Promote workflows for this iOS store identity. The release request flow does not retire those separate workflows. + +### OTA signing setup and rotation + +An operator must provision the signing material; no production key or certificate is generated by this implementation. +Follow [Expo's code-signing guide](https://docs.expo.dev/eas-update/code-signing/) using an account with the required EAS plan. +Generate an RSA key of at least 2048 bits outside this checkout. Commit only its public certificate as `releases/ota-certificate.pem`; keep the private key in the team's secure key store and as the `MOBILE_OTA_SIGNING_KEY` PEM secret only in `mobile-production`. +Never put it in EAS variables, the app directory, artifacts or a repository/organization secret. + +Production config pins that certificate with key id `main` and algorithm `rsa-v1_5-sha256`. +Without the certificate, local development still works with production OTA disabled, and hosted native/OTA releases fail before claiming a request. +The publisher rejects an invalid or expired certificate and requires its matching private key before OTA staging. +Only an approved OTA publishing step writes the private key to an owner-readable temporary file outside the app upload; it deletes the file on exit. +Staging and promotion both pass the key to EAS, which signs the manifests locally. The native client verifies updates against its embedded certificate before applying them. + +The first signed release must be **native**, with a version above the unsigned `1.4.0` baseline. Existing unsigned binaries must upgrade through TestFlight or the stores; this change cannot add verification to them through OTA. +The native receipt records the certificate fingerprint. An OTA request must match it as well as the native fingerprints; a receipt without a certificate fingerprint cannot authorize an OTA. +Rotate the key and certificate together, retain the old material securely for older runtimes, and raise the app version in a new native release before publishing updates with the new key. + +### Receipts, ordering and retries + +The workflow run and `mobile-production` deployment report its outcome. The `mobile-staging` deployment records the build/update staging outcome. +The GitHub prerelease `mobile-release/` contains a machine-readable JSON receipt; the Actions run also retains a receipt artifact, including a claimed receipt if publishing fails. +A completed request rerun does not repeat EAS publication. If only the production pointer update failed, rerunning repairs it without resubmitting. + +GitHub keeps at most one pending job in a concurrency group. When several requests merge quickly, a newer pending job can replace an older one. +The predecessor check prevents the newer request from skipping it: it fails before claiming or publishing, with the missing predecessor named in its request. +Rerun the original predecessor workflow, then rerun its successor. Keep one release outstanding at a time for the simplest operation. +No run automatically changes its checkout to a newer master commit. + +If a request has a draft/claimed receipt, **do not delete it and blindly rerun**. A timeout may have happened after EAS accepted an upload or submission. +Use its source commit, run URL, staged build IDs/group and EAS message (`mobile-release:@`) to reconcile both platforms in EAS and the stores. +If nothing reached EAS, an operator may remove the draft receipt and rerun the original workflow. +If publication happened, repair that receipt and production pointer from the verified EAS results, without resubmitting; retain the claim until reconciliation is complete. +A partially submitted native release needs operator recovery of the unfinished platform using its recorded build ID, rather than a new automatic build. +A corrective request may follow the last completed receipt only when the failed request was never claimed. Any sibling request with a receipt blocks that correction until its publication or production pointer has been reconciled. +These recovery operations require release access and are exceptional; routine publication stays in GitHub Actions. + +## Store records this app uses + +This app replaces the existing "PostHog" iOS app and uses a new Google Play app record. +The iOS store build keeps the existing identity, so an update replaces the old app on people's phones. +Android uses `com.posthog.mobile`, which installs separately from the older `com.posthog.code.mobile` app. +Development and preview builds use their own identities, see [Build variants](#build-variants). + +| Field | Value | +| --------------------------------- | -------------------------------------- | +| App name | PostHog | +| iOS bundle id | `com.posthog.code.mobile` | +| Android package | `com.posthog.mobile` | +| Expo owner and slug | `posthog` / `posthog` | +| EAS project id | `4eb57b2f-79f6-4ab0-ae2c-bbd82af2cef9` | +| App Store Connect app id | `6762366098` | +| Google Play track for submissions | `internal` | + +The old app's source is `products/desktop/apps/mobile`, and its workflows are "Desktop Mobile Build" and "Desktop Mobile Promote". +Both iOS apps share the same store record and remote build-number counter. +Stop building the old iOS app before the first build of this one goes to the store. + +The old app shipped as version `1.0.0`. This app starts at `1.1.0`, so its builds do not collide with a released `1.0.0` in App Store Connect. +The version also sets the runtime version for updates, see [OTA.md](OTA.md#runtime-version). + +Version `1.1.0` adds a native module, Skia, for the report charts. +The runtime version follows the app version, so an update published for `1.1.0` never reaches a `1.0.0` build. +Every EAS profile (development, preview and production) needs a new native build before it can run `1.1.0` code. + +Version `1.2.0` adds native modules for chat attachments: `expo-image-picker` and `expo-document-picker`. +`expo-file-system` becomes a direct dependency; it was already linked through `expo`, so it is not a new native module. +The image picker's config plugin writes the photo library usage description and removes the camera and microphone permissions. +Every EAS profile needs a new native build before it can run `1.2.0` code. +Do not publish `1.2.0` code as an update to `1.1.0` builds: the runtime versions differ, so those builds never receive it, and the picker modules are not in them. + +Version `1.3.0` adds `expo-system-ui`, automatic native appearance and a dark launch screen. +Every EAS profile needs a new native build to include these changes. +JavaScript-only updates for existing `1.2.0` and `1.2.1` builds must retain their native dependencies and configuration. + +## Build profiles + +Profiles are in `eas.json`. Each profile has its own update channel and EAS environment. + +| Workflow profile | Signing | Who installs it | Update channel | +| ---------------- | ------------------------------------- | ------------------------------------------------ | -------------- | +| `development` | Ad hoc (iOS), APK (Android) | Developers, with Metro for live reload | `development` | +| `preview` | Ad hoc (iOS), APK (Android) | Internal testers on registered devices | `preview` | +| `production` | App Store (iOS), app bundle (Android) | TestFlight, Play internal track, then the stores | `production` | + +`appVersionSource` is `remote`, so EAS stores the iOS build number and the Android version code. +Production builds raise them automatically (`autoIncrement`); the public app/runtime version still needs a deliberate bump in each native release PR. +Check the current values with `eas build:version:get`. + +Mobile Build runs `scripts/eas-build.sh`; Mobile Release uses the hosted release helper. +Both upload only `products/mobile`, filtered by `.easignore`. +Without it, EAS uploads the whole repository and installs the root pnpm workspace, which excludes this app. + +The hosted publisher accepts only these EAS production inputs: `APP_VARIANT`, `APPLE_TEAM_ID`, +`GOOGLE_SERVICES_JSON`, `EXPO_PUBLIC_POSTHOG_KEY`, `EXPO_PUBLIC_POSTHOG_HOST`, +`EXPO_PUBLIC_DEMO_MODE`, `EXPO_PUBLIC_SIGNUP_LINK`, `EXPO_PUBLIC_OAUTH_US_CLIENT_ID`, and +`EXPO_PUBLIC_OAUTH_EU_CLIENT_ID`. It refuses unrecognized names before importing any values. +Adding an app build input requires updating the publisher's allowlist. The publisher always sets +`APP_VARIANT=production` and `EXPO_PUBLIC_SIGNUP_LINK=0`. + +## Build variants + +Each profile sets `APP_VARIANT` in its `env` block in `eas.json`, and `app.config.ts` gives each variant its own identity. +The three variants install side by side on one device. They do not share sign-in, settings or the widget's data. +The names, id suffixes and schemes are in `src/lib/appVariants.json`. + +| Variant | Profile | App name | iOS bundle id | Android package | URL scheme | iOS App Group | Icon | +| ------------- | ------------- | --------------- | --------------------------------- | ---------------------------- | ----------------- | --------------------------------------- | ------------------------------------------ | +| `development` | `development` | PostHog Dev | `com.posthog.code.mobile.dev` | `com.posthog.mobile.dev` | `posthog-dev` | `group.com.posthog.code.mobile.dev` | `icon-dev.png`, amber DEV band | +| `preview` | `preview` | PostHog Preview | `com.posthog.code.mobile.preview` | `com.posthog.mobile.preview` | `posthog-preview` | `group.com.posthog.code.mobile.preview` | `icon-preview.png`, purple PREVIEW band | +| `production` | `production` | PostHog | `com.posthog.code.mobile` | `com.posthog.mobile` | `posthog` | `group.com.posthog.code.mobile` | `app.icon` on iOS 26, `icon.png` elsewhere | + +- The widget extension's bundle id is the app's id plus `.HedgehogWidget`, for example `com.posthog.code.mobile.dev.HedgehogWidget`. +- The production profile also sets `EXPO_PUBLIC_SIGNUP_LINK=0`, so store builds hide "Create an account". +- In the app, `src/lib/environment.ts` reads the variant from the bundle id, so an update cannot change it. + A Metro bundle always counts as development. +- Development and preview builds show an environment ribbon on the sign-in screen and in Settings, and a Settings Environment group. + They accept HTTP on `localhost`, `127.0.0.1` and `[::1]`, and prefill the local dev OAuth client id. Store builds do none of this. + Android's release network policy allows those exact loopback hosts only in development and preview variants; production release binaries deny cleartext. Local debug builds retain Expo's cleartext allowance for Metro on the local network, including with the store identity. A native rebuild is needed to change either policy. See [Auth](AUTH.md#redirect-uris) for port forwarding. + A store build shows a red ribbon only when the session's host is not PostHog Cloud. + +## One-time setup + +Someone with these accounts does each step once. An agent cannot do them. + +1. Get access: + - Membership of the `posthog` organization on [expo.dev](https://expo.dev). + - The App Manager or Admin role for the PostHog app in App Store Connect, in the PostHog Apple Developer team. + - Release manager access to the PostHog app in the Google Play Console. +2. Install the EAS CLI and sign in: + + ```sh + npm install -g eas-cli + eas login # or: npx eas-cli login + eas whoami + ``` + +3. Check that this directory links to the existing EAS project: + + ```sh + cd products/mobile + eas project:info # expect @posthog/posthog and id 4eb57b2f-79f6-4ab0-ae2c-bbd82af2cef9 + eas init # expect "already linked"; if it offers to create a project, stop and check your account + ``` + +4. Check the signing credentials with `eas credentials`. + Reuse the existing iOS distribution certificate and provisioning profiles. + Set up an Android upload keystore for `com.posthog.mobile` before its first build. + Reuse that keystore for subsequent releases: Google Play rejects bundles signed with a different upload key. +5. Register the internal identities from [Build variants](#build-variants) in the Apple Developer account, under Certificates, Identifiers & Profiles: + - App IDs `com.posthog.code.mobile.dev`, `com.posthog.code.mobile.dev.HedgehogWidget`, `com.posthog.code.mobile.preview` and `com.posthog.code.mobile.preview.HedgehogWidget`. + - App Groups `group.com.posthog.code.mobile.dev` and `group.com.posthog.code.mobile.preview`. + - Turn on the App Groups capability for all four App IDs and select the group of the same variant. Turn on Push Notifications for the two app ids. + + Configure the matching ad hoc provisioning profiles in EAS before starting the workflow. + Check that each App ID has its App Group linked. A missing group leaves the widget without the app's data. + Android needs no registration for these packages, because development and preview builds are APKs outside Google Play. + +6. Add the store submission keys in `eas credentials`: + - iOS: an App Store Connect API key, under the production profile. + - Android: a Google Play service account key with release access. +7. Set up push for each variant as [PUSH.md](PUSH.md#credentials) describes: the APNs key for each iOS bundle id, a Firebase Android app for each package, the FCM V1 service account key in `eas credentials`, and the `GOOGLE_SERVICES_JSON` file variable in each EAS environment. +8. Add the build-time variables to each EAS environment (`development`, `preview`, `production`). + They are public in the app bundle, so plain text visibility is correct: + + ```sh + eas env:set production --name EXPO_PUBLIC_POSTHOG_KEY --value --visibility plaintext + eas env:set production --name EXPO_PUBLIC_POSTHOG_HOST --value https://us.i.posthog.com --visibility plaintext + ``` + + Do not add `APP_VARIANT` or `EXPO_PUBLIC_SIGNUP_LINK` here. `eas.json` and the hosted publisher set them. + +9. Create separate non-first-party public OAuth applications in US and EU for `posthog-dev://callback` and `posthog-preview://callback`. Set their IDs as `EXPO_PUBLIC_OAUTH_US_CLIENT_ID` and `EXPO_PUBLIC_OAUTH_EU_CLIENT_ID` in both EAS internal environments. Production first-party applications must keep only `posthog://callback`; remove internal callback URIs if already added. See [AUTH.md](AUTH.md#redirect-uris). +10. Complete the protected environments and separate token setup in [One-time hosted release setup](#one-time-hosted-release-setup). The desktop mobile workflows' shared `EXPO_TOKEN` is not used by these workflows. + +## Install a development build on an iPhone + +A development build is the app with the Expo dev client. +It loads JavaScript from Metro on your Mac, so you rebuild it only when native code changes. + +1. Register the iPhone: + + ```sh + cd products/mobile + eas device:create + ``` + + Open the link or scan the QR code on the iPhone and install the profile it offers. + On iOS 16 and later, turn on Developer Mode in Settings, Privacy & Security. + +2. Include the device in the development provisioning profile in EAS. Start **Mobile Build** from + GitHub Actions with profile `development` and platform `ios`. +3. When the build finishes, open the EAS build page on the iPhone and tap Install. +4. Start Metro on your Mac and open the app on the iPhone: + + ```sh + pnpm start --dev-client + ``` + + The iPhone and the Mac must be on the same network. + The app lists the running server. You can also scan the QR code from the terminal with the camera. + +For Android, use the same workflow with profile `development` and platform `android`, then install the APK from the EAS build page. +No device registration is needed. + +The development build installs as "PostHog Dev" next to the store app, and shows an amber "Dev build" ribbon on the sign-in screen and in Settings. + +Rebuild the development build after a change to native code: +a new native module, a config plugin, or a native field in `app.config.ts`. + +## Share a preview build + +Preview builds have the release JavaScript bundled in, and they take updates from the `preview` channel. +Use them to test a change before it goes to TestFlight. + +Start **Mobile Build** in GitHub Actions with profile `preview` and the platforms to test. +Testers install from the EAS build page. iOS testers need a registered device. + +## Ship to TestFlight and the Play internal track + +Only production builds can go to TestFlight. Preview builds use ad hoc signing, which App Store Connect does not take. +Google Play also uses the `production` build profile: it creates an app bundle with the store package, +while `preview` creates an APK with a separate package for direct installation. + +For the first Google Play release, create a free app record for `com.posthog.mobile` in Play Console. +An account owner must approve the policy, Play App Signing and export declarations. +On **Testing > Internal testing**, create and select a tester email list, then upload the production `.aab` +under **Create new release**. Review and publish to the internal track, then share the opt-in link from +the **Testers** tab. Tester email addresses must belong to Google accounts. +The store listing and other setup tasks can be completed before expanding beyond internal testing. +See [Google Play internal testing](https://support.google.com/googleplay/android-developer/answer/9845334) +and [Expo manual submission](https://docs.expo.dev/submit/android-manual/). + +Add a `native` release request to the app PR as described above. Once it merges and the hosted checks pass, +`Mobile Release` builds both platforms and waits for submission to TestFlight and the Play internal track. +Use the receipt's exact build IDs to follow progress in EAS and the stores. + +After the iOS upload, App Store Connect processes the build for 5 to 30 minutes before it shows in TestFlight. + +## Release to the stores + +1. Test the TestFlight build and the Play internal build. +2. In App Store Connect, add the build to a new version, write the release notes, and submit it for review. +3. In the Play Console, promote the internal release to production. +4. Keep the released version while shipping compatible OTA fixes. Raise `version` in the PR for the next native release, so that request establishes a new runtime. + +## Before you share a build + +Complete the [mobile app release checks](../../../docs/internal/mobile-release-checks.md), including the demo walkthrough and real-account checks on each platform being shared. Hosted checks validate code and release policy; they do not verify store credentials, real sign-in, push delivery or physical-device behavior. diff --git a/products/mobile/docs/TESTING.md b/products/mobile/docs/TESTING.md new file mode 100644 index 000000000000..c03c8ea2a267 --- /dev/null +++ b/products/mobile/docs/TESTING.md @@ -0,0 +1,258 @@ +# Testing + +## Checks + +```sh +cd products/mobile +pnpm check:expo +pnpm typecheck +pnpm lint +pnpm test +``` + +`pnpm check:expo` compares dependencies with the installed Expo SDK's bundled compatibility metadata, rather than newer remote patch metadata. +`pnpm typecheck` includes this compatibility check automatically. +CI runs typecheck, lint, and tests; see [CI.md](CI.md). + +## Authenticated local checks + +Run the setup in [README.md](../README.md#sign-in-to-the-local-backend) first. +Until the backend changes merge, the commands below run from the paired `codex/mobile-dev-environment` checkout; the mobile development build runs from `codex/mobile-app`. +The backend needs migrated Postgres, object storage, and the normal local services. +Use the same development environment and `SITE_URL` as the running backend. + +```sh +python manage.py setup_local_dev --json +python manage.py check_local_dev --json +``` + +The first command creates the usual test account only when absent, keeps an existing account and project, registers local OAuth callbacks, and inserts repeatable personal Inbox and Chat examples. +It does not reset the database or start a real agent. +Keep the returned project and object IDs for your native checks. +An existing account with a custom password is preserved and needs its existing sign-in details. +For another existing account, seed with `python manage.py seed_local_inbox_data --team-id --user-id --json`. + +The second command checks the default test account over HTTP: password login, normal CSRF-protected consent, PKCE code exchange for the registered `posthog-dev://callback`, bearer-authenticated account/project/Inbox/Chat reads, refresh, and revocation. +Use `--callback posthog://callback` or `--callback posthog-preview://callback` to check another registered mobile scheme. +It prints a safe summary and revokes the temporary OAuth session. +It does not exercise the native browser sheet, device callback delivery, app storage, or screen rendering. + +On an iOS simulator or Android emulator with a development build: + +1. Leave demo mode and sign out. Choose **Self-hosted** with the local backend URL and prefilled local Client ID. +2. Sign in as `test@posthog.com` with `12345678`, approve access, and verify the browser returns to the app through its variant's callback scheme. +3. Confirm the account and project match the setup output. Open the ready and pending-input Inbox reports, their details, and the completed Chat. The stored user and assistant messages should load without starting a task run. +4. Open Views, Errors, and Replay. Confirm saved views and errors load for the same project. A playable replay needs the real recording step below. +5. Switch to another project available to the account, then back. Each Inbox, Chat, and saved view list must belong to the selected project. If the account only has one project, create another in the local web app before this check. +6. Background and reopen the app. Verify the account and project remain selected. Sign out and verify returning to the app requires sign-in again. + +Use the backend HTTP check to exercise refresh and revocation without waiting for a native access token to expire. +Only record a native sign-in pass after running the device steps. + +### Capture real local session recordings + +Replay needs actual browser snapshots as well as analytics events. +Install the Hedgebox dummy package dependencies first, then run `pnpm exec playwright install chromium` from `tools/hedgebox-dummy` if the browser is missing. +In the paired backend checkout, run the Hedgebox dummy with the project token shown in that selected project's settings: + +```sh +cd tools/hedgebox-dummy +NEXT_PUBLIC_POSTHOG_KEY= \ +NEXT_PUBLIC_POSTHOG_HOST=http://localhost:8010 \ +pnpm exec next dev --port 3000 +``` + +In another terminal at that backend checkout's root, run: + +```sh +NEXT_PUBLIC_POSTHOG_KEY= \ +NEXT_PUBLIC_POSTHOG_HOST=http://localhost:8010 \ +node products/demo/scripts/capture_sessions.cjs --app-url http://localhost:3000 --sessions 3 +``` + +The collector checks the SDK uses the selected token and local host, interacts with the dummy, and waits for real snapshot requests to be accepted. +Its `snapshots_accepted_pending_ingestion` result means capture succeeded; it does not prove replay is playable yet. +Keep ingestion, ClickHouse, and replay storage running, wait for the returned session IDs to appear in the local web app, then play one there and in the mobile Replay screen. +If a session is missing, inspect local capture, ingestion, and replay storage logs before recording a playback pass. +The dummy's normal development shortcut can choose the latest project; the explicit command above keeps the SDK on the account's selected project. + +## Chat drafts and rich replies + +Use a native development build that includes `react-native-webview`. + +1. Type a draft in an existing chat, a new chat and a report discussion. Close and reopen each, + then restart the app. Each composer should restore its own text and report attachments. +2. Send a draft successfully, then reopen the chat. The sent text should be gone. Fail a send + and verify the draft returns; switching chats before that request ends must not copy it + into another chat. Sign out and verify drafts are gone after signing back in. +3. Render an inline insight tag, a block insight tag and a block SQL tag. Verify links open the + current host and project, and Show all expands SQL without opening the browser. Test a + missing insight and an account without `insight:read` too. +4. Render a complete Mermaid flowchart and a wide diagram. Verify the diagram height and + horizontal scrolling. Incomplete fences, invalid diagrams and remote-image nodes should + stay as code. The web preview intentionally shows Mermaid source. +5. Delay or fail read-state lookups. Rows must not show unread dots from missing or stale + responses. Read a report elsewhere and wait for the foreground inbox refresh. Opening a + report that fails to load must not mark it read. + +## Report refresh checks + +Open a report, scroll down, background the app, then return. The report and scroll position must stay +visible while a small spinner beside Previous/Next shows the refresh, then fades away without moving +the navigation controls. Repeat with an unavailable Today page or signals endpoint, and with a failed +background request. A first load without cached data still shows the full loading state. + +## Notification checks + +Run `pnpm test --watchman=false --runInBand src/features/notifications` for foreground delivery sequences +and cold-start tap routing. The tests exercise the installed notification handler as well as the policy: +viewed reports, cached task associations, active/expired snoozes, repeated terminal events, distinct questions, +project/account boundaries and a tap received before sign-in. + +On a development build with notifications enabled: + +1. Keep a report open and deliver a push for that report (then one for its cached task). Neither should show + a foreground banner or notification-list entry. Deliver a push for a different report: it should appear. +2. Leave the report and deliver two completion pushes with different OS identifiers but the same project, + task, run and `notificationKind: completed`. Only the first should appear. A new run should still appear. +3. Time-snooze a report and deliver a report push while elsewhere in the foreground. It should stay quiet. + After expiry, the snooze reminder should appear. Repeat using a task already associated in the cache. +4. Deliver two distinct `awaiting` notifications for one run: neither should be lost to a run-wide cooldown. +5. Tap a notification while signed out, sign in, and verify that its report opens once. + For chats, open a conversation, enter a draft, background the app and tap a push for that chat. + Verify the draft and scroll position stay intact. Repeat with a chat just started in the composer. + Tap a push for a different chat and verify its history loads; one Close must return to the screen + underneath. Also tap New chat, then tap a push for the conversation you just left; it must reopen. +6. Repeat a delivery with the app backgrounded or terminated. App-side filtering does **not** provide the same + guarantees there; server consolidation and synchronized snoozes remain follow-ups in [GAPS.md](GAPS.md). + +## Demo walkthrough on the iOS simulator + +`.argent/flows/demo-smoke.yaml` drives the demo end to end with [Argent](https://docs.expo.dev/agents/argent/), a device automation CLI from Software Mansion. +The flow needs no account, because every build shows **Try the demo** on the sign-in screen. +The flow: + +1. taps **Try the demo** on the sign-in screen, +2. opens "Report load time", opens and closes the hand-off sheet, +3. resolves the report as fixed outside PostHog and checks that its inbox row is gone, +4. opens "3,721 errors", taps **Needs your answer**, picks an answer in the decision sheet, then taps Undo, reopens the question and closes its sheet, +5. opens Chat from the action pill, sends a question and waits for the demo reply, +6. goes back, opens Chats, opens the session "Fix the flaky checkout test", and checks its title and reply, +7. opens "Add dark mode to settings" and waits for its decision card, +8. goes Home and taps **Exit demo** in Settings. + +It starts and ends on the sign-in screen with demo mode off, so you can run it again right away. +Actions use the app's `testID`s, except the hand-off sheet drag, which starts on its "Choose an agent" header. + +### Set up once + +1. Install the pinned Argent version: `npm install -g @swmansion/argent@0.30.1`. +2. Install ffmpeg if you want screen recordings: `brew install ffmpeg`. +3. Install Xcode with an iOS simulator runtime, and CocoaPods. + +### Run the flow + +```sh +cd products/mobile +pnpm ios --port 8181 # builds the dev client, boots a simulator, starts Metro +``` + +Prepare the development build: + +1. On a fresh install, the dev client shows its developer menu once. Tap **Continue** and close it. +2. Open the developer menu (Device, Shake in the Simulator menu, or ⌃⌘Z) and turn off: + - **Tools button**. The floating gear covers the inbox More button. + - **Fast refresh**. When files change, its "Refreshing..." banner covers the Back and Close buttons. +3. Demo mode must be off. If the app opens on the inbox, open Settings and tap **Exit demo**. + +Then run the flow from `products/mobile`, because Argent looks for `.argent/flows` in the current directory: + +```sh +argent flow run demo-smoke --platform ios +``` + +A pass prints one line per step and exits with code 0. Locally, `demo-smoke` took about 49 seconds and +`views-smoke` about 80 seconds on Xcode 27.1 / iOS 27.0. A fresh native compile took about 8 minutes, +with simulator setup adding more time. +If more than one simulator is booted, add `--device ` (see `xcrun simctl list devices booted`). + +Two `idle` steps can warn that a small part of the screen was still changing, such as the text caret. +The step after each one waits for a stable element, so these warnings are expected. + +### Demo flows before you merge + +CI does not run the simulator flows. Run `demo-smoke` and `views-smoke` locally before you merge a change to a screen, +navigation, the demo fixtures, or a flow. The `/running-mobile-demo-flows` skill in `.agents/skills/` walks an agent +through the same steps and the result files. + +The scripts below build a bundled Release app with no Metro connection. Argent reads the native view hierarchy, +so the flows do not need a development build or its overlays. + +The build disables analytics and over-the-air updates. The script creates a temporary iPhone 17 Pro simulator, +installs the app again before each flow, and deletes the simulator on success, failure, or interruption. +Only demo fixtures are used; no PostHog account, API key, Expo token, or Apple signing credentials are needed. +Both flows run even when the first fails. JSON reports, failed-step screenshots, and build logs land in the +artifact directory you pass. + +Install the app dependencies, CocoaPods, and Xcode 26.6 with the iOS 26.5 runtime, +then run from `products/mobile`. A dedicated Argent install keeps the runner separate from an editor's server: + +```sh +DO_NOT_TRACK=1 npm install --prefix /tmp/mobile-argent-tools --no-audit --no-fund @swmansion/argent@0.30.1 +export PATH="/tmp/mobile-argent-tools/node_modules/.bin:$PATH" +bash scripts/ci-argent-build.sh /tmp/mobile-argent-build /tmp/mobile-argent-artifacts +bash scripts/ci-argent.sh \ + /tmp/mobile-argent-build/Build/Products/Release-iphonesimulator/PostHog.app \ + /tmp/mobile-argent-artifacts +``` + +The build regenerates the ignored `ios/` project, so run it in a disposable checkout if you have manual native edits. +To select another installed simulator runtime, set +`ARGENT_IOS_RUNTIME` to its identifier from `xcrun simctl list runtimes`, for example +`com.apple.CoreSimulator.SimRuntime.iOS-27-0`. Simulator-only builds do not sign or publish anything. +Argent's [e2e reference](https://docs.swmansion.com/argent/docs/reference/e2e-yaml/) documents the native +selectors and the `--output` reports. The older `argent flow` command remains an alias for `argent e2e`. + +### Record a video + +```sh +argent run screen-recording-start --udid --timeLimitSeconds 120 +argent flow run demo-smoke --platform ios --device +argent run screen-recording-stop --udid +``` + +`screen-recording-stop` prints the path of the mp4. +Argent writes it under `.argent/recordings/` in the directory where its tool server started, which can be the repo root. Move the file out before you commit. + +For a still image, run `xcrun simctl io booted screenshot .png`. + +### Use Argent from an agent session + +Run `argent init` once from the repo root. It registers Argent's MCP server and skills for coding agents. +Do not run `argent init --local`: it edits `package.json`. + +An agent can then: + +- replay the flow with the `flow-execute` tool (`name: demo-smoke`, `project_root`: the absolute path of `products/mobile`), +- find elements with `describe` or `debugger-component-tree`, and tap them with `gesture-tap`, +- read console output with `debugger-log-registry`. + +Without the MCP server, the same tools work from a shell: `argent tools`, `argent tools describe ` and `argent run --args '{...}'`. + +To change the flow, record the new steps live with `flow-start-recording`, `flow-add-step` and `flow-finish-recording`, then edit the YAML. +Argent's `argent-create-flow` skill describes the rules. Replay the whole flow before you commit it. + +### Troubleshooting + +- **"There was a problem loading the project" with an address like `http://10.x.x.x:8181`.** + The dev client tried the Mac's network address. Open the local address instead: + + ```sh + xcrun simctl openurl booted "exp+posthog://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8181" + ``` + +- **The app closes at launch, and the log says "UIScene life cycle is required".** + The `ios/` folder was generated before `plugins/withIosSceneLifecycle.js` existed. Run `npx expo prebuild --platform ios --clean`, then `pnpm ios` again. +- **A `visible` check passes while a sheet covers the screen.** + The screen under a sheet stays in the accessibility tree. Check that the sheet is `hidden` first, as the flow does. +- **iOS asks "Open in PostHog?"** after `xcrun simctl openurl`. Tap Open. The question does not come every time. diff --git a/products/mobile/docs/VIEWS.md b/products/mobile/docs/VIEWS.md new file mode 100644 index 000000000000..1500de68c7f5 --- /dev/null +++ b/products/mobile/docs/VIEWS.md @@ -0,0 +1,310 @@ +# Views + +The Views tab shows the dashboards, insights and web analytics a person checks on the phone. +This file covers the data layer under `src/features/views/`, `src/api/` and `src/model/adapters/`. +Screens render the view models in `src/model/views.ts` and never call the API themselves. + +## Requests + +| Use | Request | Scope | Module | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------ | +| Pin list | `GET file_system_shortcut/?limit=100` | `file_system_shortcut:read` (from `:write`) | `src/api/shortcuts.ts` | +| Pin, unpin | `POST file_system_shortcut/`, `DELETE file_system_shortcut/{id}/` | `file_system_shortcut:write` | `src/api/shortcuts.ts` | +| Dashboard | `GET dashboards/{id}/?refresh=force_cache[&filters_override=]` | `dashboard:read` | `src/api/dashboards.ts` | +| Browse and Recent dashboards | `GET dashboards/?ordering=-last_viewed_at&limit=30&offset=`, `limit=10` for Recent, `search=` | `dashboard:read` | `src/api/dashboards.ts` | +| Insight | `GET insights/{id}/?refresh=blocking\|force_blocking\|force_cache[&from_dashboard=][&filters_override=]` | `insight:read` | `src/api/insights.ts` | +| Insight metadata for previews | `GET insights/?short_id={short_id}&basic=true&refresh=force_cache` | `insight:read` | `src/api/insights.ts` | +| Browse insights | `GET insights/?basic=true&saved=true&order=-last_modified_at&limit=30&offset=`, `search=` | `insight:read` | `src/api/insights.ts` | +| Insight opened | `POST insights/viewed/ {insight_ids}` once per open | `insight:read` | `src/api/insights.ts` | +| Web analytics, compare, people at a point | `POST query/` with `refresh: blocking` or `force_blocking`, never polled | `query:read` | `src/api/webAnalytics.ts`, `src/api/actors.ts`, `src/api/query.ts` | + +Cache reads send `refresh=force_cache` explicitly. +The app never sends `async_except_on_cache_miss`, which the insight endpoint turns into a cache-only read. + +## Pins + +Pins are the web's starred rows (`FileSystemShortcut`, default surface), so a phone pin shows as a star on the web. +`shortcutInputFor()` writes the exact `path`, `type`, `ref` and `href` the web writes, so the server dedupes the row. +Rows for one view (a sibling environment, a rename) collapse into one pin, and unpin deletes all of them. +The Views list owns the pin list reads: on first paint unless read under a minute ago, on focus when 5 minutes old, and immediately on pull. Pin buttons and error states on view screens read the cached list, so opening a view sends no pin list request. + +Order and display mode stay on the phone, per project, in the `views-pin-prefs:v2` store (AsyncStorage). +The app never writes the server's `order`: `reorder` is closed to OAuth, and a PATCH would move a sibling-environment pin into this environment. +A new pin, made on the web or the phone, goes after the ranked pins. +A complete pin list read forgets the prefs of that project's pins that are gone. Other projects keep theirs, and signing out clears all of them. + +Recent merges the last 20 views opened on this phone (`views-recent:v1`, per project) with the dashboards this user viewed on the web. +Insights viewed on the web do not appear: no per-user source is open to OAuth without a new scope. + +## Automatic pin previews + +The Pinned section shows each real pin once. For up to three pins without a display choice on this phone, +the app selects a supported Number or Chart display. A dashboard pin shows one useful tile, with the dashboard +name and tile name together. Other pins stay as rows. An explicit Row, Number or Chart choice always wins. +The app does not create pins or add previews from unpinned dashboards, recent views or saved insights. + +Automatic choices stay on the device and do not write preferences or server shortcuts. The same card keeps its +normal menu, Edit pins, reorder and unpin actions. Unpinning removes the actual web star as usual. +Metadata discovery reads only bounded pinned dashboards and pinned insights; only visible preview cards request +fresh results. Previews share the existing refresh queue and canvas budget. A cache-only dashboard result can +populate a preview before it computes; like a dashboard tile, a visible pin can then request its first result. +The queue still enforces the minute floor between automatic direct reads of that insight. + +## Refresh + +The app holds itself to the published API rate limits. + +- **Automatic reads never force.** On open, screen focus and app foreground, an item reads with `blocking`, which computes only past `cache_target_age`. An item is due when it has no good value, its `cache_target_age` has passed, or its last read is 5 minutes old, and never within a minute of its last read. +- **Dashboards open like the web:** a `force_cache` dashboard read, then `blocking` for each visible tile with no result or a passed `cache_target_age`. A list reports what it shows only after its rows lay out, so until then the first six rows count as visible (`INITIAL_VISIBLE_ROWS`). +- **Manual refresh reads immediately.** Pull and tile refresh bypass the app's automatic minute floor. The Views list also refreshes its pin list, Recent and preview metadata immediately. A dashboard first reads its latest cached tiles. A visible item recomputes with `force_blocking` only past its `next_allowed_client_refresh` (in server time, from the `Date` header, plus 2 s) and the dashboard's shared rule based on `Dashboard.last_refresh`. Items inside their window, or without a known window, use cached results; items without a value use `blocking`. Reading cached data does not restart the recomputation window. There is no additional device-local five-minute floor. Manual refresh shows no wait countdown. Its busy state covers list reads and data reads, and another pull joins the current refresh. Freshness still reports when the data was computed. The tab and view headers have no refresh button. +- **One queue** (`refreshQueue.ts`): 2 requests in flight, one request per freshness key (a second caller joins it), one automatic request per item per minute, a pause per budget family after a 429 or 503 (`Retry-After`, else 30 s), and one retry 5 s after a rate-limited answer. Manual refresh and retries bypass only the automatic minute floor; concurrency, deduplication, budgets and server backoff still apply. A screen that loses focus drops its queued jobs. Its running reads go on, because the server finishes a started compute anyway: the answer fills the cache, the automatic minute floor holds, and a screen that comes back joins the read or finds its result. A read that has not answered after 60 seconds is aborted and frees its queue slot. "Try again" on a view with nothing to show (`refresh.retry`), in "Choose tile" and in the Point sheet always sends its read, also inside the minute, but never forces: the dashboard read, or `blocking` for each item without a value. The screen shows its skeleton while that read runs. +- **List reads share the pause** (`listReads.ts`): the pin list, Recent, Browse and search are TanStack queries, not queue jobs. They wait out the family pause, a 429 or 503 from them pauses the family too, and they never retry a 429 or 503. +- **Hard caps** (`src/api/budget.ts`): token buckets at 25% of each published budget, counted in `send()` for every request of the family, including the report metrics. An empty bucket sends nothing. +- **Freshness only from good responses.** A rate-limited insight answer carries `last_refresh: now()` with no result. Freshness and values change only on a response with a result and no error. + +The status line shows "Updating…" while a request of the screen runs, "Updated N min ago" from the oldest good `last_refresh` on screen, and "Updated just now" only when that oldest value is under a minute old. + +## Date ranges + +A range lasts for the session, per view, in memory (`rangeStore.ts`). Range keys and queue groups carry the data scope (`demo`, or the host and project), so a range or a queued refresh from demo mode or another project never applies. +Overrides always send `date_to`, also as null, because the server merges filters by top-level key. +A dashboard's saved range comes from `persisted_filters`, which a read with an override also carries, so "Reset to saved range" stays correct after the saved-range read has left the cache. +SQL tiles without `{filters}` read without the override, because it would only change their cache key. +Compare on dashboards stays off behind `DASHBOARD_COMPARE_SUPPORTED` until US and EU run a build that accepts `compareFilter` in a dashboard override. + +## Demo mode + +`src/demo/views.ts` returns API-shaped answers, so demo mode runs the same adapters, queue and status line. +Demo numbers start 42 minutes old; a forced demo read makes them current and moves the next allowed force 3 minutes ahead. +The "SQL views" dashboard (`demo-dash-sql`) has a SQL table, a SQL line chart, a SQL bar list, a SQL big number, and a six-column table wider than a phone, so its first column pins. Its responses carry `columns`, `types` and `hasMore` like the server's. + +## The Views tab + +The third slot of the bottom bar opens the Views tab (`app/(tabs)/views.tsx`, `src/features/views/list/ViewsScreen.tsx`). +It replaced the Products slot, which opened the web app's product library. `mobile_products_opened` went with it; the bar now sends `mobile_views_opened` when the tab is selected, with the pin count from the cached pin list. + +The screen is one `SectionList`: a search field, Pinned, Recent and Browse. +The screen publishes its header state to the shared tabs header (`useTabHeader('views', …)`, see DESIGN.md, "Tabs header"): the refresh hint from `useViewsList().refresh` and the "Edit pins" row of the More menu. +Loading, the pin list error and the sign-in state are separate screens. The pin list error sends `mobile_views_error_shown` (`screen: list`, `kind: failed`). The sign-in state (`views-reauth`) shows when `useViewsAccess()` is not `ok`. When the last sign-in asked for a Views scope and the server still left it out, it shows "Views isn't available yet" (`views-unavailable`) without a sign-in button, because a new sign-in gets the same grant ([AUTH.md](AUTH.md), "Server-side ceiling"). + +### Pins + +A pin shows in one of three modes, chosen on this phone: + +| Mode | Shows | Height | Reads data | +| ------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------- | ---------- | +| Row | Icon, name, the view type ("Dashboard", "Built-in view" for web analytics, "Notebook · Opens on the web") | 64 pt | No | +| Number | The value, a neutral change pill, the tile name, a 14-bucket sparkline, saved range and freshness | Fits visible content | Yes | +| Chart | A compact `MetricChart`, series legend, saved range and freshness; a value and change pill when one number represents the insight | Fits visible content | Yes | + +Row mode has a fixed height. Number and Chart pins size to their visible content, and Chart pins keep a fixed plot height while the canvas replaces its sparkline placeholder. +Number cards fit their content and only include the comparison and date row when either is present. +Both modes omit empty footer lines and the footer itself when neither the saved range nor freshness is available. +Chart pins pass `canvasSlot` and sit inside `ChartCanvasBudget`, so at most six Skia canvases are mounted; the list feeds both the budget and the refresh rules from one `onViewableItemsChanged`. The list and each dashboard pass `active={useIsFocused()}`, so a screen under a pushed view or behind another tab gives its canvases back, and a stack holds only the focused screen's canvases. +A tap on a Chart pin's plot pins a tooltip, and a tap on its name or value opens the view. +A tap on the pinned tooltip opens the Point sheet. `ViewsScreen` wraps the list in `ChartSheetsProvider`, and the sheet follows the pin's own cache entry, which reads the saved range. +A Chart pin draws every current series and its previous period with `snapshotChartSeries`, using the same palette and legend as the full insight. Compact bars keep their series color before selection; selecting a single-series bar highlights it in ink. Multi-series and breakdown charts omit the headline number rather than showing a dash or presenting one series as the whole insight. Pins whose tile uses its own dates show the resolved range, or "Uses its own range" until dates are available. +Web analytics and pins that open on the web show as rows only (`pinModes`). + +A dashboard pin in Number or Chart mode shows one tile. +The subtitle shows the tile's title. +Picking Number or Chart on a dashboard pin without a tile opens "Choose tile" at once, with the first tile that can show that mode chosen. +The sheet lists the dashboard's tiles from the cached dashboard, or one `force_cache` dashboard read when it has none or it is 5 minutes old. +A Number pin needs one meaningful value. A Chart pin supports line and bar charts with multiple series or breakdowns. Funnels, pies, tables and text cards cannot be a Chart pin's tile (`capabilitiesOf`). +The sheet sends `mobile_pin_tile_sheet_opened` (`entry`: `show_as`, `menu` or `removed_tile`; `mode`) and, once per opening, `mobile_pin_tile_sheet_closed` (`entry`, `picked`, `tiles_usable`, `tiles_unusable`; the counts are null when it closed before the tiles loaded). `picked` is false for the automatic first choice. `mobile_pin_tile_chosen` still marks each choice. +The tiles the mode can show come first, in dashboard order. The rest sit under "Can't show as a number" or "Can't show as a chart", disabled, each with its reason ("Has 3 series", "Has a breakdown", "Not a single number", "Not a chart over time", "Text card"; `tileEligibility.ts`). When no tile fits, the header says so. +A pin whose tile is gone (its insight answers 404, or the cached dashboard no longer has the tile) shows as a row with "The chosen tile was removed. Choose another." + +### Pin actions + +- Long press any pin for its menu: Show as Row, Number or Chart, Choose tile (dashboards), Move up, Move down, Unpin. +- Long press a Recent, Browse or search row for Pin to Views (or Unpin) and Open. +- Every Recent, Browse and search row has a pin button. Tap it to pin or unpin the view; rows have no swipe action. +- "Edit pins" (the More menu's Views section, or Edit next to Pinned) lists the pins with a remove button, a Row, Number and Chart toggle, and a drag handle. Screen readers move a pin with the handle's actions. It sends `mobile_edit_pins_opened` (`from`: `section` or `menu`; `pin_count`) and `mobile_edit_pins_closed` (`reason`: `done` or `no_pins`; `changes`, the number of reorders, moves, mode changes and removals; `duration_s`). + +A one-line hint, "Long press a pin to change how it shows.", sits under one pin so people find the menu (`pinHint.ts`, `PinHint.tsx`). +It points at the first pin that offers Number and Chart and already shows an automatic display, so it also appears under a person's first pin once that pin's preview resolves. +It does not show when any pin has a display chosen on this phone, in Edit pins, or while searching. +Its close button or any pin's menu dismisses it for good on this phone (`views-pin-hint:v1`, AsyncStorage; sign-out keeps it). +It sends `mobile_pin_hint_shown` (`kind`, `mode`) each time it appears and `mobile_pin_hint_dismissed` (`reason`: `close` or `long_press`) when a visible hint goes away. + +Order and modes go to the device-local prefs through `usePinActions` (`move`, `reorder`, `setDisplayMode`, `chooseTile`). The app never writes the server's `order`. +Pin and unpin show a toast with Undo, from the list, Browse and a view's pin button alike (`ViewsToast` and `useToasts`). The toast draws through the nearest `OverlayHost`, so it sits above the tab bar and any open sheet. +Undo pins the view again as a new row at the end of the list. Without a saved display choice, it may receive an automatic preview. An undo sends `mobile_view_pinned` or `mobile_view_unpinned` with `from: undo`. +Pin and unpin stay with the session and project that started them; changing either ends pending work before it can send another request or update the cache. Pin and unpin of one view run one after another from every screen (`pinView`, `unpinView`): a second tap waits for the first instead of being dropped, the toast shows once the change is saved, and an unpin made while a pin saves deletes the row that pin created. + +### Search and Browse + +Search shows local matches first (pins as their cards, Recent rows), then dashboards and insights from the server, without the views already shown. +Browse has Dashboards, Insights, Web analytics, Sessions and Errors. Sessions and Errors open the app's own lists, as a pushed screen or, when pinned, as a slot in the nav pill. A long press on either row offers "Pin to navigation" or "Unpin from navigation" (see [DECISIONS.md](DECISIONS.md#navigation-pins)). +The Dashboards and Insights lists (`app/views/browse/*.tsx`, `src/features/views/browse/BrowseListScreen.tsx`) load 30 rows at a time and search on the server with `useBrowseSearch`. +Their Browse rows send `mobile_browse_list_opened` (`section`: `dashboards` or `insights`; `from`: `browse` or `search`). Each next page that loads sends `mobile_browse_list_paged` (`section`, `pages_loaded`), and a failed first page sends `mobile_views_error_shown` (`screen: browse`). +`mobile_views_searched` (`section`: `views`, `dashboards` or `insights`; `query_length`, `result_count`) goes once per settled query, from the Views list and from each Browse list. + +### Routes + +| View | Route | +| ----------------- | -------------------------------------------------------------- | +| Dashboard | `/views/dashboard/{ref}?from={entry}[&mode={display mode}]` | +| Insight | `/views/insight/{short_id}?from={entry}[&mode={display mode}]` | +| Web analytics | `/views/web-analytics?from={entry}` | +| Full-screen chart | `/views/chart/{short_id}[?key={cache key}]` | +| Browse lists | `/views/browse/dashboards`, `/views/browse/insights` | + +`from` is the `ViewEntry` (`pinned`, `recent`, `browse`, `search` or `dashboard`) for `mobile_view_opened`; `mode` is the pin's display mode. +Build links with `src/features/views/viewRoutes.ts` (`viewHref`, `dashboardHref`, `insightHref`, `webAnalyticsHref`, `chartHref`), never by hand. +An insight opened from a tile also carries `fromDashboard` and `insightId`: it reads with `from_dashboard` and shares the dashboard's session range. +Deep links work: `posthog://views/dashboard/demo-dash-kpis`, `posthog://views/insight/demo-ins-dau`, `posthog://views/web-analytics`. + +## View screens + +The dashboard, insight and web analytics screens (`app/views/dashboard/[id].tsx`, `app/views/insight/[shortId].tsx`, `app/views/web-analytics.tsx`) share one frame: + +- `ViewScreenFrame` puts an `OverlayHost` above `ChartSheetsProvider` and `ChartSelectionProvider`. View screens are pushed above the tabs, so they bring their own host for sheets and toasts. The sheets draw outside the chart selection, so a pinned tooltip stays behind an open Point sheet. +- `ViewChrome` is the floating top row: Back (`view-back`) and More (`view-more`, "Open on web"). The More sheet sends `mobile_view_more_opened` (`view_type`) when it opens. +- `ViewTitle` holds the title with the pin button (`view-pin`), the status line (`views-updated`), the refresh hint, the date range control and the resolved dates. +- `ViewsReauthState` replaces the content when `useViewsAccess()` is not `ok`, on the list and on every view screen. +- `ViewErrorState`: "This view was deleted" (`view-deleted`), no access (`view-no-access`) or a failed read (`view-error`), with Unpin when the view is pinned. A first read that returns a query error, reaches the request deadline, or has no available request budget shows the failed state with Try again, including when the read supplied metadata but no result. It sends `mobile_views_error_shown` (`screen`: the view type; `kind`: `deleted`, `no_access`, `scope` or `failed`). `RefreshFailedBanner` shows when a visible item's last read failed, and failed values dim to 50%. + +| Screen | Content | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Dashboard | Tiles in one column (`FlatList`, `ChartCanvasBudget` keyed by tile id). The first time the dashboard shows, it sends `mobile_dashboard_tiles_shown` (`tiles_total`, `tiles_text`, `tiles_web_only`). A tile is a number, a line or bar chart, funnel steps, a retention grid, a bar list, a SQL table, a card that opens on the web, or a text card. Tiles that the phone draws have a full-screen button in their head (`view-tile-fullscreen-{tileId}`). When the dashboard date control is visible, tiles with their own date range say "Uses its own range" and keep their numbers when the range changes. | +| Insight | The big number with its change pill, then the visual (`InsightVisual`) under a caption row with the full-screen button (`view-fullscreen`), ⓘ for "About this chart" (`view-about`). | +| Web analytics | The overview numbers in two columns, each with its change from the previous period. It always compares, as the web's overview tile does. | + +A tap on a pinned tooltip opens the Point sheet (`point-sheet`): the bucket and every series value, "Watch recordings", "View people", "Open on web", "About this chart" and "Copy value". +People and recordings load through `usePointActors`, one `blocking` actors query at a time; closing the sheet drops a query that has not started. In demo, rows that open the web say "Not available in the demo". +An insight kind without a phone renderer shows "This chart opens on the web." `insightDisplay()` in `snapshotChart.ts` decides from the saved query, so a tile keeps its display while it loads; see [Kinds that still open the web](#kinds-that-still-open-the-web). +A drawable tile that the cache-only dashboard read found without a cache says "Not loaded yet" with Load (`view-tile-load-{tileId}`), which reads that tile only. It shows a skeleton while its read runs. A result with nothing to draw says "No data for this range" (`view-tile-empty-{tileId}`, `view-insight-empty`, `chart-fullscreen-empty`), or "No data" without a range. `insightRenderState()` returns `pending`, `empty` or `ready`. + +## Date range control + +The range sits in each view's header, under the title (`src/features/views/range/`). +On a dashboard, the control appears only when at least one insight tile follows the dashboard's range. +When no insight tiles follow it, the control and the repeated "Uses its own range" badges are hidden. +On mixed dashboards, the control stays visible and tiles that ignore it keep their badges. + +- **Header row** (`ViewRangeControl`): the range button (`view-range`) opens the date range sheet. The resolved dates show under the button. Changing the range updates its label without a save-status indicator or previous/next period buttons. +- **Steps:** a swipe on the button (right for the earlier period) steps through periods with a selection haptic. Steps start from the window the server resolved (`rangeStep.ts`), so they wait while a new range loads. Forward steps stop at the current period, and a step that lands on it returns to the matching relative preset, so the cache is shared with the web again. All time cannot step. Screen readers adjust the button up or down to step, and activate it to open the sheet. +- **Sheet** (`RangeSheet`, `range-sheet`): what the view shows now, six chips (`range-chip-{today|yesterday|24h|7d|30d|90d}`), the web's other presets (`range-preset-{key}`; "This year", no "Last year"), "Custom range" (`range-custom`), "Compare to previous period" (`range-compare`), and "Reset to saved range" (`range-reset`) only while the range differs. Chips and presets apply at once and keep the sheet open; Done (`sheet-done`) closes it. +- **Custom range** (`RangeCalendar`, `range-calendar`): a month grid in the project timezone. The first tap sets the start, the second the end (taps in reverse order swap), and a third starts over. Future days are disabled. ‹ › (`range-month-prev`, `range-month-next`) or a swipe change the month, and Apply (`range-apply`) sends date-only strings. +- **Compare:** available on trends insights, and off for All time. SQL, retention and other insight kinds leave out the comparison control. Turning off a saved comparison reads a separate result with `compareFilter.compare: false`, so its number and delta describe the single period. On a trends insight it runs the compare query from the data layer. The query takes `query.source` from any read of the insight, so a range change with compare on sends only the compare query. An insight saved with compare shows its saved range from the retrieve, which already carries both periods. Opened from a tile, the insight shares the dashboard's range but keeps its own compare toggle. On a dashboard the switch stays disabled with "Compare works on single insights. Open a chart to compare." while `DASHBOARD_COMPARE_SUPPORTED` is false. Web analytics has no compare row. + +A range change reads with `blocking`, never `force_*`: the new override is a new cache key, so the screen hooks run their open path again (`trigger: range`). Each range has its own queue group, so a new range drops the queued reads of the previous one and its own reads go next. +While the new range loads, the insight and web analytics screens keep the previous values on screen (`rangeLoading`), and dashboard tiles keep their layout. +Dashboard rows count as visible by tile id, because a range change gives every tile a new item key while the list keeps the same rows. +Number and Chart pins in the list keep the saved range. Ranges last for the session, per view, in memory. +`useViewRange` sends `mobile_date_range_changed` (`chip`, `more`, `custom`, `step`, `reset`) and `mobile_date_range_compared`. The sheet sends `mobile_date_range_sheet_opened` (`view_type`; `entry`: `button` or `accessibility`) when it opens. + +## Charts beyond line and bar + +`insightDisplay(snapshot)` returns `number`, `chart`, `funnel`, `retention`, `bars`, `table` or `web`. It takes the display from the saved query (`queryDisplay`), with the same rules the adapters use (`isDrawableFunnel`, `isDrawableRetention`, `isRankingQuery`, `timeSeriesChartKind`, `sqlQueryDisplay`). A SQL result can change the display; see [SQL insights](#sql-insights). `InsightVisual` (`src/features/views/InsightVisual.tsx`) renders every display except a big number, in tiles and on the insight screen. + +| Display | When | Renderer | +| ----------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `number` | Big number insights that have one value | `NumberBody` on tiles, the hero on the insight screen | +| `chart` | Trends line and bar displays, with any number of series and breakdowns | `MetricChart` with a legend | +| `funnel` | `FunnelsQuery` in the steps layout. A breakdown adds its values step by step and says so under the steps | `FunnelSteps` | +| `retention` | `RetentionQuery` that counts people, without a breakdown | `RetentionGrid` | +| `bars` | Trends value bars, tables, pies, donuts and proportion bars | `BarList` | +| `table` | SQL tables, SQL insights without a saved display, and SQL insights on Auto until the result arrives | `SqlTable` | +| `web` | Everything else, and a funnel or retention result that its adapter cannot draw | `OpensOnWeb` | + +- `FunnelSteps`, `RetentionGrid` and `BarList` live in `src/ui/chart/` and draw React Native rows, not a Skia canvas. Their data comes from `src/model/adapters/funnel.ts`, `retention.ts` and `breakdown.ts` through `InsightSnapshot.funnel`, `.retention` and `.ranking`. +- Chart pins support the same time series and breakdowns as dashboard charts, including every y column in a SQL chart. Number pins still require a single meaningful value. +- Numbers keep the insight's saved units, prefixes, suffixes, number style and decimal precision across dashboard tiles, pins, insight screens and chart labels. `src/model/adapters/insightFormatting.ts` reads the settings (`insightValueFormat`) and formats every value (`formatInsightValue`). SQL big numbers use the first result column's table formatting, SQL charts and bar lists the first y column's formatting, and trends the aggregation axis settings. +- A breakdown insight has no big number, because no one value stands for it. A trends insight with a breakdown, or a ranking with several values, has `capabilities.number = false`. +- A tap on a funnel step, bar row or retention cell selects it (`useItemSelection`), under the same one-selected-chart rule as a line chart tooltip: an outside tap, a scroll away, Android back or new data clears it. A funnel step shows the median time from the previous step, a bar row its share of the total (or its rank when the values do not add up), and a retention cell a callout under the grid. +- A tile ranks six values; "+N more" opens the bar list full screen. +- Every chart kind is one adjustable VoiceOver element: a summary label, the selected item as its value, and a swipe up or down to move. Its actions are "About this chart" and "Open full screen". +- These charts have no Point sheet: the API inventory has no actors query for funnels, retention or bars. + +### SQL insights + +A SQL insight is a `DataVisualizationNode` with a `HogQLQuery` source. Its response carries the rows (`result`), the column names (`columns`), a `[expression, type]` pair per column (`types`) and `hasMore`. `src/model/adapters/sqlVisualization.ts` ports the web's rules from `dataVisualizationLogic.ts`, so the phone picks the same display and axes as the web. + +- **Columns.** The ClickHouse type loses its `Nullable(...)` and `LowCardinality(...)` wrappers and becomes a scalar: `INTEGER`, `FLOAT`, `DECIMAL`, `BOOLEAN`, `DATE`, `DATETIME`, `STRING`, `ARRAY` or `TUPLE`. The first three are numbers. +- **Table.** `snapshot.sqlTable` holds every SQL result, whatever its display: the columns with their formats, the rows in the server's order (objects and arrays as JSON), and `hasMore`. It is null for other kinds. +- **Numbers.** `sqlCellNumber` reads a cell for the chart, the bar list, the big number and the table. A numeric string counts as a number, because the server's JSON writes a ClickHouse `Decimal` as a string. Other text is a gap in a chart and no row in a bar list. The table keeps an integer string past 2^53 as written when its column has no style or decimals. +- **No display.** A query without a saved `display` is a table, as on the web (`display ?? ActionsTable`). +- **Auto.** Only a saved `Auto` resolves from the result. With a date column, a number column and more than one row, a line chart. Else with two text columns and a number, a heatmap. Else one number column alone is a big number, any number column gives a bar chart, and anything else is a table. Before the result, Auto shows as a table. +- **Axes.** Without saved axes, x is the first date column, else the first column that is not a number, and y is every number column. As on the web, a saved x column missing from the result, or a saved y column that is missing or not a number, drops both axes back to these defaults. The x column and the breakdown column are never a y series. +- **Dates.** A datetime x value keeps the wall clock the server wrote: `2026-10-01T14:05:09.123456-04:00` is the bucket `2026-10-01 14:05:09` in local time, like a trends bucket and like the table shows it. + +| Saved display, or what Auto resolves to | Phone display | Data | +| ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | -------------------------------------------------------------------------------- | +| `ActionsTable` | `table` | `sqlTable` | +| `ActionsLineGraph`, `ActionsLineGraphCumulative`, `ActionsAreaGraph`, `ActionsBar`, `ActionsStackedBar`, `ActionsUnstackedBar` over a date x axis | `chart` | `allSeries`, one per y column, or per y column and breakdown value | +| `ActionsBar`, `ActionsBarValue`, `ActionsPie`, `ActionsDonut`, `ActionsProportionBar` over any other x axis, with one y column and no breakdown column | `bars` | `ranking`, one row per result row | +| The same displays with several y columns or a breakdown column: the web draws grouped bars or a slice per column | `web` | none | +| `BoldNumber` | `number` | `value`, the first cell of the first row. Other SQL displays have no big number. | +| `Metric`: the web shows the latest, total or mean of the first y series by date, which the phone does not port | `web` | none | +| Anything else, and a transposed table | `web` | none | + +- A chart's series share every date of the result, in date order. A date without a value is a gap, and rows with the same date and series add up. A cumulative display draws a running sum. The interval comes from the median gap between dates. +- `ActionsBar*` displays, and a first y axis drawn as bars, draw bars from zero. Negative values extend below zero, positive values above it, and zero has no bar. Area draws as a line and stacked bars draw side by side. +- Bar rows keep the server's order, because the SQL orders them on purpose. The total is their sum. +- Formatting comes from `insightFormatting.ts`, so a value reads the same in the table, the chart tooltip and the bar list. A table column takes its `tableSettings.columns` formatting, else its `chartSettings.yAxis` formatting. A chart and a bar list take the first saved y axis entry's formatting as `snapshot.format`, else the first y column's own format. As on the web, a percent column is a fraction scaled by 100 everywhere: 0.25 reads 25% in the table, on the chart and as a big number. +- The table draws six rows on a tile and up to 500 rows (`SQL_TABLE_MAX_ROWS`) on the insight screen and full screen, because the rows are plain views, not a virtual list. Past that, the caption and the line under the rows say "First 500 of 5,000 rows". An empty result says "No rows", or "No rows for this range" when the result has a range. +- `capabilities.number` is true for a big number, `capabilities.chart` for a chart over time with a y column and at least one date, whose first y column a pin draws. A SQL insight has no "View people". +- `kindLabel` names the resolved display: "Table", "Line", "Bar", "Big number" or "Pie", else "SQL". + +## Full screen + +`app/views/chart/[shortId].tsx` renders `FullScreenChart`. Open it with `useOpenFullScreen()`, which sends `mobile_chart_fullscreen_opened` (`entry`: `button`, `legend_more`, `rows_more` or `accessibility`; `display`: `chart`, `funnel`, `retention`, `bars` or `table`) and pushes `chartHref(shortId, queryKey)`. + +- Entries: the tile head button, the insight screen's caption row, the legend "+N" pill, the bar list "+N more" row, and the VoiceOver action "Open full screen". +- `key` is the JSON cache key of the snapshot that opened the screen. Full screen therefore shows the same session range, compare and tile layers as the tile or insight. While a new range loads on the insight screen, `key` names the previous range's entry, which is the one on screen (`InsightScreenState.queryKey`). Without `key`, it reads the insight's own entry. +- It reads the cache only and never sends a request. A key without a cached snapshot (the app restarted, or a deep link to an insight not opened yet) shows "This chart isn't loaded" with "Go back" (`chart-fullscreen-missing`). +- A line or bar chart fills the space, scrubs on a drag in any direction, shows every tooltip row, and lists every series as a 44 pt legend row that scrolls. Funnels, retention (all periods), bar lists (all rows) and SQL tables (up to 500 rows) show in a card. +- Charts on this screen send the usual chart events with `context: fullscreen`. +- Portrait only: the app has no `expo-screen-orientation`, and landscape needs that native module in a new binary. There is no "Zoom to range" or "Show as table" yet. + +## Kinds that still open the web + +These show the "This chart opens on the web." card, on tiles and on the insight screen. The phone never computes them once it knows the kind: the item's `drawable()` is false, so open, focus, pull and "Try again" send no `blocking` or `force_*` read for them, on a dashboard and on the insight screen. A pull still sends cache-only reads (the dashboard read, or `force_cache` on the insight screen), and the tile has no refresh button. A cold open of an insight with nothing cached reads once with `blocking`, because the kind is unknown until that read. + +- Paths (`PathsQuery`), stickiness (`StickinessQuery`: its x axis is "Day N", not dates) and lifecycle (`LifecycleQuery`: stacked bars with negative dormant values). +- SQL insights shown as a world map, a calendar or two-dimensional heatmap, a box plot, a slope graph or a scatter plot, and transposed SQL tables. A line chart over an x axis that is not a date, and a bar chart with a breakdown column over such an x axis, open on the web too. +- SQL settings the phone ignores: conditional formatting, goal lines, pinned columns, a right y axis, per-breakdown colors and annotations. The table cannot sort or resize columns. +- Trends world maps, calendar heatmaps and box plots. +- Funnels in the trends or time-to-convert layout, and funnels broken down by cohort: one person can be in several cohorts, so a sum would count them twice. +- Retention with a breakdown, or with a sum or average aggregation (amounts, not people). +- Web analytics query tiles on a dashboard, and any other query kind. + +The web analytics screen shows the overview numbers only. Its top lists open on the web, because the data layer reads only the overview query. + +## What waits on the backend + +The tab makes no backend change. [`VIEWS_BACKEND_FOLLOWUPS.md`](VIEWS_BACKEND_FOLLOWUPS.md) lists the server work that would unlock more, ranked by user value, with the app change that follows each item. The main ones: + +- A server-side check of `next_allowed_client_refresh` for app callers, so focus can force a refresh safely. +- Display mode and chosen tile on the server, so pins keep their mode on a new phone. +- Insights viewed on the web in Recent, and phone views in the web's "Recently viewed". +- Compare on dashboards: `DASHBOARD_COMPARE_SUPPORTED` stays false until US and EU run a build that accepts `compareFilter` in a dashboard override (the deploy check at the end of that file). + +## Sessions and Errors release controls + +The `mobile-session-replay` and `mobile-error-tracking` feature flags control Sessions and Errors independently. +Both are hidden until the app receives an enabled flag for the signed-in account. The app sends the account's +normalized email and selected project as person-property overrides for flag evaluation, and refreshes flags on +account changes and foreground returns. An earlier account's pending flag response cannot replace the current flags. + +An off flag hides the corresponding Browse and search entry and navigation pin. Saved pin preferences remain on +the device, so enabling the flag restores the slot. Both tab and pushed list/detail routes also check the flag; +a disabled route returns to the person's start page before mounting the feature screen. + +For local UI checks, development bundles accept comma-separated keys in `EXPO_PUBLIC_FEATURE_FLAGS`. +Release bundles ignore that override and use the remote release conditions. + +## Smoke flow + +`.argent/flows/views-smoke.yaml` starts on sign-in with demo mode off and: + +1. pins Signups as a number in Edit pins; +2. opens Product KPIs, picks 7 days, checks that the header has no save-status indicator or period buttons, and resets; +3. opens the Daily active users tile, turns on compare, opens the Point sheet from the chart tooltip (`chart-tooltip`), and opens the chart full screen with the previous period in the legend; +4. back on the dashboard, pins a tooltip on the first tile, selects a bar list row and a funnel step, opens the funnel full screen, and selects a retention cell; +5. opens the Dashboards list from Browse, goes home and exits the demo. + +After an edit, restart Metro with `--clear` before you replay the flow: the watcher can serve stale modules. diff --git a/products/mobile/docs/VIEWS_BACKEND_FOLLOWUPS.md b/products/mobile/docs/VIEWS_BACKEND_FOLLOWUPS.md new file mode 100644 index 000000000000..baa42daef514 --- /dev/null +++ b/products/mobile/docs/VIEWS_BACKEND_FOLLOWUPS.md @@ -0,0 +1,541 @@ +# Views tab: backend follow-ups + +The Views tab uses only the APIs that exist today, and it makes no backend change. +This file lists the backend work that would unlock more for Views users. +For each item, it also names the app change that follows when the backend work lands. + +## Summary + +Ranked by user value, highest first. + +| Item | What users get | Backend change | Size | Priority | +| --------------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| 1. Refresh on focus, checked on the server | Views refresh when they come back into focus, without a tap | The server enforces `next_allowed_client_refresh` for app callers: a forced read inside the window returns the cache | M | P1 | +| 2. Display mode and chosen tile sync across devices | Number and Chart pins survive a new phone, a reinstall and a new sign-in | Two nullable fields on `FileSystemShortcut`, with serializer fields and a migration | S | P1 | +| 3. Insights viewed on the web appear in Recent | Recent on the phone lists the insights the person opened on the web | `required_scopes` on `insights/my_last_viewed/` | XS | P1 | +| 4. Phone views count as "recently viewed" | Phone opens show in the web's "Recently viewed" and in Recent on other phones | The explicit `log_view` endpoint logs views for OAuth callers | S | P2 | +| 5. Pin order sync with the web's starred order | One pin order on the phone and on the web | `required_scopes` on `reorder` and `bulk_update`, project-wide scoping, and no `team_id` rewrite in `update()` | S | P2 (the `update()` fix is P1, because item 2 needs it) | +| 6. "New" badge for agent-built dashboards | A small badge on dashboards an AI agent built in the last 7 days | Provenance fields on `Dashboard` | S | P3 | +| 7. Trustworthy freshness on capacity errors | No API client shows "Updated just now" over a failed refresh | `last_refresh=None` on a rate-limited result with no data | XS | P2 | +| 8. A "Last year" date preset | One tap for the previous calendar year | Fix `-1yEnd` in `posthog/utils.py` | XS | P3 | +| 9. SQL insight staleness matches its `cache_target_age` | SQL insights refresh on the schedule their response describes | Align `_is_stale` and `cache_target_age` in `HogQLQueryRunner` | XS | P3 | +| Web, optional: a "Pinned" group in the new web navigation | Phone pins show in the web's Views pane | Web only, no backend change | M | Optional | + +Sizes: XS is under 100 changed lines, S under 300, M under 800, L over 800, tests included. +Priorities: P1 first, then P2, then P3. + +## Items + +Backend line numbers are from `origin/master` at `e9f815a9ced` (2026-10-14). +Check them again before you start. +App modules are the Views code in `src/features/views/`, `src/api/` and `src/model/views.ts`. + +### 1. Refresh on focus, checked on the server + +**What it enables:** +The app can force a refresh when a view comes back into focus, not only when the person taps refresh. +A check on the server makes that safe: a forced read inside the window returns the cache, whatever the client sends. + +**Why it matters to users:** +People open a view on the phone to see the latest number. +Today, on focus, the phone shows the cached number until the server's staleness window passes. +For daily data that window is 6 hours. +People often refresh right after they open a view, so today they must pull to refresh each time. +With a server-side check, the app can do that refresh for them. + +**What the app does today:** + +- Open, focus and foreground never force. They send `refresh=blocking`, which computes only past `cache_target_age`. +- Pull and tile refresh read immediately, bypassing the device's automatic minute floor. They send `force_blocking` only for items whose `next_allowed_client_refresh` has passed, in server time with a 2 s margin, and honor the dashboard's shared rule based on `Dashboard.last_refresh`. +- Items inside their window, or without a known window, read cached results. The app adds no device-local five-minute floor. +- One queue (`src/features/views/refreshQueue.ts`) allows 2 requests in flight and one automatic request per item per 60 s. Manual reads still join existing requests, respect budgets and pause on `Retry-After`. +- Token buckets in `src/api/budget.ts` cap each budget family at 25% of the published limits. +- The window check lives in the app only, so forcing on focus would depend on every app version getting it right. + +**Backend change:** + +Enforce `next_allowed_client_refresh` on the server for app callers. + +- Today the server sets the field on a fresh compute (`posthog/hogql_queries/query_runner.py:2800`), and the web honors it in its refresh controls. + `force_*` computes inside the window (`:2481-2513`). + Only shared links clamp a forced refresh (`shared_insights_execution_mode`, `:494-510`). +- The change: when the caller is not a browser session, run `force_blocking` as `RECENT_CACHE_CALCULATE_BLOCKING_IF_STALE` and `force_async` as `RECENT_CACHE_CALCULATE_ASYNC_IF_STALE`. + Set the staleness override to the runner's `_refresh_frequency()`. + Inside the window the server then returns the cache. Past the window it computes, as a force does today. +- The runner already supports a per-request staleness override. + `run(cache_age_seconds=...)` sets `_cache_age_override` (`query_runner.py:2327, 2344`), and `_is_stale_for_request` honors it (`:3362-3376`). + The shared-link clamp uses the same mechanism (`:428-432, 494-510`). +- The window depends on the runner: the base `_refresh_frequency` is 1 minute (`:3385-3386`), and the insight runners use 3 or 15 minutes. + So resolve the window inside the runner, for example with a flag on `run()` that sets the override from `_refresh_frequency()`. +- Apply the check in `resolve_execution_mode` (`posthog/hogql_queries/refresh_policy.py:71-93`) for insight and dashboard reads. + Apply it in `_process_query_request` (`posthog/api/query.py:176-186`) for `POST query/`. +- Read the caller type from the `access_method` query tag. + The OAuth authenticator sets it to `AccessMethod.OAUTH` (`posthog/auth.py:1058-1062`). + `posthog/api/query.py:325, 332` read it with `get_query_tag_value("access_method")`. +- The platform owners decide which callers count as app callers. + +Skills and tests: + +- Invoke `/improving-drf-endpoints` and `/writing-tests`. +- Extend `posthog/hogql_queries/test/test_refresh_policy.py` with a parameterized table of refresh mode by access method. + Add one runner test: a forced read inside the window returns the cached result. +- No migration and no OpenAPI change. + +**App change when it lands:** + +- `useViewRefresh` can send `force_blocking` on focus for each visible item that `isEligible` accepts ("force when the server allows"). +- Keep `isEligible`, the queue and the token buckets. They keep the app inside the limits on older deploys and on self-hosted instances. + +**Size:** M. + +### 2. Display mode and chosen tile sync across devices + +**What it enables:** +A pin's display mode (Row, Number or Chart) and the tile that a dashboard pin shows follow the person to every phone. + +**Why it matters to users:** +People set up Number and Chart pins once. +Today a new phone, a reinstall or a new sign-in shows every pin as a Row again. +The person must then set up each pin again. + +**What the app does today:** + +- `src/features/views/pinPrefsStore.ts` keeps `display: Record` per project in AsyncStorage (store `views-pin-prefs:v2`). + It clears on sign-out. +- No server field fits. + `FileSystemShortcut` exposes only `path`, `type`, `ref`, `href` and `order` (`posthog/api/file_system/file_system_shortcut.py:29-38`), and a PATCH with other keys is a silent no-op. + `User.ui_configuration` has a fixed schema, and each web save replaces it whole (`posthog/api/user.py:286-292`). + +**Backend change:** + +- Model: add two nullable fields to `FileSystemShortcut` (`posthog/models/file_system/file_system_shortcut.py:11-34`): + - `display_mode`: a `CharField` with the choices `row`, `number` and `chart`. Null means Row. + - `display_config`: a `JSONField`. +- Migration: two nullable `AddField` operations in `posthog/migrations/`, with no default, no backfill and no table rewrite. + Invoke `/django-migrations`. +- Serializer: add both fields to `FileSystemShortcutSerializer.Meta.fields` (`posthog/api/file_system/file_system_shortcut.py:26-52`). + Give each a `help_text` in `extra_kwargs`, like the other fields (`:44-52`). + - Validate `display_config` with a typed nested serializer, not a free dict: `{tile_id: int, insight_id: int}`. + - Allow it only for `type == "dashboard"` with a `number` or `chart` mode. Otherwise require null. Reject unknown keys. + - Leave both fields out of `FileSystemShortcutBulkItemSerializer` (`:108-132`). The web's bulk path never sets them. +- Do the `update()` fix from item 5 first. + Today a PATCH moves a sibling-environment pin into the route's environment (`:57-60`). + A display-mode PATCH from the phone would then break the web's starred order for that pin. +- A rename rewrites only `path` and `href` (`posthog/models/file_system/file_system.py:84-94`), so the new fields survive renames. +- Run `hogli build:openapi` and commit the generated types. + Invoke `/improving-drf-endpoints`. +- Tests: extend `posthog/api/file_system/test/test_file_system_shortcut.py` with one parameterized PATCH test. + A valid mode and config round-trip. An invalid `display_config` returns 400. + Invoke `/writing-tests`. + +**App change when it lands:** + +- `src/api/shortcuts.ts` reads `display_mode` and `display_config` from `GET file_system_shortcut/`. + It writes them with `PATCH file_system_shortcut/{id}/`. +- `PinView.display` in `src/model/views.ts` comes from the server row. +- Detect support by the `display_mode` key in the list response. + Older deploys and self-hosted instances keep the local store. +- Move local values up once: for each row with `display_mode: null` and a local entry, PATCH the row, then delete the local entry. + After that the server is the only source. Two phones share one mode per pin, and the last write wins. + +**Size:** S. + +### 3. Insights viewed on the web appear in Recent + +**What it enables:** +Recent on the phone lists the insights that the person opened on the web, next to the dashboards. + +**Why it matters to users:** +People look at an insight on the web and later want to check it again on the phone. +Today they must search for it, because Recent shows only dashboards from the web. + +**What the app does today:** + +- Recent merges two sources: + - views opened on this phone, kept on the device (last 20); + - `GET dashboards/?ordering=-last_viewed_at&limit=10`, a per-user list that needs only `dashboard:read`. +- Insights viewed on the web do not appear. + `GET insights/my_last_viewed/` is per user, but it returns 403 for OAuth. + `GET insights/?order=-last_viewed_at` is team-wide (`products/product_analytics/backend/logic.py:68-72`), so it is not a substitute. +- The phone sends `POST insights/viewed/` on each insight open (`products/product_analytics/backend/presentation/insight.py:2381`). + So the web's "Last viewed" already includes phone opens. + +**Backend change:** + +- `my_last_viewed` (`products/product_analytics/backend/presentation/insight.py:1987-1995`) is a custom action with no `required_scopes`. + `_get_required_scopes` then returns `None`, and every token gets 403 "This action does not support personal API key access" (`posthog/permissions.py:698-725, 885-889`). +- Add `required_scopes=["insight:read"]` to its `@action`, as `viewed` does (`insight.py:2381`). +- Add `@extend_schema(responses=InsightBasicSerializer(many=True))`, so the generated types describe the response. + Run `hogli build:openapi`. + Invoke `/improving-drf-endpoints`. +- The action returns the last 5 insights that this user viewed, from `recently_viewed_insights` (`products/product_analytics/backend/logic.py:75-89`). + The list includes phone opens, because `POST insights/viewed/` writes the same `InsightViewed` rows. +- Tests: `posthog/api/test/test_personal_api_keys.py:732-735` uses `my_last_viewed` as its example of an action without `required_scopes`. + Point that test at another action that still has none. + Add a case where a key with `insight:read` gets 200. + Invoke `/writing-tests`. + +**Alternative that needs only ops and an app change:** + +- Request `file_system:read`, and read `GET file_system/?order_by=-last_viewed_at¬_type=folder&limit=20` (`posthog/api/file_system/file_system.py:275-281`; `_list_recents`, `:694-750`). +- This is the web's "Recently viewed" list. It is per user and per environment, and it covers dashboards, insights, notebooks and the other types that the web logs. +- It adds one more scope to the shared OAuth client (see "Ops and deploy checks"). Existing users must sign in again once. +- Phone opens are absent from this list until item 4 lands. + +**App change when it lands:** + +- `src/api/insights.ts` gets a `myLastViewed()` call. +- The Recent merge adds the insights as a third source. + It dedupes by match key against the device list, drops pinned views and keeps the cap of 10. + +**Size:** XS. + +### 4. Phone views count as "recently viewed" + +**What it enables:** +A dashboard or insight that the person opens on the phone counts as a view, the same as a web open. +The view then shows in the web's "Recently viewed", in the dashboards list ordered by `last_viewed_at`, and in Recent on the person's other phones. + +**Why it matters to users:** +People move between the phone and the web. +Today a view opened on the phone is invisible everywhere else. +A reinstall also loses the phone's Recent list. + +**What the app does today:** + +- Recent keeps phone views in a device-local list (AsyncStorage, last 20). It clears on sign-out. +- The phone does not call `POST file_system/log_view/`. For OAuth it returns 204 and logs nothing. + +**Backend change:** + +- `log_view` (`posthog/api/file_system/file_system.py:1169-1196`) calls `log_api_file_system_view` (`posthog/api/file_system/file_system_logging.py:27-68`). + That function returns early when the request has no session cookie (`:18-24, 40-41`). +- Let the explicit endpoint log a view for any authenticated, non-impersonated caller. + For example, add a keyword `require_session_cookie: bool = True` to `log_api_file_system_view`, and pass `False` from `log_view`. +- Keep the cookie gate for implicit logging. + `FileSystemViewSetMixin.retrieve` (`posthog/api/mixins.py:345-364`) and the recordings playlist API call the same function on reads. + Without the gate, every API-key or OAuth read of a resource would fill "Recently viewed". +- Keep the impersonation checks: `@disallow_if_impersonated` (`file_system.py:1170`) and `is_impersonated_session` (`file_system_logging.py:43-44`). + Check that an OAuth token minted during impersonation (`access_token.impersonated_by_id`, `posthog/auth.py:1055, 1071`) also counts as impersonated there. +- Scope: `log_view` is in `scope_object_write_actions` (`file_system.py:282-293`), so the POST needs `file_system:write`. + That scope also allows create, move, link and delete in the file tree. + Decide whether the POST should accept a narrower scope. `dangerously_get_required_scopes` can choose scopes per request (`posthog/permissions.py:704-709`). +- Effect: `GET dashboards/?ordering=-last_viewed_at` joins this user's `FileSystemViewLog` rows (`products/dashboards/backend/api/dashboard.py:2948-2958`). + The `file_system/` recents read the same log. Both then include phone opens. +- Tests: extend the parameterized `test_log_view_endpoint_creates_entry` (`posthog/api/test/test_file_system.py:538`) with a bearer-token case. + Add a case that a bearer-token GET of a dashboard still logs nothing. + Invoke `/improving-drf-endpoints` and `/writing-tests`. +- No migration. + +**App change when it lands:** + +- On each dashboard and insight open, send `POST file_system/log_view/ {type, ref}` once. + The dashboard `ref` is its id. The insight `ref` is its `short_id`. + Send it once per open, not once per refresh. +- Add the scope to `OAUTH_SCOPES` in `src/auth/oauth.ts`, after the ceiling change (see "Ops and deploy checks"). +- `file_system:write` covers `file_system:read`. + So Recent can then read `GET file_system/?order_by=-last_viewed_at¬_type=folder` for every type. + The device list becomes a fallback for older deploys. + +**Size:** S. + +### 5. Pin order sync with the web's starred order + +**What it enables:** +The phone and the web share one pin order. +"Edit pins" and "Move up" / "Move down" on the phone change the web's starred order, and the reverse. + +**Why it matters to users:** +People who arrange their stars on the web expect the same order on the phone. +Today each surface keeps its own order. +A new phone starts from the web's order again. + +**What the app does today:** + +- The phone never writes `order`. + `src/features/views/pinPrefsStore.ts` keeps `rank: Record` in AsyncStorage. + New pins have no rank, so they go to the end, in server order. +- The phone cannot write the server order, for three reasons: + - `POST file_system_shortcut/reorder/` and `bulk_update/` return 403 for OAuth. + Both are custom actions without `required_scopes` (`posthog/api/file_system/file_system_shortcut.py:196, 231`). + - Both actions filter the route environment (`team=self.team`, `:202-204` and `:239-241`), but the list is project-wide (`:167-189`). + A pin from a sibling environment then fails with 400 "Unknown shortcut ids". + The web shows "Could not save starred order" for the same reason (`frontend/src/layout/panel-layout/ProjectTree/projectTreeDataLogic.tsx:1088`). + - A per-row `PATCH {order}` works for OAuth. + But `update()` sets `team_id` to the route environment and `user` to the caller (`:57-60`). + A PATCH from the phone moves a sibling-environment pin into the phone's environment. + The web's reorder then fails for the person in the other environment. + +**Backend change:** + +- Add `required_scopes=["file_system_shortcut:write"]` to the `@action` of `reorder` (`:196`) and of `bulk_update` (`:231`). +- Scope both querysets like the list: this user's rows in every environment of the project (`_scope_by_project_and_environment`, `:170-174`), not `team=self.team`. +- `lock_user_shortcuts` keys its lock by team (`posthog/models/file_system/file_system_shortcut.py:40-48`). + `create()` reads the last order per team (`file_system_shortcut.py:84-90`). + If the writes become project-wide, key the lock and the last-order read by project too. +- Fix `update()` (`:57-60`): do not rewrite `team_id` or `user` on update. + The queryset already limits rows to the caller's own (`:180`). + Before you remove the two lines, read their history for the reason they exist. +- No migration. + Run `hogli build:openapi` and commit any diff. + Invoke `/improving-drf-endpoints`. +- Tests in `posthog/api/file_system/test/test_file_system_shortcut.py` (invoke `/writing-tests`): + - `reorder` with a token that has `file_system_shortcut:write` returns 200. + - `reorder` accepts ids from two environments of one project. + - A PATCH from environment B keeps the `team_id` of a row made in environment A. +- This change also fixes the web's "Could not save starred order" error for stars from a sibling environment. + +**App change when it lands:** + +- "Edit pins" and "Move up" / "Move down" call `POST file_system_shortcut/reorder/ {ordered_ids}` through `src/api/shortcuts.ts`. + On 400 "Unknown shortcut ids", refetch the list and retry once. +- The list renders in server order (`PinView.serverOrder` in `src/model/views.ts`). +- Upload the local order once, then drop `rank` from `pinPrefsStore.ts`. +- Detect support with the first `reorder` call. + A 403 "This action does not support personal API key access" means an older deploy, so keep the local order. + +**Size:** S. + +### 6. "New" badge for agent-built dashboards + +**What it enables:** +Recent marks a dashboard that an AI agent built in the last 7 days with a small "New" badge. + +**Why it matters to users:** +People ask an agent, such as PostHog AI or a coding agent through MCP, to build a dashboard. +They check it later on the phone. +The badge helps them find what the agent made. + +**What the app does today:** + +- The app shows no badge. +- No dashboard field says which client created the dashboard. + `Dashboard.creation_mode` (`products/dashboards/backend/models/dashboard.py:45-58, 80`) records default, template, duplicate or unlisted, not the client. +- A client rule such as "created in the last 7 days" cannot tell an agent from the person. + +**Backend change:** + +- Decide first where provenance lives: on `Dashboard` only, or on a model that every view type shares. +- Model: add two nullable fields to `Dashboard` (`products/dashboards/backend/models/dashboard.py:44`): + - `created_via`: a `CharField` that holds an `EventSource` value (`posthog/event_usage.py:330-355`), for example `posthog_ai`, `mcp` or `wizard`; + - `created_via_client`: a `CharField` that holds the sanitized MCP client name from `get_mcp_properties` (`posthog/event_usage.py:568`). +- Set both on create, from `get_event_source(request)` (`posthog/event_usage.py:487`): + - in `DashboardSerializer.create` (`products/dashboards/backend/api/dashboard.py:1778`), next to the "dashboard created" event (`:1890-1900`); + - in the create-from-template path (`:4012-4020`). +- Expose both fields read-only on `DashboardBasicSerializer` (`dashboard.py:1209`), each with a `help_text`. + Run `hogli build:openapi`. +- Migration: two nullable `AddField` operations, with no default and no backfill. + Old dashboards stay null and get no badge. + Invoke `/django-migrations` and `/improving-drf-endpoints`, and run `hogli product:lint` for the `products/dashboards` isolation rules. +- Tests: one parameterized create test over event sources, in the dashboards API tests. + Invoke `/writing-tests`. + +**App change when it lands:** + +- The dashboards adapter in `src/api/dashboards.ts` reads `created_via` and `created_at`. +- `RecentView` in `src/model/views.ts` gets a flag for "built by an agent in the last 7 days". + The Recent row shows the badge. + Write the badge copy with `/writing-user-facing-copy`. + +**Size:** S, for dashboards only. + +### 7. Trustworthy freshness on capacity errors + +**What it enables:** +Every API client can trust `last_refresh` on an insight response. + +**Why it matters to users:** +When the server is busy, an insight refresh fails and returns no result. +Today that response still says that the insight refreshed just now. +A client that trusts the field shows "Updated just now" over an old number, or over no number. + +**What the app does today:** +The insight adapter takes `lastRefreshAt`, `cacheTargetAge`, `nextAllowedRefreshAt` and the value only from a response with a non-null result and no `query_status.error`. +Otherwise it keeps the previous snapshot and marks it `refreshFailed`. +The status line then says "Couldn't refresh" with the older age. + +**Backend change:** + +- The rate-limited branch of `InsightSerializer` (`products/product_analytics/backend/presentation/insight.py:1432-1459`) calls `_degraded_insight_result` (`:1461-1502`) with `last_refresh=now() if is_rate_limited else None` (`:1458`). + The result in that response is `None`. +- Pass `last_refresh=None` instead, as the other degraded branch does (`:1430`). +- The web does not read the stamp. + It treats a result-less error as a rejection and keeps the tile (`frontend/src/scenes/dashboard/dashboardUtils.ts:214-219`). + Its retry keys on `error_code` and `error_message` (`:386-389`). + The code does not say why the branch stamps `now()`, so read the history of that line first. +- Tests: add a `last_refresh is None` assertion to the parameterized `test_retrieve_labels_every_capacity_failure_as_rate_limited` (`products/product_analytics/backend/tests/api/test_insight.py:4268`). + Invoke `/improving-drf-endpoints` and `/writing-tests`. +- No migration and no schema change. + +**App change when it lands:** +None is needed. +Keep the adapter rule for older deploys and self-hosted instances. + +**Size:** XS. + +### 8. A "Last year" date preset + +**What it enables:** +A one-tap "Last year" range, from January 1 to December 31 of the previous year, in the date range sheet. + +**Why it matters to users:** +People who review a whole year need the previous calendar year. +Today they must step back from "This year", or pick both dates in the calendar. + +**What the app does today:** +The date range sheet does not offer "Last year". The web has no such preset either. +The phone reaches last year with a step back from "This year", or with the custom calendar. +Both send absolute dates. + +**Backend change:** + +- `get_delta_mapping_for` (`posthog/utils.py:300-371`) handles the kind `y`. + For the position `End` it sets only `day = 31` (`:368-369`). + So `-1yEnd` resolves to the end of the current month one year ago, not to December 31. + On 2020-01-31 it resolves to 2019-01-31. +- Set `month = 12` as well, the same way `Start` sets `month = 1` and `day = 1` (`:365-367`). +- Tests: `test_year` in `posthog/test/test_utils.py:434` runs at a frozen 2020-01-31. + Add `-1yEnd` → `2019-12-31` and `yEnd` → `2020-12-31`, as a parameterized table like `test_quarter`. + Invoke `/writing-tests`. +- Saved insights and dashboards that already use `-1yEnd` will show a different range after the fix. + Search for them first. If any exist, invoke `/announcing-behavior-changes`. + +**App change when it lands:** +Add "Last year" (`date_from: "-1yStart"`, `date_to: "-1yEnd"`) to the "More" presets in the date range sheet. +Add it only after US and EU run the fix. +Write the label with `/writing-user-facing-copy`. + +**Size:** XS. + +### 9. SQL insight staleness matches its `cache_target_age` + +**What it enables:** +The `cache_target_age` of a SQL insight matches the time when the server actually recomputes it. + +**Why it matters to users:** +Clients plan automatic refresh from `cache_target_age`. +Today a SQL insight says that it stays fresh for 6 hours, but a `blocking` or `async` read recomputes it after 5 minutes. +People see numbers change, and the server does work, on a schedule that the response does not describe. +This finding comes from a code reading. Nobody has run it yet. + +**What the app does today:** +`dueAt` rechecks a visible item at most every 5 minutes, whatever its `cache_target_age` says. +For a SQL tile the server then recomputes, because its real staleness is 5 minutes. + +**Backend change:** + +- `HogQLQueryRunner.cache_target_age` (`posthog/hogql_queries/hogql_query_runner.py:66-70`) reports `last_refresh` plus the day threshold, 6 hours. + Its comment says "Treat SQL query caching like day insight". +- The stale check is the base `_is_stale` (`posthog/hogql_queries/query_runner.py:3378-3383`). + A HogQL runner has no `query_date_range`, so the check falls back to the `minute` interval, 5 minutes (`posthog/caching/utils.py:46-66`). +- `DataVisualizationNode` and `DataTableNode` insights use their source runner (`query_runner.py:633-641`), so SQL tiles on dashboards have the same mismatch. +- Pick one direction with the owners: + - Make the check follow the comment. Override `_is_stale` in `HogQLQueryRunner`, and pass `target_age=self.cache_target_age(last_refresh, lazy)` to `is_stale` (`posthog/caching/utils.py:69-97` accepts it). + SQL insights then recompute less often. Web users see that change, so consider `/announcing-behavior-changes`. + - Or make `cache_target_age` report the 5 minutes that the check uses. +- Tests: first write a test in `posthog/hogql_queries/test/test_hogql_query_runner.py` that shows the mismatch, then fix it. + Invoke `/writing-tests`. + +**App change when it lands:** +None is needed, because `dueAt` already reads `cache_target_age`. +With the first direction, focus reads of SQL tiles become cache hits until 6 hours pass. + +**Size:** XS. + +## Web (not backend) + +### Optional: a "Pinned" group in the new web navigation + +This item is optional. +Mobile is a companion, not a copy of the web, and the app does not need this item. + +**What it enables:** +Under the `today-rail-nav` flag, the web's Views pane shows the person's pins next to "Recently viewed" and "Views". +The pins are the same `FileSystemShortcut` rows that the phone uses. + +**Why it matters to users:** +A person who pins a view on the phone finds it in the new web navigation without the ⌘K palette. + +**What the app does today:** +Nothing. Phone pins are the web's stars. +They already show on the web in both navigations: in the ⌘K "Starred" group, and as the star state in dashboard and insight menus. + +**Web change:** + +- `frontend/src/layout/today/TodayViewsSidebar.tsx` renders "Recently viewed" (`:94`) and "Views" (`:109`) only. + Add a "Pinned" group that reads `file_system_shortcut/` and offers pin and unpin on the same rows. +- Build it with quill, per `frontend/src/design.md`. + Invoke `/writing-ui-components` and `/adopting-generated-api-types`. +- On 400 "Unknown shortcut ids" from `reorder`, refetch and retry once. + Today `projectTreeDataLogic.tsx:1088` shows the toast "Could not save starred order" and stops. +- Add usage tracking, as the repo rules require for a new feature. + +**App change when it lands:** +None. The empty Pinned copy can then mention the web. + +**Size:** M. + +## Ops and deploy checks (no code) + +### OAuth scope ceiling, US and EU + +- The Views tab needs `dashboard:read`, `insight:read` and `file_system_shortcut:write`, which covers `:read`. + The app already has `query:read`. + All three are unprivileged scopes that an OAuth app can request (`posthog/scopes.py:288-313`). +- Each OAuth application has a scope ceiling: `OAuthApplication.scopes` plus `optional_scopes`, two Postgres array columns (`posthog/models/oauth.py:144-172`). + These are data rows, not code. +- `/authorize` drops a scope outside the ceiling without an error (`posthog/api/oauth/views.py:765-806`). + The token works, the feature gets 403, and the server records an `oauth_scopes_clamped` event (`views.py:1520-1557`). + +**Coordinate first.** +The app's two client ids (`products/mobile/src/auth/regions.ts:15-16`) are shared with two desktop-team mobile apps: + +- `products/desktop/apps/mobile` (`src/features/auth/lib/constants.ts:3-4`); +- `products/desktop/apps/mobilehog` (`src/lib/oauth.ts:10-11`). + +The ids are also in `POSTHOG_DESKTOP_OAUTH_CLIENT_IDS` (`posthog/temporal/oauth.py:33-34, 49-57`). +A ceiling change applies to all three apps. +Agree the change with the desktop team before anyone edits a row. + +**To check**, in US and in EU: + +1. Open Django admin at `/admin/posthog/oauthapplication/`. Find the row by client id. +2. Read `is_first_party` (`posthog/models/oauth.py:129`), `scopes` and `optional_scopes`. Write the values down. +3. If `scopes` and `optional_scopes` are both empty, or contain `@default`, change nothing. + Every unprivileged scope is then grantable (`posthog/scopes.py:401-428, 561-562`). + +**To edit**, when the ceiling is an explicit list: + +- First-party row: add the scopes to `scopes`. + Consent is skipped, and each app gets only the scopes it requests (`views.py:1658-1674`). + - `python manage.py seed_oauth_app_scopes --client-id --scopes ""` replaces the whole list. + - The command refuses when the row has optional scopes, unless you pass `--clear-optional-scopes` (`posthog/management/commands/seed_oauth_app_scopes.py:28-117`). + If the row has optional scopes, use Django admin. +- Other rows: every entry in `scopes` is required, and the consent screen grants all of them to every client of the row (`views.py:1678-1684, 1796-1829`). + Add the scopes to `optional_scopes` in Django admin instead. +- `file_system_shortcut:write` alone is enough. An explicit ceiling also allows the read half of each listed `:write` scope (`posthog/scopes.py:559`). +- Write down the values after the change, per region. + +**Order:** + +- Change the ceiling before the app release that requests the scopes. + Some paths answer `invalid_scope` instead of dropping the scope, and that blocks sign-in ([AUTH.md](AUTH.md), "Server-side ceiling"). +- A token refresh never widens a grant (`posthog/api/oauth/views.py:808-851`). + Existing users get the new scopes after one new sign-in. +- Items 3 (the alternative) and 4 add `file_system:read` or `file_system:write`. + Repeat this check for them. + +### Is #114532 deployed? + +- #114532 (`8f3821e77c5`, "feat(insights): add a dashboard-level compare filter") lets a dashboard `filters_override` carry `compareFilter`. + It is on master. We did not check its deploy to US and EU on 2026-10-14. +- To check, use one of these: + - Find the commit that each region runs. Then run `git merge-base --is-ancestor 8f3821e77c5 `. Exit code 0 means that the region has it. + - On a project you own, send `GET insights/{id}/?refresh=blocking&from_dashboard={d}&filters_override={"compareFilter":{"compare":true}}` for a trends tile. + With the change, the result series carry `compare_label` `current` and `previous` (`products/product_analytics/backend/hogql_queries/trends/trends_query_runner.py:664`). + Without it, `DashboardFilter` rejects the unknown key (`extra="forbid"`). The exact error is unverified. +- When both regions have it, set `DASHBOARD_COMPARE_SUPPORTED = true` in the app, and remove the note of the disabled toggle. + Dashboards then send `compareFilter: {compare: true}` in the override of the dashboard read and of each tile read. +- World map, calendar heatmap and proportion bar tiles ignore compare (`_accepts_dashboard_compare_filter`, `trends_query_runner.py:1320-1329`). + Show them without a compare row. +- Self-hosted instances on an older build do not accept the key. diff --git a/products/mobile/eas.json b/products/mobile/eas.json new file mode 100644 index 000000000000..cf4a0f7d8f4c --- /dev/null +++ b/products/mobile/eas.json @@ -0,0 +1,54 @@ +{ + "cli": { + "version": ">= 16.28.0", + "appVersionSource": "remote" + }, + "build": { + "base": { + "node": "24.13.0", + "pnpm": "10.29.3" + }, + "development": { + "extends": "base", + "developmentClient": true, + "distribution": "internal", + "channel": "development", + "environment": "development", + "env": { + "APP_VARIANT": "development" + } + }, + "preview": { + "extends": "base", + "distribution": "internal", + "channel": "preview", + "environment": "preview", + "env": { + "APP_VARIANT": "preview" + } + }, + "production": { + "extends": "base", + "autoIncrement": true, + "channel": "production", + "environment": "production", + "env": { + "APP_VARIANT": "production", + "EXPO_PUBLIC_SIGNUP_LINK": "0" + }, + "android": { + "buildType": "app-bundle" + } + } + }, + "submit": { + "production": { + "ios": { + "ascAppId": "6762366098" + }, + "android": { + "track": "internal" + } + } + } +} diff --git a/products/mobile/jest.config.js b/products/mobile/jest.config.js new file mode 100644 index 000000000000..e278df955c76 --- /dev/null +++ b/products/mobile/jest.config.js @@ -0,0 +1,9 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'jest-expo', + setupFiles: ['/jest.setup.ts'], + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, + testPathIgnorePatterns: ['/node_modules/', '/ios/', '/android/', '/dist/'], +} diff --git a/products/mobile/jest.setup.ts b/products/mobile/jest.setup.ts new file mode 100644 index 000000000000..b9fb3742ffd5 --- /dev/null +++ b/products/mobile/jest.setup.ts @@ -0,0 +1,29 @@ +import type { MetricChartProps } from '@/ui/chart/types' + +jest.mock('@react-native-async-storage/async-storage', () => + jest.requireActual('@react-native-async-storage/async-storage/jest/async-storage-mock') +) + +// Skia draws through a native module or CanvasKit wasm, and Jest has neither. A test that renders a screen with a +// chart gets an empty box of the same size and testID in its place. +jest.mock('@/ui/chart/MetricChartCanvas', () => { + const { createElement } = jest.requireActual('react') + const { View } = jest.requireActual('react-native') + const { CHART_DEFAULT_HEIGHT } = jest.requireActual('@/ui/chart/chartMath') + return { + MetricChartCanvas: ({ testID, height, label }: MetricChartProps) => + createElement(View, { + testID, + accessibilityLabel: `${label} chart`, + style: { height: height ?? CHART_DEFAULT_HEIGHT }, + }), + } +}) + +// The native browser module is unavailable in Jest; retain its callbacks so screen tests can exercise rendering. +jest.mock('react-native-webview', () => { + const { createElement } = jest.requireActual('react') + const { View } = jest.requireActual('react-native') + const WebView = (props: Record): ReturnType => createElement(View, props) + return { __esModule: true, default: WebView, WebView } +}) diff --git a/products/mobile/metro.config.js b/products/mobile/metro.config.js new file mode 100644 index 000000000000..70ef9e18e1b5 --- /dev/null +++ b/products/mobile/metro.config.js @@ -0,0 +1,5 @@ +const { getDefaultConfig } = require('expo/metro-config') + +const config = getDefaultConfig(__dirname) +config.transformer.babelTransformerPath = require.resolve('./metro.transformer.js') +module.exports = config diff --git a/products/mobile/metro.transformer.js b/products/mobile/metro.transformer.js new file mode 100644 index 000000000000..5de359e1415c --- /dev/null +++ b/products/mobile/metro.transformer.js @@ -0,0 +1,25 @@ +const path = require('node:path') + +const upstream = require( + require.resolve('@expo/metro-config/babel-transformer', { + paths: [path.dirname(require.resolve('expo/package.json'))], + }) +) + +// The WebView gets mermaid's browser bundle as text. Exporting it as a string +// keeps Hermes from running a DOM library and keeps it out of startup until a +// diagram requires it. +const RAW_MODULES = [path.join('mermaid', 'dist', 'mermaid.min.js')] + +module.exports = { + ...upstream, + transform(props) { + if (RAW_MODULES.some((suffix) => props.filename.endsWith(suffix))) { + return upstream.transform({ + ...props, + src: `module.exports = ${JSON.stringify(props.src)};`, + }) + } + return upstream.transform(props) + }, +} diff --git a/products/mobile/modules/hedgehog-widget/README.md b/products/mobile/modules/hedgehog-widget/README.md new file mode 100644 index 000000000000..2b9fb446d786 --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/README.md @@ -0,0 +1,205 @@ +# Hedgehog widget + +A home-screen widget for iOS and Android with a PostHog hedgehog that wanders, jumps, waves and dances. +Tapping the hog cycles through seven illustrated characters: coffee, party, wizard, explorer, superhero, reading and float. +The selected character stays until the next tap, including across app launches and daily script syncs. +The app has no hedgehog mode of its own yet; the widget is the only place the hedgehog appears. + +## How it moves + +Widgets cannot run JavaScript and cannot animate at frame rate on their own. +So the app's JavaScript plans the choreography and the widget plays it back: + +1. `src/features/hedgehog/choreography.ts` generates a deterministic, seeded script for the day. + The motion comes from `src/features/hedgehog/physics.ts`, a 60 Hz port of the hedgehog actor in `@posthog/hedgehog-mode` + (the Matter.js body, the sprite rule and the AI that the web and desktop apps run), so the widget hedgehog walks, + jumps, bounces and waves with the same speeds and timing. See "Physics" below. +2. `useHedgehogWidgetSync` hands the script to this native module on launch and when the app returns to the foreground. + The module writes it to shared storage the widget can read (an App Group on iOS, SharedPreferences on Android) and asks the OS to refresh the widget. +3. Each platform plays the script with what the OS allows: + - **iOS (WidgetKit)** cannot animate continuously, so it uses the script's _keyframes_, one timeline entry per 2-second beat. + From iOS 17 WidgetKit animates the change between entries, and each keyframe's `motion` says what that change is. + A walk glides x linearly over the whole beat, at hedgehog-mode's walking speed. A jump plays its main hop for its + `airtime`, then up to two bounces as delayed, nested full-turn rotations: each is a loop about a point above the + sprite, sized by its `apex`, with a counter-rotation that keeps the sprite upright. `turns` adds whole flips during + the main hop. A jump entry shows the `fall` sprite (the landed `jump` frame with reduce motion), because the hog is + airborne for most of the beat. A geometry group keeps the button's content updates from overriding its position + animation; facing changes do not insert a stationary beat. Every entry ends upright on the ground, so a delayed or + skipped update cannot leave the hog upside down. Image changes and facing changes do not crossfade or spring. + Scripts without `motion` (older app versions) hop in place from `y` and `turn` and glide walk keyframes as before. + The widget saves its scheduled entries per family: tapping changes only the character, and a refresh resumes the + existing timeline exactly. After it expires, the new routine starts at the last position; a plan that starts away + from the script walks over at walking speed first. Dialogue is not rendered on either platform, including when + reading an older script. The hedgehog is an interactive `Button(intent:)`; a tap anywhere else opens the app. + - **Android (RemoteViews)** uses `ViewFlipper` to play a closed loop, four frames per 120 ms tick (one every 30 ms, about 33 fps). + Each frame interpolates the position between two ticks and shows the next sprite frame: ticks sample the 30 fps + sheets 3 or 4 frames apart, so the frames in between fill the gap. A pose change holds the tick's sprite frame, + except at a takeoff: frames that rise toward a `fall` tick already show `fall`. `y` 1 is the highest bottom offset + that keeps the sprite inside the widget, so a jump arcs up to the top edge instead of flattening against it. + The sprite uses the script's facing. When the facing changes before the next tick, the frames in between face the + way they move, so a turn shows once and the hog never walks backwards. A tick without `facing` faces the way it walks. + A placed widget keeps its loop across daily script syncs. The saved loop's key carries a version (`loop.v2.`), + so a renderer update that changes loops replaces the pinned loop once. Android 12+ reuses stable frame views so taps, refreshes + and resizing retain the launcher's displayed frame. On older Android versions, a tap carries the visible frame's + index and restores it when the views are rebuilt; an OS recreation or resize may restart the loop. + There are no periodic loop replacements or reaction-end alarms. Each frame's sprite sends its own poke broadcast, + scoped to that widget, and a tap anywhere else opens the app. +4. With no script in shared storage (the app was never opened) each platform plays a small built-in fallback. + +### Physics + +The simulation uses sprite heights (one 80 px sprite) as its unit and hedgehog-mode's constants: + +| | hedgehog-mode (Matter.js) | Simulation | +| ------------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| Step | 60 Hz runner | fixed 1/60 s | +| Gravity | `gravity.y` 2, scale 0.001 | 25 sprites/s² | +| Jump | velocity y -15 px/step, at most 2 jumps before landing | 11.25 sprites/s | +| Walk | `walkSpeed` ±1 px/step sets x velocity every step | 0.75 sprites/s | +| Air friction | `frictionAir` 0.01 | velocity × 0.99 per step | +| Bounce | `restitution` 0.5 | rebounds at half the impact speed; a rebound under 1.5 sprites/s settles | +| Sprites | airborne `fall`, sliding `walk`, else `idle`; 30 frames/s | same | +| AI | `walk` 10, `wait` 3, `jump` 1, `wave` 1, a new action every 1 s; a wave adds its animation and 1 s | same | + +With air friction a jump from the ground flies 0.8 s, peaks at 2.05 sprites, and bounces twice (0.35 s and 0.15 s). +The AI runs once per 2-second beat for iOS keyframes and once per second for Android loops. + +Deviations from hedgehog-mode: + +- At an edge the hedgehog turns around. hedgehog-mode wraps to the other side, which looks like a teleport on a widget. +- `x` 0..1 spans a travel width of 2.8 sprites (a medium iOS widget or a 4x2 Android widget). + The widget is shorter than a jump, so `y` 1 is the apex of a jump from the ground. Timing is unchanged. +- A wave plants the feet first. In hedgehog-mode the slide after a walk replaces the wave with the walk sprite. +- AI decisions stay on the cadence grid, so a wave and its rest take two 1-second slots (1.87 s in hedgehog-mode). +- iOS keyframes add a rare flip (weight 0.3), a jump that the renderer turns over once. hedgehog-mode never rotates the hedgehog. +- Ground friction is a fixed slowdown that stops a walk within about 5 steps, close to Matter's friction impulse. + +## Choreography JSON (version 1) + +```ts +type Pose = 'idle' | 'walk' | 'jump' | 'wave' | 'action' | 'fall' +type Facing = 'left' | 'right' + +interface Tick { + pose: Pose + frame: number // index into the pose's sprite frames, see "Sprites" + x: number // 0..1, centre of the hedgehog across the widget width (0 = left edge, 1 = right edge; renderers clamp so it stays inside) + y: number // 0..1, height above the floor: 0 = feet on the floor, 1 = top of the available rise (the apex of a jump from the ground) + facing: Facing + motion?: Motion // iOS keyframes only, see below + bubble?: string // legacy dialogue; ignored by the native renderers + turn?: number // legacy half-turn markers; iOS plays one complete flip when entering an airborne sequence + hoggie?: 'coffee_cup' | 'party' | 'wizard' | 'explorer' | 'superhero' | 'reading' | 'float' + // A bundled illustration replaces the pixel sprite. Missing/unknown artwork falls back to the pixel sprite. + // Consecutive ticks with a turn add up, and the run ends on whole turns with y 0, so the hedgehog lands upright +} + +// How the hedgehog gets from the previous keyframe to this one within the beat. Every keyframe with a motion has y 0. +// Its pose and frame are the sprite the beat shows most: `fall` for a jump, `wave` for a wave, else `walk` or `idle`. +type Motion = + | { kind: 'idle' } // stands still; x may differ by up to 0.02 from the slide after a walk; facing may change + | { kind: 'walk' } // x glides linearly from the previous keyframe's x to this x over the whole beat + | { + kind: 'jump' + airtime: number // seconds, takes off at the start of the beat + apex: number // 0..1 of the available rise + bounces: { airtime: number; apex: number }[] // in order after the landing; airtime + all bounce airtimes <= keyframeSec + turns?: number // whole flips during the main airtime, clockwise on screen (negative = counterclockwise) + } +// A jump does not move x: it stays at the previous keyframe's x. Legacy `turn` markers are not used by keyframes with a motion. + +interface Choreography { + version: 1 + seed: number + generatedAt: string // ISO 8601 UTC + startsAt: string // ISO 8601 UTC; keyframes[0] is shown from here + keyframeSec: 2 // iOS: one keyframe every keyframeSec seconds + keyframes: Tick[] // 900 (30 minutes). A renderer that runs past the end wraps around (index modulo length), and the last keyframe leads back to the first + tickMs: 120 // Android: one tick every tickMs milliseconds + loopMinutes: 10 // Android: loop index = floor(minutesSince(startsAt) / loopMinutes) modulo loops.length + loops: Tick[][] // each loop is a closed cycle (last tick ends near the first) of at most 120 ticks + // Loop ticks sample the simulation every tickMs: y follows the jump arc, pose is `fall` while airborne, and frames advance at 30 per second (3 or 4 per tick) + reaction: { keyframes: Tick[]; ticks: Tick[] } // legacy tap reaction, retained for compatibility with older native builds. + surprises?: { keyframes: Tick[]; ticks: Tick[] }[] // legacy illustrated reactions, retained for older native builds + // Reaction ticks are authored at x 0.5 facing right. A renderer shifts every x by (current x - 0.5), clamps, and keeps the current facing, so the hedgehog reacts where it stands. +} +``` + +Illustrations and surprises remain in version 1 for older native builds. Current widgets ignore scripted character +changes and apply their saved selection to every movement frame. Every tap advances to a different bundled character. +New scripts contain no automatic visitor scenes. The first character is the pixel hedgehog until the first tap. + +Shared storage: + +| Platform | Store | Keys | +| -------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| iOS | `UserDefaults(suiteName: "group.")`, for example `group.com.posthog.code.mobile` | `choreography` (JSON string); per family: `selectedHog.` and `timeline.v2.` (encoded scheduled entries) | +| Android | `SharedPreferences` file `hedgehog_widget` | `choreography` (JSON string); per widget ID: `selectedHog.` and `loop.v2.` (saved movement loop; the version in the key replaces loops pinned by older renderers once) | + +## Garden background + +Both widgets bundle the grass layer from PostHog's **Keyboard Garden** website wallpaper, used by +[`src/components/Desktop/Wallpapers.tsx`](https://github.com/PostHog/posthog.com/blob/master/src/components/Desktop/Wallpapers.tsx). +The source is [`9000_bg_light_07316896be.jpg`](https://res.cloudinary.com/dmukukwp6/image/upload/9000_bg_light_07316896be.jpg), +downloaded at 1000px wide (`/image/upload/w_1000/9000_bg_light_07316896be.jpg`). +It is bundled as `hoglet_garden.jpg` in both native asset catalogs and fills the widget with a centered crop. +There is no runtime image download. + +## Native timeline check + +From `products/mobile` on macOS: + +```sh +swiftc targets/HedgehogWidget/{Choreography,HedgehogTimelineProvider}.swift scripts/check_hedgehog_timeline.swift -o /tmp/check-hoglet +/tmp/check-hoglet [choreography.json ...] +``` + +This checks the built-in routine, an inline contract sample and each JSON script given as an argument. +Walk entries glide over the whole beat; jump entries take their airtime, bounces and flips from `motion` and end upright on the floor. +Scripts without `motion` still play. The check also covers tap and reload continuity, character cycling, timeline persistence, +delayed refreshes and timelines saved by older builds. + +## Native module API (`modules/hedgehog-widget`) + +```ts +import { isAvailable, setChoreography, clearChoreography } from 'hedgehog-widget' + +isAvailable(): boolean // false on web and when the native module is missing +setChoreography(json: string): void // persist the script and refresh every placed widget +clearChoreography(): void // remove the source script; new routines use the fallback, placed widgets retain their current motion +``` + +## Sprites + +Frames come from `@posthog/hedgehog-mode`'s TexturePacker atlas (`assets/sprites.json` + `assets/sprites.png`), default skin only. +`scripts/extract_hedgehog_sprites.py` cuts each 80x80 frame out, upscales it 2x with nearest-neighbour (pixel art stays crisp), and writes: + +- Android: `modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog__.png`, plus a mirrored `hog___l.png` for `facing: 'left'` (RemoteViews cannot flip an ImageView) +- iOS: `targets/HedgehogWidget/Assets.xcassets/hog__.imageset/` (with `Contents.json`) + +`` is the zero-padded frame index. Frame counts: idle 25, walk 11, jump 10, wave 26, action 16, fall 9. +Every atlas frame faces right. iOS mirrors with `scaleEffect(x: -1)`; Android uses the `_l` resources. +The sprite's feet sit about 4px above the bottom of the 160px cell (opaque body x 28..130, y 36..156 in `hog_idle_00`), so put the cell's bottom edge on the floor line. Jump and fall frames do not move inside the cell; height comes only from `y`. + +### Illustrated visitors + +The illustrations come from [PostHog's hoggies library](https://brand.posthog.com/hoggies): +`coffee-cup.png`, `party.png`, `wizard-1.png`, `explorer.png`, `superhero.png`, `reading.png` and `float.png`. +Download them from `https://brand.posthog.com/hoggies/` into a local directory and name `wizard-1.png` as `wizard.png`. +Run `scripts/prepare_widget_hoggies.py --source ` with Pillow available to regenerate both platforms' assets. +It trims transparent margins, normalizes facing, and fits each illustration into a 320px transparent cell with its bottom at 312px. +Artwork fits within x 40..280 and y 40..312; SwiftUI uses those bounds when clamping visitors inside the widget. +Android also bundles mirrored `_l` copies. The widget never downloads artwork at runtime. + +## Identifiers + +| | iOS | Android | +| ------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------- | +| Widget | kind `HedgehogWidget`, target `HedgehogWidget`, bundle id `.HedgehogWidget` | receiver `HedgehogWidgetProvider` | +| App Group | `group.` | n/a | +| Tap on the hedgehog | `PokeHedgehogIntent` (AppIntent) | broadcast `com.posthog.code.mobile.HEDGEHOG_POKE` | +| Tap anywhere else | opens the app (default widget tap) | launch intent on `hedgehog_root` | + +The app bundle id depends on the build variant: `com.posthog.code.mobile` for store builds, with `.dev` or `.preview` appended for internal builds (see `src/lib/appVariants.json`). +Both Swift readers derive the App Group from their own bundle id, so the variants keep separate widget stores. +On Android the broadcasts name the provider class, so the same action strings stay inside each installed variant. diff --git a/products/mobile/modules/hedgehog-widget/android/build.gradle b/products/mobile/modules/hedgehog-widget/android/build.gradle new file mode 100644 index 000000000000..ff52387d091c --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/android/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'com.posthog.code.mobile' +version = '1.0.0' + +android { + namespace "com.posthog.code.mobile.hedgehog" + defaultConfig { + versionCode 1 + versionName '1.0.0' + } +} diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/AndroidManifest.xml b/products/mobile/modules/hedgehog-widget/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..542809c2f120 --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/android/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/java/com/posthog/code/mobile/hedgehog/Choreography.kt b/products/mobile/modules/hedgehog-widget/android/src/main/java/com/posthog/code/mobile/hedgehog/Choreography.kt new file mode 100644 index 000000000000..be5f2666e0cb --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/android/src/main/java/com/posthog/code/mobile/hedgehog/Choreography.kt @@ -0,0 +1,174 @@ +package com.posthog.code.mobile.hedgehog + +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone +import kotlin.math.abs + +enum class Facing { LEFT, RIGHT } + +data class Tick( + val pose: String, + val frame: Int, + val x: Float, + val y: Float, + val facing: Facing, + val hoggie: String? = null, + val bubble: String? = null, +) + +data class Choreography( + val startsAtMs: Long, + val tickMs: Int, + val loopMinutes: Int, + val loops: List>, + val reactionTicks: List, + val surprises: List> = emptyList(), +) { + fun reactionAt(pokedAtMs: Long): List = + if (surprises.isEmpty()) reactionTicks else surprises[Math.floorMod(pokedAtMs, surprises.size.toLong()).toInt()] + + fun loopAt(nowMs: Long): List { + val slot = Math.floorDiv(nowMs - startsAtMs, loopMinutes * MS_PER_MINUTE) + return loops[Math.floorMod(slot, loops.size.toLong()).toInt()] + } + + fun nextSlotStartMs(nowMs: Long): Long { + val slotMs = loopMinutes * MS_PER_MINUTE + return startsAtMs + (Math.floorDiv(nowMs - startsAtMs, slotMs) + 1) * slotMs + } + + val reactionDurationMs: Long + get() = reactionTicks.size.toLong() * tickMs + + companion object { + const val MAX_TICKS = 120 + private const val MIN_TRAVEL = 0.001f + private const val MS_PER_MINUTE = 60_000L + private const val TAG = "HedgehogWidget" + private val POSES = setOf("idle", "walk", "jump", "wave", "action", "fall") + + /** Returns null for anything this renderer cannot play, so the caller falls back. */ + fun parse(json: String): Choreography? = + try { + parseOrThrow(JSONObject(json)) + } catch (e: Exception) { + Log.w(TAG, "Ignoring an unreadable choreography", e) + null + } + + private fun parseOrThrow(root: JSONObject): Choreography? { + val version = root.optInt("version", -1) + if (version != 1) { + Log.w(TAG, "Ignoring choreography version $version") + return null + } + val loopsJson = root.optJSONArray("loops") ?: return null + val loops = (0 until loopsJson.length()) + .mapNotNull { loopsJson.optJSONArray(it)?.let(::parseTicks) } + .filter { it.isNotEmpty() } + if (loops.isEmpty()) { + Log.w(TAG, "Choreography has no playable loops") + return null + } + val reaction = root.optJSONObject("reaction")?.optJSONArray("ticks")?.let(::parseTicks).orEmpty() + return Choreography( + startsAtMs = parseIsoMs(root.optString("startsAt")) ?: 0L, + tickMs = root.optInt("tickMs", DEFAULT_TICK_MS).coerceIn(40, 2_000), + loopMinutes = root.optInt("loopMinutes", DEFAULT_LOOP_MINUTES).coerceIn(1, 24 * 60), + loops = loops, + reactionTicks = reaction, + surprises = root.optJSONArray("surprises")?.let { array -> + (0 until array.length()).mapNotNull { + array.optJSONObject(it)?.optJSONArray("ticks")?.let(::parseTicks)?.takeIf { ticks -> ticks.isNotEmpty() } + } + }.orEmpty(), + ) + } + + /** A tick without `facing` faces the way it walks, or keeps the previous tick's facing. */ + private fun parseTicks(array: JSONArray): List { + var facing = Facing.RIGHT + var previousX: Float? = null + return (0 until minOf(array.length(), MAX_TICKS)).mapNotNull { index -> + val tick = array.optJSONObject(index) ?: return@mapNotNull null + val pose = tick.optString("pose") + if (pose !in POSES) return@mapNotNull null + val x = unit(tick.optDouble("x", 0.5)) + val walked = previousX?.let { x - it }?.takeIf { pose == "walk" && abs(it) > MIN_TRAVEL } + facing = when (tick.optString("facing")) { + "left" -> Facing.LEFT + "right" -> Facing.RIGHT + else -> walked?.let { if (it < 0) Facing.LEFT else Facing.RIGHT } ?: facing + } + previousX = x + Tick( + pose = pose, + frame = tick.optInt("frame", 0).coerceAtLeast(0), + x = x, + y = unit(tick.optDouble("y", 0.0)), + facing = facing, + hoggie = tick.optString("hoggie").takeIf { it.isNotEmpty() }, + bubble = tick.optString("bubble").takeIf { it.isNotEmpty() }?.take(24), + ) + } + } + + private fun unit(value: Double): Float = if (value.isNaN()) 0f else value.toFloat().coerceIn(0f, 1f) + + // java.time needs API 26 and the app supports API 24, so parse the two shapes Date.toISOString() and Swift emit. + private fun parseIsoMs(value: String): Long? { + for (pattern in listOf("yyyy-MM-dd'T'HH:mm:ss.SSSX", "yyyy-MM-dd'T'HH:mm:ssX")) { + val format = SimpleDateFormat(pattern, Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") } + runCatching { format.parse(value)?.time }.getOrNull()?.let { return it } + } + Log.w(TAG, "Unreadable startsAt '$value', using the epoch") + return null + } + } +} + +const val DEFAULT_TICK_MS = 120 +const val DEFAULT_LOOP_MINUTES = 10 + +/** Played until the app has written a choreography: walk right, wave, walk back. */ +object FallbackChoreography { + private const val WALK_FRAMES = 11 + private const val WAVE_FRAMES = 26 + private const val WALK_TICKS = 30 + private const val LEFT_X = 0.2f + private const val RIGHT_X = 0.8f + + val value: Choreography by lazy { + Choreography( + startsAtMs = 0L, + tickMs = DEFAULT_TICK_MS, + loopMinutes = DEFAULT_LOOP_MINUTES, + loops = listOf(loop()), + reactionTicks = (0 until WAVE_FRAMES).map { Tick("wave", it, 0.5f, 0f, Facing.RIGHT) }, + surprises = listOf("party", "wizard", "superhero").map { hoggie -> + (0 until 40).map { index -> + val progress = index / 39f + Tick("idle", 0, 0.5f, 0.8f * progress * (1 - progress), Facing.RIGHT, hoggie, "Hi there!") + } + }, + ) + } + + private fun loop(): List { + val walkRight = (0 until WALK_TICKS).map { + Tick("walk", it % WALK_FRAMES, lerp(LEFT_X, RIGHT_X, it / WALK_TICKS.toFloat()), 0f, Facing.RIGHT) + } + val wave = (0 until WAVE_FRAMES).map { Tick("wave", it, RIGHT_X, 0f, Facing.LEFT) } + val walkBack = (0 until WALK_TICKS).map { + Tick("walk", it % WALK_FRAMES, lerp(RIGHT_X, LEFT_X, it / WALK_TICKS.toFloat()), 0f, Facing.LEFT) + } + val rest = (0 until 20).map { Tick("idle", it, LEFT_X, 0f, Facing.RIGHT) } + return walkRight + wave + walkBack + rest + } + + private fun lerp(from: Float, to: Float, t: Float): Float = from + (to - from) * t +} diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/java/com/posthog/code/mobile/hedgehog/HedgehogRenderer.kt b/products/mobile/modules/hedgehog-widget/android/src/main/java/com/posthog/code/mobile/hedgehog/HedgehogRenderer.kt new file mode 100644 index 000000000000..848b49201a88 --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/android/src/main/java/com/posthog/code/mobile/hedgehog/HedgehogRenderer.kt @@ -0,0 +1,247 @@ +package com.posthog.code.mobile.hedgehog + +import android.app.AlarmManager +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Parcel +import android.util.Log +import android.util.TypedValue +import android.widget.RemoteViews +import java.util.Locale +import kotlin.math.abs +import kotlin.math.roundToInt + +object HedgehogRenderer { + const val TAG = "HedgehogWidget" + const val ACTION_POKE = "com.posthog.code.mobile.HEDGEHOG_POKE" + const val ACTION_ADVANCE = "com.posthog.code.mobile.HEDGEHOG_ADVANCE" + + private const val FALLBACK_SPRITE = "hog_idle_00" + private const val REQUEST_OPEN = 2 + private const val REQUEST_ADVANCE = 3 + + // A 4x2 widget on a phone, used when the launcher has not reported a size yet. + private const val DEFAULT_WIDTH_DP = 250 + private const val DEFAULT_HEIGHT_DP = 110 + + // Frame counts of the bundled hog__ sheets. + private val FRAME_COUNTS = mapOf("idle" to 25, "walk" to 11, "jump" to 10, "wave" to 26, "action" to 16, "fall" to 9) + private const val MAX_FRAME_ADVANCE = 6 + private const val MIN_TRAVEL = 0.001f + + private val spriteIds = HashMap() + private var cachedJson: String? = null + private var cachedChoreography: Choreography? = null + + fun widgetIds(context: Context): IntArray = + AppWidgetManager.getInstance(context) + .getAppWidgetIds(ComponentName(context, HedgehogWidgetProvider::class.java)) + + /** Catches everything, because an exception thrown from a broadcast receiver crashes the whole app process. */ + fun renderAll(context: Context, ids: IntArray = widgetIds(context)) { + try { + val manager = AppWidgetManager.getInstance(context) + val choreography = choreography(context) + val now = System.currentTimeMillis() + for (id in ids) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S || HedgehogStore.loop(context, id) == null) { + manager.updateAppWidget(id, build(context, manager, id, choreography, now)) + } + } + cancelAlarms(context) + } catch (e: Exception) { + Log.e(TAG, "Could not render the hedgehog widget", e) + } + } + + fun renderOne(context: Context, manager: AppWidgetManager, id: Int, frameIndex: Int? = null) { + try { + val choreography = choreography(context) + val now = System.currentTimeMillis() + manager.updateAppWidget(id, build(context, manager, id, choreography, now, frameIndex)) + } catch (e: Exception) { + Log.e(TAG, "Could not render hedgehog widget $id", e) + } + } + + @Synchronized + fun choreography(context: Context): Choreography { + val json = HedgehogStore.choreographyJson(context) ?: return FallbackChoreography.value + if (json != cachedJson) { + cachedJson = json + cachedChoreography = Choreography.parse(json) + } + return cachedChoreography ?: FallbackChoreography.value + } + + private fun build( + context: Context, + manager: AppWidgetManager, + id: Int, + choreography: Choreography, + now: Long, + frameIndex: Int? = null, + ): RemoteViews { + val pkg = context.packageName + val resources = context.resources + val density = resources.displayMetrics.density + val options = manager.getAppWidgetOptions(id) + // Portrait size: the launcher reports min width and max height for the portrait cell grid. + val widthPx = (options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH).takeIf { it > 0 } ?: DEFAULT_WIDTH_DP) * density + val heightPx = (options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT).takeIf { it > 0 } ?: DEFAULT_HEIGHT_DP) * density + val floorPx = resources.getDimensionPixelSize(R.dimen.hedgehog_floor_inset) + + val loop = HedgehogStore.loop(context, id) ?: choreography.loopAt(now) + .take(Choreography.MAX_TICKS).map { it.copy(hoggie = null, bubble = null) } + .also { HedgehogStore.saveLoop(context, id, it) } + val substeps = ((choreography.tickMs + 32) / 33).coerceIn(1, 4) + val character = HedgehogStore.selected(context, id) + val ticks = loop.flatMapIndexed { index, tick -> + val next = loop[(index + 1) % loop.size] + List(substeps) { part -> + val progress = part.toFloat() / substeps + // Substeps that rise toward an airborne tick are off the ground, so they show `fall` like the simulation. + val takingOff = part > 0 && next.pose == "fall" && tick.pose != "fall" + tick.copy( + pose = if (takingOff) next.pose else tick.pose, + frame = if (takingOff) fallFrame(next, substeps - part) else substepFrame(tick, next, part, substeps), + x = tick.x + (next.x - tick.x) * progress, + y = tick.y + (next.y - tick.y) * progress, + facing = substepFacing(tick, next, part), + hoggie = character, + bubble = null, + ) + } + } + val root = RemoteViews(pkg, R.layout.hedgehog_widget) + root.removeAllViews(R.id.hedgehog_flipper) + for ((index, tick) in ticks.withIndex()) { + val largeIllustration = heightPx >= 140 * density + val spritePx = resources.getDimensionPixelSize(if (largeIllustration) R.dimen.hoggie_sprite_size else R.dimen.hedgehog_sprite_size) + // The same centre/lift envelope for every character prevents a tap from shifting its feet. + val envelope = spritePx + val margin = envelope / 2f + val artInset = envelope * 0.125f + val minBottom = minOf(floorPx.toFloat(), (heightPx - envelope).coerceAtLeast(0f)) + val maxBottom = (heightPx - envelope + artInset).coerceAtLeast(minBottom) + val stableFrames = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val frame = RemoteViews(pkg, if (!stableFrames && largeIllustration) R.layout.hedgehog_hoggie_frame else R.layout.hedgehog_frame) + if (stableFrames) { + frame.setViewLayoutWidth(R.id.hedgehog_sprite, spritePx.toFloat(), TypedValue.COMPLEX_UNIT_PX) + frame.setViewLayoutHeight(R.id.hedgehog_sprite, spritePx.toFloat(), TypedValue.COMPLEX_UNIT_PX) + } + frame.setImageViewResource(R.id.hedgehog_sprite, spriteId(context, tick)) + val left = (margin + tick.x * (widthPx - 2 * margin).coerceAtLeast(0f) - spritePx / 2f).roundToInt() + // `y` 1 is the top of the rise that keeps the sprite inside the widget, so a jump apex is not clipped flat. + val bottom = (floorPx + tick.y * (maxBottom - floorPx).coerceAtLeast(0f)).coerceIn(minBottom, maxBottom).roundToInt() + frame.setViewPadding(R.id.hedgehog_frame, left, 0, 0, bottom) + // The flipper hides every frame except the one on screen, so a tap reaches the sprite where it is now. + frame.setOnClickPendingIntent(R.id.hedgehog_sprite, pokeIntent(context, id, index)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Reapply the same child views in place, retaining the launcher's actual displayed frame. + root.addStableView(R.id.hedgehog_flipper, frame, index) + } else { + root.addView(R.id.hedgehog_flipper, frame) + } + } + root.setInt(R.id.hedgehog_flipper, "setFlipInterval", choreography.tickMs / substeps) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S && frameIndex != null) { + root.setDisplayedChild(R.id.hedgehog_flipper, Math.floorMod(frameIndex, ticks.size)) + } + // A tap that misses the sprite falls through the frames, which are not clickable, to the root. + openAppIntent(context)?.let { root.setOnClickPendingIntent(R.id.hedgehog_root, it) } + + if (Log.isLoggable(TAG, Log.DEBUG)) { + val parcel = Parcel.obtain() + try { + root.writeToParcel(parcel, 0) + Log.d(TAG, "widget $id: ${ticks.size} ticks, ${widthPx.roundToInt()}x${heightPx.roundToInt()}px, parcel ${parcel.dataSize()} bytes") + } finally { + parcel.recycle() + } + } + return root + } + + /** + * Ticks sample a 30 fps sprite sheet, so consecutive ticks of one pose skip 3 or 4 frames. Substeps fill in the + * skipped frames. A pose change or a larger gap is a restarted animation, so those substeps hold the tick's frame. + */ + private fun substepFrame(tick: Tick, next: Tick, part: Int, substeps: Int): Int { + val count = FRAME_COUNTS[tick.pose] ?: return tick.frame + if (part == 0 || next.pose != tick.pose) return tick.frame + val advance = Math.floorMod(next.frame - tick.frame, count) + if (advance > MAX_FRAME_ADVANCE) return tick.frame + return (tick.frame + (advance * part + substeps / 2) / substeps) % count + } + + /** The `fall` frame shown `framesBefore` substeps before the next tick, at about one sprite frame per substep. */ + private fun fallFrame(next: Tick, framesBefore: Int): Int = + Math.floorMod(next.frame - framesBefore, FRAME_COUNTS.getValue("fall")) + + /** + * The script's facing is the simulation's facing at the tick. When it changes before the next tick, the hedgehog + * turned somewhere in between, so the substeps face the way they move instead of walking backwards. + */ + private fun substepFacing(tick: Tick, next: Tick, part: Int): Facing { + if (part == 0 || next.facing == tick.facing || abs(next.x - tick.x) <= MIN_TRAVEL) return tick.facing + return if (next.x < tick.x) Facing.LEFT else Facing.RIGHT + } + + @Synchronized + private fun hoggieId(context: Context, tick: Tick): Int { + val name = "hoggie_${tick.hoggie}${if (tick.facing == Facing.LEFT) "_l" else ""}" + return spriteIds.getOrPut(name) { context.resources.getIdentifier(name, "drawable", context.packageName) } + } + + @Synchronized + private fun spriteId(context: Context, tick: Tick): Int { + if (tick.hoggie != null) { + val id = hoggieId(context, tick) + if (id != 0) return id + } + val name = String.format(Locale.US, "hog_%s_%02d%s", tick.pose, tick.frame, if (tick.facing == Facing.LEFT) "_l" else "") + return spriteIds.getOrPut(name) { + val id = context.resources.getIdentifier(name, "drawable", context.packageName) + if (id != 0) id else context.resources.getIdentifier(FALLBACK_SPRITE, "drawable", context.packageName) + } + } + + private fun pokeIntent(context: Context, id: Int, frameIndex: Int): PendingIntent = + PendingIntent.getBroadcast( + context, + 0, + Intent(context, HedgehogWidgetProvider::class.java).setAction(ACTION_POKE) + .setData(Uri.parse("posthog-widget://poke/$id/$frameIndex")) + .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id) + .putExtra("frameIndex", frameIndex), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + private fun openAppIntent(context: Context): PendingIntent? { + val launch = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null + return PendingIntent.getActivity( + context, + REQUEST_OPEN, + launch, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + private fun advanceIntent(context: Context): PendingIntent = + PendingIntent.getBroadcast( + context, + REQUEST_ADVANCE, + Intent(context, HedgehogWidgetProvider::class.java).setAction(ACTION_ADVANCE), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + fun cancelAlarms(context: Context) { + context.getSystemService(AlarmManager::class.java)?.cancel(advanceIntent(context)) + } +} diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/java/com/posthog/code/mobile/hedgehog/HedgehogStore.kt b/products/mobile/modules/hedgehog-widget/android/src/main/java/com/posthog/code/mobile/hedgehog/HedgehogStore.kt new file mode 100644 index 000000000000..cd38de13bc4d --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/android/src/main/java/com/posthog/code/mobile/hedgehog/HedgehogStore.kt @@ -0,0 +1,60 @@ +package com.posthog.code.mobile.hedgehog + +import android.content.Context +import android.content.SharedPreferences +import org.json.JSONArray +import org.json.JSONObject + +/** The SharedPreferences contract shared with the app's JavaScript, see modules/hedgehog-widget/README.md. */ +object HedgehogStore { + private const val FILE = "hedgehog_widget" + private const val KEY_CHOREOGRAPHY = "choreography" + // Bump when loops change meaning, so a placed widget drops its pinned loop once and picks up the new script. + private const val KEY_LOOP = "loop.v2" + private const val KEY_LEGACY_LOOP = "loop" + + private fun prefs(context: Context): SharedPreferences = + context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE) + + fun choreographyJson(context: Context): String? = prefs(context).getString(KEY_CHOREOGRAPHY, null) + + // commit() rather than apply() so the widget refresh that follows reads the new script from disk-backed state. + fun setChoreographyJson(context: Context, json: String?) { + val editor = prefs(context).edit() + if (json == null) editor.remove(KEY_CHOREOGRAPHY) else editor.putString(KEY_CHOREOGRAPHY, json) + editor.commit() + } + + private val characters = listOf("coffee_cup", "party", "wizard", "explorer", "superhero", "reading", "float") + + fun selected(context: Context, id: Int): String? = prefs(context).getString("selectedHog.$id", null) + + fun selectNext(context: Context, id: Int) { + val next = (characters.indexOf(selected(context, id)) + 1) % characters.size + prefs(context).edit().putString("selectedHog.$id", characters[next]).commit() + } + + // Pin the loop while a widget is placed. Daily sync and launcher refreshes must not restart a ViewFlipper. + fun loop(context: Context, id: Int): List? = runCatching { + val json = prefs(context).getString("$KEY_LOOP.$id", null) ?: return null + val array = JSONArray(json) + (0 until array.length()).map { index -> + val tick = array.getJSONObject(index) + Tick(tick.getString("pose"), tick.getInt("frame"), tick.getDouble("x").toFloat(), + tick.getDouble("y").toFloat(), Facing.valueOf(tick.getString("facing"))) + }.takeIf { it.isNotEmpty() } + }.getOrNull() + + fun saveLoop(context: Context, id: Int, ticks: List) { + val array = JSONArray() + ticks.forEach { tick -> + array.put(JSONObject().put("pose", tick.pose).put("frame", tick.frame) + .put("x", tick.x).put("y", tick.y).put("facing", tick.facing.name)) + } + prefs(context).edit().putString("$KEY_LOOP.$id", array.toString()).remove("$KEY_LEGACY_LOOP.$id").commit() + } + + fun remove(context: Context, id: Int) { + prefs(context).edit().remove("$KEY_LOOP.$id").remove("$KEY_LEGACY_LOOP.$id").remove("selectedHog.$id").commit() + } +} diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/java/com/posthog/code/mobile/hedgehog/HedgehogWidgetModule.kt b/products/mobile/modules/hedgehog-widget/android/src/main/java/com/posthog/code/mobile/hedgehog/HedgehogWidgetModule.kt new file mode 100644 index 000000000000..e033bfbe000d --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/android/src/main/java/com/posthog/code/mobile/hedgehog/HedgehogWidgetModule.kt @@ -0,0 +1,27 @@ +package com.posthog.code.mobile.hedgehog + +import expo.modules.kotlin.exception.Exceptions +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import kotlin.concurrent.thread + +class HedgehogWidgetModule : Module() { + override fun definition() = ModuleDefinition { + Name("HedgehogWidget") + + Function("setChoreography") { json: String -> + store(json) + } + + Function("clearChoreography") { + store(null) + } + } + + private fun store(json: String?) { + val context = appContext.reactContext?.applicationContext ?: throw Exceptions.ReactContextLost() + HedgehogStore.setChoreographyJson(context, json) + // A full day of loops is a large JSON document, so parse and render off the JS thread. + thread(name = "HedgehogWidgetRender") { HedgehogRenderer.renderAll(context) } + } +} diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/java/com/posthog/code/mobile/hedgehog/HedgehogWidgetProvider.kt b/products/mobile/modules/hedgehog-widget/android/src/main/java/com/posthog/code/mobile/hedgehog/HedgehogWidgetProvider.kt new file mode 100644 index 000000000000..f67f08b54621 --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/android/src/main/java/com/posthog/code/mobile/hedgehog/HedgehogWidgetProvider.kt @@ -0,0 +1,52 @@ +package com.posthog.code.mobile.hedgehog + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.util.Log + +class HedgehogWidgetProvider : AppWidgetProvider() { + override fun onReceive(context: Context, intent: Intent) { + try { + when (intent.action) { + HedgehogRenderer.ACTION_POKE -> poke(context, intent) + HedgehogRenderer.ACTION_ADVANCE -> HedgehogRenderer.renderAll(context) + else -> super.onReceive(context, intent) + } + } catch (e: Exception) { + Log.e(HedgehogRenderer.TAG, "Hedgehog widget broadcast ${intent.action} failed", e) + } + } + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + appWidgetIds.forEach { HedgehogRenderer.renderOne(context, appWidgetManager, it) } + } + + override fun onAppWidgetOptionsChanged( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + newOptions: Bundle, + ) { + HedgehogRenderer.renderOne(context, appWidgetManager, appWidgetId) + } + + override fun onDisabled(context: Context) { + HedgehogRenderer.cancelAlarms(context) + } + + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + appWidgetIds.forEach { HedgehogStore.remove(context, it) } + } + + private fun poke(context: Context, intent: Intent) { + val id = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + if (id !in HedgehogRenderer.widgetIds(context)) return + HedgehogStore.selectNext(context, id) + // The tapped frame supplies its own index: launcher animations pause off-screen, so elapsed time cannot + // tell us which frame is visible. Keep its position and continue the same loop in the new character. + HedgehogRenderer.renderOne(context, AppWidgetManager.getInstance(context), id, intent.getIntExtra("frameIndex", 0)) + } +} diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_00.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_00.png new file mode 100644 index 000000000000..a7e23cbef7f0 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_00.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_00_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_00_l.png new file mode 100644 index 000000000000..ca610d8c5cc6 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_00_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_01.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_01.png new file mode 100644 index 000000000000..d2ca1a7ca61a Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_01.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_01_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_01_l.png new file mode 100644 index 000000000000..a371445a4ed4 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_01_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_02.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_02.png new file mode 100644 index 000000000000..6a81f3eb152f Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_02.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_02_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_02_l.png new file mode 100644 index 000000000000..48db0a02fc3f Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_02_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_03.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_03.png new file mode 100644 index 000000000000..e959863b6b46 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_03.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_03_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_03_l.png new file mode 100644 index 000000000000..144cba7d9234 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_03_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_04.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_04.png new file mode 100644 index 000000000000..0d132c6212d5 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_04.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_04_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_04_l.png new file mode 100644 index 000000000000..be439d1fa853 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_04_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_05.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_05.png new file mode 100644 index 000000000000..552d0b0b6dae Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_05.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_05_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_05_l.png new file mode 100644 index 000000000000..e140f00d4ae8 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_05_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_06.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_06.png new file mode 100644 index 000000000000..264bdee0463d Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_06.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_06_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_06_l.png new file mode 100644 index 000000000000..dfc2f94b8ce5 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_06_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_07.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_07.png new file mode 100644 index 000000000000..e548b1f1f357 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_07.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_07_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_07_l.png new file mode 100644 index 000000000000..a9a9ef442a2f Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_07_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_08.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_08.png new file mode 100644 index 000000000000..74c3c226f1f5 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_08.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_08_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_08_l.png new file mode 100644 index 000000000000..42360f4ec7ec Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_08_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_09.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_09.png new file mode 100644 index 000000000000..6cdd11094a44 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_09.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_09_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_09_l.png new file mode 100644 index 000000000000..52cbcd554db8 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_09_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_10.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_10.png new file mode 100644 index 000000000000..cc6e7c58b8bd Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_10.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_10_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_10_l.png new file mode 100644 index 000000000000..37b40b07603f Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_10_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_11.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_11.png new file mode 100644 index 000000000000..595ff7cc94a8 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_11.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_11_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_11_l.png new file mode 100644 index 000000000000..3be3a1c8ca1c Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_11_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_12.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_12.png new file mode 100644 index 000000000000..eaa0e97c3cc0 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_12.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_12_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_12_l.png new file mode 100644 index 000000000000..1758d1ca5939 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_12_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_13.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_13.png new file mode 100644 index 000000000000..f1b56e41e028 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_13.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_13_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_13_l.png new file mode 100644 index 000000000000..d7e884d0d0a4 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_13_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_14.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_14.png new file mode 100644 index 000000000000..472e61af1e4a Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_14.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_14_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_14_l.png new file mode 100644 index 000000000000..3a86cbf8795b Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_14_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_15.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_15.png new file mode 100644 index 000000000000..db8452f6d522 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_15.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_15_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_15_l.png new file mode 100644 index 000000000000..02fb5a35e10a Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_action_15_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_00.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_00.png new file mode 100644 index 000000000000..597ee859cc30 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_00.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_00_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_00_l.png new file mode 100644 index 000000000000..a594f5c35618 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_00_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_01.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_01.png new file mode 100644 index 000000000000..9ef421c773d8 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_01.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_01_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_01_l.png new file mode 100644 index 000000000000..a400edc9e7ef Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_01_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_02.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_02.png new file mode 100644 index 000000000000..b3dbbd18bdd9 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_02.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_02_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_02_l.png new file mode 100644 index 000000000000..b2197b9b408e Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_02_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_03.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_03.png new file mode 100644 index 000000000000..6e0ccc6b1643 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_03.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_03_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_03_l.png new file mode 100644 index 000000000000..a68963f8b8c5 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_03_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_04.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_04.png new file mode 100644 index 000000000000..6e0ccc6b1643 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_04.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_04_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_04_l.png new file mode 100644 index 000000000000..a68963f8b8c5 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_04_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_05.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_05.png new file mode 100644 index 000000000000..5a32a987d244 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_05.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_05_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_05_l.png new file mode 100644 index 000000000000..5349dd48d84d Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_05_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_06.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_06.png new file mode 100644 index 000000000000..5a32a987d244 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_06.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_06_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_06_l.png new file mode 100644 index 000000000000..5349dd48d84d Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_06_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_07.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_07.png new file mode 100644 index 000000000000..1dbb70e92548 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_07.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_07_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_07_l.png new file mode 100644 index 000000000000..0b631ff1b6bf Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_07_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_08.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_08.png new file mode 100644 index 000000000000..98248e7e8522 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_08.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_08_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_08_l.png new file mode 100644 index 000000000000..ad25d2d05b63 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_fall_08_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_00.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_00.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_00.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_00_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_00_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_00_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_01.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_01.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_01.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_01_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_01_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_01_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_02.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_02.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_02.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_02_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_02_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_02_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_03.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_03.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_03.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_03_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_03_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_03_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_04.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_04.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_04.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_04_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_04_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_04_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_05.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_05.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_05.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_05_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_05_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_05_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_06.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_06.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_06.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_06_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_06_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_06_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_07.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_07.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_07.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_07_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_07_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_07_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_08.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_08.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_08.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_08_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_08_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_08_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_09.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_09.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_09.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_09_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_09_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_09_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_10.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_10.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_10.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_10_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_10_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_10_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_11.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_11.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_11.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_11_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_11_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_11_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_12.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_12.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_12.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_12_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_12_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_12_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_13.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_13.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_13.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_13_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_13_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_13_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_14.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_14.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_14.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_14_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_14_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_14_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_15.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_15.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_15.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_15_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_15_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_15_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_16.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_16.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_16.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_16_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_16_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_16_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_17.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_17.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_17.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_17_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_17_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_17_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_18.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_18.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_18.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_18_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_18_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_18_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_19.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_19.png new file mode 100644 index 000000000000..cd687ff400e9 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_19.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_19_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_19_l.png new file mode 100644 index 000000000000..b7d5dc6dc0d9 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_19_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_20.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_20.png new file mode 100644 index 000000000000..cd687ff400e9 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_20.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_20_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_20_l.png new file mode 100644 index 000000000000..b7d5dc6dc0d9 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_20_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_21.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_21.png new file mode 100644 index 000000000000..cd687ff400e9 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_21.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_21_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_21_l.png new file mode 100644 index 000000000000..b7d5dc6dc0d9 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_21_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_22.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_22.png new file mode 100644 index 000000000000..cd687ff400e9 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_22.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_22_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_22_l.png new file mode 100644 index 000000000000..b7d5dc6dc0d9 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_22_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_23.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_23.png new file mode 100644 index 000000000000..cd687ff400e9 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_23.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_23_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_23_l.png new file mode 100644 index 000000000000..b7d5dc6dc0d9 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_23_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_24.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_24.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_24.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_24_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_24_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_idle_24_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_00.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_00.png new file mode 100644 index 000000000000..29dda3e3fd93 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_00.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_00_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_00_l.png new file mode 100644 index 000000000000..325add9f7c52 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_00_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_01.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_01.png new file mode 100644 index 000000000000..9d9a99b983b3 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_01.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_01_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_01_l.png new file mode 100644 index 000000000000..0cf679d705a1 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_01_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_02.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_02.png new file mode 100644 index 000000000000..c8f4d143e76f Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_02.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_02_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_02_l.png new file mode 100644 index 000000000000..d0870e42db19 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_02_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_03.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_03.png new file mode 100644 index 000000000000..062955665f29 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_03.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_03_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_03_l.png new file mode 100644 index 000000000000..05e22c1aef61 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_03_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_04.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_04.png new file mode 100644 index 000000000000..cd687ff400e9 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_04.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_04_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_04_l.png new file mode 100644 index 000000000000..b7d5dc6dc0d9 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_04_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_05.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_05.png new file mode 100644 index 000000000000..2e1b9923bd79 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_05.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_05_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_05_l.png new file mode 100644 index 000000000000..8042d2d9f1af Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_05_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_06.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_06.png new file mode 100644 index 000000000000..749ffd0701e6 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_06.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_06_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_06_l.png new file mode 100644 index 000000000000..b003ddc4c078 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_06_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_07.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_07.png new file mode 100644 index 000000000000..4fb6bbb8bed6 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_07.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_07_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_07_l.png new file mode 100644 index 000000000000..0daf812e5b58 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_07_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_08.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_08.png new file mode 100644 index 000000000000..1958ca32e412 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_08.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_08_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_08_l.png new file mode 100644 index 000000000000..9ed31aff5b10 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_08_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_09.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_09.png new file mode 100644 index 000000000000..6b4eb78e99ea Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_09.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_09_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_09_l.png new file mode 100644 index 000000000000..76e505a5d547 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_jump_09_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_00.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_00.png new file mode 100644 index 000000000000..979b8921cc5f Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_00.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_00_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_00_l.png new file mode 100644 index 000000000000..51effa0e7ba7 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_00_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_01.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_01.png new file mode 100644 index 000000000000..6c6f3e5b4611 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_01.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_01_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_01_l.png new file mode 100644 index 000000000000..b3fcf6865b99 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_01_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_02.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_02.png new file mode 100644 index 000000000000..bd866598c48c Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_02.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_02_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_02_l.png new file mode 100644 index 000000000000..8179fb82140e Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_02_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_03.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_03.png new file mode 100644 index 000000000000..35232c3417bb Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_03.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_03_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_03_l.png new file mode 100644 index 000000000000..311db4697dc6 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_03_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_04.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_04.png new file mode 100644 index 000000000000..147df3fa51f7 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_04.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_04_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_04_l.png new file mode 100644 index 000000000000..7444344ee2a5 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_04_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_05.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_05.png new file mode 100644 index 000000000000..0a2f86654475 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_05.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_05_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_05_l.png new file mode 100644 index 000000000000..62e00484219d Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_05_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_06.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_06.png new file mode 100644 index 000000000000..50b08577094b Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_06.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_06_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_06_l.png new file mode 100644 index 000000000000..6938ba4b595f Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_06_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_07.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_07.png new file mode 100644 index 000000000000..0a2f86654475 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_07.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_07_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_07_l.png new file mode 100644 index 000000000000..62e00484219d Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_07_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_08.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_08.png new file mode 100644 index 000000000000..09b71ce0426d Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_08.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_08_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_08_l.png new file mode 100644 index 000000000000..bf2876341e39 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_08_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_09.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_09.png new file mode 100644 index 000000000000..6828fd393ecd Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_09.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_09_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_09_l.png new file mode 100644 index 000000000000..d9a65b1873aa Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_09_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_10.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_10.png new file mode 100644 index 000000000000..3320647af4d3 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_10.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_10_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_10_l.png new file mode 100644 index 000000000000..48e7ed5b4f8d Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_walk_10_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_00.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_00.png new file mode 100644 index 000000000000..1d5855a9b7af Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_00.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_00_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_00_l.png new file mode 100644 index 000000000000..0be69bfcb896 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_00_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_01.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_01.png new file mode 100644 index 000000000000..c52b72e0ac75 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_01.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_01_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_01_l.png new file mode 100644 index 000000000000..abcd8ca34793 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_01_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_02.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_02.png new file mode 100644 index 000000000000..517a77b0da19 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_02.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_02_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_02_l.png new file mode 100644 index 000000000000..4e2ce10af263 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_02_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_03.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_03.png new file mode 100644 index 000000000000..585d1971c4f4 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_03.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_03_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_03_l.png new file mode 100644 index 000000000000..aa8c27f5136b Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_03_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_04.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_04.png new file mode 100644 index 000000000000..d1e5bee3a256 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_04.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_04_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_04_l.png new file mode 100644 index 000000000000..f1562d2c38bb Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_04_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_05.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_05.png new file mode 100644 index 000000000000..1a1936a23c9d Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_05.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_05_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_05_l.png new file mode 100644 index 000000000000..bc009cfbf553 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_05_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_06.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_06.png new file mode 100644 index 000000000000..d8b01f16d72e Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_06.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_06_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_06_l.png new file mode 100644 index 000000000000..017023666ebd Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_06_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_07.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_07.png new file mode 100644 index 000000000000..28ed1db2aba3 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_07.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_07_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_07_l.png new file mode 100644 index 000000000000..2c66d89e7eb0 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_07_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_08.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_08.png new file mode 100644 index 000000000000..51c954920161 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_08.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_08_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_08_l.png new file mode 100644 index 000000000000..be14cbe6fe38 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_08_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_09.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_09.png new file mode 100644 index 000000000000..65f1cf758811 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_09.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_09_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_09_l.png new file mode 100644 index 000000000000..6dcc961bbf20 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_09_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_10.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_10.png new file mode 100644 index 000000000000..3c112528259d Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_10.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_10_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_10_l.png new file mode 100644 index 000000000000..cda41b2ea2d5 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_10_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_11.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_11.png new file mode 100644 index 000000000000..c50fdd39d608 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_11.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_11_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_11_l.png new file mode 100644 index 000000000000..889bc2e351a2 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_11_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_12.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_12.png new file mode 100644 index 000000000000..8dac27134f3b Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_12.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_12_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_12_l.png new file mode 100644 index 000000000000..c80164ecacb8 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_12_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_13.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_13.png new file mode 100644 index 000000000000..140f15f1e224 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_13.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_13_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_13_l.png new file mode 100644 index 000000000000..6cc7e3f5e993 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_13_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_14.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_14.png new file mode 100644 index 000000000000..53ba3134ed01 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_14.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_14_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_14_l.png new file mode 100644 index 000000000000..a1cbc3957e88 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_14_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_15.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_15.png new file mode 100644 index 000000000000..c6153a665879 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_15.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_15_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_15_l.png new file mode 100644 index 000000000000..b396d124fb37 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_15_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_16.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_16.png new file mode 100644 index 000000000000..35ac7d2c505b Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_16.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_16_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_16_l.png new file mode 100644 index 000000000000..b44a7b443052 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_16_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_17.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_17.png new file mode 100644 index 000000000000..30b2ceec74ea Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_17.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_17_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_17_l.png new file mode 100644 index 000000000000..b983f340bd8b Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_17_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_18.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_18.png new file mode 100644 index 000000000000..c9fecba072f2 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_18.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_18_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_18_l.png new file mode 100644 index 000000000000..e4e7703ef543 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_18_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_19.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_19.png new file mode 100644 index 000000000000..4353b0e47f95 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_19.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_19_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_19_l.png new file mode 100644 index 000000000000..d26d1f1d489a Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_19_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_20.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_20.png new file mode 100644 index 000000000000..45cc3d017a07 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_20.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_20_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_20_l.png new file mode 100644 index 000000000000..2aafd9c583f6 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_20_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_21.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_21.png new file mode 100644 index 000000000000..2958fb10e0f6 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_21.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_21_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_21_l.png new file mode 100644 index 000000000000..a4f1b9429a8e Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_21_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_22.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_22.png new file mode 100644 index 000000000000..2eb36f9f2a34 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_22.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_22_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_22_l.png new file mode 100644 index 000000000000..04e73539072a Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_22_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_23.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_23.png new file mode 100644 index 000000000000..d36350ce104f Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_23.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_23_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_23_l.png new file mode 100644 index 000000000000..4ebd3efa4d85 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_23_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_24.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_24.png new file mode 100644 index 000000000000..f2681a07a6da Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_24.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_24_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_24_l.png new file mode 100644 index 000000000000..c01ae5b4838d Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_24_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_25.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_25.png new file mode 100644 index 000000000000..155f7f8dd69c Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_25.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_25_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_25_l.png new file mode 100644 index 000000000000..405924d58c18 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hog_wave_25_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_coffee_cup.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_coffee_cup.png new file mode 100644 index 000000000000..9cd70bc3db2b Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_coffee_cup.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_coffee_cup_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_coffee_cup_l.png new file mode 100644 index 000000000000..67fe90adc48b Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_coffee_cup_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_explorer.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_explorer.png new file mode 100644 index 000000000000..cf941dcb0056 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_explorer.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_explorer_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_explorer_l.png new file mode 100644 index 000000000000..9eb6c46a6de0 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_explorer_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_float.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_float.png new file mode 100644 index 000000000000..0d0a66f6ce6b Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_float.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_float_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_float_l.png new file mode 100644 index 000000000000..f64deb3090b4 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_float_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_party.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_party.png new file mode 100644 index 000000000000..609aaf91ee22 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_party.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_party_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_party_l.png new file mode 100644 index 000000000000..3080d04df1bc Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_party_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_reading.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_reading.png new file mode 100644 index 000000000000..e00194392125 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_reading.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_reading_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_reading_l.png new file mode 100644 index 000000000000..8f74e198581e Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_reading_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_superhero.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_superhero.png new file mode 100644 index 000000000000..11bce939e3cb Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_superhero.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_superhero_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_superhero_l.png new file mode 100644 index 000000000000..47b87a37d17a Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_superhero_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_wizard.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_wizard.png new file mode 100644 index 000000000000..f95fc1270727 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_wizard.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_wizard_l.png b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_wizard_l.png new file mode 100644 index 000000000000..d7694f996e69 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoggie_wizard_l.png differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoglet_garden.jpg b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoglet_garden.jpg new file mode 100644 index 000000000000..351c3eaf01d2 Binary files /dev/null and b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable-nodpi/hoglet_garden.jpg differ diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable/hedgehog_bubble.xml b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable/hedgehog_bubble.xml new file mode 100644 index 000000000000..c772038f23a3 --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable/hedgehog_bubble.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable/hedgehog_widget_background.xml b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable/hedgehog_widget_background.xml new file mode 100644 index 000000000000..7dc3b056ceaf --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/android/src/main/res/drawable/hedgehog_widget_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/layout/hedgehog_frame.xml b/products/mobile/modules/hedgehog-widget/android/src/main/res/layout/hedgehog_frame.xml new file mode 100644 index 000000000000..ecfe80011618 --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/android/src/main/res/layout/hedgehog_frame.xml @@ -0,0 +1,14 @@ + + + + diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/layout/hedgehog_hoggie_frame.xml b/products/mobile/modules/hedgehog-widget/android/src/main/res/layout/hedgehog_hoggie_frame.xml new file mode 100644 index 000000000000..86205e7b1359 --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/android/src/main/res/layout/hedgehog_hoggie_frame.xml @@ -0,0 +1,14 @@ + + + + diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/layout/hedgehog_widget.xml b/products/mobile/modules/hedgehog-widget/android/src/main/res/layout/hedgehog_widget.xml new file mode 100644 index 000000000000..3f6b8fb2f0ca --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/android/src/main/res/layout/hedgehog_widget.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/layout/hedgehog_widget_preview.xml b/products/mobile/modules/hedgehog-widget/android/src/main/res/layout/hedgehog_widget_preview.xml new file mode 100644 index 000000000000..6d0ad5082b64 --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/android/src/main/res/layout/hedgehog_widget_preview.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/values/dimens.xml b/products/mobile/modules/hedgehog-widget/android/src/main/res/values/dimens.xml new file mode 100644 index 000000000000..18030de3b440 --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/android/src/main/res/values/dimens.xml @@ -0,0 +1,5 @@ + + 78dp + 106dp + 12dp + diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/values/strings.xml b/products/mobile/modules/hedgehog-widget/android/src/main/res/values/strings.xml new file mode 100644 index 000000000000..2fd25cf35c17 --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/android/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Hedgehog + Your own little garden hedgehog. Tap to change its character. + diff --git a/products/mobile/modules/hedgehog-widget/android/src/main/res/xml/hedgehog_widget_info.xml b/products/mobile/modules/hedgehog-widget/android/src/main/res/xml/hedgehog_widget_info.xml new file mode 100644 index 000000000000..4a5570522e0f --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/android/src/main/res/xml/hedgehog_widget_info.xml @@ -0,0 +1,14 @@ + diff --git a/products/mobile/modules/hedgehog-widget/expo-module.config.json b/products/mobile/modules/hedgehog-widget/expo-module.config.json new file mode 100644 index 000000000000..a5edf3111c8b --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["apple", "android"], + "apple": { + "modules": ["HedgehogWidgetModule"] + }, + "android": { + "modules": ["com.posthog.code.mobile.hedgehog.HedgehogWidgetModule"] + } +} diff --git a/products/mobile/modules/hedgehog-widget/index.ts b/products/mobile/modules/hedgehog-widget/index.ts new file mode 100644 index 000000000000..79197e8607f2 --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/index.ts @@ -0,0 +1,23 @@ +import { requireOptionalNativeModule } from 'expo-modules-core' + +interface HedgehogWidgetNativeModule { + setChoreography(json: string): void + clearChoreography(): void +} + +const native = requireOptionalNativeModule('HedgehogWidget') + +/** False on web and in any build without the native module, so callers can skip the sync. */ +export function isAvailable(): boolean { + return native !== null +} + +/** Persists the choreography JSON where the widget reads it and refreshes every placed widget. */ +export function setChoreography(json: string): void { + native?.setChoreography(json) +} + +/** Removes the stored choreography; widgets then play their built-in fallback. */ +export function clearChoreography(): void { + native?.clearChoreography() +} diff --git a/products/mobile/modules/hedgehog-widget/ios/HedgehogWidgetModule.swift b/products/mobile/modules/hedgehog-widget/ios/HedgehogWidgetModule.swift new file mode 100644 index 000000000000..7deef0450aa1 --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/ios/HedgehogWidgetModule.swift @@ -0,0 +1,24 @@ +import ExpoModulesCore +import WidgetKit + +// The suite, keys and widget kind must match targets/HedgehogWidget and modules/hedgehog-widget/README.md. +// app.config.ts names the App Group "group." plus the bundle id, so each app variant (dev, preview, store) has its own. +private let suiteName = "group.\(Bundle.main.bundleIdentifier ?? "com.posthog.code.mobile")" +private let choreographyKey = "choreography" +private let widgetKind = "HedgehogWidget" + +public class HedgehogWidgetModule: Module { + public func definition() -> ModuleDefinition { + Name("HedgehogWidget") + + Function("setChoreography") { (json: String) in + UserDefaults(suiteName: suiteName)?.set(json, forKey: choreographyKey) + WidgetCenter.shared.reloadTimelines(ofKind: widgetKind) + } + + Function("clearChoreography") { + UserDefaults(suiteName: suiteName)?.removeObject(forKey: choreographyKey) + WidgetCenter.shared.reloadTimelines(ofKind: widgetKind) + } + } +} diff --git a/products/mobile/modules/hedgehog-widget/ios/PostHogHedgehogWidget.podspec b/products/mobile/modules/hedgehog-widget/ios/PostHogHedgehogWidget.podspec new file mode 100644 index 000000000000..c4faa6625fec --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/ios/PostHogHedgehogWidget.podspec @@ -0,0 +1,27 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) + +# Not named HedgehogWidget: the widget extension target already owns that Swift module name, and the +# appex's module would shadow this one when ExpoModulesProvider imports it. +Pod::Spec.new do |s| + s.name = 'PostHogHedgehogWidget' + s.version = package['version'] + s.summary = 'Hands the hedgehog choreography to the iOS home-screen widget' + s.license = 'MIT' + s.author = 'PostHog' + s.homepage = 'https://posthog.com' + s.platforms = { :ios => '16.4' } + s.swift_version = '5.9' + s.source = { git: 'https://github.com/PostHog/posthog.git' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + s.frameworks = 'WidgetKit' + + s.source_files = '**/*.{h,m,swift}' + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } +end diff --git a/products/mobile/modules/hedgehog-widget/package.json b/products/mobile/modules/hedgehog-widget/package.json new file mode 100644 index 000000000000..5abda8688dcc --- /dev/null +++ b/products/mobile/modules/hedgehog-widget/package.json @@ -0,0 +1,6 @@ +{ + "name": "hedgehog-widget", + "version": "1.0.0", + "private": true, + "main": "index.ts" +} diff --git a/products/mobile/package.json b/products/mobile/package.json new file mode 100644 index 000000000000..df06e9dcf389 --- /dev/null +++ b/products/mobile/package.json @@ -0,0 +1,111 @@ +{ + "name": "posthog-mobile", + "version": "1.0.0", + "private": true, + "main": "expo-router/entry", + "packageManager": "pnpm@10.29.3", + "scripts": { + "start": "expo start", + "ios": "expo run:ios", + "android": "expo run:android", + "web": "setup-skia-web public && expo start --web", + "check:expo": "node scripts/check-expo.mjs", + "typecheck": "node scripts/check-expo.mjs && tsc --noEmit", + "lint": "biome check .", + "fix": "biome check --write .", + "test": "jest", + "prebuild": "expo prebuild", + "build:dev": "sh scripts/eas-build.sh --profile development --platform ios", + "build:dev:android": "sh scripts/eas-build.sh --profile development --platform android", + "build:preview": "sh scripts/eas-build.sh --profile preview --platform ios", + "build:preview:android": "sh scripts/eas-build.sh --profile preview --platform android", + "build:production": "node scripts/releases/release.mjs hosted-only", + "build:production:android": "node scripts/releases/release.mjs hosted-only", + "submit:ios": "node scripts/releases/release.mjs hosted-only", + "submit:android": "node scripts/releases/release.mjs hosted-only", + "update:preview": "node scripts/releases/release.mjs hosted-only", + "update:production": "node scripts/releases/release.mjs hosted-only", + "release:request": "node scripts/releases/release.mjs request", + "release:check": "node scripts/releases/release.mjs check", + "release:fingerprint": "node scripts/releases/release.mjs fingerprint", + "release:test": "node --test scripts/releases/*.test.mjs" + }, + "expo": { + "autolinking": { + "buildFromSource": [ + "react-native-worklets" + ] + }, + "install": { + "exclude": [ + "react", + "react-dom", + "typescript" + ] + } + }, + "dependencies": { + "@expo/metro-runtime": "~57.0.7", + "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-community/datetimepicker": "9.1.0", + "@react-native-masked-view/masked-view": "0.3.2", + "@shopify/react-native-skia": "2.6.2", + "@tanstack/react-query": "^5.90.12", + "expo": "~57.0.8", + "expo-application": "~57.0.3", + "expo-auth-session": "~57.0.5", + "expo-blur": "~57.0.3", + "expo-clipboard": "~57.0.1", + "expo-constants": "~57.0.7", + "expo-crypto": "~57.0.1", + "expo-dev-client": "~57.0.9", + "expo-device": "~57.0.2", + "expo-document-picker": "~57.0.3", + "expo-file-system": "~57.0.7", + "expo-font": "~57.0.1", + "expo-haptics": "~57.0.1", + "expo-image-picker": "~57.0.20", + "expo-linking": "~57.0.4", + "expo-localization": "~57.0.2", + "expo-notifications": "~57.0.7", + "expo-router": "~57.0.8", + "expo-secure-store": "~57.0.1", + "expo-splash-screen": "~57.0.5", + "expo-status-bar": "~57.0.1", + "expo-system-ui": "~57.0.4", + "expo-updates": "~57.0.9", + "expo-web-browser": "~57.0.2", + "markdown-it": "10.0.0", + "mermaid": "11.17.0", + "posthog-react-native": "^4.78.0", + "react": "19.2.6", + "react-dom": "19.2.6", + "react-native": "0.86.3", + "react-native-gesture-handler": "~2.32.0", + "react-native-keyboard-controller": "1.21.9", + "react-native-markdown-display": "7.0.2", + "react-native-reanimated": "4.5.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.2", + "react-native-svg": "15.15.4", + "react-native-web": "~0.21.0", + "react-native-webview": "13.16.1", + "react-native-worklets": "0.10.1", + "victory-native": "42.0.1", + "zustand": "^4.5.7" + }, + "devDependencies": { + "@bacons/apple-targets": "5.0.0", + "@biomejs/biome": "2.2.4", + "@react-native/jest-preset": "0.86.3", + "@react-native/metro-config": "0.86.3", + "@testing-library/react-native": "^13.3.3", + "@types/jest": "^29.5.14", + "@types/markdown-it": "14.1.2", + "@types/react": "~19.2.4", + "jest": "^29.7.0", + "jest-expo": "~57.0.2", + "react-test-renderer": "19.2.6", + "typescript": "~5.9.2" + } +} diff --git a/products/mobile/plugins/withAndroidAdaptiveIconInsets.js b/products/mobile/plugins/withAndroidAdaptiveIconInsets.js new file mode 100644 index 000000000000..bafb5e2a48b0 --- /dev/null +++ b/products/mobile/plugins/withAndroidAdaptiveIconInsets.js @@ -0,0 +1,34 @@ +const { join } = require('node:path') + +const { withFinalizedMod, XML } = require('expo/config-plugins') + +// Adaptive layers are 108dp, but launchers mask them to the central 72dp. +// Scale the artwork into that viewport so the hedgehog fits the 66dp safe circle. +const INSET = `${(18 / 108) * 100}%` + +module.exports = function withAndroidAdaptiveIconInsets(config) { + return withFinalizedMod(config, [ + 'android', + async (cfg) => { + const resourceDir = join(cfg.modRequest.platformProjectRoot, 'app/src/main/res/mipmap-anydpi-v26') + for (const name of ['ic_launcher.xml', 'ic_launcher_round.xml']) { + const path = join(resourceDir, name) + const xml = await XML.readXMLAsync({ path }) + xml['adaptive-icon'].foreground = [ + { + inset: [ + { + $: { + 'android:inset': INSET, + 'android:drawable': '@mipmap/ic_launcher_foreground', + }, + }, + ], + }, + ] + await XML.writeXMLAsync({ path, xml }) + } + return cfg + }, + ]) +} diff --git a/products/mobile/plugins/withAndroidLoopbackNetworking.js b/products/mobile/plugins/withAndroidLoopbackNetworking.js new file mode 100644 index 000000000000..1f3fc8f9f7fb --- /dev/null +++ b/products/mobile/plugins/withAndroidLoopbackNetworking.js @@ -0,0 +1,45 @@ +const { mkdir, writeFile } = require('node:fs/promises') +const { join } = require('node:path') + +const { AndroidConfig, withAndroidManifest, withDangerousMod } = require('expo/config-plugins') + +module.exports = function withAndroidLoopbackNetworking(config, { allowLoopback = false } = {}) { + config = withAndroidManifest(config, (cfg) => { + const application = AndroidConfig.Manifest.getMainApplicationOrThrow(cfg.modResults) + application.$['android:usesCleartextTraffic'] = 'false' + application.$['android:networkSecurityConfig'] = '@xml/posthog_network_security_config' + return cfg + }) + + return withDangerousMod(config, [ + 'android', + async (cfg) => { + const resourceDir = join(cfg.modRequest.platformProjectRoot, 'app/src/main/res/xml') + await mkdir(resourceDir, { recursive: true }) + await writeFile( + join(resourceDir, 'posthog_network_security_config.xml'), + ` + + + + localhost + 127.0.0.1 + ::1 + + +` + ) + const debugResourceDir = join(cfg.modRequest.platformProjectRoot, 'app/src/debug/res/xml') + await mkdir(debugResourceDir, { recursive: true }) + await writeFile( + join(debugResourceDir, 'posthog_network_security_config.xml'), + ` + + + +` + ) + return cfg + }, + ]) +} diff --git a/products/mobile/plugins/withAndroidLoopbackNetworking.test.js b/products/mobile/plugins/withAndroidLoopbackNetworking.test.js new file mode 100644 index 000000000000..076c0fd89f64 --- /dev/null +++ b/products/mobile/plugins/withAndroidLoopbackNetworking.test.js @@ -0,0 +1,78 @@ +const assert = require('node:assert/strict') +const { mkdtemp, mkdir, readFile, rm, writeFile } = require('node:fs/promises') +const { tmpdir } = require('node:os') +const { dirname, join, resolve } = require('node:path') + +const { compileModsAsync, XML } = require('expo/config-plugins') + +const appConfig = require('../app.config').default +const withAndroidLoopbackNetworking = require('./withAndroidLoopbackNetworking') + +describe('Android native loopback networking', () => { + test.each(['development', 'preview', 'production', undefined])( + '%s generates the matching native cleartext policy', + async (variant) => { + const previousVariant = process.env.APP_VARIANT + const projectRoot = await mkdtemp(join(tmpdir(), 'posthog-network-policy-')) + try { + if (variant === undefined) { + delete process.env.APP_VARIANT + } else { + process.env.APP_VARIANT = variant + } + const exp = appConfig({ config: { name: 'test', slug: 'test' }, projectRoot: resolve('.') }) + const plugin = exp.plugins.find( + (entry) => Array.isArray(entry) && entry[0] === './plugins/withAndroidLoopbackNetworking' + ) + assert.ok(plugin, 'the app must register the native network policy plugin') + const manifestPath = join(projectRoot, 'android/app/src/main/AndroidManifest.xml') + await mkdir(dirname(manifestPath), { recursive: true }) + await writeFile( + manifestPath, + '' + ) + await compileModsAsync(withAndroidLoopbackNetworking({ name: 'test', slug: 'test' }, plugin[1]), { + projectRoot, + platforms: ['android'], + }) + const manifest = await XML.readXMLAsync({ path: manifestPath }) + assert.equal(manifest.manifest.application[0].$['android:usesCleartextTraffic'], 'false') + const resource = manifest.manifest.application[0].$['android:networkSecurityConfig'] + assert.equal(resource, '@xml/posthog_network_security_config') + const policy = await XML.parseXMLAsync( + await readFile( + join(projectRoot, 'android/app/src/main/res/xml', `${resource.slice(5)}.xml`), + 'utf8' + ) + ) + assert.deepEqual(policy['network-security-config'], { + 'base-config': [{ $: { cleartextTrafficPermitted: 'false' } }], + 'domain-config': [ + { + $: { + cleartextTrafficPermitted: String(variant === 'development' || variant === 'preview'), + }, + domain: ['localhost', '127.0.0.1', '::1'].map((host) => ({ + _: host, + $: { includeSubdomains: 'false' }, + })), + }, + ], + }) + const debugPolicy = await XML.readXMLAsync({ + path: join(projectRoot, 'android/app/src/debug/res/xml', `${resource.slice(5)}.xml`), + }) + assert.deepEqual(debugPolicy['network-security-config'], { + 'base-config': [{ $: { cleartextTrafficPermitted: 'true' } }], + }) + } finally { + if (previousVariant === undefined) { + delete process.env.APP_VARIANT + } else { + process.env.APP_VARIANT = previousVariant + } + await rm(projectRoot, { recursive: true, force: true }) + } + } + ) +}) diff --git a/products/mobile/plugins/withIosSceneLifecycle.js b/products/mobile/plugins/withIosSceneLifecycle.js new file mode 100644 index 000000000000..7f0c2bd3cdf0 --- /dev/null +++ b/products/mobile/plugins/withIosSceneLifecycle.js @@ -0,0 +1,59 @@ +// Apps built with the iOS 27 SDK do not launch unless they adopt the UIScene life cycle. +// The Expo SDK 57 prebuild template predates that rule; the SDK 58 template follows it. +// This plugin applies the SDK 58 template's three changes on top of SDK 57, using the +// ExpoAppSceneDelegate that expo 57.0.26 already ships. Remove it when the app moves to SDK 58. +const { IOSConfig, withAppDelegate, withInfoPlist } = require('expo/config-plugins') + +const SCENE_DELEGATE = `internal import Expo + +@objc(SceneDelegate) +class SceneDelegate: ExpoAppSceneDelegate {} +` + +const CLASS_LINE = 'class AppDelegate: ExpoAppDelegate {' +const PROVIDER_CLASS_LINE = 'class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider {' +// The scene delegate creates the window and starts React Native, so the app delegate must not. +const WINDOW_BLOCK = + /\n#if os\(iOS\) \|\| os\(tvOS\)\n\s*window = UIWindow\(frame: UIScreen\.main\.bounds\)\n\s*factory\.startReactNative\([^)]*\)\n#endif\n/ + +function withSceneManifest(config) { + return withInfoPlist(config, (cfg) => { + cfg.modResults.UIApplicationSceneManifest = { + UIApplicationSupportsMultipleScenes: false, + UISceneConfigurations: { + UIWindowSceneSessionRoleApplication: [ + { + UISceneConfigurationName: 'Default Configuration', + UISceneDelegateClassName: '$(PRODUCT_MODULE_NAME).SceneDelegate', + }, + ], + }, + } + return cfg + }) +} + +function withFactoryProviderAppDelegate(config) { + return withAppDelegate(config, (cfg) => { + let contents = cfg.modResults.contents.replace(CLASS_LINE, PROVIDER_CLASS_LINE) + contents = contents.replace(WINDOW_BLOCK, '\n') + // Fail the prebuild loudly if the template changes shape, instead of shipping an app that does not launch. + if (!contents.includes(PROVIDER_CLASS_LINE) || contents.includes('UIWindow(frame:')) { + throw new Error( + 'withIosSceneLifecycle: AppDelegate.swift does not match the Expo SDK 57 template. Update or remove this plugin.' + ) + } + cfg.modResults.contents = contents + return cfg + }) +} + +module.exports = function withIosSceneLifecycle(config) { + config = withSceneManifest(config) + config = withFactoryProviderAppDelegate(config) + return IOSConfig.XcodeProjectFile.withBuildSourceFile(config, { + filePath: 'SceneDelegate.swift', + contents: SCENE_DELEGATE, + overwrite: true, + }) +} diff --git a/products/mobile/pnpm-lock.yaml b/products/mobile/pnpm-lock.yaml new file mode 100644 index 000000000000..9205bf16f83b --- /dev/null +++ b/products/mobile/pnpm-lock.yaml @@ -0,0 +1,10483 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@expo/metro-runtime': + specifier: ~57.0.7 + version: 57.0.16(@expo/log-box@57.0.4)(expo@57.0.26)(react-dom@19.2.6(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + '@react-native-async-storage/async-storage': + specifier: 2.2.0 + version: 2.2.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + '@react-native-community/datetimepicker': + specifier: 9.1.0 + version: 9.1.0(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + '@react-native-masked-view/masked-view': + specifier: 0.3.2 + version: 0.3.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + '@shopify/react-native-skia': + specifier: 2.6.2 + version: 2.6.2(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + '@tanstack/react-query': + specifier: ^5.90.12 + version: 5.104.1(react@19.2.6) + expo: + specifier: ~57.0.8 + version: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-application: + specifier: ~57.0.3 + version: 57.0.3(expo@57.0.26) + expo-auth-session: + specifier: ~57.0.5 + version: 57.0.13(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + expo-blur: + specifier: ~57.0.3 + version: 57.0.3(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + expo-clipboard: + specifier: ~57.0.1 + version: 57.0.2(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + expo-constants: + specifier: ~57.0.7 + version: 57.0.20(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + expo-crypto: + specifier: ~57.0.1 + version: 57.0.3(expo@57.0.26) + expo-dev-client: + specifier: ~57.0.9 + version: 57.0.19(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + expo-device: + specifier: ~57.0.2 + version: 57.0.2(expo@57.0.26) + expo-document-picker: + specifier: ~57.0.3 + version: 57.0.3(expo@57.0.26) + expo-file-system: + specifier: ~57.0.7 + version: 57.0.7(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + expo-font: + specifier: ~57.0.1 + version: 57.0.4(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + expo-haptics: + specifier: ~57.0.1 + version: 57.0.3(expo@57.0.26) + expo-image-picker: + specifier: ~57.0.20 + version: 57.0.20(expo@57.0.26) + expo-linking: + specifier: ~57.0.4 + version: 57.0.11(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + expo-localization: + specifier: ~57.0.2 + version: 57.0.2(expo@57.0.26)(react@19.2.6) + expo-notifications: + specifier: ~57.0.7 + version: 57.0.21(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-router: + specifier: ~57.0.8 + version: 57.0.24(7fcc41656b5e4943165628ee06c6ab65) + expo-secure-store: + specifier: ~57.0.1 + version: 57.0.4(expo@57.0.26) + expo-splash-screen: + specifier: ~57.0.5 + version: 57.0.9(expo@57.0.26)(typescript@5.9.3) + expo-status-bar: + specifier: ~57.0.1 + version: 57.0.1(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + expo-system-ui: + specifier: ~57.0.4 + version: 57.0.4(expo@57.0.26)(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + expo-updates: + specifier: ~57.0.9 + version: 57.0.24(expo-dev-client@57.0.19(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)))(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + expo-web-browser: + specifier: ~57.0.2 + version: 57.0.3(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + markdown-it: + specifier: 10.0.0 + version: 10.0.0 + mermaid: + specifier: 11.17.0 + version: 11.17.0 + posthog-react-native: + specifier: ^4.78.0 + version: 4.78.4(104cf9436c90229daceea0056d76c82b) + react: + specifier: 19.2.6 + version: 19.2.6 + react-dom: + specifier: 19.2.6 + version: 19.2.6(react@19.2.6) + react-native: + specifier: 0.86.3 + version: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + react-native-gesture-handler: + specifier: ~2.32.0 + version: 2.32.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-native-keyboard-controller: + specifier: 1.21.9 + version: 1.21.9(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-native-markdown-display: + specifier: 7.0.2 + version: 7.0.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-native-reanimated: + specifier: 4.5.1 + version: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-native-safe-area-context: + specifier: ~5.7.0 + version: 5.7.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-native-screens: + specifier: ~4.26.2 + version: 4.26.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-native-svg: + specifier: 15.15.4 + version: 15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-native-web: + specifier: ~0.21.0 + version: 0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-native-webview: + specifier: 13.16.1 + version: 13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-native-worklets: + specifier: 0.10.1 + version: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + victory-native: + specifier: 42.0.1 + version: 42.0.1(b83e10df08cd1eb622b79f5e20a13446) + zustand: + specifier: ^4.5.7 + version: 4.5.7(@types/react@19.2.18)(react@19.2.6) + devDependencies: + '@bacons/apple-targets': + specifier: 5.0.0 + version: 5.0.0(expo@57.0.26)(typescript@5.9.3) + '@biomejs/biome': + specifier: 2.2.4 + version: 2.2.4 + '@react-native/jest-preset': + specifier: 0.86.3 + version: 0.86.3(@babel/core@7.29.7)(react@19.2.6) + '@react-native/metro-config': + specifier: 0.86.3 + version: 0.86.3(@babel/core@7.29.7) + '@testing-library/react-native': + specifier: ^13.3.3 + version: 13.3.3(jest@29.7.0(@types/node@26.6.4))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + '@types/markdown-it': + specifier: 14.1.2 + version: 14.1.2 + '@types/react': + specifier: ~19.2.4 + version: 19.2.18 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@26.6.4) + jest-expo: + specifier: ~57.0.2 + version: 57.0.5(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(expo@57.0.26)(jest@29.7.0(@types/node@26.6.4))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-test-renderer: + specifier: 19.2.6 + version: 19.2.6(react@19.2.6) + typescript: + specifier: ~5.9.2 + version: 5.9.3 + +packages: + + '@antfu/install-pkg@2.1.0': + resolution: {integrity: sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==} + + '@babel/code-frame@7.10.4': + resolution: {integrity: sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.29.7': + resolution: {integrity: sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.29.7': + resolution: {integrity: sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.29.7': + resolution: {integrity: sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/highlight@7.25.9': + resolution: {integrity: sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-proposal-decorators@7.29.7': + resolution: {integrity: sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-export-default-from@7.29.7': + resolution: {integrity: sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.29.7': + resolution: {integrity: sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-dynamic-import@7.8.3': + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-export-default-from@7.29.7': + resolution: {integrity: sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-flow@7.29.7': + resolution: {integrity: sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-arrow-functions@7.29.7': + resolution: {integrity: sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.7': + resolution: {integrity: sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.29.7': + resolution: {integrity: sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.29.7': + resolution: {integrity: sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.29.7': + resolution: {integrity: sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.29.7': + resolution: {integrity: sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.29.7': + resolution: {integrity: sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.29.7': + resolution: {integrity: sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.29.7': + resolution: {integrity: sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-flow-strip-types@7.29.7': + resolution: {integrity: sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.29.7': + resolution: {integrity: sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7': + resolution: {integrity: sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7': + resolution: {integrity: sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.29.7': + resolution: {integrity: sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.29.7': + resolution: {integrity: sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.29.7': + resolution: {integrity: sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.29.7': + resolution: {integrity: sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.29.7': + resolution: {integrity: sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.29.7': + resolution: {integrity: sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-display-name@7.29.7': + resolution: {integrity: sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-development@7.29.7': + resolution: {integrity: sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.29.7': + resolution: {integrity: sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-pure-annotations@7.29.7': + resolution: {integrity: sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.8': + resolution: {integrity: sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-runtime@7.29.7': + resolution: {integrity: sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.29.7': + resolution: {integrity: sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.29.7': + resolution: {integrity: sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.29.9': + resolution: {integrity: sha512-FFwIwzU+7SCOuxxV4YtJql6T9981ZVTm+FHO5GhVsRqCTdy0WwrEZh3l42ARpXruJUDGOptdduHW6Zr7pNPLNg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.29.7': + resolution: {integrity: sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.29.7': + resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bacons/apple-targets@5.0.0': + resolution: {integrity: sha512-03LEidnuAAccH5ueL03sOaauQMnpJ7ZGwrVUlzNc5K3BrlfGE9SDUJs1ITkXC7YLKfFPJyiB7VzoZYiRxJmUdA==} + peerDependencies: + expo: '>=52' + + '@bacons/xcode@1.0.0-alpha.32': + resolution: {integrity: sha512-OGpH7+yMbWC2cgYZon5B+VVadH9HsB2V/abtEiplA65XnSuV4GAYAVixOCDc5k182WkfoakfdM0zW6U9cbcsbw==} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@biomejs/biome@2.2.4': + resolution: {integrity: sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.2.4': + resolution: {integrity: sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.2.4': + resolution: {integrity: sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.2.4': + resolution: {integrity: sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.2.4': + resolution: {integrity: sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.2.4': + resolution: {integrity: sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.2.4': + resolution: {integrity: sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.2.4': + resolution: {integrity: sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.2.4': + resolution: {integrity: sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + + '@egjs/hammerjs@2.0.17': + resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} + engines: {node: '>=0.8.0'} + + '@expo-google-fonts/material-symbols@0.4.48': + resolution: {integrity: sha512-aZztj44PyA/sj3TXSwwpLJwqMlTUJ49okbZJYURzEcuERxMQB0KbX7RrLBi8WJnuWz5HFOLlTRxORrJfNnUPNQ==} + + '@expo/cli@57.0.27': + resolution: {integrity: sha512-Jauk4chxmpVG5ElrMLTCwjFP20jbYU7PB4l3LYV8j+4e/3HU5jdl7qTQ4eVy1AaDUE9Ia2NCQ5A9kar2V1gaLg==} + hasBin: true + peerDependencies: + expo: '*' + expo-router: '*' + react-native: '*' + peerDependenciesMeta: + expo-router: + optional: true + react-native: + optional: true + + '@expo/code-signing-certificates@0.0.6': + resolution: {integrity: sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==} + + '@expo/config-plugins@55.0.11': + resolution: {integrity: sha512-85ZSmIK8rMfvYbG/2IHtwnykVdb6LI0ZavduM3ZwUMThyyVegYjVsO5Zek2ec8xzPhCmYX0ar5onnFEcwUDUlw==} + + '@expo/config-plugins@57.0.9': + resolution: {integrity: sha512-hHgfL1avkCdEvDSw7IwlKwRYYNgcxzbNNMIk6W6lTkJpY0MajinAfeJUS0J+wPCsjUfGbVqOJM+XhPaO5ulUxg==} + + '@expo/config-types@55.0.6': + resolution: {integrity: sha512-S+GJKYoIjnWlert/9vXuTohaTsMbyOLSVxdIgPgoq3P4N1p4CWrfyZLnz6qRug8wYSO5fcYcS9mFleyEP8wRLg==} + + '@expo/config-types@57.0.2': + resolution: {integrity: sha512-ewW08OonrcRIsRKIlFvvcmmafE5zemb1ocu3HkNwtVPyRtj2w42pZCAkMIROYpcVBaPnc3mDT9UZDzwXWC3i6g==} + + '@expo/config@55.0.21': + resolution: {integrity: sha512-yHmSSnBrm2ecB1DNsCrZZrScVnvXrtg3+PAMwI3ocxEtD9YK8yX5xV2xi2pmor9TYitAO+udFWxo+XDEGibznw==} + + '@expo/config@57.0.9': + resolution: {integrity: sha512-dmzlKraIFxa7wLwV6K7WzI8jp6QZpW6Mc5mGjLimJUFjzh4uQdYaT3m3plEutM5yxBoBEwqzks7l+I/ljCbxAQ==} + + '@expo/devcert@1.2.2': + resolution: {integrity: sha512-wz1YedXwdNbYqk/PRr34vixnuw1+l6uSwmPXUj649cot5iYNccb2zIOnK7Nonb1Pfd/dxAY6CvU6KM3uSdxC7A==} + + '@expo/devtools@57.0.1': + resolution: {integrity: sha512-GyUf+wFNkbttaX0jR7MZa9bm77U0IrLg6d2AjpxdyoXw/w4abHoXG0oFufwLMgP9zLTd5+Ct4X/ffNUTnlzZgg==} + peerDependencies: + react: '*' + react-native: '*' + peerDependenciesMeta: + react: + optional: true + react-native: + optional: true + + '@expo/dom-webview@57.0.1': + resolution: {integrity: sha512-lAKsME4SAq+8sf56oN0DX5TBYyruupoRxbWbD2xf9RnKY8y6x8eb9LCE5pxSN0qyWdqnp+0wmyWzDkKboThKAw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + '@expo/env@2.4.3': + resolution: {integrity: sha512-M1NXeZCA1mkMkYOyIe7PlyRX0/jqFtMoJgyblnlq/vpCRfmueFT7RnGSQG8uEFDF5WHOFGijAQ3fogPh3/n5Ng==} + engines: {node: '>=20.12.0'} + + '@expo/expo-modules-macros-plugin@0.6.1': + resolution: {integrity: sha512-cpsLZE4rqkc1Y3eZTkxB98jrqY1YXgetmtxFt8q89jBRmk3quRuk1BZo+VcnCSObZardjg99r1k5xijEMONFGA==} + + '@expo/fingerprint@0.20.13': + resolution: {integrity: sha512-h1x+w+JpNRu88a/5SMZRzJs9YDGR/f7R8ApCr7OdenhqmJ00hFxQMzvxajDLL+1P2Y2g/ND0xksusqvBqWSW5Q==} + hasBin: true + + '@expo/image-utils@0.11.5': + resolution: {integrity: sha512-KPQBTpmpAfy/Vu9y4wPW808/qtZxjYmyJg8cm2QCPAupp+qEWA3b5zmk0ulOwQ9OgeHxuCPgUqWgkwHFo7UsrQ==} + + '@expo/image-utils@0.8.17': + resolution: {integrity: sha512-1eVrX6FFkPNl9DJNiw3Gjsf6PtG7HttbapEVvcROVrA9PCMikujOJWWHDYKGptmKoVIIVajFZE8UHGHUb3mPug==} + + '@expo/inline-modules@0.1.7': + resolution: {integrity: sha512-Bz/khd1gIJqDkje7t5ejD5e9jFbm4xEJzWSwRKadRo6gruepbw1xJ3Eb+e58OS8fY1ZNQYjzZbspnuph67sN0g==} + + '@expo/json-file@10.0.16': + resolution: {integrity: sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw==} + + '@expo/json-file@10.2.0': + resolution: {integrity: sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==} + + '@expo/json-file@11.0.1': + resolution: {integrity: sha512-zxHWj4MKKMAL29ZQSY/Fssx4Thluk40JmuGNaeS078wy/NhlFhnVi+rHHunulE3xJAJ0CM73m8VK2+GkF9eRwQ==} + + '@expo/local-build-cache-provider@57.0.8': + resolution: {integrity: sha512-SEdE0pAQrr90bRh3MNR0ZuwoIBw390cYdFgbn7Vk0Mtm9EHaBfP7kYj+2hXnXZJgOEm3/JMtfoD3rV7rWA9FGg==} + + '@expo/log-box@57.0.4': + resolution: {integrity: sha512-IxwS9s1L2muj8mj8AQSuiy7u8OFJdc02NRFo2me/Tj6DiaeG5SREqmpBE4rQpR2cadqSg5jl8Qab8Cjie616dg==} + peerDependencies: + '@expo/dom-webview': ^57.0.1 + expo: '*' + react: '*' + react-native: '*' + + '@expo/metro-config@57.0.12': + resolution: {integrity: sha512-S62Lrq35HZqBFD55423pmWb8PjaiR/W02zQC1uECBmw1vTN8WZaFz4TJ0i21EeJzwfebMb9MLxL8JZ102Z6VbA==} + peerDependencies: + expo: '*' + peerDependenciesMeta: + expo: + optional: true + + '@expo/metro-file-map@57.0.3': + resolution: {integrity: sha512-1OXy+uPYY5uc7Tm4VBsd2NRn+3wHhqeqNuEO/Xo4kmYgv8FjYgUAc+bUXON9FpC2ikcLn4EVlGM9ce2exx9Mlg==} + + '@expo/metro-runtime@57.0.16': + resolution: {integrity: sha512-x43WgZoICpp37BhDV7vixe0bNxbVPPGaBLHYc98ZJ8MU+UC2dk39Y2XvkfYMRRbAUSIkCIJiwdERnptlPg0Ckw==} + peerDependencies: + '@expo/log-box': ^57.0.4 + expo: '*' + react: '*' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + + '@expo/metro@56.0.2': + resolution: {integrity: sha512-Ld5AeYMCCDa8bLeWhfuLbZFFjlV3f6ORqyPz2glGh6RltIngMuLf9BTC2yvHFjkKuGxL5SynijmA8xmNNWn5iA==} + + '@expo/osascript@2.7.1': + resolution: {integrity: sha512-Zn03EX6In7ts2lPUW2ESUSkEhEWQN1qqsiXjadtZMJOuZRkMiAg1ZQHuvz9DjByDWNJ2pBwAGyrts9lj9k389g==} + engines: {node: '>=12'} + + '@expo/package-manager@1.13.1': + resolution: {integrity: sha512-y/K+CaYYpZpNGZhSX4HyLT/vyIunFjNfyoxNysPBCefeLKI/VCx6f9LNPzrxayr3rCYO5bl9O8H+HRQK265Nkg==} + + '@expo/plist@0.0.18': + resolution: {integrity: sha512-+48gRqUiz65R21CZ/IXa7RNBXgAI/uPSdvJqoN9x1hfL44DNbUoWHgHiEXTx7XelcATpDwNTz6sHLfy0iNqf+w==} + + '@expo/plist@0.5.4': + resolution: {integrity: sha512-Jqppj0FULNq6Zp5JtQrFICl8TtpMjwwUbxEcEC2T3z7m+TOrTQEHZXz3D3Ay7vhbmvD+VMgfWJ4ARclJXeN8Eg==} + + '@expo/plist@0.8.1': + resolution: {integrity: sha512-3gTReGIUm0oRaMClsAJYxBnVPCl6fVpsl8HS+DTVxDhW4GyVyxg9E/Znm3BvcHtUJ51RJJI14pC1wvrNilCRHw==} + + '@expo/prebuild-config@55.0.22': + resolution: {integrity: sha512-2p1lE2hOCy9HUV9qxbEqmetxUgcBnVY8jR+j/SYRXgaBeHalyQ5d09Ol5XL36/HO2fViK8VmSnTbS7C3t8eFGg==} + peerDependencies: + expo: '*' + + '@expo/prebuild-config@57.0.16': + resolution: {integrity: sha512-QA8oMZ7y70kjOzw+abhRl9uYC8Nu/abeUTHr0YVxb3pgIC14gc7w543bYtI+5gEDI3q55jKUViIauC76QvZN1w==} + + '@expo/require-utils@55.0.8': + resolution: {integrity: sha512-DDS5R67iz2SJwncbpcUMHtnsq31dSfllhaEncKxGlK0/ewgc2CaXUCkDA2AumfT7ubnSXiPkU9Gn8v2xCrvj+A==} + peerDependencies: + typescript: ^5.0.0 || ^5.0.0-0 + peerDependenciesMeta: + typescript: + optional: true + + '@expo/require-utils@57.0.5': + resolution: {integrity: sha512-kTAXj9lDFEIPMsbAOGCGbjBbMF0oi7CqkYM79KOX0DDD9wSwXmlKL1z2h8OwsrBf7mbOo2DjlRvZu4BEjrIxGw==} + peerDependencies: + typescript: ^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@expo/router-server@57.0.11': + resolution: {integrity: sha512-0QxbqEkceb1WDeu5VqNdnTPVHlm3U183F7Y1t5LMohmGRAkHVL0PB4tCHBvEqKpLzOzHVObw3reGkJ1tX48h8Q==} + peerDependencies: + '@expo/metro-runtime': ^57.0.16 + expo: '*' + expo-constants: ^57.0.19 + expo-font: ^57.0.4 + expo-router: '*' + expo-server: ^57.0.3 + react: '*' + react-dom: '*' + react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.1 + peerDependenciesMeta: + '@expo/metro-runtime': + optional: true + expo-router: + optional: true + react-dom: + optional: true + react-server-dom-webpack: + optional: true + + '@expo/schema-utils@57.0.2': + resolution: {integrity: sha512-fMu/jyN0l1Wzv7XkeWR4IYCx1M8ryui3FdBNGrWwbRgJ7EhxXxK8E2jxP2W3pbgUwUY0V3hG8+GyfCZwny+Lxw==} + + '@expo/sdk-runtime-versions@1.0.1': + resolution: {integrity: sha512-kvisefkhxu8Fm/4M8G6DEqUaigrXfrSOm2lFw7hsk/nq0zchcp6B4wSk8rlheHY8FRi5iHxKtoVMGY/3M6h04A==} + + '@expo/spawn-async@1.8.0': + resolution: {integrity: sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==} + engines: {node: '>=12'} + + '@expo/sudo-prompt@9.3.2': + resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==} + + '@expo/ui@57.0.21': + resolution: {integrity: sha512-4s1ypGNxJhJv2r1fj3wZo4Aubao0zGnCajCuiaTp8pTNrT+1vR8KvyoFEwp+c7gpKm2D58nGCiGBXhEnmKHtDQ==} + peerDependencies: + '@babel/core': '*' + expo: '*' + react: '*' + react-dom: '*' + react-native: '*' + react-native-worklets: '*' + peerDependenciesMeta: + '@babel/core': + optional: true + react-dom: + optional: true + react-native-worklets: + optional: true + + '@expo/ws-tunnel@2.0.1': + resolution: {integrity: sha512-mljsRffbOHzmSimYQbQu9h9NR3alwCCwEEouRGFL4QRTt8dNvsNtKEPa/5jHmtDLDtsh4xXsWKwzjN9BTpeXHQ==} + peerDependencies: + ws: ^8.0.0 + + '@expo/xcpretty@4.4.6': + resolution: {integrity: sha512-uRaZZ/IsX1WkFW69xxTZtMeRvI5ziD9yoian+FMqdhITkU6NGvIWd5uRMh2Gbo/6xstpQ4v0GtF1V5YatTwzDA==} + hasBin: true + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.7': + resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@isaacs/ttlcache@1.4.1': + resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/create-cache-key-function@29.7.0': + resolution: {integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/diff-sequences@30.5.0': + resolution: {integrity: sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/get-type@30.5.0': + resolution: {integrity: sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/schemas@30.5.0': + resolution: {integrity: sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mermaid-js/parser@1.2.1': + resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@posthog/core@1.55.3': + resolution: {integrity: sha512-XnmG9jo7WlxTeN4oCNWH4ESLQHR6ZPZ+mcq+8JxU62k5i1BAe3BSrybnmXHjqKp7e3rUvLK2bOzGZGSlfHvlNA==} + + '@posthog/types@1.413.0': + resolution: {integrity: sha512-tYAVzNojWckuG/wxBB0fwE70sFruQQq4xbhkci7eH2ARU3mMfUTkvpN1HwBMpm0AtbxidLiH0IFvagmdJ8opPw==} + + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + + '@radix-ui/react-collection@1.1.15': + resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.23': + resolution: {integrity: sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.4': + resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.19': + resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.6': + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.16': + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-portal@1.1.17': + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.10': + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.19': + resolution: {integrity: sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.1.21': + resolution: {integrity: sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.4': + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@react-native-async-storage/async-storage@2.2.0': + resolution: {integrity: sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==} + peerDependencies: + react-native: ^0.0.0-0 || >=0.65 <1.0 + + '@react-native-community/datetimepicker@9.1.0': + resolution: {integrity: sha512-eadbnk+I2vxvW30iTAsm/qlCnMMAadkifIMYNEB2lzhxN/SvlKc7S2V4k5DyrwjdCbqdcMk3t9K6fnUMcAV34w==} + peerDependencies: + expo: '>=52.0.0' + react: '*' + react-native: '*' + react-native-windows: '*' + peerDependenciesMeta: + expo: + optional: true + react-native-windows: + optional: true + + '@react-native-masked-view/masked-view@0.3.2': + resolution: {integrity: sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==} + peerDependencies: + react: '>=16' + react-native: '>=0.57' + + '@react-native/assets-registry@0.86.3': + resolution: {integrity: sha512-TDhgCZA4wjJg84d5A9swiOQYPIWSKEEVdg9IwMFZDupQzW/F3QoLUrfAJOcalgqTDA9/buTB8awhE3Whwg6u9Q==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/babel-plugin-codegen@0.86.3': + resolution: {integrity: sha512-O6Xza4JBGPIU8J7YbKTyBoYL4thpy8jMW/oaLDWdAyOwYHKIjK47pAL5HUEbOe2bWz2PEKjbYRF2ApkJv1ottQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/babel-preset@0.86.3': + resolution: {integrity: sha512-/eqs/Hy9RZRcjdcs4wj3Cqmxvtb3NM5g+Uuh1RIvsjynMO8PRsrVWWLWgBcZL/jYUo+ogXd2NFB0W8L5Bg89xw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/codegen@0.86.3': + resolution: {integrity: sha512-Ux4jHi0fh+bdtVEcL0gaPLbY56V+SvFUDl/8sRAE1jdb4k+o7fT/4Nc29yz4X+qfjstkSqObQTMBGhdzxH9JvA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/community-cli-plugin@0.86.3': + resolution: {integrity: sha512-qSDL9LQc5mZSZPNczT95WU9YQuPzxBklgON9vLhhqfI0yWIwKInqFx88dQ/uiEXBtf0yossthaQIqA4Ml6bF6g==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@react-native-community/cli': '*' + '@react-native/metro-config': 0.86.3 + peerDependenciesMeta: + '@react-native-community/cli': + optional: true + '@react-native/metro-config': + optional: true + + '@react-native/debugger-frontend@0.86.3': + resolution: {integrity: sha512-TQmeofQ0PcuylhhlleOeuzHYZfbrgm3gayXzowqUEzgRisTm1D40/J3ggqs7XkQi5HP5ZA3n8dHmKL9vIzPcsw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/debugger-shell@0.86.3': + resolution: {integrity: sha512-O4ds+J7xZfxkbih9T+cAGegBdvKSPKYJm/lDgC9CpEjFMkmzWTpVLU3Qsv9sqZuo58z+sGhIcJfPsCFFWHpqbQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/dev-middleware@0.86.3': + resolution: {integrity: sha512-LiEPTqTg/63bYUnrPyHLfjTDCNhA/+CUqI1+DsA9tYyewtSbULd5awsva6SgE10I+2iMhgKXS3ymkhU/kSCrGA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/gradle-plugin@0.86.3': + resolution: {integrity: sha512-lxmx0GqLEWRIpZfpYFXlYVIs3ENQwaW6Vmp6oi29l2GoQJ1wZfFZRdMimDWlGEk8LKfHar3QH3iaPMkTcK9lEQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/jest-preset@0.86.3': + resolution: {integrity: sha512-oMmBwXUpTgJvGlcXQQPuiQLykoM8jsWYp6RwdJ2NWyIWzJcGp1rmSr3XV7istoIcUt0wdOVLtQs0A0nDm9ND+A==} + engines: {node: '>= 20.19.4'} + peerDependencies: + react: ^19.2.3 + + '@react-native/js-polyfills@0.86.3': + resolution: {integrity: sha512-eYIJ0es967+tePBFQDnl/gidVFxLns3fnbiK6rxscQrGodvuUO6hwxpQnfNynJ8MjbbndImXihXjcnJdc7SzJg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/metro-babel-transformer@0.86.3': + resolution: {integrity: sha512-0nlwVkG0uT9o72nrGTKUz/IqDnCkM4zE0ihe4r+OJcxH93yBdDm58AXJ3EXZeSjv3osHEFXU7ceajdZiS34mTw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/metro-config@0.86.3': + resolution: {integrity: sha512-qdzDMepV2xdUhsO4XC7idnnbt8K6+Hd59AjeK9y4KS86fxSQq5oL0TAbqUuajDGR7beNQoYTuS6AowN/zP0oaQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/normalize-colors@0.74.89': + resolution: {integrity: sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==} + + '@react-native/normalize-colors@0.79.7': + resolution: {integrity: sha512-RrvewhdanEWhlyrHNWGXGZCc6MY0JGpNgRzA8y6OomDz0JmlnlIsbBHbNpPnIrt9Jh2KaV10KTscD1Ry8xU9gQ==} + + '@react-native/normalize-colors@0.83.10': + resolution: {integrity: sha512-dWgqcxaBy27oLx9tndcTF0917vbLOOstyNjYD58J6Z7YxkEZSaKG6+A+3I1KV6O1Xg2D69QThp4t6ezoC3NiGA==} + + '@react-native/normalize-colors@0.86.3': + resolution: {integrity: sha512-Cv3CDkprb67GrzuaS9BGbBJC/6G4lIw3nyKOHRKTqTTum4bn37y5+R0Z04L8mcbQN85eEohNrRwb7IOM4j6uvg==} + + '@react-native/virtualized-lists@0.86.3': + resolution: {integrity: sha512-1j44NEyNn05Ut40vHAmoSWbsIcybFkMAOBTwQt1PrESyfSS+qBoyU1LGIogNva0VIa0rQyEC5PzbA7R4/7Nhyw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@types/react': ^19.2.0 + react: '*' + react-native: 0.86.3 + peerDependenciesMeta: + '@types/react': + optional: true + + '@shopify/react-native-skia@2.6.2': + resolution: {integrity: sha512-NzZ3+MRedZAUhguWw9DTCpWFd09Bq+tdGWhimGfJLGckuyoWGyimTiNTmaO2DeeivHTnGdv+eXbw7j/AV3LkRQ==} + hasBin: true + peerDependencies: + react: '>=19.0' + react-native: '>=0.78' + react-native-reanimated: '>=3.19.1' + peerDependenciesMeta: + react-native: + optional: true + react-native-reanimated: + optional: true + + '@sinclair/typebox@0.27.12': + resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} + + '@sinclair/typebox@0.34.52': + resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@tanstack/query-core@5.104.1': + resolution: {integrity: sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==} + + '@tanstack/react-query@5.104.1': + resolution: {integrity: sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==} + peerDependencies: + react: ^18 || ^19 + + '@testing-library/react-native@13.3.3': + resolution: {integrity: sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==} + engines: {node: '>=18'} + peerDependencies: + jest: '>=29.0.0' + react: '>=18.2.0' + react-native: '>=0.71' + react-test-renderer: '>=18.2.0' + peerDependenciesMeta: + jest: + optional: true + + '@tootallnate/once@2.0.1': + resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} + engines: {node: '>= 10'} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.1': + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.12': + resolution: {integrity: sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.9': + resolution: {integrity: sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/hammerjs@2.0.46': + resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + + '@types/jsdom@20.0.1': + resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/node@26.6.4': + resolution: {integrity: sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==} + + '@types/react-reconciler@0.28.9': + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} + peerDependencies: + '@types/react': '*' + + '@types/react-test-renderer@19.3.0': + resolution: {integrity: sha512-TX4p2D4Z2oigM3SXU7+QyLSUnSR0vR1S1Pfxm/qFL9nHQ3jclgi6ioGoWQmlIr1DeKRVmPJrmNlhgM0JlWlLUg==} + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + + '@webgpu/types@0.1.21': + resolution: {integrity: sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==} + + '@xmldom/xmldom@0.7.13': + resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==} + engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version + + '@xmldom/xmldom@0.8.15': + resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==} + engines: {node: '>=10.0.0'} + + '@xmldom/xmldom@0.9.12': + resolution: {integrity: sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==} + engines: {node: '>=14.6'} + + abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + deprecated: Use your platform's native atob() and btoa() methods instead + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-globals@7.0.1: + resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + agent-cli-detector@0.1.7: + resolution: {integrity: sha512-d8OWDVdZMgjhLUT9ZPgSv/BdFFF9pVuscC0JdUSz3bjwE15gcp6u/o0/JooM2yyAWC49KThFhXlgTXRa9B7yng==} + engines: {node: '>=18.18'} + hasBin: true + + anser@1.4.10: + resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@6.2.1: + resolution: {integrity: sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==} + engines: {node: '>=14.16'} + + ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} + engines: {node: '>=12'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.13.0: + resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + + babel-plugin-react-native-web@0.21.3: + resolution: {integrity: sha512-e4fG8NbyRvk5GwU/7G2U9WxzPYtpC4IQN28AyRDzKE0kvywgohq8vT2x0/xBfg8a0RfSkXLlHAtUAGlSy3VhAw==} + + babel-plugin-syntax-hermes-parser@0.36.0: + resolution: {integrity: sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q==} + + babel-plugin-syntax-hermes-parser@0.36.1: + resolution: {integrity: sha512-ycduwJbvdvIMmVvlAZqGggS+pm5Eu4Bk9pcV9Sm2Z4PJNRVsKkv0g7vHj+LeuC1gHTeF67sJXFOq61IlqCa2hA==} + + babel-plugin-transform-flow-enums@0.0.2: + resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-expo@57.0.13: + resolution: {integrity: sha512-OOl3lqNQ4IagJfsNgtxuna0wpTWt1BswdyktGTOJ5EJCgdQUk/B2vaj/jsl03+kMyuIvpxKioVtbTzxh3mdvvQ==} + peerDependencies: + '@babel/runtime': ^7.20.0 + expo: '*' + expo-widgets: ^57.0.21 + react-refresh: '>=0.14.0 <1.0.0' + peerDependenciesMeta: + '@babel/runtime': + optional: true + expo: + optional: true + expo-widgets: + optional: true + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + badgin@1.2.3: + resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.11.27: + resolution: {integrity: sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + bplist-creator@0.1.0: + resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} + + bplist-parser@0.3.1: + resolution: {integrity: sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==} + engines: {node: '>= 5.10.0'} + + bplist-parser@0.3.2: + resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} + engines: {node: '>= 5.10.0'} + + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} + + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.29.3: + resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + camelize@1.0.1: + resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} + + caniuse-lite@1.0.30001814: + resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==} + + canvaskit-wasm@0.41.0: + resolution: {integrity: sha512-cnbL02NFB3yOYMF/MtxViZHgD1vh55Pvy+zR8q4JuFvyCPejZP3eClkt2GuZ0S7jOmGMCJXaHBasbMChbR9JZg==} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + char-regex@2.0.2: + resolution: {integrity: sha512-cbGOjAptfM2LVmWhwRFHEKTPkLwNddVmuqYZQt895yXwAsWsXObCG+YN4DGQ/JBtT4GP1a1lPPdio2z413LmTg==} + engines: {node: '>=12.20'} + + chrome-launcher@0.15.2: + resolution: {integrity: sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==} + engines: {node: '>=12.13.0'} + hasBin: true + + chromium-edge-launcher@0.3.0: + resolution: {integrity: sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==} + + ci-info@2.0.0: + resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cli-cursor@2.1.0: + resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} + engines: {node: '>=4'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.2: + resolution: {integrity: sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==} + engines: {node: '>= 0.8.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + connect@3.7.0: + resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} + engines: {node: '>= 0.10.0'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + core-js-compat@3.50.0: + resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==} + engines: {node: '>=6.4.0'} + + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + + create-jest@29.7.0: + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-color-keywords@1.0.0: + resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} + engines: {node: '>=4'} + + css-in-js-utils@3.1.0: + resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-to-react-native@3.2.0: + resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} + + css-tree@1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} + engines: {node: '>=8.0.0'} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssom@0.3.8: + resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + cssstyle@2.3.0: + resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} + engines: {node: '>=8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.3: + resolution: {integrity: sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + + data-urls@3.0.2: + resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} + engines: {node: '>=12'} + + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + dnssd-advertise@1.1.6: + resolution: {integrity: sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + deprecated: Use your platform's native DOMException instead + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + dompurify@3.4.16: + resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.444: + resolution: {integrity: sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + entities@2.0.3: + resolution: {integrity: sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + error-stack-parser@2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + expo-application@57.0.3: + resolution: {integrity: sha512-t/CxeLio0NjY9/cOak1tQp62/hAUjzJxmhR4rVUR7K5bb9YoEtfl1ZekTn+Iuh+dYOMYzF0l72ODMSmY3CR1NA==} + peerDependencies: + expo: '*' + + expo-asset@57.0.18: + resolution: {integrity: sha512-zDIQkUr0NM2S7YRj98Zohf/OBuM1q0o4ctxLz1C1oe4/33lHNLjp+2v3qUtBWVwn0zKka8UGADmS8M+83Wi6pw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-auth-session@57.0.13: + resolution: {integrity: sha512-Ky40Vn2ZQVTesh0pEALgcxPxKZdp7WHu2ODYh0lh1YD3ciQd6WhobYYj2oq8MO9Rx9BBsXC64d2L5+TG+mDgSw==} + peerDependencies: + react: '*' + react-native: '*' + + expo-blur@57.0.3: + resolution: {integrity: sha512-uyBeJwxIuXzWXXNrnRub5pcF8VzQsiOz0VBTWDpMG2UztmaFyfAz6pI/GCwRtWpHhiOpLoJj79WLdg1GMB+9cg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-clipboard@57.0.2: + resolution: {integrity: sha512-VB4Au8X/RbvJKUtJk+87vdtveWSNEa5RzY3ooeO6VNF7Rd49RbQXZOu3/TfEW9p7mU8TCfyTvjyCiAUwSoSJ4w==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-constants@57.0.20: + resolution: {integrity: sha512-WycPVvBgbluSa6yAh6DKnX646ZHrae0gV2ZLum5fioNkR+DiWyulq1+UfS++Jc6yfAh/l55NOirv6mt5cnQhSw==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-crypto@57.0.3: + resolution: {integrity: sha512-SAWqEfF37nc8ot7bXsVmu9AFYwQdC2kGvH/eNAHiR3bmlcf1Vv8wu9AnGKT+a90r3T8YfwMpZP4QAYlo4vCn9w==} + peerDependencies: + expo: '*' + + expo-dev-client@57.0.19: + resolution: {integrity: sha512-7giMK6BLL685GECHem2UAQvS473ImZLi5l+Q5tq+XSyv6I9UNVPs0p07mKwgBzq8z7DR3d4nTl702ziFxTjO3Q==} + peerDependencies: + expo: '*' + + expo-dev-launcher@57.0.20: + resolution: {integrity: sha512-F6oAHaDn9j3L/gyMGE+OalriX6PHZWnZLxpN5jc6crTrp8f6hSwGhmsHpAvdOzijfMjyi3R2UkcISFsCIIQYFA==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-dev-menu-interface@57.0.0: + resolution: {integrity: sha512-F47VdzOHYc19FhI/jBgctpO8a5UskTIxG6a1E5t3W5gF8VImuvBQffdXXfLHhsuCl7dS3v3U0R45cleeVXO1Zg==} + peerDependencies: + expo: '*' + + expo-dev-menu@57.0.18: + resolution: {integrity: sha512-H7m9ENtVYz4FpJktQj+z/njIN1t7kOEaKCv8Qgv2SdAlyFTW8s2EnufxPoSlg2zUTanaauJ6wrR6XESHwW7kpw==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-device@57.0.2: + resolution: {integrity: sha512-1A0/FICR3CgrH43FeiDQP3l5eHDmiMoh4EjXfhfoHHkMgdBQdyD0kwDWskiGZlQ3E0Nd0LHkCFH6vJ1ofm/jow==} + peerDependencies: + expo: '*' + + expo-document-picker@57.0.3: + resolution: {integrity: sha512-XlxdZ0zzv44JRD8O15DISgIO8FTaZ581oc4kQp6MJoR7vdahjcY+2qCuDt5k1J8/lc0YR+bXon/v81norciuxw==} + peerDependencies: + expo: '*' + + expo-eas-client@57.0.4: + resolution: {integrity: sha512-VSPi6Kldu0EpPkC2e3bv3QNAUVDogbz1tTj7KgcdbsuUySt0hu24g4f1fJ/YjYzCw9G1l755x4QQctcX1j7UEA==} + + expo-file-system@57.0.7: + resolution: {integrity: sha512-1vkZRadOsIputFDBcclqBa1pO3iXOO6iIsdqJipQWw4V5TSnWgmRTuOF7ro8IzrVue+PHbum++KCG3Tawem4cg==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-font@57.0.4: + resolution: {integrity: sha512-7WOMC2oA6xCfOm8bbofqWn2bIqyWFm7YLEnpc/gA1MJH0Ll0TfT/3+CqRkoW2eNRWMS8sxrz5WukUmSvmNquHA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-glass-effect@57.0.4: + resolution: {integrity: sha512-/AG2Kljbqfgxu+GchI2YFIbxiF9cPsIpCy4iNDzlRiZk9rvR6BwRfB6gfQVu3V2ExaoGf2h5IsEnqVI5MYNBAg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-haptics@57.0.3: + resolution: {integrity: sha512-qucUXtwtJdtECKSQK5D6TQBc/0SxKj7s5wQ4mrfSxhZsHrS9a4rcxINl/gbruMaY9spTXStco32ST4pV0vXmxw==} + peerDependencies: + expo: '*' + + expo-image-loader@57.0.1: + resolution: {integrity: sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug==} + peerDependencies: + expo: '*' + + expo-image-picker@57.0.20: + resolution: {integrity: sha512-plgcGuMZMWqDchjXQW6S/WepYLB5KqfbZOzwC6xe0+j5IhY4ipvVuRZcXeP/0NgKgU0Jiu/XoTK4Bi8crtZ+sQ==} + peerDependencies: + expo: '*' + + expo-json-utils@57.0.2: + resolution: {integrity: sha512-9kYZECLKE3pYZQs+kt3+ggY83Lfryog9/UEo3HJKGcHUZeKKtlFBIPFjI4LzREubsvjP7AsvG0FdEX9EquIoAQ==} + + expo-keep-awake@57.0.2: + resolution: {integrity: sha512-GqgH746wtJImmsbyHmCQoi7cOslKuWQBbJHXr53S35Bpf5UvxCAztvnitUEd7D0QvFN2rvkOhylbZpgZZfBLlQ==} + peerDependencies: + expo: '*' + react: '*' + + expo-linking@57.0.11: + resolution: {integrity: sha512-bfTyiPb8szacs8XkGowXt5ssH/vbF26vFG8WUWQar8CcRpQcIzXY980ZkP7pK7Pcdy5MattwFdujrWOxwv5s3Q==} + peerDependencies: + react: '*' + react-native: '*' + + expo-localization@57.0.2: + resolution: {integrity: sha512-sIKYH3Ivddoge568WY6j+Kp2MAAZzWWqEnY2bokLkJFwX366tGL1mEnoY2WUi/qHatFFPop2yqDdurzbhnnimQ==} + peerDependencies: + expo: '*' + react: '*' + + expo-manifests@57.0.2: + resolution: {integrity: sha512-pPIIcKR73OShwlWMg8H90WRzKUd2krDdKlKAwuQV1FtUwE7H3hQEFw0ePFQY9yFoGMF/77ryYwNKfI5umMcCog==} + peerDependencies: + expo: '*' + + expo-modules-autolinking@57.0.13: + resolution: {integrity: sha512-Hj5NjZRlfccazhKab+wK19leRVUr1Y/PUERaRvegIbUSkq4GVxKq6lU9QHlKQyC16MdD/VS4iwsiLivB4o7lvQ==} + hasBin: true + + expo-modules-core@57.0.20: + resolution: {integrity: sha512-vEL3aeih32yuD7sQHB3Y3LKXRT8mMgoVQWFIcHtX811Bl0StnNZUG2vT5je4fe+OWYVfkjb6gNqfMc04moREgA==} + peerDependencies: + react: '*' + react-native: '*' + react-native-worklets: ^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0 + peerDependenciesMeta: + react-native-worklets: + optional: true + + expo-modules-jsi@57.1.1: + resolution: {integrity: sha512-ssnwz3ZuIgCbZj5A3J6NlhQrXZojg+BjkeWrOWqVJhyMHlV/A/cRT7s986chKku97BFt6ucmDu6/BCzrV45Wcw==} + peerDependencies: + react-native: '*' + + expo-notifications@57.0.21: + resolution: {integrity: sha512-CWBW6DxqRJ2MaYZW5+/WfEWwIwbiVk5UAPqx0kE676B4R1+PlAXARk/kuNeQBcVrcDNV6i9ihE9B0db8Cd2RXQ==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-router@57.0.24: + resolution: {integrity: sha512-XpqE4Oh4yrmc+sVfsDQPLErBlus//85ulLRghVZltYcdvFm9yNzErmaKrXuBVMUL6D3tnpDtkIgxESG9hYBd7w==} + peerDependencies: + '@expo/log-box': ^57.0.4 + '@expo/metro-runtime': ^57.0.16 + '@testing-library/react-native': '>= 13.2.0' + expo: '*' + expo-constants: ^57.0.20 + expo-linking: ^57.0.11 + react: '*' + react-dom: '*' + react-native: '*' + react-native-gesture-handler: '*' + react-native-reanimated: '*' + react-native-safe-area-context: '>= 5.4.0' + react-native-screens: ^4.26.0 + react-native-web: '*' + react-server-dom-webpack: ~19.0.4 || ~19.1.5 || ~19.2.4 + peerDependenciesMeta: + '@testing-library/react-native': + optional: true + react-dom: + optional: true + react-native-gesture-handler: + optional: true + react-native-reanimated: + optional: true + react-native-web: + optional: true + react-server-dom-webpack: + optional: true + + expo-secure-store@57.0.4: + resolution: {integrity: sha512-7zBA1RThURTXdP4w65kBzZNbcSsfArqVd+p+ETAnPRWXO8J296Qd0YaCVyRpJ0HSwlCnGdvOnqO+NgSreHsOhA==} + peerDependencies: + expo: '*' + + expo-server@57.0.3: + resolution: {integrity: sha512-aK+LdKzauHSGmsOStZtyxdzv0zWssCkxTw3m4QuOhfDSJsZaMRTd9O41d8ixU/QfELTbaJ0oRNcF7JFV/7O9YQ==} + engines: {node: '>=20.16.0'} + + expo-splash-screen@57.0.9: + resolution: {integrity: sha512-7cKDn7uBwg0RkcmY015/5j9st4msWJ4zQiPmQdVZoppW3cFSbv05n7xhM6CjUU466Tx4VRTqDF21HuIHkmnEtA==} + peerDependencies: + expo: '*' + + expo-status-bar@57.0.1: + resolution: {integrity: sha512-Xwaq1gAoVRWx5dPG5VhT5RSbnI9OilhZnO5qoPBnUaBAa5VzRzfdS8q0/bsPt0jR2DKLtGuP0bQ6efMJ4RIMDg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-structured-headers@57.0.1: + resolution: {integrity: sha512-5VwmitVr2J716pOuHXWmEnHbZhZg63DBOZKH5tj0SR8Sps/3jSAf9wf9+UGTKcKgAxGf1PPwQ0lAQ2bG3RwjAg==} + + expo-symbols@57.0.3: + resolution: {integrity: sha512-uuK5yHLVR7kExUCrO/ItBvp/kp1maQ9Nkb6KvNy5ZUQnN0NMKwuhJ4GvbfS9N3ACmvCjTT4gKvg4yH5UuKIYNA==} + peerDependencies: + expo: '*' + expo-font: '*' + react: '*' + react-native: '*' + + expo-system-ui@57.0.4: + resolution: {integrity: sha512-z//rxQJzhTNA1PcKrUtWmrd21jAS3nEDu1b0n8B/7LTTR933OcsZYP1HnbaZifuo2ZH/h7nce2Ow61WZ1Ig2bg==} + peerDependencies: + expo: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + + expo-updates-interface@57.0.2: + resolution: {integrity: sha512-x3SA5toiHZEkW+5oeNdUM1Rvt6M3ExegQsSwS3Z1+S9gn9utGYTGBkb8MBQivhjIZiRlQYDIhauxA8kySQEWtQ==} + peerDependencies: + expo: '*' + + expo-updates@57.0.24: + resolution: {integrity: sha512-6n7rBuAn+yjfxGjVf9hPUiMcEW4ggxwlimfO7CJg+OLOJssWsKB1qyp9cj/InHc2XGolpvgigZzECNNFUkv3zA==} + hasBin: true + peerDependencies: + expo: '*' + expo-dev-client: '*' + react: '*' + react-native: '*' + peerDependenciesMeta: + expo-dev-client: + optional: true + + expo-web-browser@57.0.3: + resolution: {integrity: sha512-+ecvhyS/PdWkpv4ai6FF1Trd6Y/4kn/eQ3O86mie2ecU3w+I8bKZh13kppY/jw1uSL7yikt03L5W/iiHojrFwQ==} + peerDependencies: + expo: '*' + react-native: '*' + + expo@57.0.26: + resolution: {integrity: sha512-U1rsFwcuZNnU61Z/YLnnVC/ZqP0wpxh/rCKUMff3ot/rhLpxFY1zxEqu4vyaexyvBAB1mwpl9lA/ltcHBoQGyg==} + hasBin: true + peerDependencies: + '@expo/dom-webview': '*' + '@expo/metro-runtime': '*' + react: '*' + react-dom: '*' + react-native: '*' + react-native-web: '*' + react-native-webview: '*' + peerDependenciesMeta: + '@expo/dom-webview': + optional: true + '@expo/metro-runtime': + optional: true + react-dom: + optional: true + react-native-web: + optional: true + react-native-webview: + optional: true + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fastdom@1.0.12: + resolution: {integrity: sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==} + + fb-dotslash@0.5.8: + resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==} + engines: {node: '>=20'} + hasBin: true + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fbjs-css-vars@1.0.2: + resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} + + fbjs@3.0.5: + resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-nodeshim@0.4.10: + resolution: {integrity: sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + + finalhandler@1.1.2: + resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + engines: {node: '>= 0.8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + flow-enums-runtime@0.0.6: + resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + + fontfaceobserver@2.3.0: + resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + getenv@2.0.0: + resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} + engines: {node: '>=6'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hermes-compiler@250829098.0.17: + resolution: {integrity: sha512-qG1PXzTEtriF6oQLZF3vyHhSMxOdW5h2TqqLri0rdpstPustd2fSvRZQMVAPdlhgFwBfYnj3OUZtiO6LjYsEFw==} + + hermes-estree@0.35.0: + resolution: {integrity: sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==} + + hermes-estree@0.36.0: + resolution: {integrity: sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==} + + hermes-estree@0.36.1: + resolution: {integrity: sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw==} + + hermes-parser@0.35.0: + resolution: {integrity: sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==} + + hermes-parser@0.36.0: + resolution: {integrity: sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==} + + hermes-parser@0.36.1: + resolution: {integrity: sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ==} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + hyphenate-style-name@1.1.0: + resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inline-style-prefixer@7.0.1: + resolution: {integrity: sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + + is-core-module@2.17.0: + resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} + engines: {node: '>= 0.4'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + its-fine@1.2.5: + resolution: {integrity: sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==} + peerDependencies: + react: '>=18.0' + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-cli@29.7.0: + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@29.7.0: + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-diff@30.5.2: + resolution: {integrity: sha512-rBtHnI6BWTiWj3lM7fOLVfChx7R9B0u9VV3PRHawKs79x6ZjW1QoSrKenaigNGWlvhtoi2BqvDbABDie+Os3xQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-environment-jsdom@29.7.0: + resolution: {integrity: sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-expo@57.0.5: + resolution: {integrity: sha512-U08fwjaDBwnnbKnZcOA0WQbHfKqOWTCGku+IdCaekrLZmfbbZr3P1WqEsXHo6ntidQ3DmW+TT6G1aOp+ChYfKA==} + hasBin: true + peerDependencies: + '@react-native/jest-preset': ^0.86.3 + expo: '*' + react-native: '*' + react-server-dom-webpack: ~19.0.4 || ~19.1.5 || ~19.2.4 + peerDependenciesMeta: + expo: + optional: true + react-server-dom-webpack: + optional: true + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@30.5.2: + resolution: {integrity: sha512-lFkB365PTIHOhPzwrb9T6gvlDnPpOZ7/c3ewOKKB7bzztqjVrtlyRL2MkyfJXpyY2u2SKw001JKrTx14fBUFcQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-watch-select-projects@2.0.0: + resolution: {integrity: sha512-j00nW4dXc2NiCW6znXgFLF9g8PJ0zP25cpQ1xRro/HU2GBfZQFZD0SoXnAlaoKkIY4MlfTMkKGbNXFpvCdjl1w==} + + jest-watch-typeahead@2.2.1: + resolution: {integrity: sha512-jYpYmUnTzysmVnwq49TAxlmtOAwp8QIqvZyoofQFn8fiWhEDZj33ZXzg3JA4nGnzWFm1hbWf3ADpteUokvXgFA==} + engines: {node: ^14.17.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + jest: ^27.0.0 || ^28.0.0 || ^29.0.0 + + jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest@29.7.0: + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jimp-compact@0.16.1: + resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.15.2: + resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} + hasBin: true + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + jsc-safe-url@0.2.4: + resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} + + jsdom@20.0.3: + resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} + engines: {node: '>=14'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + lan-network@0.2.1: + resolution: {integrity: sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==} + hasBin: true + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + lighthouse-logger@1.4.2: + resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + linkify-it@2.2.0: + resolution: {integrity: sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@2.2.0: + resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} + engines: {node: '>=4'} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + markdown-it@10.0.0: + resolution: {integrity: sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==} + hasBin: true + + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + + marky@1.3.0: + resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.0.14: + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + + mdurl@1.0.1: + resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==} + + memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + merge-options@3.0.4: + resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} + engines: {node: '>=10'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + mermaid@11.17.0: + resolution: {integrity: sha512-Jo9N377Wb4MSnHFPTbLi2SxFpsQl4eVHoxnW5U1Md9EazvgMp3s+4ohDxr81YNTgbn5Kj7HJ3yslrSJ52kwpbA==} + + metro-babel-transformer@0.84.5: + resolution: {integrity: sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-babel-transformer@0.84.6: + resolution: {integrity: sha512-B1ozl6KxFHbQjXZ2U8WiTPWXylqiD3V4EXyV5r0fqETOrqIrJlBvbVTD82UxIN+B3Vpe4l8G1tb15ocd2RyOQA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-cache-key@0.84.5: + resolution: {integrity: sha512-3dPB2TnvGjjf0/9O7AXVQURKXuQNauTZE7WpTGTlR017Gh/B5y0m/2wcqxfveUguHSpu89KhVxCAlr2k/H7uhQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-cache-key@0.84.6: + resolution: {integrity: sha512-6tyXt1BZ/3U183XV48oif7Nm65TE+pTo0Vs31u4FxMgqiYQR/SiSr3RoXn3pmy4pd280ZmLmct0x3jZpu0pV4A==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-cache@0.84.5: + resolution: {integrity: sha512-WHS0n2OxQqtwEjSeQFPePNrMvEFhmQcUQM9cRJMHByWoi/GMWFBEWOf7hVkAM/0KRutAXNbDlSu/cZB6CyxgQQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-cache@0.84.6: + resolution: {integrity: sha512-KBJVpb02oKNO+jlWQ1Aax7cd11YP0MClDaMgx6C4MhyZ9g95J/dC1Bo6sCzXaU9L9h/w5cQfT5zDkGW5sOj99A==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-config@0.84.5: + resolution: {integrity: sha512-zie+uN6oohscowi2S7ByU+wUw6CrT4ZxW9uAbONOObSxx86RGmnIAmjXHLkfmcdYoY7jzOPEbqcI6oeVmqyBQA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-config@0.84.6: + resolution: {integrity: sha512-cD2mLEofcuV26kxSV4hfV1Pbuh6igdUhYQriO8ZVeJ7HyZfq4/wqHV0gSR7fEO8NUp9odr60/Kzm5YakmghWwQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-core@0.84.5: + resolution: {integrity: sha512-xwm605hCi5Y6eJTTb8ZWo6pkUcoBEIyiQOfkZh5GwtDwUrP9SNhTQZhzJHrBCwwxlf3Ptl/pxWJgQ1rsNYMnrA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-core@0.84.6: + resolution: {integrity: sha512-ZqYskM8+f3PFMosBDJd8DLqRvT1ltRGhWL/ZfTpBvf4SoadO30VVrdhAoHFVGz3G1CJpWa3fgZH7nnnX/ybllA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-file-map@0.84.5: + resolution: {integrity: sha512-mlm/JL8toSbSc2akpKIGmzvrVRSCgZ5vkbycI34oMLoOnLGuLyC8WTyVJ6P0hZG/usDaGwZSl/s9BCRriqjGJA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-file-map@0.84.6: + resolution: {integrity: sha512-ov9VywWBsHPt54Zc7/DxWvqjTwxuZvhfeyHg8ZAMGKtf2PcH1wS3EnoBH7In53Rspk/eXVWAO5+avxunPn081w==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-minify-terser@0.84.5: + resolution: {integrity: sha512-BJoFwCEDsYnagPqarayInv2+diCDNDdLlaof/p6s9w4gh+gc9HXYM+pDvsKGKKUumpZswNF3Z/ftTMqKl/5IBg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-minify-terser@0.84.6: + resolution: {integrity: sha512-SqhB/Kxrw0XfyW0k8mEscqX/CQopm6Fp3EpdSIPzvKfGgQp9bTMahg9PAdVsXUmIWwErbmFw1VqB+VFBOKksUw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-resolver@0.84.5: + resolution: {integrity: sha512-VSSnepg1k6LyCwtb6eirWdAWlpKwBG8Rdtsr1mU38rMelFyWgh3/QuMSiZIZAIjwg/fsa8GhW5/FO54CAUPCEA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-resolver@0.84.6: + resolution: {integrity: sha512-PplpGc/OCLxgKeLNsLQO/VHsYDIvkcGGr3zNT7UanHCGRqhxKv245o+naV8cRI4/pcLqyc+9j+J0AJbT3Kp5Sg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-runtime@0.84.5: + resolution: {integrity: sha512-U1m2+d1Pr+JO2/iVXBB2OfXXityz7tqwIorxfrT15IEgaHvpJBq/OHiqnOWPKJbUl3JcxjcdviZZOKk85oK4Qg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-runtime@0.84.6: + resolution: {integrity: sha512-47EYO/DOai0PA3GoAyDpyXOgHNMwbl/Z9dQlQineDLHkpD42xOwMrIx1crT0+n6F+aGoEyxZmeFwfMzaIqVqKQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-source-map@0.84.5: + resolution: {integrity: sha512-2BtV5L9uPc49F13Gn5wiP6bX/EncqzqTIk2VL/0F/96Vo0YEOjluT/qktQjFODfqGFsucwnh5mPEAl/2jVEfeg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-source-map@0.84.6: + resolution: {integrity: sha512-4p/EplRbgNxhdqA6kAj1AmIq9qqYXYSAjgxuZ73rYA+p037xDDrxK8CyDKNfbn1k6aMuVt8LV0fpt7NHUTKiZw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-symbolicate@0.84.5: + resolution: {integrity: sha512-rQ40zYDAkaWBN9yvjUuAD0ZpzBMZSoKyGYXnb5JrfbKjun7fTvfoLHL3KXFYenBTYZkQtlp4cKSCv/1utxFyOw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + + metro-symbolicate@0.84.6: + resolution: {integrity: sha512-wJFyyF5ysbVyoYVWGL6GwVMiNnGllwDU2gF096376fl0fc8IFUlADSyNlswj31K9og2sOilXe/FdyWFPczPUYg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + + metro-transform-plugins@0.84.5: + resolution: {integrity: sha512-+InaSVGaOyt0DyRo4Y/zIdPI6CZwnbNho5LAL23tgmuGwv7fyfkF7kKfPjZcfxXBcoYdTLLFnCfCH/dHSiCqNg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-transform-plugins@0.84.6: + resolution: {integrity: sha512-pROPMFaj25Y9+3LlLqpBRz/7B/2YPnfEQz/z3juYxkh3FGQ+3c/Qh4E+khPSTdwTw0M6LitLYHMqaHMbQmuBJw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-transform-worker@0.84.5: + resolution: {integrity: sha512-ui1Z8x4s5RL36gMmKLaMMO7O9NNDHNdthEZSCDQHAau3JcAsTaFOK6I+2q4I/kW5u8hSEjJk9L45TXSVJw6g1A==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-transform-worker@0.84.6: + resolution: {integrity: sha512-xLTVrOENaHR9DiuhnHS+zcle0rKsCy2CpXSIx7xD+zhJs+qVRNUyV7Z1zIt/0P/d0cSGVx846R+dIjHEudhcww==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro@0.84.5: + resolution: {integrity: sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + + metro@0.84.6: + resolution: {integrity: sha512-ty/tx/imE5Ph2gvPU788Ckim6RAC2tyZxLz2hWRgC9U7/fGKV7qY/slADy0qHPEw9cpUgc4BfmV3oUsYwm7oeg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-fn@1.2.0: + resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} + engines: {node: '>=4'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multitars@1.0.2: + resolution: {integrity: sha512-6GwVw5eLi9sThdtlS4PKwC7yRLaf45pYhIEzKBHdKxi+YOXGKFX8acIniH+Uh/+k9mS2lQOupTccjoe5r0/1IQ==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + engines: {node: '>= 6.13.0'} + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.57: + resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-package-arg@11.0.3: + resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} + engines: {node: ^16.14.0 || >=18.0.0} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nullthrows@1.1.1: + resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + + nwsapi@2.2.28: + resolution: {integrity: sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==} + + ob1@0.84.5: + resolution: {integrity: sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + ob1@0.84.6: + resolution: {integrity: sha512-+s+6zjd0X68hfAcn92NsEPXnSYOF3O7exw/Fj4UfRTB+UL+Tdjdkzu5/4WquXlzqgvNm8FWuuW6/0Yd8S2rYxw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@2.0.1: + resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} + engines: {node: '>=4'} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + + ora@3.4.0: + resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} + engines: {node: '>=6'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + package-manager-detector@1.9.0: + resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-png@2.1.0: + resolution: {integrity: sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==} + engines: {node: '>=10'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + plist@3.1.1: + resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} + engines: {node: '>=10.4.0'} + + pngjs@3.4.0: + resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} + engines: {node: '>=4.0.0'} + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + posthog-react-native@4.78.4: + resolution: {integrity: sha512-elvwRn3eWOtBl5sVpk4oT8lB+EFxIr8A/uDw1DqVawJ5LKHm/R3fdYWZnpzM9CLOPBlrhWo/7UWdXEi+A8GNxQ==} + hasBin: true + peerDependencies: + '@posthog/react-native-plugin': '>= 2.9.3' + '@react-native-async-storage/async-storage': '>=1.0.0' + '@react-navigation/native': '>= 5.0.0' + expo-application: '>= 4.0.0' + expo-device: '>= 4.0.0' + expo-file-system: '>= 13.0.0' + expo-localization: '>= 11.0.0' + expo-updates: '>= 0.25.0' + posthog-react-native-session-replay: '>= 1.6.0' + react-native-device-info: '>= 10.0.0' + react-native-localize: '>= 3.0.0' + react-native-navigation: '>= 6.0.0' + react-native-safe-area-context: '>= 4.0.0' + react-native-svg: '>= 15.0.0' + peerDependenciesMeta: + '@posthog/react-native-plugin': + optional: true + '@react-native-async-storage/async-storage': + optional: true + '@react-navigation/native': + optional: true + expo-application: + optional: true + expo-device: + optional: true + expo-file-system: + optional: true + expo-localization: + optional: true + expo-updates: + optional: true + posthog-react-native-session-replay: + optional: true + react-native-device-info: + optional: true + react-native-localize: + optional: true + react-native-navigation: + optional: true + react-native-safe-area-context: + optional: true + react-native-svg: + optional: true + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + pretty-format@30.5.1: + resolution: {integrity: sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + proc-log@4.2.0: + resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + + promise@8.3.0: + resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + query-string@7.1.3: + resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} + engines: {node: '>=6'} + + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + react-devtools-core@6.1.5: + resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} + + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + peerDependencies: + react: ^19.2.6 + + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + + react-freeze@1.0.4: + resolution: {integrity: sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==} + engines: {node: '>=10'} + peerDependencies: + react: '>=17.0.0' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.3.0: + resolution: {integrity: sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==} + + react-native-drawer-layout@4.2.11: + resolution: {integrity: sha512-Aua+LAZJ6L3v7ESHsNtxN2sO+hFQKsSz3EDKOLIpWni6PdQEJGFT0Mc2UnEsvFdfV743aHfODTAaHxsJ2D0uFw==} + peerDependencies: + react: '>= 18.2.0' + react-native: '*' + react-native-gesture-handler: '>= 2.0.0' + react-native-reanimated: '>= 2.0.0' + + react-native-fit-image@1.5.5: + resolution: {integrity: sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg==} + + react-native-gesture-handler@2.32.0: + resolution: {integrity: sha512-uYIMOKlKENORq2SABE+jIjbPU+h5I/sQKcq2v16zRq848nwEp1fWRVwML4QWqijc8UcXJC25o54S8GQd4Mf2OA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-is-edge-to-edge@1.3.1: + resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-keyboard-controller@1.21.9: + resolution: {integrity: sha512-+TkkFldht4+AXBQeDy1hLE7iqiW8/NkY/ekhcFsKIiRdI9qC5JDzx0TfAg1iYZB2IeOXppmURIy2jFCUjOcV1w==} + peerDependencies: + react: '*' + react-native: '*' + react-native-reanimated: '>=3.0.0' + + react-native-markdown-display@7.0.2: + resolution: {integrity: sha512-Mn4wotMvMfLAwbX/huMLt202W5DsdpMO/kblk+6eUs55S57VVNni1gzZCh5qpznYLjIQELNh50VIozEfY6fvaQ==} + peerDependencies: + react: '>=16.2.0' + react-native: '>=0.50.4' + + react-native-reanimated@4.5.1: + resolution: {integrity: sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==} + peerDependencies: + react: '*' + react-native: 0.83 - 0.86 + react-native-worklets: 0.10.x + + react-native-safe-area-context@5.7.0: + resolution: {integrity: sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-screens@4.26.2: + resolution: {integrity: sha512-2XnWsZToKj76trGtEZzx5ELD/qOICFEprEeUntImmitQFVUkea27fiWdUSITArI356Y1qynpXZINW+Unbhky/A==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-skia-android@147.1.0: + resolution: {integrity: sha512-pWA0M0G74AhjEop0HLCkjWJMup2HJxOmuUjfPt6kSDhYeWKVx8AEzWh0Fh19ah78zE/s4hD0Of0Tyem5shhiTg==} + + react-native-skia-apple-ios@147.1.0: + resolution: {integrity: sha512-cr4rWe4Bf0H0TTutUp5cgHt5/Felttl1bh4BAAAsgAeL2F10FAK9urX8spjUshzMwjqXD7rNOWuFzU6ZcNlGKw==} + + react-native-skia-apple-macos@147.1.0: + resolution: {integrity: sha512-Qbv0Y7LgawtRKuGk8gnGeh8nDWwNiu03LcX0mVaQzBBbxFDYvqejanA+AkO3p8gsQb+fsXRc9DAk+U8cBnzZvA==} + + react-native-skia-apple-tvos@147.1.0: + resolution: {integrity: sha512-b+4vILXHPu++t8H41PHLBVsTab2LPqwXNdzgdScyl4+Cu8Ta34aUQW3T469cB0ogAMPdu//KV00w4YVpDZoRUQ==} + + react-native-svg@15.15.4: + resolution: {integrity: sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-web@0.21.3: + resolution: {integrity: sha512-ozvZM0z8UWfduoKnYx03KUccM2LdHNaW8nNJu4Md0glyqWL+Ysa6dRZzMOO6Yp/hhTYT9yluwjknpd9l04uvDw==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + react-native-webview@13.16.1: + resolution: {integrity: sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-worklets@0.10.1: + resolution: {integrity: sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==} + peerDependencies: + '@babel/core': '*' + '@react-native/metro-config': '*' + react: '*' + react-native: 0.83 - 0.86 + + react-native@0.86.3: + resolution: {integrity: sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + peerDependencies: + '@react-native/jest-preset': 0.86.3 + '@types/react': ^19.1.1 + react: ^19.2.3 + peerDependenciesMeta: + '@react-native/jest-preset': + optional: true + '@types/react': + optional: true + + react-reconciler@0.31.0: + resolution: {integrity: sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.0.0 + + react-refresh@0.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-test-renderer@19.2.3: + resolution: {integrity: sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw==} + peerDependencies: + react: ^19.2.3 + + react-test-renderer@19.2.6: + resolution: {integrity: sha512-GbS6V23YduFTPiWJ5xICbKEjRcqx1Z90js/V5miqhz7qp/d6xSe9Dd6NjSQODFRdzdsqRMPW82E/sFpPRbY5Mw==} + peerDependencies: + react: ^19.2.6 + + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + regenerate-unicode-properties@10.3.0: + resolution: {integrity: sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + + regexpu-core@6.5.2: + resolution: {integrity: sha512-zkmVH92DlzjSFc4xKGZp/2BXIFrDIdBQBC8gUXKCsMgKYb1bkCkxfSuoW1werDjaTEMTbULkHtKFHe1L+Abnaw==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.3: + resolution: {integrity: sha512-ycwFAS14Jw4mppvmK4GR/J6u3WpWpjkEApehuHtLc/8VpPNpDMbQ4WjqwplXifGeyKOzHSFLmSPqzksDQE2Sfg==} + hasBin: true + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-workspace-root@2.0.1: + resolution: {integrity: sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@2.0.0: + resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} + engines: {node: '>=4'} + + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rtl-detect@1.1.2: + resolution: {integrity: sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sandbox-cli-detector@0.2.0: + resolution: {integrity: sha512-4lyHX0ZU0AZKwjgZ1InxZAa3PNpyEb8rOQ+Zss1ReYmhNzW0Q+h1zE5nvniXN0HaAWZaZE1zgVNEirb0R7LmNg==} + engines: {node: '>=18.18'} + hasBin: true + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.25.0: + resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serialize-error@2.1.0: + resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} + engines: {node: '>=0.10.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sf-symbols-typescript@2.2.0: + resolution: {integrity: sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==} + engines: {node: '>=10'} + + shallowequal@1.1.0: + resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.11.0: + resolution: {integrity: sha512-JdxDPD0DBTyu08pq0kPC0xSNet/qsU07qT6IsX1AS8oO2ICNRY4ldNa8OAI6PuwAH8tG3lxEhbqmyp4Dw4036g==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-plist@1.3.1: + resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} + + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slugify@1.6.9: + resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} + engines: {node: '>=8.0.0'} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.5.6: + resolution: {integrity: sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==} + engines: {node: '>=0.10.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-generator@2.0.10: + resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stackframe@1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + + stacktrace-gps@3.1.2: + resolution: {integrity: sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==} + + stacktrace-js@2.0.2: + resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} + + stacktrace-parser@0.1.11: + resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} + engines: {node: '>=6'} + + standard-navigation@0.0.5: + resolution: {integrity: sha512-YAmzwAiiQVocZxO/VGPFiQHcu5pKiz09QIGC0MK6aRMoa3E0QkoTQgcqJr7ZZ3OMiNhu4DkaGElFI5htjOIDbw==} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stream-buffers@2.2.0: + resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} + engines: {node: '>= 0.10.0'} + + strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=4'} + + strictdom@1.0.1: + resolution: {integrity: sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-length@5.0.1: + resolution: {integrity: sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==} + engines: {node: '>=12.20'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + structured-headers@0.4.1: + resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} + + styleq@0.1.3: + resolution: {integrity: sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@2.3.0: + resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + terminal-link@2.1.1: + resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} + engines: {node: '>=8'} + + terser@5.51.2: + resolution: {integrity: sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==} + engines: {node: '>=10'} + hasBin: true + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + throat@5.0.0: + resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + toqr@0.1.1: + resolution: {integrity: sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==} + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@3.0.0: + resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} + engines: {node: '>=12'} + + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@0.7.1: + resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} + engines: {node: '>=8'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ua-parser-js@0.7.41: + resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==} + hasBin: true + + ua-parser-js@1.0.41: + resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} + hasBin: true + + uc.micro@1.0.6: + resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==} + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-latest-callback@0.2.6: + resolution: {integrity: sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==} + peerDependencies: + react: '>=16.8' + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + + uuid@7.0.3: + resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + victory-native@42.0.1: + resolution: {integrity: sha512-W3dNBMeRtZ8AQGBkV1PrYYKLFSstTy6uulmlJJFch4hvPX/R1xH1VJXzZopK6GGbcq/ejcr8WwntWwhqTo0lzA==} + peerDependencies: + '@shopify/react-native-skia': '>=2.6.0 <3.0.0' + react: '*' + react-native: '*' + react-native-gesture-handler: '>=2.0.0' + react-native-reanimated: '>=3.19.1' + + vlq@1.0.1: + resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + + w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + warn-once@0.1.1: + resolution: {integrity: sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-url-minimum@0.1.2: + resolution: {integrity: sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==} + + whatwg-url@11.0.0: + resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} + engines: {node: '>=12'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + ws@7.5.13: + resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xcode@3.0.1: + resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} + engines: {node: '>=10.0.0'} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xml2js@0.6.0: + resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + xmlbuilder@14.0.0: + resolution: {integrity: sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg==} + engines: {node: '>=8.0'} + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + +snapshots: + + '@antfu/install-pkg@2.1.0': + dependencies: + package-manager-detector: 1.9.0 + tinyexec: 1.3.1 + + '@babel/code-frame@7.10.4': + dependencies: + '@babel/highlight': 7.25.9 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.29.3 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.8 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + regexpu-core: 6.5.2 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + debug: 4.4.3 + lodash.debounce: 4.0.8 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-wrap-function': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helper-wrap-function@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/highlight@7.25.9': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-export-default-from@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-decorators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-export-default-from@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-flow@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-export-namespace-from@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-flow-strip-types@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-flow': 7.29.7(@babel/core@7.29.7) + + '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-logical-assignment-operators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-object-rest-spread@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-display-name@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-development@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-pure-annotations@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-regenerator@7.29.8(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.7) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-shorthand-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-template-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-typescript@7.29.9(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/preset-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bacons/apple-targets@5.0.0(expo@57.0.26)(typescript@5.9.3)': + dependencies: + '@bacons/xcode': 1.0.0-alpha.32 + '@expo/prebuild-config': 55.0.22(expo@57.0.26)(typescript@5.9.3) + '@react-native/normalize-colors': 0.79.7 + debug: 4.4.3 + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + glob: 10.5.0 + transitivePeerDependencies: + - supports-color + - typescript + + '@bacons/xcode@1.0.0-alpha.32': + dependencies: + '@expo/plist': 0.0.18 + debug: 4.4.3 + uuid: 8.3.2 + transitivePeerDependencies: + - supports-color + + '@bcoe/v8-coverage@0.2.3': {} + + '@biomejs/biome@2.2.4': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.2.4 + '@biomejs/cli-darwin-x64': 2.2.4 + '@biomejs/cli-linux-arm64': 2.2.4 + '@biomejs/cli-linux-arm64-musl': 2.2.4 + '@biomejs/cli-linux-x64': 2.2.4 + '@biomejs/cli-linux-x64-musl': 2.2.4 + '@biomejs/cli-win32-arm64': 2.2.4 + '@biomejs/cli-win32-x64': 2.2.4 + + '@biomejs/cli-darwin-arm64@2.2.4': + optional: true + + '@biomejs/cli-darwin-x64@2.2.4': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.2.4': + optional: true + + '@biomejs/cli-linux-arm64@2.2.4': + optional: true + + '@biomejs/cli-linux-x64-musl@2.2.4': + optional: true + + '@biomejs/cli-linux-x64@2.2.4': + optional: true + + '@biomejs/cli-win32-arm64@2.2.4': + optional: true + + '@biomejs/cli-win32-x64@2.2.4': + optional: true + + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/types@11.1.2': {} + + '@egjs/hammerjs@2.0.17': + dependencies: + '@types/hammerjs': 2.0.46 + + '@expo-google-fonts/material-symbols@0.4.48': {} + + '@expo/cli@57.0.27(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-constants@57.0.20)(expo-font@57.0.4)(expo-router@57.0.24)(expo@57.0.26)(react-dom@19.2.6(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)': + dependencies: + '@expo/code-signing-certificates': 0.0.6 + '@expo/config': 57.0.9(typescript@5.9.3) + '@expo/config-plugins': 57.0.9(typescript@5.9.3) + '@expo/devcert': 1.2.2 + '@expo/env': 2.4.3 + '@expo/image-utils': 0.11.5(typescript@5.9.3) + '@expo/inline-modules': 0.1.7(typescript@5.9.3) + '@expo/json-file': 11.0.1 + '@expo/log-box': 57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + '@expo/metro': 56.0.2 + '@expo/metro-config': 57.0.12(expo@57.0.26)(typescript@5.9.3) + '@expo/metro-file-map': 57.0.3 + '@expo/osascript': 2.7.1 + '@expo/package-manager': 1.13.1 + '@expo/plist': 0.8.1 + '@expo/prebuild-config': 57.0.16(typescript@5.9.3) + '@expo/require-utils': 57.0.5(typescript@5.9.3) + '@expo/router-server': 57.0.11(@expo/metro-runtime@57.0.16)(expo-constants@57.0.20)(expo-font@57.0.4)(expo-router@57.0.24)(expo-server@57.0.3)(expo@57.0.26)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@expo/schema-utils': 57.0.2 + '@expo/spawn-async': 1.8.0 + '@expo/ws-tunnel': 2.0.1(ws@8.22.0) + '@expo/xcpretty': 4.4.6 + '@react-native/dev-middleware': 0.86.3 + accepts: 1.3.8 + agent-cli-detector: 0.1.7 + arg: 5.0.2 + bplist-creator: 0.1.0 + bplist-parser: 0.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + compression: 1.8.2 + connect: 3.7.0 + debug: 4.4.3 + dnssd-advertise: 1.1.6 + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-server: 57.0.3 + fetch-nodeshim: 0.4.10 + getenv: 2.0.0 + glob: 13.0.6 + lan-network: 0.2.1 + multitars: 1.0.2 + node-forge: 1.4.0 + npm-package-arg: 11.0.3 + ora: 3.4.0 + picomatch: 4.0.7 + pretty-format: 29.7.0 + progress: 2.0.3 + prompts: 2.4.2 + resolve-from: 5.0.0 + sandbox-cli-detector: 0.2.0 + semver: 7.8.5 + send: 0.19.2 + slugify: 1.6.9 + stacktrace-parser: 0.1.11 + structured-headers: 0.4.1 + terminal-link: 2.1.1 + toqr: 0.1.1 + wrap-ansi: 7.0.0 + ws: 8.22.0 + zod: 3.25.76 + optionalDependencies: + expo-router: 57.0.24(7fcc41656b5e4943165628ee06c6ab65) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + transitivePeerDependencies: + - '@expo/dom-webview' + - '@expo/metro-runtime' + - bufferutil + - expo-constants + - expo-font + - react + - react-dom + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate + + '@expo/code-signing-certificates@0.0.6': + dependencies: + node-forge: 1.4.0 + + '@expo/config-plugins@55.0.11': + dependencies: + '@expo/config-types': 55.0.6 + '@expo/json-file': 10.0.16 + '@expo/plist': 0.5.4 + '@expo/sdk-runtime-versions': 1.0.1 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + resolve-from: 5.0.0 + semver: 7.8.5 + slugify: 1.6.9 + xcode: 3.0.1 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + + '@expo/config-plugins@57.0.9(typescript@5.9.3)': + dependencies: + '@expo/config-types': 57.0.2 + '@expo/json-file': 11.0.1 + '@expo/plist': 0.8.1 + '@expo/require-utils': 57.0.5(typescript@5.9.3) + '@expo/sdk-runtime-versions': 1.0.1 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + semver: 7.8.5 + slugify: 1.6.9 + xcode: 3.0.1 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/config-types@55.0.6': {} + + '@expo/config-types@57.0.2': {} + + '@expo/config@55.0.21(typescript@5.9.3)': + dependencies: + '@expo/config-plugins': 55.0.11 + '@expo/config-types': 55.0.6 + '@expo/json-file': 10.2.0 + '@expo/require-utils': 55.0.8(typescript@5.9.3) + deepmerge: 4.3.1 + getenv: 2.0.0 + glob: 13.0.6 + resolve-workspace-root: 2.0.1 + semver: 7.8.5 + slugify: 1.6.9 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/config@57.0.9(typescript@5.9.3)': + dependencies: + '@expo/config-plugins': 57.0.9(typescript@5.9.3) + '@expo/config-types': 57.0.2 + '@expo/json-file': 11.0.1 + '@expo/require-utils': 57.0.5(typescript@5.9.3) + deepmerge: 4.3.1 + getenv: 2.0.0 + glob: 13.0.6 + resolve-workspace-root: 2.0.1 + semver: 7.8.5 + slugify: 1.6.9 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/devcert@1.2.2': + dependencies: + '@expo/sudo-prompt': 9.3.2 + debug: 3.2.7 + transitivePeerDependencies: + - supports-color + + '@expo/devtools@57.0.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)': + dependencies: + chalk: 4.1.2 + optionalDependencies: + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + + '@expo/dom-webview@57.0.1(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)': + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + + '@expo/env@2.4.3': + dependencies: + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + transitivePeerDependencies: + - supports-color + + '@expo/expo-modules-macros-plugin@0.6.1': {} + + '@expo/fingerprint@0.20.13': + dependencies: + '@expo/env': 2.4.3 + '@expo/spawn-async': 1.8.0 + arg: 5.0.2 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + ignore: 5.3.2 + minimatch: 10.2.6 + resolve-from: 5.0.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + '@expo/image-utils@0.11.5(typescript@5.9.3)': + dependencies: + '@expo/require-utils': 57.0.5(typescript@5.9.3) + '@expo/spawn-async': 1.8.0 + chalk: 4.1.2 + getenv: 2.0.0 + jimp-compact: 0.16.1 + parse-png: 2.1.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/image-utils@0.8.17(typescript@5.9.3)': + dependencies: + '@expo/require-utils': 55.0.8(typescript@5.9.3) + '@expo/spawn-async': 1.8.0 + chalk: 4.1.2 + getenv: 2.0.0 + jimp-compact: 0.16.1 + parse-png: 2.1.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/inline-modules@0.1.7(typescript@5.9.3)': + dependencies: + '@expo/config-plugins': 57.0.9(typescript@5.9.3) + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/json-file@10.0.16': + dependencies: + '@babel/code-frame': 7.10.4 + json5: 2.2.3 + + '@expo/json-file@10.2.0': + dependencies: + '@babel/code-frame': 7.29.7 + json5: 2.2.3 + + '@expo/json-file@11.0.1': + dependencies: + '@babel/code-frame': 7.29.7 + json5: 2.2.3 + + '@expo/local-build-cache-provider@57.0.8(typescript@5.9.3)': + dependencies: + '@expo/config': 57.0.9(typescript@5.9.3) + chalk: 4.1.2 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/log-box@57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)': + dependencies: + '@expo/dom-webview': 57.0.1(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + anser: 1.4.10 + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + stacktrace-parser: 0.1.11 + + '@expo/metro-config@57.0.12(expo@57.0.26)(typescript@5.9.3)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@expo/config': 57.0.9(typescript@5.9.3) + '@expo/env': 2.4.3 + '@expo/json-file': 11.0.1 + '@expo/metro': 56.0.2 + '@expo/require-utils': 57.0.5(typescript@5.9.3) + '@expo/spawn-async': 1.8.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.6.0 + browserslist: 4.29.3 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + hermes-parser: 0.36.1 + jsc-safe-url: 0.2.4 + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + resolve-from: 5.0.0 + optionalDependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - typescript + - utf-8-validate + + '@expo/metro-file-map@57.0.3': + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.2 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + '@expo/metro-runtime@57.0.16(@expo/log-box@57.0.4)(expo@57.0.26)(react-dom@19.2.6(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)': + dependencies: + '@expo/log-box': 57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + anser: 1.4.10 + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + pretty-format: 29.7.0 + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + + '@expo/metro@56.0.2': + dependencies: + metro: 0.84.5 + metro-babel-transformer: 0.84.5 + metro-cache: 0.84.5 + metro-cache-key: 0.84.5 + metro-config: 0.84.5 + metro-core: 0.84.5 + metro-file-map: 0.84.5 + metro-minify-terser: 0.84.5 + metro-resolver: 0.84.5 + metro-runtime: 0.84.5 + metro-source-map: 0.84.5 + metro-symbolicate: 0.84.5 + metro-transform-plugins: 0.84.5 + metro-transform-worker: 0.84.5 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@expo/osascript@2.7.1': + dependencies: + '@expo/spawn-async': 1.8.0 + + '@expo/package-manager@1.13.1': + dependencies: + '@expo/json-file': 11.0.1 + '@expo/spawn-async': 1.8.0 + chalk: 4.1.2 + npm-package-arg: 11.0.3 + ora: 3.4.0 + resolve-workspace-root: 2.0.1 + + '@expo/plist@0.0.18': + dependencies: + '@xmldom/xmldom': 0.7.13 + base64-js: 1.5.1 + xmlbuilder: 14.0.0 + + '@expo/plist@0.5.4': + dependencies: + '@xmldom/xmldom': 0.8.15 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + '@expo/plist@0.8.1': + dependencies: + '@xmldom/xmldom': 0.8.15 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + '@expo/prebuild-config@55.0.22(expo@57.0.26)(typescript@5.9.3)': + dependencies: + '@expo/config': 55.0.21(typescript@5.9.3) + '@expo/config-plugins': 55.0.11 + '@expo/config-types': 55.0.6 + '@expo/image-utils': 0.8.17(typescript@5.9.3) + '@expo/json-file': 10.2.0 + '@react-native/normalize-colors': 0.83.10 + debug: 4.4.3 + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + resolve-from: 5.0.0 + semver: 7.8.5 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/prebuild-config@57.0.16(typescript@5.9.3)': + dependencies: + '@expo/config': 57.0.9(typescript@5.9.3) + '@expo/config-plugins': 57.0.9(typescript@5.9.3) + '@expo/config-types': 57.0.2 + '@expo/image-utils': 0.11.5(typescript@5.9.3) + '@expo/json-file': 11.0.1 + '@react-native/normalize-colors': 0.86.3 + debug: 4.4.3 + expo-modules-autolinking: 57.0.13(typescript@5.9.3) + resolve-from: 5.0.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/require-utils@55.0.8(typescript@5.9.3)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7 + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@expo/require-utils@57.0.5(typescript@5.9.3)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7 + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@expo/router-server@57.0.11(@expo/metro-runtime@57.0.16)(expo-constants@57.0.20)(expo-font@57.0.4)(expo-router@57.0.24)(expo-server@57.0.3)(expo@57.0.26)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + debug: 4.4.3 + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-constants: 57.0.20(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + expo-font: 57.0.4(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + expo-server: 57.0.3 + react: 19.2.6 + optionalDependencies: + '@expo/metro-runtime': 57.0.16(@expo/log-box@57.0.4)(expo@57.0.26)(react-dom@19.2.6(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + expo-router: 57.0.24(7fcc41656b5e4943165628ee06c6ab65) + react-dom: 19.2.6(react@19.2.6) + transitivePeerDependencies: + - supports-color + + '@expo/schema-utils@57.0.2': {} + + '@expo/sdk-runtime-versions@1.0.1': {} + + '@expo/spawn-async@1.8.0': + dependencies: + cross-spawn: 7.0.6 + + '@expo/sudo-prompt@9.3.2': {} + + '@expo/ui@57.0.21(@babel/core@7.29.7)(@types/react@19.2.18)(expo@57.0.26)(react-dom@19.2.6(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)': + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + sf-symbols-typescript: 2.2.0 + vaul: 1.1.2(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + optionalDependencies: + '@babel/core': 7.29.7 + react-dom: 19.2.6(react@19.2.6) + react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + '@expo/ws-tunnel@2.0.1(ws@8.22.0)': + dependencies: + ws: 8.22.0 + + '@expo/xcpretty@4.4.6': + dependencies: + '@babel/code-frame': 7.29.7 + chalk: 4.1.2 + js-yaml: 4.3.2 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.7': + dependencies: + '@antfu/install-pkg': 2.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@isaacs/ttlcache@1.4.1': {} + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.15.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 26.6.4 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 26.6.4 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@26.6.4) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/create-cache-key-function@29.7.0': + dependencies: + '@jest/types': 29.6.3 + + '@jest/diff-sequences@30.5.0': {} + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 26.6.4 + jest-mock: 29.7.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 26.6.4 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/get-type@30.5.0': {} + + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 26.6.4 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.2.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.12 + + '@jest/schemas@30.5.0': + dependencies: + '@sinclair/typebox': 0.34.52 + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.29.7 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 26.6.4 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@mermaid-js/parser@1.2.1': + dependencies: + '@chevrotain/types': 11.1.2 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@posthog/core@1.55.3': + dependencies: + '@posthog/types': 1.413.0 + + '@posthog/types@1.413.0': {} + + '@radix-ui/primitive@1.1.7': {} + + '@radix-ui/react-collection@1.1.15(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-context@1.2.2(@types/react@19.2.18)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-dialog@1.1.23(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.16(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-portal': 1.1.17(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.6) + aria-hidden: 1.2.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-direction@1.1.4(@types/react@19.2.18)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-dismissable-layer@1.1.19(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.18)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-focus-scope@1.1.16(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-id@1.1.4(@types/react@19.2.18)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-portal@1.1.17(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-presence@1.1.10(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-primitive@2.1.10(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-roving-focus@1.1.19(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-slot@1.3.3(@types/react@19.2.18)(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-tabs@1.1.21(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.19(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.18)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.18)(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.18)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.18)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.18)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.18 + + '@react-native-async-storage/async-storage@2.2.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))': + dependencies: + merge-options: 3.0.4 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + + '@react-native-community/datetimepicker@9.1.0(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)': + dependencies: + invariant: 2.2.4 + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + optionalDependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + + '@react-native-masked-view/masked-view@0.3.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)': + dependencies: + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + + '@react-native/assets-registry@0.86.3': {} + + '@react-native/babel-plugin-codegen@0.86.3(@babel/core@7.29.7)': + dependencies: + '@babel/traverse': 7.29.8 + '@react-native/codegen': 0.86.3(@babel/core@7.29.7) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + '@react-native/babel-preset@0.86.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-regenerator': 7.29.8(@babel/core@7.29.7) + '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) + '@react-native/babel-plugin-codegen': 0.86.3(@babel/core@7.29.7) + babel-plugin-syntax-hermes-parser: 0.36.0 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) + react-refresh: 0.14.2 + transitivePeerDependencies: + - supports-color + + '@react-native/codegen@0.86.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.9 + hermes-parser: 0.36.0 + invariant: 2.2.4 + nullthrows: 1.1.1 + tinyglobby: 0.2.17 + yargs: 17.7.3 + + '@react-native/community-cli-plugin@0.86.3(@react-native/metro-config@0.86.3(@babel/core@7.29.7))': + dependencies: + '@react-native/dev-middleware': 0.86.3 + debug: 4.4.3 + invariant: 2.2.4 + metro: 0.84.6 + metro-config: 0.84.6 + metro-core: 0.84.6 + semver: 7.8.5 + optionalDependencies: + '@react-native/metro-config': 0.86.3(@babel/core@7.29.7) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/debugger-frontend@0.86.3': {} + + '@react-native/debugger-shell@0.86.3': + dependencies: + cross-spawn: 7.0.6 + debug: 4.4.3 + fb-dotslash: 0.5.8 + transitivePeerDependencies: + - supports-color + + '@react-native/dev-middleware@0.86.3': + dependencies: + '@isaacs/ttlcache': 1.4.1 + '@react-native/debugger-frontend': 0.86.3 + '@react-native/debugger-shell': 0.86.3 + chrome-launcher: 0.15.2 + chromium-edge-launcher: 0.3.0 + connect: 3.7.0 + debug: 4.4.3 + invariant: 2.2.4 + nullthrows: 1.1.1 + open: 7.4.2 + serve-static: 1.16.3 + ws: 7.5.13 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/gradle-plugin@0.86.3': {} + + '@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6)': + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@react-native/js-polyfills': 0.86.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + jest-environment-node: 29.7.0 + react: 19.2.6 + regenerator-runtime: 0.13.11 + transitivePeerDependencies: + - '@babel/core' + - supports-color + + '@react-native/js-polyfills@0.86.3': {} + + '@react-native/metro-babel-transformer@0.86.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@react-native/babel-preset': 0.86.3(@babel/core@7.29.7) + hermes-parser: 0.36.0 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + '@react-native/metro-config@0.86.3(@babel/core@7.29.7)': + dependencies: + '@react-native/js-polyfills': 0.86.3 + '@react-native/metro-babel-transformer': 0.86.3(@babel/core@7.29.7) + metro-config: 0.84.6 + metro-runtime: 0.84.6 + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/normalize-colors@0.74.89': {} + + '@react-native/normalize-colors@0.79.7': {} + + '@react-native/normalize-colors@0.83.10': {} + + '@react-native/normalize-colors@0.86.3': {} + + '@react-native/virtualized-lists@0.86.3(@types/react@19.2.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.18 + + '@shopify/react-native-skia@2.6.2(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)': + dependencies: + canvaskit-wasm: 0.41.0 + react: 19.2.6 + react-native-skia-android: 147.1.0 + react-native-skia-apple-ios: 147.1.0 + react-native-skia-apple-macos: 147.1.0 + react-native-skia-apple-tvos: 147.1.0 + react-reconciler: 0.31.0(react@19.2.6) + optionalDependencies: + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + react-native-reanimated: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + + '@sinclair/typebox@0.27.12': {} + + '@sinclair/typebox@0.34.52': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@tanstack/query-core@5.104.1': {} + + '@tanstack/react-query@5.104.1(react@19.2.6)': + dependencies: + '@tanstack/query-core': 5.104.1 + react: 19.2.6 + + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.6.4))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + jest-matcher-utils: 30.5.2 + picocolors: 1.1.1 + pretty-format: 30.5.1 + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + react-test-renderer: 19.2.6(react@19.2.6) + redent: 3.0.0 + optionalDependencies: + jest: 29.7.0(@types/node@26.6.4) + + '@tootallnate/once@2.0.1': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.1': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.12': {} + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-zoom@3.0.9': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.12 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.1 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.12 + '@types/d3-shape': 3.2.0 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.9 + + '@types/geojson@7946.0.16': {} + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 26.6.4 + + '@types/hammerjs@2.0.46': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.14': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/jsdom@20.0.1': + dependencies: + '@types/node': 26.6.4 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@2.0.0': {} + + '@types/node@26.6.4': + dependencies: + undici-types: 8.9.0 + + '@types/react-reconciler@0.28.9(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react-test-renderer@19.3.0': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@types/stack-utils@2.0.3': {} + + '@types/tough-cookie@4.0.5': {} + + '@types/trusted-types@2.0.7': + optional: true + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@ungap/structured-clone@1.4.0': {} + + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + '@webgpu/types@0.1.21': {} + + '@xmldom/xmldom@0.7.13': {} + + '@xmldom/xmldom@0.8.15': {} + + '@xmldom/xmldom@0.9.12': {} + + abab@2.0.6: {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + acorn-globals@7.0.1: + dependencies: + acorn: 8.18.0 + acorn-walk: 8.3.5 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + agent-cli-detector@0.1.7: {} + + anser@1.4.10: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@6.2.1: {} + + ansi-regex@4.1.1: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.4.0: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@4.1.3: {} + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + asap@2.0.6: {} + + asynckit@0.4.0: {} + + babel-jest@29.7.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.29.7) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.29.7 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + core-js-compat: 3.50.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.8 + + babel-plugin-react-native-web@0.21.3: {} + + babel-plugin-syntax-hermes-parser@0.36.0: + dependencies: + hermes-parser: 0.36.0 + + babel-plugin-syntax-hermes-parser@0.36.1: + dependencies: + hermes-parser: 0.36.1 + + babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.7): + dependencies: + '@babel/plugin-syntax-flow': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - '@babel/core' + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) + + babel-preset-expo@57.0.13(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@57.0.26)(react-refresh@0.14.2): + dependencies: + '@babel/generator': 7.29.8 + '@babel/helper-module-imports': 7.29.7 + '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-development': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-pure-annotations': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) + '@react-native/babel-plugin-codegen': 0.86.3(@babel/core@7.29.7) + babel-plugin-react-compiler: 1.0.0 + babel-plugin-react-native-web: 0.21.3 + babel-plugin-syntax-hermes-parser: 0.36.1 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) + debug: 4.4.3 + react-refresh: 0.14.2 + optionalDependencies: + '@babel/runtime': 7.29.7 + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + babel-preset-jest@29.6.3(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + + badgin@1.2.3: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.11.27: {} + + big-integer@1.6.52: {} + + boolbase@1.0.0: {} + + bplist-creator@0.1.0: + dependencies: + stream-buffers: 2.2.0 + + bplist-parser@0.3.1: + dependencies: + big-integer: 1.6.52 + + bplist-parser@0.3.2: + dependencies: + big-integer: 1.6.52 + + brace-expansion@1.1.21: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.7: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.29.3: + dependencies: + baseline-browser-mapping: 2.11.27 + caniuse-lite: 1.0.30001814 + electron-to-chromium: 1.5.444 + node-releases: 2.0.57 + update-browserslist-db: 1.3.3(browserslist@4.29.3) + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-from@1.1.2: {} + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + camelize@1.0.1: {} + + caniuse-lite@1.0.30001814: {} + + canvaskit-wasm@0.41.0: + dependencies: + '@webgpu/types': 0.1.21 + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + char-regex@1.0.2: {} + + char-regex@2.0.2: {} + + chrome-launcher@0.15.2: + dependencies: + '@types/node': 26.6.4 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + transitivePeerDependencies: + - supports-color + + chromium-edge-launcher@0.3.0: + dependencies: + '@types/node': 26.6.4 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + mkdirp: 1.0.4 + transitivePeerDependencies: + - supports-color + + ci-info@2.0.0: {} + + ci-info@3.9.0: {} + + cjs-module-lexer@1.4.3: {} + + cli-cursor@2.1.0: + dependencies: + restore-cursor: 2.0.0 + + cli-spinners@2.9.2: {} + + client-only@0.0.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@12.1.0: {} + + commander@2.20.3: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.2: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + destroy: 1.2.0 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + concat-map@0.0.1: {} + + connect@3.7.0: + dependencies: + debug: 2.6.9 + finalhandler: 1.1.2 + parseurl: 1.3.3 + utils-merge: 1.0.1 + transitivePeerDependencies: + - supports-color + + content-type@2.1.0: {} + + convert-source-map@2.0.0: {} + + core-js-compat@3.50.0: + dependencies: + browserslist: 4.29.3 + + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + + create-jest@29.7.0(@types/node@26.6.4): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@26.6.4) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-color-keywords@1.0.0: {} + + css-in-js-utils@3.1.0: + dependencies: + hyphenate-style-name: 1.1.0 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-to-react-native@3.2.0: + dependencies: + camelize: 1.0.1 + css-color-keywords: 1.0.0 + postcss-value-parser: 4.2.0 + + css-tree@1.1.3: + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 + + css-what@6.2.2: {} + + cssom@0.3.8: {} + + cssom@0.5.0: {} + + cssstyle@2.3.0: + dependencies: + cssom: 0.3.8 + + csstype@3.2.3: {} + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.3): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.3 + + cytoscape-fcose@2.2.0(cytoscape@3.34.3): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.3 + + cytoscape@3.34.3: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + + data-urls@3.0.2: + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + + dayjs@1.11.23: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + decode-uri-component@0.2.2: {} + + dedent@1.7.2: {} + + deepmerge@4.3.1: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: {} + + detect-newline@3.1.0: {} + + detect-node-es@1.1.0: {} + + diff-sequences@29.6.3: {} + + dnssd-advertise@1.1.6: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domexception@4.0.0: + dependencies: + webidl-conversions: 7.0.0 + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + dompurify@3.4.16: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.444: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + entities@2.0.3: {} + + entities@4.5.0: {} + + entities@6.0.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + error-stack-parser@2.1.4: + dependencies: + stackframe: 1.3.4 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es-toolkit@1.52.0: {} + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + esprima@4.0.1: {} + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit@0.1.2: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + expo-application@57.0.3(expo@57.0.26): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + + expo-asset@57.0.18(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3): + dependencies: + '@expo/image-utils': 0.11.5(typescript@5.9.3) + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-constants: 57.0.20(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + transitivePeerDependencies: + - supports-color + - typescript + + expo-auth-session@57.0.13(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + expo-application: 57.0.3(expo@57.0.26) + expo-constants: 57.0.20(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + expo-crypto: 57.0.3(expo@57.0.26) + expo-linking: 57.0.11(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + expo-web-browser: 57.0.3(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + invariant: 2.2.4 + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + transitivePeerDependencies: + - expo + - supports-color + + expo-blur@57.0.3(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + + expo-clipboard@57.0.2(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + + expo-constants@57.0.20(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)): + dependencies: + '@expo/env': 2.4.3 + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + transitivePeerDependencies: + - supports-color + + expo-crypto@57.0.3(expo@57.0.26): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + + expo-dev-client@57.0.19(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-dev-launcher: 57.0.20(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + expo-dev-menu: 57.0.18(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + expo-dev-menu-interface: 57.0.0(expo@57.0.26) + expo-manifests: 57.0.2(expo@57.0.26) + expo-updates-interface: 57.0.2(expo@57.0.26) + transitivePeerDependencies: + - react-native + + expo-dev-launcher@57.0.20(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)): + dependencies: + '@expo/schema-utils': 57.0.2 + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-dev-menu: 57.0.18(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + expo-manifests: 57.0.2(expo@57.0.26) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + + expo-dev-menu-interface@57.0.0(expo@57.0.26): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + + expo-dev-menu@57.0.18(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-dev-menu-interface: 57.0.0(expo@57.0.26) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + + expo-device@57.0.2(expo@57.0.26): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + ua-parser-js: 0.7.41 + + expo-document-picker@57.0.3(expo@57.0.26): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + + expo-eas-client@57.0.4: {} + + expo-file-system@57.0.7(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + + expo-font@57.0.4(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + fontfaceobserver: 2.3.0 + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + + expo-glass-effect@57.0.4(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + + expo-haptics@57.0.3(expo@57.0.26): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + + expo-image-loader@57.0.1(expo@57.0.26): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + + expo-image-picker@57.0.20(expo@57.0.26): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-image-loader: 57.0.1(expo@57.0.26) + + expo-json-utils@57.0.2: {} + + expo-keep-awake@57.0.2(expo@57.0.26)(react@19.2.6): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react: 19.2.6 + + expo-linking@57.0.11(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + expo-constants: 57.0.20(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + invariant: 2.2.4 + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + transitivePeerDependencies: + - expo + - supports-color + + expo-localization@57.0.2(expo@57.0.26)(react@19.2.6): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react: 19.2.6 + rtl-detect: 1.1.2 + + expo-manifests@57.0.2(expo@57.0.26): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-json-utils: 57.0.2 + + expo-modules-autolinking@57.0.13(typescript@5.9.3): + dependencies: + '@expo/require-utils': 57.0.5(typescript@5.9.3) + '@expo/spawn-async': 1.8.0 + chalk: 4.1.2 + commander: 7.2.0 + transitivePeerDependencies: + - supports-color + - typescript + + expo-modules-core@57.0.20(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + '@expo/expo-modules-macros-plugin': 0.6.1 + expo-modules-jsi: 57.1.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + invariant: 2.2.4 + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + optionalDependencies: + react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + + expo-modules-jsi@57.1.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)): + dependencies: + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + + expo-notifications@57.0.21(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3): + dependencies: + '@expo/image-utils': 0.11.5(typescript@5.9.3) + abort-controller: 3.0.0 + badgin: 1.2.3 + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-application: 57.0.3(expo@57.0.26) + expo-constants: 57.0.20(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + transitivePeerDependencies: + - supports-color + - typescript + + expo-router@57.0.24(7fcc41656b5e4943165628ee06c6ab65): + dependencies: + '@expo/log-box': 57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + '@expo/metro-runtime': 57.0.16(@expo/log-box@57.0.4)(expo@57.0.26)(react-dom@19.2.6(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + '@expo/schema-utils': 57.0.2 + '@expo/ui': 57.0.21(@babel/core@7.29.7)(@types/react@19.2.18)(expo@57.0.26)(react-dom@19.2.6(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.6) + '@radix-ui/react-tabs': 1.1.21(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-native-masked-view/masked-view': 0.3.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + client-only: 0.0.1 + color: 4.2.3 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-constants: 57.0.20(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + expo-glass-effect: 57.0.4(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + expo-linking: 57.0.11(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + expo-server: 57.0.3 + expo-symbols: 57.0.3(expo-font@57.0.4)(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + fast-deep-equal: 3.1.3 + invariant: 2.2.4 + nanoid: 3.3.19 + query-string: 7.1.3 + react: 19.2.6 + react-fast-compare: 3.2.2 + react-is: 19.3.0 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + react-native-drawer-layout: 4.2.11(215478474156a453c7776ce7df03b364) + react-native-safe-area-context: 5.7.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-native-screens: 4.26.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + server-only: 0.0.1 + sf-symbols-typescript: 2.2.0 + shallowequal: 1.1.0 + standard-navigation: 0.0.5 + vaul: 1.1.2(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + optionalDependencies: + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.6.4))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + react-native-gesture-handler: 2.32.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-native-reanimated: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-native-web: 0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + transitivePeerDependencies: + - '@babel/core' + - '@types/react' + - '@types/react-dom' + - expo-font + - react-native-worklets + - supports-color + + expo-secure-store@57.0.4(expo@57.0.26): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + + expo-server@57.0.3: {} + + expo-splash-screen@57.0.9(expo@57.0.26)(typescript@5.9.3): + dependencies: + '@expo/config-plugins': 57.0.9(typescript@5.9.3) + '@expo/image-utils': 0.11.5(typescript@5.9.3) + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + - typescript + + expo-status-bar@57.0.1(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + + expo-structured-headers@57.0.1: {} + + expo-symbols@57.0.3(expo-font@57.0.4)(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + '@expo-google-fonts/material-symbols': 0.4.48 + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-font: 57.0.4(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + sf-symbols-typescript: 2.2.0 + + expo-system-ui@57.0.4(expo@57.0.26)(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)): + dependencies: + '@react-native/normalize-colors': 0.86.3 + debug: 4.4.3 + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + optionalDependencies: + react-native-web: 0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + transitivePeerDependencies: + - supports-color + + expo-updates-interface@57.0.2(expo@57.0.26): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + + expo-updates@57.0.24(expo-dev-client@57.0.19(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)))(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + '@expo/code-signing-certificates': 0.0.6 + '@expo/plist': 0.8.1 + '@expo/spawn-async': 1.8.0 + arg: 4.1.3 + chalk: 4.1.2 + debug: 4.4.3 + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-eas-client: 57.0.4 + expo-manifests: 57.0.2(expo@57.0.26) + expo-structured-headers: 57.0.1 + expo-updates-interface: 57.0.2(expo@57.0.26) + getenv: 2.0.0 + glob: 13.0.6 + ignore: 5.3.2 + nullthrows: 1.1.1 + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + resolve-from: 5.0.0 + optionalDependencies: + expo-dev-client: 57.0.19(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + transitivePeerDependencies: + - supports-color + + expo-web-browser@57.0.3(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + + expo@57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + '@expo/cli': 57.0.27(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-constants@57.0.20)(expo-font@57.0.4)(expo-router@57.0.24)(expo@57.0.26)(react-dom@19.2.6(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + '@expo/config': 57.0.9(typescript@5.9.3) + '@expo/config-plugins': 57.0.9(typescript@5.9.3) + '@expo/devtools': 57.0.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + '@expo/fingerprint': 0.20.13 + '@expo/local-build-cache-provider': 57.0.8(typescript@5.9.3) + '@expo/log-box': 57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + '@expo/metro': 56.0.2 + '@expo/metro-config': 57.0.12(expo@57.0.26)(typescript@5.9.3) + '@ungap/structured-clone': 1.4.0 + babel-preset-expo: 57.0.13(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@57.0.26)(react-refresh@0.14.2) + expo-asset: 57.0.18(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-constants: 57.0.20(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + expo-file-system: 57.0.7(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + expo-font: 57.0.4(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + expo-keep-awake: 57.0.2(expo@57.0.26)(react@19.2.6) + expo-modules-autolinking: 57.0.13(typescript@5.9.3) + expo-modules-core: 57.0.20(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + pretty-format: 29.7.0 + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + react-refresh: 0.14.2 + whatwg-url-minimum: 0.1.2 + optionalDependencies: + '@expo/dom-webview': 57.0.1(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + '@expo/metro-runtime': 57.0.16(@expo/log-box@57.0.4)(expo@57.0.26)(react-dom@19.2.6(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + react-native-web: 0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-native-webview: 13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - expo-router + - expo-widgets + - react-native-worklets + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate + + exponential-backoff@3.1.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fastdom@1.0.12: + dependencies: + strictdom: 1.0.1 + + fb-dotslash@0.5.8: {} + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fbjs-css-vars@1.0.2: {} + + fbjs@3.0.5: + dependencies: + cross-fetch: 3.2.0 + fbjs-css-vars: 1.0.2 + loose-envify: 1.4.0 + object-assign: 4.1.1 + promise: 7.3.1 + setimmediate: 1.0.5 + ua-parser-js: 1.0.41 + transitivePeerDependencies: + - encoding + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fetch-nodeshim@0.4.10: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + filter-obj@1.1.0: {} + + finalhandler@1.1.2: + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.3.0 + parseurl: 1.3.3 + statuses: 1.5.0 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + flow-enums-runtime@0.0.6: {} + + fontfaceobserver@2.3.0: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fresh@0.5.2: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@6.0.1: {} + + getenv@2.0.0: {} + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + hachure-fill@0.5.2: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hermes-compiler@250829098.0.17: {} + + hermes-estree@0.35.0: {} + + hermes-estree@0.36.0: {} + + hermes-estree@0.36.1: {} + + hermes-parser@0.35.0: + dependencies: + hermes-estree: 0.35.0 + + hermes-parser@0.36.0: + dependencies: + hermes-estree: 0.36.0 + + hermes-parser@0.36.1: + dependencies: + hermes-estree: 0.36.1 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + html-encoding-sniffer@3.0.0: + dependencies: + whatwg-encoding: 2.0.0 + + html-escaper@2.0.2: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.1 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + hyphenate-style-name@1.1.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + import-meta-resolve@4.2.0: {} + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + inline-style-prefixer@7.0.1: + dependencies: + css-in-js-utils: 3.1.0 + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + + is-arrayish@0.2.1: {} + + is-arrayish@0.3.4: {} + + is-core-module@2.17.0: + dependencies: + hasown: 2.0.4 + + is-docker@2.2.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-number@7.0.0: {} + + is-plain-obj@2.1.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-stream@2.0.1: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.9 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.9 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + its-fine@1.2.5(@types/react@19.2.18)(react@19.2.6): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@19.2.18) + react: 19.2.6 + transitivePeerDependencies: + - '@types/react' + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 26.6.4 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@26.6.4): + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@26.6.4) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@26.6.4) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-config@29.7.0(@types/node@26.6.4): + dependencies: + '@babel/core': 7.29.7 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 26.6.4 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-diff@30.5.2: + dependencies: + '@jest/diff-sequences': 30.5.0 + '@jest/get-type': 30.5.0 + chalk: 4.1.2 + pretty-format: 30.5.1 + + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + jest-environment-jsdom@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/jsdom': 20.0.1 + '@types/node': 26.6.4 + jest-mock: 29.7.0 + jest-util: 29.7.0 + jsdom: 20.0.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 26.6.4 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-expo@57.0.5(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(expo@57.0.26)(jest@29.7.0(@types/node@26.6.4))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@jest/globals': 29.7.0 + '@react-native/jest-preset': 0.86.3(@babel/core@7.29.7)(react@19.2.6) + babel-jest: 29.7.0(@babel/core@7.29.7) + jest-environment-jsdom: 29.7.0 + jest-snapshot: 29.7.0 + jest-watch-select-projects: 2.0.0 + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.6.4)) + json5: 2.2.3 + lodash: 4.18.1 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + react-test-renderer: 19.2.3(react@19.2.6) + server-only: 0.0.1 + stacktrace-js: 2.0.2 + optionalDependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.6(react@19.2.6))(react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - canvas + - jest + - react + - supports-color + - utf-8-validate + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 26.6.4 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@30.5.2: + dependencies: + '@jest/get-type': 30.5.0 + chalk: 4.1.2 + jest-diff: 30.5.2 + pretty-format: 30.5.1 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.29.7 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 26.6.4 + jest-util: 29.7.0 + + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-resolve-dependencies@29.7.0: + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.12 + resolve.exports: 2.0.3 + slash: 3.0.0 + + jest-runner@29.7.0: + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 26.6.4 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 26.6.4 + chalk: 4.1.2 + cjs-module-lexer: 1.4.3 + collect-v8-coverage: 1.0.3 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.8 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 26.6.4 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-watch-select-projects@2.0.0: + dependencies: + ansi-escapes: 4.3.2 + chalk: 3.0.0 + prompts: 2.4.2 + + jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.6.4)): + dependencies: + ansi-escapes: 6.2.1 + chalk: 4.1.2 + jest: 29.7.0(@types/node@26.6.4) + jest-regex-util: 29.6.3 + jest-watcher: 29.7.0 + slash: 5.1.0 + string-length: 5.0.1 + strip-ansi: 7.2.0 + + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 26.6.4 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + jest-worker@29.7.0: + dependencies: + '@types/node': 26.6.4 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@26.6.4): + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@26.6.4) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jimp-compact@0.16.1: {} + + js-tokens@4.0.0: {} + + js-yaml@3.15.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + jsc-safe-url@0.2.4: {} + + jsdom@20.0.3: + dependencies: + abab: 2.0.6 + acorn: 8.18.0 + acorn-globals: 7.0.1 + cssom: 0.5.0 + cssstyle: 2.3.0 + data-urls: 3.0.2 + decimal.js: 10.6.0 + domexception: 4.0.0 + escodegen: 2.1.0 + form-data: 4.0.6 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.28 + parse5: 7.3.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + ws: 8.22.0 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json5@2.2.3: {} + + katex@0.16.47: + dependencies: + commander: 8.3.0 + + khroma@2.1.0: {} + + kleur@3.0.3: {} + + lan-network@0.2.1: {} + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + + leven@3.1.0: {} + + lighthouse-logger@1.4.2: + dependencies: + debug: 2.6.9 + marky: 1.3.0 + transitivePeerDependencies: + - supports-color + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lines-and-columns@1.2.4: {} + + linkify-it@2.2.0: + dependencies: + uc.micro: 1.0.6 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + lodash-es@4.18.1: {} + + lodash.debounce@4.0.8: {} + + lodash.throttle@4.1.1: {} + + lodash@4.18.1: {} + + log-symbols@2.2.0: + dependencies: + chalk: 2.4.2 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@10.4.3: {} + + lru-cache@11.5.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + markdown-it@10.0.0: + dependencies: + argparse: 1.0.10 + entities: 2.0.3 + linkify-it: 2.2.0 + mdurl: 1.0.1 + uc.micro: 1.0.6 + + marked@16.4.2: {} + + marky@1.3.0: {} + + math-intrinsics@1.1.0: {} + + mdn-data@2.0.14: {} + + mdurl@1.0.1: {} + + memoize-one@5.2.1: {} + + memoize-one@6.0.0: {} + + merge-options@3.0.4: + dependencies: + is-plain-obj: 2.1.0 + + merge-stream@2.0.0: {} + + mermaid@11.17.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.7 + '@mermaid-js/parser': 1.2.1 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.3 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.3) + cytoscape-fcose: 2.2.0(cytoscape@3.34.3) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.23 + dompurify: 3.4.16 + es-toolkit: 1.52.0 + fastdom: 1.0.12 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.2 + + metro-babel-transformer@0.84.5: + dependencies: + '@babel/core': 7.29.7 + flow-enums-runtime: 0.0.6 + hermes-parser: 0.35.0 + metro-cache-key: 0.84.5 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-babel-transformer@0.84.6: + dependencies: + '@babel/core': 7.29.7 + flow-enums-runtime: 0.0.6 + hermes-parser: 0.35.0 + metro-cache-key: 0.84.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-cache-key@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-cache-key@0.84.6: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-cache@0.84.5: + dependencies: + exponential-backoff: 3.1.3 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6 + metro-core: 0.84.5 + transitivePeerDependencies: + - supports-color + + metro-cache@0.84.6: + dependencies: + exponential-backoff: 3.1.3 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6 + metro-core: 0.84.6 + transitivePeerDependencies: + - supports-color + + metro-config@0.84.5: + dependencies: + connect: 3.7.0 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.84.5 + metro-cache: 0.84.5 + metro-core: 0.84.5 + metro-runtime: 0.84.5 + yaml: 2.9.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-config@0.84.6: + dependencies: + connect: 3.7.0 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.84.6 + metro-cache: 0.84.6 + metro-core: 0.84.6 + metro-runtime: 0.84.6 + yaml: 2.9.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-core@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.84.5 + + metro-core@0.84.6: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.84.6 + + metro-file-map@0.84.5: + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.2 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + nullthrows: 1.1.1 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + metro-file-map@0.84.6: + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.2 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + nullthrows: 1.1.1 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + metro-minify-terser@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.51.2 + + metro-minify-terser@0.84.6: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.51.2 + + metro-resolver@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-resolver@0.84.6: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-runtime@0.84.5: + dependencies: + '@babel/runtime': 7.29.7 + flow-enums-runtime: 0.0.6 + + metro-runtime@0.84.6: + dependencies: + '@babel/runtime': 7.29.7 + flow-enums-runtime: 0.0.6 + + metro-source-map@0.84.5: + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.84.5 + nullthrows: 1.1.1 + ob1: 0.84.5 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-source-map@0.84.6: + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.84.6 + nullthrows: 1.1.1 + ob1: 0.84.6 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-symbolicate@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.84.5 + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-symbolicate@0.84.6: + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.84.6 + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-transform-plugins@0.84.5: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-transform-plugins@0.84.6: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-transform-worker@0.84.5: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + flow-enums-runtime: 0.0.6 + metro: 0.84.5 + metro-babel-transformer: 0.84.5 + metro-cache: 0.84.5 + metro-cache-key: 0.84.5 + metro-minify-terser: 0.84.5 + metro-source-map: 0.84.5 + metro-transform-plugins: 0.84.5 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-transform-worker@0.84.6: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + flow-enums-runtime: 0.0.6 + metro: 0.84.6 + metro-babel-transformer: 0.84.6 + metro-cache: 0.84.6 + metro-cache-key: 0.84.6 + metro-minify-terser: 0.84.6 + metro-source-map: 0.84.6 + metro-transform-plugins: 0.84.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro@0.84.5: + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + accepts: 2.0.0 + ci-info: 2.0.0 + connect: 3.7.0 + debug: 4.4.3 + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + hermes-parser: 0.35.0 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.84.5 + metro-cache: 0.84.5 + metro-cache-key: 0.84.5 + metro-config: 0.84.5 + metro-core: 0.84.5 + metro-file-map: 0.84.5 + metro-resolver: 0.84.5 + metro-runtime: 0.84.5 + metro-source-map: 0.84.5 + metro-symbolicate: 0.84.5 + metro-transform-plugins: 0.84.5 + metro-transform-worker: 0.84.5 + mime-types: 3.0.2 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.13 + yargs: 17.7.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro@0.84.6: + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + accepts: 2.0.0 + ci-info: 2.0.0 + connect: 3.7.0 + debug: 4.4.3 + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + hermes-parser: 0.35.0 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.84.6 + metro-cache: 0.84.6 + metro-cache-key: 0.84.6 + metro-config: 0.84.6 + metro-core: 0.84.6 + metro-file-map: 0.84.6 + metro-resolver: 0.84.6 + metro-runtime: 0.84.6 + metro-source-map: 0.84.6 + metro-symbolicate: 0.84.6 + metro-transform-plugins: 0.84.6 + metro-transform-worker: 0.84.6 + mime-types: 3.0.2 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.13 + yargs: 17.7.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@1.6.0: {} + + mimic-fn@1.2.0: {} + + mimic-fn@2.1.0: {} + + min-indent@1.0.1: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.21 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.7 + + minipass@7.1.3: {} + + mkdirp@1.0.4: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + multitars@1.0.2: {} + + nanoid@3.3.19: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + negotiator@0.6.4: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-forge@1.4.0: {} + + node-int64@0.4.0: {} + + node-releases@2.0.57: {} + + normalize-path@3.0.0: {} + + npm-package-arg@11.0.3: + dependencies: + hosted-git-info: 7.0.2 + proc-log: 4.2.0 + semver: 7.8.5 + validate-npm-package-name: 5.0.1 + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nullthrows@1.1.1: {} + + nwsapi@2.2.28: {} + + ob1@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + + ob1@0.84.6: + dependencies: + flow-enums-runtime: 0.0.6 + + object-assign@4.1.1: {} + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@2.0.1: + dependencies: + mimic-fn: 1.2.0 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + + ora@3.4.0: + dependencies: + chalk: 2.4.2 + cli-cursor: 2.1.0 + cli-spinners: 2.9.2 + log-symbols: 2.2.0 + strip-ansi: 5.2.0 + wcwidth: 1.0.1 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + package-manager-detector@1.9.0: {} + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-png@2.1.0: + dependencies: + pngjs: 3.4.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: {} + + path-data-parser@0.1.0: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.3 + minipass: 7.1.3 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + plist@3.1.1: + dependencies: + '@xmldom/xmldom': 0.9.12 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + pngjs@3.4.0: {} + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + posthog-react-native@4.78.4(104cf9436c90229daceea0056d76c82b): + dependencies: + '@posthog/core': 1.55.3 + '@posthog/types': 1.413.0 + optionalDependencies: + '@react-native-async-storage/async-storage': 2.2.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + expo-application: 57.0.3(expo@57.0.26) + expo-device: 57.0.2(expo@57.0.26) + expo-file-system: 57.0.7(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)) + expo-localization: 57.0.2(expo@57.0.26)(react@19.2.6) + expo-updates: 57.0.24(expo-dev-client@57.0.19(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6)))(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-native-safe-area-context: 5.7.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-native-svg: 15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + pretty-format@30.5.1: + dependencies: + '@jest/react-is-18': react-is@18.3.1 + '@jest/react-is-19': react-is@19.3.0 + '@jest/schemas': 30.5.0 + ansi-styles: 5.2.0 + + proc-log@4.2.0: {} + + progress@2.0.3: {} + + promise@7.3.1: + dependencies: + asap: 2.0.6 + + promise@8.3.0: + dependencies: + asap: 2.0.6 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + query-string@7.1.3: + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + + querystringify@2.2.0: {} + + range-parser@1.2.1: {} + + react-devtools-core@6.1.5: + dependencies: + shell-quote: 1.11.0 + ws: 7.5.13 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + react-dom@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + + react-fast-compare@3.2.2: {} + + react-freeze@1.0.4(react@19.2.6): + dependencies: + react: 19.2.6 + + react-is@16.13.1: {} + + react-is@18.3.1: {} + + react-is@19.3.0: {} + + react-native-drawer-layout@4.2.11(215478474156a453c7776ce7df03b364): + dependencies: + color: 4.2.3 + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + react-native-gesture-handler: 2.32.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-native-reanimated: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + use-latest-callback: 0.2.6(react@19.2.6) + + react-native-fit-image@1.5.5: + dependencies: + prop-types: 15.8.1 + + react-native-gesture-handler@2.32.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + '@egjs/hammerjs': 2.0.17 + '@types/react-test-renderer': 19.3.0 + hoist-non-react-statics: 3.3.2 + invariant: 2.2.4 + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + + react-native-is-edge-to-edge@1.3.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + + react-native-keyboard-controller@1.21.9(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-native-reanimated: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + + react-native-markdown-display@7.0.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + css-to-react-native: 3.2.0 + markdown-it: 10.0.0 + prop-types: 15.8.1 + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + react-native-fit-image: 1.5.5 + + react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + semver: 7.8.5 + + react-native-safe-area-context@5.7.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + + react-native-screens@4.26.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-freeze: 1.0.4(react@19.2.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + warn-once: 0.1.1 + + react-native-skia-android@147.1.0: {} + + react-native-skia-apple-ios@147.1.0: {} + + react-native-skia-apple-macos@147.1.0: {} + + react-native-skia-apple-tvos@147.1.0: {} + + react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + css-select: 5.2.2 + css-tree: 1.1.3 + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + warn-once: 0.1.1 + + react-native-web@0.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.7 + '@react-native/normalize-colors': 0.74.89 + fbjs: 3.0.5 + inline-style-prefixer: 7.0.1 + memoize-one: 6.0.0 + nullthrows: 1.1.1 + postcss-value-parser: 4.2.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + styleq: 0.1.3 + transitivePeerDependencies: + - encoding + + react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + escape-string-regexp: 4.0.0 + invariant: 2.2.4 + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + + react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.8 + '@react-native/metro-config': 0.86.3(@babel/core@7.29.7) + convert-source-map: 2.0.0 + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6): + dependencies: + '@react-native/assets-registry': 0.86.3 + '@react-native/codegen': 0.86.3(@babel/core@7.29.7) + '@react-native/community-cli-plugin': 0.86.3(@react-native/metro-config@0.86.3(@babel/core@7.29.7)) + '@react-native/gradle-plugin': 0.86.3 + '@react-native/js-polyfills': 0.86.3 + '@react-native/normalize-colors': 0.86.3 + '@react-native/virtualized-lists': 0.86.3(@types/react@19.2.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-plugin-syntax-hermes-parser: 0.36.0 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + hermes-compiler: 250829098.0.17 + invariant: 2.2.4 + memoize-one: 5.2.1 + metro-runtime: 0.84.6 + metro-source-map: 0.84.6 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.2.6 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.27.0 + semver: 7.8.5 + stacktrace-parser: 0.1.11 + tinyglobby: 0.2.17 + whatwg-fetch: 3.6.20 + ws: 7.5.13 + yargs: 17.7.3 + optionalDependencies: + '@react-native/jest-preset': 0.86.3(@babel/core@7.29.7)(react@19.2.6) + '@types/react': 19.2.18 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + + react-reconciler@0.31.0(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.25.0 + + react-refresh@0.14.2: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.6): + dependencies: + react: 19.2.6 + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.6) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + + react-remove-scroll@2.7.2(@types/react@19.2.18)(react@19.2.6): + dependencies: + react: 19.2.6 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.18)(react@19.2.6) + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.6) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.18)(react@19.2.6) + use-sidecar: 1.1.3(@types/react@19.2.18)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.18 + + react-style-singleton@2.2.3(@types/react@19.2.18)(react@19.2.6): + dependencies: + get-nonce: 1.0.1 + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + + react-test-renderer@19.2.3(react@19.2.6): + dependencies: + react: 19.2.6 + react-is: 19.3.0 + scheduler: 0.27.0 + + react-test-renderer@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + react-is: 19.3.0 + scheduler: 0.27.0 + + react@19.2.6: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + regenerate-unicode-properties@10.3.0: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regenerator-runtime@0.13.11: {} + + regexpu-core@6.5.2: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.3.0 + regjsgen: 0.8.0 + regjsparser: 0.13.3 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.3: + dependencies: + jsesc: 3.1.0 + + require-directory@2.1.1: {} + + requires-port@1.0.0: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@5.0.0: {} + + resolve-workspace-root@2.0.1: {} + + resolve.exports@2.0.3: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.17.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@2.0.0: + dependencies: + onetime: 2.0.1 + signal-exit: 3.0.7 + + robust-predicates@3.0.3: {} + + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rtl-detect@1.1.2: {} + + rw@1.3.3: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + sandbox-cli-detector@0.2.0: {} + + sax@1.6.1: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.25.0: {} + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serialize-error@2.1.0: {} + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + server-only@0.0.1: {} + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + + sf-symbols-typescript@2.2.0: {} + + shallowequal@1.1.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.11.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-plist@1.3.1: + dependencies: + bplist-creator: 0.1.0 + bplist-parser: 0.3.1 + plist: 3.1.1 + + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + + sisteransi@1.0.5: {} + + slash@3.0.0: {} + + slash@5.1.0: {} + + slugify@1.6.9: {} + + source-map-js@1.2.2: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.5.6: {} + + source-map@0.5.7: {} + + source-map@0.6.1: {} + + split-on-first@1.1.0: {} + + sprintf-js@1.0.3: {} + + stack-generator@2.0.10: + dependencies: + stackframe: 1.3.4 + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stackframe@1.3.4: {} + + stacktrace-gps@3.1.2: + dependencies: + source-map: 0.5.6 + stackframe: 1.3.4 + + stacktrace-js@2.0.2: + dependencies: + error-stack-parser: 2.1.4 + stack-generator: 2.0.10 + stacktrace-gps: 3.1.2 + + stacktrace-parser@0.1.11: + dependencies: + type-fest: 0.7.1 + + standard-navigation@0.0.5: {} + + statuses@1.5.0: {} + + statuses@2.0.2: {} + + stream-buffers@2.2.0: {} + + strict-uri-encode@2.0.0: {} + + strictdom@1.0.1: {} + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-length@5.0.1: + dependencies: + char-regex: 2.0.2 + strip-ansi: 7.2.0 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + strip-ansi@5.2.0: + dependencies: + ansi-regex: 4.1.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.4.0 + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + structured-headers@0.4.1: {} + + styleq@0.1.3: {} + + stylis@4.4.0: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@2.3.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + terminal-link@2.1.1: + dependencies: + ansi-escapes: 4.3.2 + supports-hyperlinks: 2.3.0 + + terser@5.51.2: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.18.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 7.2.3 + minimatch: 3.1.5 + + throat@5.0.0: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + toqr@0.1.1: {} + + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tr46@0.0.3: {} + + tr46@3.0.0: + dependencies: + punycode: 2.3.1 + + ts-dedent@2.3.0: {} + + tslib@2.8.1: {} + + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + + type-fest@0.7.1: {} + + typescript@5.9.3: {} + + ua-parser-js@0.7.41: {} + + ua-parser-js@1.0.41: {} + + uc.micro@1.0.6: {} + + undici-types@8.9.0: {} + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + + universalify@0.2.0: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.3.3(browserslist@4.29.3): + dependencies: + browserslist: 4.29.3 + escalade: 3.2.0 + picocolors: 1.1.1 + + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + + use-callback-ref@1.3.3(@types/react@19.2.18)(react@19.2.6): + dependencies: + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + + use-latest-callback@0.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + + use-sidecar@1.1.3(@types/react@19.2.18)(react@19.2.6): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + + use-sync-external-store@1.7.0(react@19.2.6): + dependencies: + react: 19.2.6 + + utils-merge@1.0.1: {} + + uuid@14.0.2: {} + + uuid@7.0.3: {} + + uuid@8.3.2: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + validate-npm-package-name@5.0.1: {} + + vary@1.1.2: {} + + vaul@1.1.2(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@radix-ui/react-dialog': 1.1.23(@types/react@19.2.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + victory-native@42.0.1(b83e10df08cd1eb622b79f5e20a13446): + dependencies: + '@shopify/react-native-skia': 2.6.2(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-zoom: 3.0.0 + its-fine: 1.2.5(@types/react@19.2.18)(react@19.2.6) + react: 19.2.6 + react-fast-compare: 3.2.2 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6) + react-native-gesture-handler: 2.32.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + react-native-reanimated: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.6))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.6))(react@19.2.6) + transitivePeerDependencies: + - '@types/react' + + vlq@1.0.1: {} + + w3c-xmlserializer@4.0.0: + dependencies: + xml-name-validator: 4.0.0 + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + warn-once@0.1.1: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + webidl-conversions@3.0.1: {} + + webidl-conversions@7.0.0: {} + + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + + whatwg-fetch@3.6.20: {} + + whatwg-mimetype@3.0.0: {} + + whatwg-url-minimum@0.1.2: {} + + whatwg-url@11.0.0: + dependencies: + tr46: 3.0.0 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + ws@7.5.13: {} + + ws@8.22.0: {} + + xcode@3.0.1: + dependencies: + simple-plist: 1.3.1 + uuid: 7.0.3 + + xml-name-validator@4.0.0: {} + + xml2js@0.6.0: + dependencies: + sax: 1.6.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + xmlbuilder@14.0.0: {} + + xmlbuilder@15.1.1: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml@2.9.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + zod@3.25.76: {} + + zustand@4.5.7(@types/react@19.2.18)(react@19.2.6): + dependencies: + use-sync-external-store: 1.7.0(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.18 + react: 19.2.6 diff --git a/products/mobile/pnpm-workspace.yaml b/products/mobile/pnpm-workspace.yaml new file mode 100644 index 000000000000..5ac4b93ad8d1 --- /dev/null +++ b/products/mobile/pnpm-workspace.yaml @@ -0,0 +1,14 @@ +# products/mobile is a standalone workspace with its own lockfile. This file stops pnpm +# from walking up to the repo root workspace, which excludes this directory. +packages: + - '.' + +# Match the repo root: only install versions that have been public for 7 days. +minimumReleaseAge: 10080 +blockExoticSubdeps: true +trustPolicy: no-downgrade + +# pnpm 10 skips dependency install scripts unless they are listed here. Skia's postinstall links its +# prebuilt iOS and Android binaries, and `pod install` fails without them. +onlyBuiltDependencies: + - '@shopify/react-native-skia' diff --git a/products/mobile/product.yaml b/products/mobile/product.yaml new file mode 100644 index 000000000000..37f3388d1cb8 --- /dev/null +++ b/products/mobile/product.yaml @@ -0,0 +1,3 @@ +name: Mobile +owners: + - team-self-driving diff --git a/products/mobile/releases/config.json b/products/mobile/releases/config.json new file mode 100644 index 000000000000..48d4f92264cd --- /dev/null +++ b/products/mobile/releases/config.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, + "initialRuntimeVersion": "1.4.0", + "projectId": "4eb57b2f-79f6-4ab0-ae2c-bbd82af2cef9" +} diff --git a/products/mobile/releases/ota-certificate.pem b/products/mobile/releases/ota-certificate.pem new file mode 100644 index 000000000000..b9a4984e767a --- /dev/null +++ b/products/mobile/releases/ota-certificate.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC3zCCAcegAwIBAgIJLMuFmxo68guCMA0GCSqGSIb3DQEBCwUAMBkxFzAVBgNV +BAMTDlBvc3RIb2cgTW9iaWxlMB4XDTI2MTAyODIwMDAwNloXDTM2MTAyODIwMDAw +NlowGTEXMBUGA1UEAxMOUG9zdEhvZyBNb2JpbGUwggEiMA0GCSqGSIb3DQEBAQUA +A4IBDwAwggEKAoIBAQCV3EEAkYv3rHwvYhHd3hYllEMFbbj4rZIa2ZN3/wIs17FL +EHujbFQkUKihwEBTuFQU8cSEDWt4Vw6hwBWqzrSjGRCds84zuADG7tK7ae0s+2mZ +iZKflcl8vKSA2j1O5DJBdzdTVmSGgC6lqMxlr0L/gw4n4JjvqPL1LDrtm/H5+Vey +RC+tQ4LLwhyln4XtvXLIRPLQ92H+OTrY1hZi1u52nAUg9Mlxihrv9mx9C+MTSiIE +ZThgtOjd7u4HFCdpprUW2NbuLUNkEfoj+oVcahlzqgSasm96qb1Im+pkqbAql4eJ +vek3EPQOsxTK5YsaaRKVFduxH0rKdD9JAOyhcTpdAgMBAAGjKjAoMA4GA1UdDwEB +/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOC +AQEAk5WjU6hhgwwos368c31DgMmFaEwhTkjYoFSy2eYg4GQrdqqxnk51BmH8vTwI +lREfGLNXZ592WuHvzfTrK1qoDLAnRRqKUID30HrZECITTqRUU0h7rZ6lsRRmaP6E +v6lIxDZosC3EN6lg0/0rYE8SV39ohHiOGTXG79bQxp1ebMFlf7jiX1PCf3Tz/bye +v31K2gITuGML5ZhD3k2ei/qctg03fSvvL/UaRTct/7WJZiGSxBeNyTjirMvIvgOP +gMCsMrxBiEswFSlVwN4L4LEQ69x88hPwdCJxzSBObNBZ2MWSVhUa1Au7qMXnoVRt +UR16gvOzQaBff5QJf5OtQ8Awmg== +-----END CERTIFICATE----- diff --git a/products/mobile/releases/request.json.example b/products/mobile/releases/request.json.example new file mode 100644 index 000000000000..bdfd8c86a39d --- /dev/null +++ b/products/mobile/releases/request.json.example @@ -0,0 +1,8 @@ +{ + "schemaVersion": 1, + "id": "example-release", + "kind": "native", + "runtimeVersion": "1.4.0", + "previousRelease": null, + "notes": "Describe the changes testers should verify." +} diff --git a/products/mobile/scripts/check-expo.mjs b/products/mobile/scripts/check-expo.mjs new file mode 100644 index 000000000000..89feff28be03 --- /dev/null +++ b/products/mobile/scripts/check-expo.mjs @@ -0,0 +1,18 @@ +import { spawnSync } from 'node:child_process' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const result = spawnSync(process.execPath, [require.resolve('expo/bin/cli'), 'install', '--check'], { + // Bundled SDK versions keep CI independent of newer remote patch metadata. + env: { ...process.env, EXPO_OFFLINE: '1' }, + stdio: 'inherit', +}) + +if (result.error) { + throw result.error +} +if (result.signal) { + process.kill(process.pid, result.signal) +} else { + process.exitCode = result.status ?? 1 +} diff --git a/products/mobile/scripts/check_hedgehog_timeline.swift b/products/mobile/scripts/check_hedgehog_timeline.swift new file mode 100644 index 000000000000..c26eba284b63 --- /dev/null +++ b/products/mobile/scripts/check_hedgehog_timeline.swift @@ -0,0 +1,230 @@ +import Foundation + +// Usage: check-hoglet [choreography.json ...] +// Each JSON file is checked like the built-in fallback, for example a script the app wrote. + +@main +struct CheckHedgehogTimeline { + static let step: TimeInterval = 2 + static let reach = HedgehogTimelineProvider.walkSpeed * step + + static func main() throws { + var scripts: [(String, Choreography)] = [("fallback", FallbackChoreography.value), ("inline", try decode(inlineScript))] + for path in CommandLine.arguments.dropFirst() { + scripts.append((path, try ChoreographyLoader.decode(Data(contentsOf: URL(fileURLWithPath: path))))) + } + for (name, choreography) in scripts { + let jumps = try checkMotionScript(choreography) + print("\(name): \(choreography.keyframes.count) keyframes, \(jumps) jump entries checked") + } + checkLegacyScript() + try checkOlderSavedTimeline() + print("Passed: walk glides, jump airtime/bounces/flips, upright landings, resume and delayed refreshes, character cycling, scripts without motion, timelines from older builds") + } + + /// Plans a motion-bearing script and checks every entry against the keyframe it plays. + static func checkMotionScript(_ choreography: Choreography) throws -> Int { + let now = choreography.startsAt.addingTimeInterval(120) + let plan = HedgehogTimelineProvider.plan(for: choreography, now: now) + let keyframes = choreography.keyframes + func keyframe(_ index: Int) -> Tick { keyframes[((index % keyframes.count) + keyframes.count) % keyframes.count] } + + checkUpright(plan.entries) + precondition(plan.entries[0].motion == .settle && !plan.entries[0].airborne && plan.entries[0].spin == 0) + var index = Int(floor(now.timeIntervalSince(choreography.startsAt) / step)) + var jumps = 0 + for (from, to) in zip(plan.entries, plan.entries.dropFirst()) { + precondition(to.date.timeIntervalSince(from.date) == step) + index += 1 + let target = keyframe(index) + guard abs(to.tick.x - target.x) < 1e-9 else { + // A catch-up walk towards a keyframe that is out of reach; the keyframe plays after it. + index -= 1 + precondition(to.tick.pose == .walk && !to.airborne && to.motion == .glide(step)) + precondition(abs(abs(to.tick.x - from.tick.x) - reach) < 1e-9) + continue + } + guard let motion = target.motion else { continue } + switch motion.kind { + case "walk", "idle": + precondition(!to.airborne && to.spin == 0) + precondition(to.motion == .glide(step), "walk and idle entries glide over the whole beat") + precondition(to.tick.facing == target.facing && to.tick.pose == target.pose && to.tick.frame == target.frame) + precondition(abs(to.tick.x - from.tick.x) <= reach + 0.01, "no faster than hedgehog-mode's walk") + case "jump": + jumps += 1 + let flights = [TickMotion.Bounce(airtime: min(motion.airtime!, step), apex: motion.apex!)] + (motion.bounces ?? []) + let direction: Double = target.facing == .right ? -1 : 1 + var takeoff = 0.0 + for layer in 0.. 0 || entry.hop(layer).degrees == 0, "a grounded layer resets to zero") + } + if !entry.airborne { + precondition(entry.spin == 0) + } + } + } + + static func same(_ a: HedgehogEntry, _ b: HedgehogEntry) -> Bool { + a.date == b.date && a.tick == b.tick && a.spin == b.spin && a.motion == b.motion && a.hops == b.hops + } + + static func checkResume(_ choreography: Choreography, plan: HedgehogTimelineProvider.Plan, now: Date) throws { + let saved = try JSONDecoder().decode([HedgehogEntry].self, from: JSONEncoder().encode(plan.entries)) + for elapsed in [0.5, 7.0, 200.0] { + let date = now.addingTimeInterval(elapsed) + let resumed = HedgehogTimelineProvider.plan(for: choreography, now: date, previous: saved) + let current = saved.lastIndex { $0.date <= date }! + precondition(resumed.entries.count == saved.count - current) + precondition(zip(resumed.entries, saved[current...]).allSatisfy(same), "a refresh keeps the saved timeline") + precondition(resumed.reloadAt == plan.reloadAt) + var character: String? = nil + var seen = Set() + for _ in 0.. Choreography { + try ChoreographyLoader.decode(Data(json.utf8)) + } + + /// The JSON contract, including a double jump without bounces and a kind this build does not know. + static let inlineScript = """ + {"version": 1, "seed": 7, "generatedAt": "2026-10-15T00:00:00.000Z", "startsAt": "2026-10-15T00:00:00.000Z", + "keyframeSec": 2, "tickMs": 120, "loopMinutes": 10, "loops": [], "reaction": {"keyframes": [], "ticks": []}, + "keyframes": [ + {"pose": "idle", "frame": 3, "x": 0.4, "y": 0, "facing": "right", "motion": {"kind": "idle"}}, + {"pose": "walk", "frame": 2, "x": 0.85, "y": 0, "facing": "right", "motion": {"kind": "walk"}}, + {"pose": "walk", "frame": 5, "x": 0.95, "y": 0, "facing": "right", "motion": {"kind": "walk"}}, + {"pose": "walk", "frame": 8, "x": 0.6, "y": 0, "facing": "left", "motion": {"kind": "walk"}}, + {"pose": "fall", "frame": 2, "x": 0.58, "y": 0, "facing": "left", + "motion": {"kind": "jump", "airtime": 0.9, "apex": 1, "bounces": [{"airtime": 0.45, "apex": 0.25}, {"airtime": 0.22, "apex": 0.0625}, {"airtime": 0.11, "apex": 0.016}]}}, + {"pose": "fall", "frame": 6, "x": 0.58, "y": 0, "facing": "right", "motion": {"kind": "jump", "airtime": 0.9, "apex": 1, "bounces": [], "turns": 1}}, + {"pose": "fall", "frame": 6, "x": 0.58, "y": 0, "facing": "right", "motion": {"kind": "jump", "airtime": 1.5, "apex": 1, "bounces": [{"airtime": 0.45, "apex": 0.25}, {"airtime": 0.22, "apex": 0.0625}]}}, + {"pose": "wave", "frame": 10, "x": 0.58, "y": 0, "facing": "right", "motion": {"kind": "teleport"}}, + {"pose": "walk", "frame": 1, "x": 0.4, "y": 0, "facing": "left", "motion": {"kind": "walk"}} + ]} + """ + + /// The built-in routine before `motion` existed. + static let legacyScript: Choreography = { + let fallback = FallbackChoreography.value + var keyframes: [Tick] = [] + for step in 0...4 { + keyframes.append(Tick(pose: .walk, frame: step * 2, x: 0.15 + Double(step) * 0.15, y: 0, facing: .right)) + } + keyframes.append(Tick(pose: .jump, frame: 4, x: 0.8, y: 0.8, facing: .right, turn: 0.5)) + keyframes.append(Tick(pose: .jump, frame: 9, x: 0.85, y: 0, facing: .right, turn: 0.5)) + keyframes.append(Tick(pose: .wave, frame: 8, x: 0.85, y: 0, facing: .left, bubble: "Hi there!")) + keyframes.append(Tick(pose: .idle, frame: 12, x: 0.85, y: 0, facing: .left)) + keyframes.append(Tick(pose: .jump, frame: 4, x: 0.72, y: 0.35, facing: .left)) + keyframes.append(Tick(pose: .jump, frame: 9, x: 0.62, y: 0, facing: .left)) + for step in 0...3 { + keyframes.append(Tick(pose: .walk, frame: step * 3, x: 0.5 - Double(step) * 0.12, y: 0, facing: .left)) + } + keyframes.append(Tick(pose: .idle, frame: 6, x: 0.15, y: 0, facing: .right)) + return Choreography( + version: 1, seed: 0, generatedAt: fallback.startsAt, startsAt: fallback.startsAt, keyframeSec: 2, + keyframes: keyframes, tickMs: 120, loopMinutes: 10, loops: [], reaction: fallback.reaction + ) + }() +} diff --git a/products/mobile/scripts/ci-argent-build.sh b/products/mobile/scripts/ci-argent-build.sh new file mode 100755 index 000000000000..c54b53547a2b --- /dev/null +++ b/products/mobile/scripts/ci-argent-build.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +app_dir=$(cd "$(dirname "$0")/.." && pwd) +mkdir -p "$1" "$2" +build_dir=$(cd "$1" && pwd) +artifact_dir=$(cd "$2" && pwd) +cd "$app_dir" + +export CI=1 EXPO_NO_DOTENV=1 EXPO_NO_TELEMETRY=1 DO_NOT_TRACK=1 +export COCOAPODS_DISABLE_STATS=true +export APP_VARIANT=production EXPO_PUBLIC_DEMO_MODE=0 +export EXPO_PUBLIC_POSTHOG_KEY='' EXPO_PUBLIC_POSTHOG_HOST='' +export EXTRA_PACKAGER_ARGS='--max-workers 2' +unset EXPO_TOKEN APPLE_TEAM_ID GOOGLE_SERVICES_JSON + +# Regenerate like EAS: apple-targets cannot reliably update an existing widget Xcode target in place. +pnpm exec expo prebuild --platform ios --no-install --clean 2>&1 | tee "$artifact_dir/prebuild.log" +pod install --project-directory=ios 2>&1 | tee "$artifact_dir/pods.log" + +# A demo smoke must exercise this checkout's bundle, never download an update from its EAS project. +/usr/libexec/PlistBuddy -c 'Set :EXUpdatesEnabled false' ios/PostHog/Supporting/Expo.plist + +xcodebuild \ + -workspace ios/PostHog.xcworkspace \ + -scheme PostHog \ + -configuration Release \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$build_dir" \ + -jobs 2 \ + ARCHS="$(uname -m)" \ + ONLY_ACTIVE_ARCH=YES \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGN_IDENTITY='' \ + build 2>&1 | tee "$artifact_dir/build.log" + +binary="$build_dir/Build/Products/Release-iphonesimulator/PostHog.app" +test -f "$binary/main.jsbundle" +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "app_path=$binary" >> "$GITHUB_OUTPUT" +fi +echo "Built simulator app: $binary" diff --git a/products/mobile/scripts/ci-argent.sh b/products/mobile/scripts/ci-argent.sh new file mode 100755 index 000000000000..211689c249d1 --- /dev/null +++ b/products/mobile/scripts/ci-argent.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +app_dir=$(cd "$(dirname "$0")/.." && pwd) +binary=$(cd "$1" && pwd) +mkdir -p "$2" +artifact_dir=$(cd "$2" && pwd) +cd "$app_dir" + +export DO_NOT_TRACK=1 +unset ARGENT_TOOLS_URL ARGENT_AUTH_TOKEN +if [[ $(argent --version) != '0.30.1' ]]; then + echo 'Install the pinned CLI: npm install --prefix @swmansion/argent@0.30.1' >&2 + exit 1 +fi + +bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$binary/Info.plist") +if [[ "$bundle_id" != 'com.posthog.code.mobile' || ! -f "$binary/main.jsbundle" ]]; then + echo 'Expected the bundled production-identity simulator app from ci-argent-build.sh.' >&2 + exit 1 +fi +if [[ $(/usr/libexec/PlistBuddy -c 'Print :EXUpdatesEnabled' "$binary/Expo.plist") != 'false' ]]; then + echo 'The simulator app must have OTA updates disabled by ci-argent-build.sh.' >&2 + exit 1 +fi + +private_dir=$(mktemp -d "${TMPDIR:-/tmp}/mobile-argent.XXXXXX") +simulator='' +server_started=false +# shellcheck disable=SC2329 # Invoked by the EXIT trap. +cleanup() { + result=$1 + trap - EXIT + if [[ -n "$simulator" ]]; then + if [[ "$result" -ne 0 ]]; then + xcrun simctl io "$simulator" screenshot "$artifact_dir/failure.png" >/dev/null 2>&1 || true + fi + xcrun simctl shutdown "$simulator" >/dev/null 2>&1 || true + xcrun simctl delete "$simulator" >/dev/null 2>&1 || true + fi + if [[ "$server_started" == true ]]; then + argent server stop >/dev/null 2>&1 || true + fi + rm -rf "$private_dir" + exit "$result" +} +trap 'cleanup "$?"' EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +runtime=${ARGENT_IOS_RUNTIME:-com.apple.CoreSimulator.SimRuntime.iOS-26-5} +simulator=$(xcrun simctl create "PostHog Argent $$" com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro "$runtime") +echo "Demo simulator: $simulator" +xcrun simctl boot "$simulator" +xcrun simctl bootstatus "$simulator" -b +xcrun simctl ui "$simulator" appearance light + +# The dedicated CLI install owns this server; never replace a developer's running Argent server. +argent server start --port 0 --detach > "$private_dir/server-start.log" +server_started=true +connection=$(sed -n 's/.*argent link argent:\/\/\([^ ]*\).*/\1/p' "$private_dir/server-start.log" | head -1) +if [[ "$connection" != *@127.0.0.1:* ]]; then + echo 'Argent did not return a local authenticated tool-server connection.' >&2 + exit 1 +fi +export ARGENT_TOOLS_URL="http://${connection#*@}" +export ARGENT_AUTH_TOKEN="${connection%@*}" + +failed=0 +for flow in demo-smoke views-smoke; do + # Reinstalling drops demo changes even when the preceding flow stopped before Exit demo. + xcrun simctl terminate "$simulator" "$bundle_id" >/dev/null 2>&1 || true + xcrun simctl uninstall "$simulator" "$bundle_id" >/dev/null 2>&1 || true + xcrun simctl install "$simulator" "$binary" + echo "Running $flow" + if ! argent e2e run "$flow" --platform ios --device "$simulator" --output "$artifact_dir" --json > "$artifact_dir/$flow.json"; then + failed=1 + xcrun simctl io "$simulator" screenshot "$artifact_dir/$flow-failure.png" >/dev/null 2>&1 || true + echo "$flow failed; see $artifact_dir/$flow.json and its failed-step screenshots." >&2 + fi +done +exit "$failed" diff --git a/products/mobile/scripts/eas-build.sh b/products/mobile/scripts/eas-build.sh new file mode 100755 index 000000000000..2dccbc6aaa1a --- /dev/null +++ b/products/mobile/scripts/eas-build.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env sh +# Runs `eas build` with this app directory as the whole upload. +# +# By default EAS archives the git root: the full monorepo, whose root pnpm workspace +# excludes this app, so the build would install the wrong dependencies. EAS_NO_VCS=1 +# alone is not enough, because eas-cli still asks git for the root. An absolute +# EAS_PROJECT_ROOT pins it here, and .easignore filters the archive. +set -eu + +app_dir=$(cd "$(dirname "$0")/.." && pwd) + +if ! command -v eas >/dev/null 2>&1; then + echo "eas-cli is not installed. Install it with: npm install -g eas-cli" >&2 + exit 1 +fi + +EAS_NO_VCS=1 EAS_PROJECT_ROOT="$app_dir" exec eas build "$@" diff --git a/products/mobile/scripts/extract_hedgehog_sprites.py b/products/mobile/scripts/extract_hedgehog_sprites.py new file mode 100644 index 000000000000..83fbf167a16e --- /dev/null +++ b/products/mobile/scripts/extract_hedgehog_sprites.py @@ -0,0 +1,119 @@ +"""Cut the default-skin hedgehog frames out of the @posthog/hedgehog-mode atlas for the home-screen widget. + +Usage: python3 -I scripts/extract_hedgehog_sprites.py --source +""" + +import json +import logging +import argparse +from pathlib import Path + +from PIL import Image, ImageDraw + +POSES: tuple[str, ...] = ("idle", "walk", "jump", "wave", "action", "fall") +SKIN = "default" +SCALE = 2 +CELL = 80 + +MOBILE_ROOT = Path(__file__).resolve().parent.parent +ANDROID_DIR = MOBILE_ROOT / "modules/hedgehog-widget/android/src/main/res/drawable-nodpi" +IOS_CATALOG = MOBILE_ROOT / "targets/HedgehogWidget/Assets.xcassets" +XCODE_INFO: dict[str, object] = {"author": "xcode", "version": 1} + +logger = logging.getLogger(__name__) + + +class SpriteExtractor: + def __init__(self, source: Path) -> None: + atlas_json = source / "sprites.json" + atlas_png = source / "sprites.png" + for path in (atlas_json, atlas_png): + if not path.is_file(): + raise SystemExit(f"Missing atlas file: {path}") + self.atlas: dict = json.loads(atlas_json.read_text()) + self.sheet = Image.open(atlas_png).convert("RGBA") + + def frames_for(self, pose: str) -> list[Image.Image]: + key = f"skins/{SKIN}/{pose}/tile" + names = self.atlas["animations"].get(key) + if not names: + raise SystemExit(f'Pose "{pose}" is missing from the atlas (no animation "{key}")') + frames: list[Image.Image] = [] + for name in names: + entry = self.atlas["frames"][name] + # Rotated or trimmed frames need extra un-packing that this script does not do, + # so stop instead of writing a wrong crop. + if entry["rotated"] or entry["trimmed"]: + raise SystemExit(f"Frame {name} is rotated or trimmed; this script handles only plain frames") + rect = entry["frame"] + frames.append(self.sheet.crop((rect["x"], rect["y"], rect["x"] + rect["w"], rect["y"] + rect["h"]))) + return frames + + +def write_json(path: Path, payload: dict[str, object]) -> None: + path.write_text(json.dumps(payload, indent=4) + "\n") + + +def write_frame(pose: str, index: int, frame: Image.Image) -> None: + name = f"hog_{pose}_{index:02d}" + # Nearest-neighbour keeps the pixel art crisp; any smoothing filter blurs the outlines. + big = frame.resize((frame.width * SCALE, frame.height * SCALE), Image.Resampling.NEAREST) + + big.save(ANDROID_DIR / f"{name}.png", optimize=True) + # RemoteViews cannot flip an ImageView, so Android needs a pre-mirrored copy for facing left. + big.transpose(Image.Transpose.FLIP_LEFT_RIGHT).save(ANDROID_DIR / f"{name}_l.png", optimize=True) + + imageset = IOS_CATALOG / f"{name}.imageset" + imageset.mkdir(parents=True, exist_ok=True) + big.save(imageset / f"{name}.png", optimize=True) + write_json( + imageset / "Contents.json", + {"images": [{"filename": f"{name}.png", "idiom": "universal", "scale": "1x"}], "info": XCODE_INFO}, + ) + + +def write_contact_sheet(frames_by_pose: dict[str, list[Image.Image]], path: Path) -> None: + label_width = 70 + columns = max(len(frames) for frames in frames_by_pose.values()) + sheet = Image.new("RGBA", (label_width + columns * CELL, len(frames_by_pose) * CELL), (235, 235, 235, 255)) + draw = ImageDraw.Draw(sheet) + for row, (pose, frames) in enumerate(frames_by_pose.items()): + top = row * CELL + draw.text((6, top + CELL // 2 - 6), pose, fill=(0, 0, 0, 255)) + for column, frame in enumerate(frames): + left = label_width + column * CELL + draw.rectangle((left, top, left + CELL - 1, top + CELL - 1), outline=(200, 200, 200, 255)) + sheet.alpha_composite(frame, (left, top)) + draw.text((left + 2, top + 2), f"{column:02d}", fill=(150, 150, 150, 255)) + path.parent.mkdir(parents=True, exist_ok=True) + sheet.save(path) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True, help="Directory with sprites.json and sprites.png") + parser.add_argument("--contact-sheet", type=Path, help="Optional PNG path for a sheet of every frame") + args = parser.parse_args() + + extractor = SpriteExtractor(args.source) + frames_by_pose = {pose: extractor.frames_for(pose) for pose in POSES} + + ANDROID_DIR.mkdir(parents=True, exist_ok=True) + IOS_CATALOG.mkdir(parents=True, exist_ok=True) + catalog_contents = IOS_CATALOG / "Contents.json" + if not catalog_contents.exists(): + write_json(catalog_contents, {"info": XCODE_INFO}) + + for pose, frames in frames_by_pose.items(): + for index, frame in enumerate(frames): + write_frame(pose, index, frame) + logger.info("%s: %d frames", pose, len(frames)) + + if args.contact_sheet: + write_contact_sheet(frames_by_pose, args.contact_sheet) + logger.info("contact sheet: %s", args.contact_sheet) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(message)s") + main() diff --git a/products/mobile/scripts/make_variant_icons.py b/products/mobile/scripts/make_variant_icons.py new file mode 100644 index 000000000000..05799061523d --- /dev/null +++ b/products/mobile/scripts/make_variant_icons.py @@ -0,0 +1,86 @@ +"""Draw the development and preview app icons: the production icon with a labeled color band. + +Run from products/mobile after a change to assets/icon.png or assets/adaptive-icon.png: +uv run --with pillow python scripts/make_variant_icons.py +""" + +import argparse +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +MOBILE_ROOT = Path(__file__).resolve().parent.parent +ASSETS = MOBILE_ROOT / "assets" +DEFAULT_FONT = Path("/System/Library/Fonts/Supplemental/Arial Bold.ttf") + + +@dataclass(frozen=True) +class Badge: + variant: str + label: str + fill: str + text: str + + +# Fills match the environment ribbon in src/ui/tokens.ts, so the icon and the in-app band agree. +BADGES = ( + Badge(variant="dev", label="DEV", fill="#F9BD2B", text="#1F1F1C"), + Badge(variant="preview", label="PREVIEW", fill="#7A4DD6", text="#FFFFFF"), +) + + +@dataclass(frozen=True) +class Band: + top: int + bottom: int + font_size: int + + +# The iOS mask rounds the corners only, so the band can run the full width near the bottom edge. +# An Android adaptive icon can be masked to a circle that shows only the central 2/3 of the layer, +# so its band sits just under the logomark, where that circle is still wide enough for the label. +ICON_BAND = Band(top=790, bottom=930, font_size=88) +ADAPTIVE_BAND = Band(top=740, bottom=840, font_size=64) + + +class VariantIconMaker: + def __init__(self, font_path: Path) -> None: + self.font_path = font_path + + def draw(self, source: Path, target: Path, band: Band, badge: Badge) -> None: + image = Image.open(source).convert("RGB") + scale = image.width / 1024 + draw = ImageDraw.Draw(image) + top, bottom = round(band.top * scale), round(band.bottom * scale) + draw.rectangle((0, top, image.width, bottom), fill=badge.fill) + font = ImageFont.truetype(str(self.font_path), round(band.font_size * scale)) + draw.text( + (image.width / 2, (top + bottom) / 2), + badge.label, + font=font, + fill=badge.text, + anchor="mm", + ) + image.save(target, optimize=True) + + def run(self) -> None: + for badge in BADGES: + self.draw(ASSETS / "icon.png", ASSETS / f"icon-{badge.variant}.png", ICON_BAND, badge) + self.draw( + ASSETS / "adaptive-icon.png", + ASSETS / f"adaptive-icon-{badge.variant}.png", + ADAPTIVE_BAND, + badge, + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--font", type=Path, default=DEFAULT_FONT, help="A bold TrueType font for the label.") + args = parser.parse_args() + VariantIconMaker(args.font).run() + + +if __name__ == "__main__": + main() diff --git a/products/mobile/scripts/prepare_widget_hoggies.py b/products/mobile/scripts/prepare_widget_hoggies.py new file mode 100644 index 000000000000..679d9c4862b4 --- /dev/null +++ b/products/mobile/scripts/prepare_widget_hoggies.py @@ -0,0 +1,70 @@ +"""Prepare bundled widget illustrations from https://brand.posthog.com/hoggies. + +Download the PNGs into a directory, naming wizard-1.png as wizard.png, then run: +python scripts/prepare_widget_hoggies.py --source +""" + +import json +import argparse +from pathlib import Path + +from PIL import Image + +MOBILE_ROOT = Path(__file__).resolve().parent.parent +ANDROID_DIR = MOBILE_ROOT / "modules/hedgehog-widget/android/src/main/res/drawable-nodpi" +IOS_CATALOG = MOBILE_ROOT / "targets/HedgehogWidget/Assets.xcassets" +FACES_LEFT: dict[str, bool] = { + "coffee-cup": True, + "party": True, + "wizard": True, + "explorer": False, + "superhero": False, + "reading": True, + "float": True, +} + + +class WidgetHoggiePreparer: + def __init__(self, source: Path) -> None: + self.source = source + + def prepare(self) -> None: + for slug, faces_left in FACES_LEFT.items(): + with Image.open(self.source / f"{slug}.png") as source: + image = source.convert("RGBA") + bounds = image.getbbox() + if bounds is None: + raise ValueError(f"Empty illustration: {slug}") + image = image.crop(bounds) + if faces_left: + image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + image.thumbnail((240, 272), Image.Resampling.LANCZOS) + cell = Image.new("RGBA", (320, 320)) + cell.alpha_composite(image, ((320 - image.width) // 2, 312 - image.height)) + name = f"hoggie_{slug.replace('-', '_')}" + cell.save(ANDROID_DIR / f"{name}.png", optimize=True) + cell.transpose(Image.Transpose.FLIP_LEFT_RIGHT).save(ANDROID_DIR / f"{name}_l.png", optimize=True) + imageset = IOS_CATALOG / f"{name}.imageset" + imageset.mkdir(parents=True, exist_ok=True) + cell.save(imageset / f"{name}.png", optimize=True) + (imageset / "Contents.json").write_text( + json.dumps( + { + "images": [{"filename": f"{name}.png", "idiom": "universal", "scale": "1x"}], + "info": {"author": "xcode", "version": 1}, + }, + indent=4, + ) + + "\n" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True) + args = parser.parse_args() + WidgetHoggiePreparer(args.source).prepare() + + +if __name__ == "__main__": + main() diff --git a/products/mobile/scripts/releases/fixtures/hosted-cli.mjs b/products/mobile/scripts/releases/fixtures/hosted-cli.mjs new file mode 100644 index 000000000000..741f5b843bd8 --- /dev/null +++ b/products/mobile/scripts/releases/fixtures/hosted-cli.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +import { createPrivateKey, sign, verify, X509Certificate } from 'node:crypto' +import { readFileSync, writeFileSync } from 'node:fs' +import { basename } from 'node:path' + +const [command, ...args] = process.argv.slice(2) +const state = JSON.parse(readFileSync(process.env.MOBILE_FAKE_STATE, 'utf8')) +const tool = basename(process.argv[1]) +state.commands.push({ tool, command, args }) +function done(value = '') { + writeFileSync(process.env.MOBILE_FAKE_STATE, JSON.stringify(state)) + console.log(typeof value === 'string' ? value : JSON.stringify(value)) +} +function flag(name) { + return args[args.indexOf(name) + 1] +} +if (tool === 'eas') { + if (command === 'build') { + done( + ['IOS', 'ANDROID'].map((platform, index) => ({ + id: `${index + 1}`.repeat(36), + platform, + status: 'FINISHED', + distribution: 'STORE', + appVersion: process.env.MOBILE_FAKE_VERSION, + runtime: { version: process.env.MOBILE_FAKE_VERSION }, + buildProfile: 'production', + updateChannel: { name: 'production' }, + isForIosSimulator: false, + appIdentifier: platform === 'IOS' ? 'com.example.ios' : 'com.example.android', + fingerprint: { + hash: + process.env.MOBILE_FAKE_FAIL === 'fingerprint' + ? 'different-native-build' + : `${platform.toLowerCase()}-native-hash`, + }, + })) + ) + } else if (command === 'submit') { + state.submissions.push(flag('--id')) + done() + if (process.env.MOBILE_FAKE_FAIL === 'submit') { + process.exit(1) + } + } else if (command === 'update' || command === 'update:republish') { + // Model the EAS signing boundary: both new manifests and republished + // manifests must be signed by the key pinned in the installed binary. + if (!args.includes('--private-key-path')) { + throw new Error('An OTA manifest cannot be published without its signing key') + } + const key = createPrivateKey(readFileSync(flag('--private-key-path'))) + const certificate = new X509Certificate(readFileSync('releases/ota-certificate.pem')) + const manifest = Buffer.from(JSON.stringify({ command, runtimeVersion: process.env.MOBILE_FAKE_VERSION })) + if (!verify('sha256', manifest, certificate.publicKey, sign('sha256', manifest, key))) { + throw new Error('The OTA signature does not match the binary certificate') + } + done( + ['ios', 'android'].map((platform) => ({ + id: 'a'.repeat(36), + group: (command === 'update' ? 'b' : 'c').repeat(36), + platform, + ...(command === 'update' + ? { runtimeVersion: process.env.MOBILE_FAKE_VERSION } + : { runtime: { id: 'runtime-example', version: process.env.MOBILE_FAKE_VERSION } }), + })) + ) + } else { + throw new Error(`Unexpected EAS command ${command}`) + } +} else if (command === 'release') { + const [operation, tag] = args + if (operation === 'create') { + if (state.releases[tag]) { + throw new Error('Duplicate release claim') + } + state.releases[tag] = { draft: true, body: readFileSync(flag('--notes-file'), 'utf8') } + } else if (operation === 'edit') { + state.releases[tag].body = readFileSync(flag('--notes-file'), 'utf8') + if (args.includes('--draft=false')) { + state.releases[tag].draft = false + } + } else { + throw new Error(`Unexpected release operation ${operation}`) + } + done() +} else if (command === 'api') { + const path = args[0].replace('repos/PostHog/posthog/', '') + const method = flag('--method') + const data = args.includes('--input') ? JSON.parse(readFileSync(0, 'utf8')) : null + if (method === 'GET') { + let value + if (path.startsWith('releases/tags/')) { + value = state.releases[decodeURIComponent(path.slice('releases/tags/'.length))] + } else if (path.startsWith('git/ref/')) { + value = state.pointer ? { object: { type: 'tag', sha: state.pointer } } : null + } else if (path.startsWith('git/tags/')) { + value = state.tags[path.slice('git/tags/'.length)] + } else if (path.startsWith('compare/')) { + value = { status: 'ahead' } + } else { + throw new Error(`Unexpected API read ${path}`) + } + if (!value) { + writeFileSync(process.env.MOBILE_FAKE_STATE, JSON.stringify(state)) + console.error('HTTP 404') + process.exit(1) + } + done(value) + } else if (path === 'deployments') { + const id = state.deployments.length + 1 + state.deployments.push({ ...data, id }) + done({ id }) + } else if (path.startsWith('deployments/')) { + state.deploymentStatuses.push(data.state) + done({ id: 1 }) + } else if (path === 'git/tags') { + const sha = String(Object.keys(state.tags).length + 1).repeat(40) + state.tags[sha] = { message: data.message, object: { sha: data.object } } + done({ sha }) + } else if (path.startsWith('git/refs')) { + state.pointer = data.sha + done({}) + } else { + throw new Error(`Unexpected API mutation ${path}`) + } +} else { + throw new Error(`Unexpected tool ${tool} command ${command}`) +} diff --git a/products/mobile/scripts/releases/hosted.test.mjs b/products/mobile/scripts/releases/hosted.test.mjs new file mode 100644 index 000000000000..059b76964378 --- /dev/null +++ b/products/mobile/scripts/releases/hosted.test.mjs @@ -0,0 +1,312 @@ +import assert from 'node:assert/strict' +import { execFileSync, spawnSync } from 'node:child_process' +import { generateKeyPairSync } from 'node:crypto' +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const scriptRoot = dirname(fileURLToPath(import.meta.url)) +const require = createRequire(import.meta.url) +const { privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, +}) + +function fixture(t) { + const temporary = mkdtempSync(join(tmpdir(), 'mobile-hosted-test-')) + t.after(() => rmSync(temporary, { recursive: true, force: true })) + const root = join(temporary, 'repo') + const app = join(root, 'products/mobile') + const scripts = join(app, 'scripts/releases') + const requests = join(app, 'releases/requests') + const bin = join(temporary, 'bin') + const stateFile = join(temporary, 'state.json') + const preload = join(temporary, 'expo-api.mjs') + const keyPath = join(temporary, 'private-key.pem') + writeFileSync(keyPath, privateKey, { mode: 0o600 }) + const certificate = execFileSync( + 'openssl', + ['req', '-new', '-x509', '-key', keyPath, '-subj', '/CN=Invented mobile release test', '-days', '1'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ) + writeFileSync( + preload, + `import {readFileSync,writeFileSync} from 'node:fs';globalThis.fetch=async()=>{const state=JSON.parse(readFileSync(process.env.MOBILE_FAKE_STATE,'utf8'));state.expoReads=(state.expoReads??0)+1;writeFileSync(process.env.MOBILE_FAKE_STATE,JSON.stringify(state));const variables=process.env.MOBILE_FAKE_SECRET==='1'?[{name:'APPLE_TEAM_ID',type:'STRING',visibility:'SECRET',value:null}]:process.env.MOBILE_FAKE_UNSUPPORTED==='1'?[{name:'UNSUPPORTED_BUILD_INPUT',type:'STRING',visibility:'PUBLIC',value:'example'}]:process.env.MOBILE_FAKE_FILE==='unreadable'?[{name:'GOOGLE_SERVICES_JSON',type:'FILE_BASE64',visibility:'SENSITIVE'}]:process.env.MOBILE_FAKE_CHANGE==='remove'&&state.expoReads===1?[{name:'EXPO_PUBLIC_POSTHOG_HOST',type:'STRING',visibility:'SENSITIVE',value:'on'}]:[];return {ok:true,json:async()=>({data:{app:{byId:{environmentVariablesIncludingSensitive:variables,ownerAccount:{environmentVariablesIncludingSensitive:[]}}}}})}}` + ) + for (const path of [scripts, requests, bin]) { + mkdirSync(path, { recursive: true }) + } + for (const name of ['release.mjs', 'policy.mjs']) { + copyFileSync(join(scriptRoot, name), join(scripts, name)) + } + copyFileSync(join(scriptRoot, '../eas-build.sh'), join(app, 'scripts/eas-build.sh')) + for (const tool of ['gh', 'eas']) { + const source = readFileSync(join(scriptRoot, 'fixtures/hosted-cli.mjs'), 'utf8') + writeFileSync(join(bin, tool), source, { mode: 0o755 }) + } + const expo = join(app, 'node_modules/expo') + for (const name of ['@expo/config', '@expo/fingerprint']) { + mkdirSync(join(expo, 'node_modules', name), { recursive: true }) + } + writeFileSync(join(expo, 'package.json'), '{"name":"expo","version":"57.0.0"}') + writeFileSync( + join(expo, 'node_modules/@expo/config/index.js'), + `exports.getConfig=()=>({exp:{version:process.env.MOBILE_FAKE_VERSION,runtimeVersion:{policy:'appVersion'},updates:process.env.MOBILE_FAKE_SIGNING==='disabled'?{enabled:false}:{codeSigningCertificate:'./releases/ota-certificate.pem',codeSigningMetadata:{keyid:'main',alg:'rsa-v1_5-sha256'}},extra:{eas:{projectId:'example-project'}},ios:{bundleIdentifier:'com.example.ios'},android:{package:'com.example.android'}}})` + ) + writeFileSync( + join(expo, 'node_modules/@expo/fingerprint/index.js'), + `exports.createFingerprintAsync=async(_root,options)=>({hash:options.platforms[0]+'-native-hash'})` + ) + symlinkSync(dirname(require.resolve('typescript/package.json')), join(app, 'node_modules/typescript')) + writeFileSync(join(app, 'releases/ota-certificate.pem'), certificate) + writeFileSync(join(app, '.gitignore'), 'node_modules/\n') + writeFileSync(join(app, 'package.json'), '{"name":"mobile-test","private":true}') + writeFileSync(join(app, 'app.config.ts'), `export default {version:'1.3.0'}`) + writeFileSync( + join(app, 'releases/config.json'), + JSON.stringify({ initialRuntimeVersion: '1.3.0', projectId: 'example-project' }) + ) + writeFileSync( + join(app, 'eas.json'), + JSON.stringify({ + build: { + production: { + channel: 'production', + environment: 'production', + env: { APP_VARIANT: 'production', EXPO_PUBLIC_SIGNUP_LINK: '0' }, + }, + }, + submit: { production: { android: { track: 'internal' } } }, + }) + ) + writeFileSync( + stateFile, + JSON.stringify({ + releases: {}, + tags: {}, + pointer: null, + deployments: [], + deploymentStatuses: [], + commands: [], + submissions: [], + }) + ) + const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: 'pipe' }).trim() + git('init') + git('config', 'user.name', 'Example test') + git('config', 'user.email', 'test@example.com') + git('config', 'commit.gpgsign', 'false') + const commit = () => { + git('add', '.') + git('commit', '-m', 'test fixture') + return git('rev-parse', 'HEAD') + } + commit() + let before + let head + const addRequest = (id, kind, previousRelease, version = '1.4.0') => { + before = git('rev-parse', 'HEAD') + writeFileSync( + join(requests, `${id}.json`), + JSON.stringify({ + schemaVersion: 1, + id, + kind, + runtimeVersion: version, + previousRelease, + notes: 'Invented test release.', + }) + ) + writeFileSync(join(app, 'app.config.ts'), `export default {version:'${version}'}`) + head = commit() + writeFileSync(join(temporary, 'event.json'), JSON.stringify({ before, after: head })) + } + const invoke = (command, options = {}) => + spawnSync(process.execPath, [join(scripts, 'release.mjs'), ...command], { + cwd: app, + encoding: 'utf8', + env: { + ...process.env, + NODE_OPTIONS: `--import=${preload}`, + PATH: `${bin}:${process.env.PATH}`, + MOBILE_FAKE_STATE: stateFile, + MOBILE_FAKE_VERSION: '1.4.0', + GITHUB_ACTIONS: 'true', + GITHUB_EVENT_NAME: 'push', + GITHUB_REPOSITORY: 'PostHog/posthog', + GITHUB_REF: 'refs/heads/master', + GITHUB_SHA: head, + GITHUB_EVENT_PATH: join(temporary, 'event.json'), + RUNNER_TEMP: temporary, + GITHUB_STEP_SUMMARY: join(temporary, 'summary.md'), + GITHUB_OUTPUT: join(temporary, 'output'), + MOBILE_RELEASE_RUN_URL: 'https://example.com/run', + MOBILE_RELEASE_BASE: '', + MOBILE_OTA_SIGNING_KEY_PATH: keyPath, + ...options, + }, + }) + return { + root, + app, + temporary, + keyPath, + stateFile, + addRequest, + invoke, + git, + state: () => JSON.parse(readFileSync(stateFile, 'utf8')), + before: () => before, + head: () => head, + } +} + +test('real CLI stages exact native builds, submits their IDs, then promotes an OTA group and deduplicates reruns', (t) => { + const f = fixture(t) + f.addRequest('native-140', 'native', null) + let outcome = f.invoke(['publish']) + assert.equal(outcome.status, 0, outcome.stderr) + let state = f.state() + assert.deepEqual(state.submissions, ['1'.repeat(36), '2'.repeat(36)]) + assert.equal(state.releases['mobile-release/native-140'].draft, false) + assert.equal(JSON.parse(state.releases['mobile-release/native-140'].body).state, 'complete') + assert.deepEqual(state.deploymentStatuses, ['in_progress', 'success']) + assert.ok(state.commands.filter(({ tool }) => tool === 'eas').every(({ args }) => !args.includes('--latest'))) + f.addRequest('chart-fix', 'ota', 'native-140') + outcome = f.invoke(['publish']) + assert.equal(outcome.status, 0, outcome.stderr) + state = f.state() + const ota = JSON.parse(state.releases['mobile-release/chart-fix'].body) + assert.equal(ota.staged.group, 'b'.repeat(36)) + assert.equal(ota.result.productionGroup, 'c'.repeat(36)) + assert.equal(ota.native.runtimeVersion, '1.4.0') + const promote = state.commands.find(({ command }) => command === 'update:republish') + assert.ok(promote.args.includes('b'.repeat(36))) + assert.ok(promote.args.includes('--destination-channel')) + const updates = state.commands.filter(({ command }) => ['update', 'update:republish'].includes(command)) + assert.equal(updates.length, 2) + for (const { args } of updates) { + assert.equal(args[args.indexOf('--private-key-path') + 1], f.keyPath) + } + const effectsBefore = state.commands.filter(({ tool }) => tool === 'eas').length + outcome = f.invoke(['publish']) + assert.equal(outcome.status, 0, outcome.stderr) + assert.equal(f.state().commands.filter(({ tool }) => tool === 'eas').length, effectsBefore) +}) + +test('lost submission response retains staged IDs and rerunning the workflow cannot duplicate submissions', (t) => { + const f = fixture(t) + f.addRequest('native-140', 'native', null) + const first = f.invoke(['publish'], { MOBILE_FAKE_FAIL: 'submit' }) + assert.notEqual(first.status, 0) + const receipt = JSON.parse(f.state().releases['mobile-release/native-140'].body) + assert.equal(receipt.state, 'claimed') + assert.equal(receipt.staged.builds.length, 2) + assert.equal(f.state().pointer, null) + const retry = f.invoke(['publish']) + assert.notEqual(retry.status, 0) + assert.match(retry.stderr, /already claimed/) + assert.equal(f.state().submissions.length, 1) + f.addRequest('native-150', 'native', null, '1.5.0') + const bypass = f.invoke(['publish'], { MOBILE_FAKE_VERSION: '1.5.0' }) + assert.notEqual(bypass.status, 0) + assert.match(bypass.stderr, /sharing this predecessor/) + assert.equal(f.state().submissions.length, 1) + assert.equal(f.state().releases['mobile-release/native-150'], undefined) +}) + +test('plan uses actual git additions and PR validation rejects unchanged native versions before claiming', (t) => { + const f = fixture(t) + f.addRequest('native-140', 'native', null, '1.3.0') + const planned = f.invoke(['plan', '--base', f.before(), '--head', f.head()]) + assert.equal(planned.status, 0, planned.stderr) + assert.match(readFileSync(join(f.temporary, 'output'), 'utf8'), /present=true/) + const checked = f.invoke(['check'], { MOBILE_RELEASE_BASE: f.before(), MOBILE_FAKE_VERSION: '1.3.0' }) + assert.notEqual(checked.status, 0) + assert.match(checked.stderr, /must increase/) + assert.deepEqual(f.state().commands, []) + const wrongVersion = f.invoke(['check'], { MOBILE_RELEASE_BASE: f.before(), MOBILE_FAKE_VERSION: '1.5.0' }) + assert.notEqual(wrongVersion.status, 0) + assert.match(wrongVersion.stderr, /match the current app version/) + assert.notEqual(f.invoke(['publish'], { GITHUB_EVENT_NAME: 'pull_request' }).status, 0) + assert.deepEqual(f.state().commands, []) + f.addRequest('native-corrected', 'native', null) + const corrected = f.invoke(['publish']) + assert.equal(corrected.status, 0, corrected.stderr) + assert.equal(f.state().releases['mobile-release/native-140'], undefined) + assert.equal(f.state().releases['mobile-release/native-corrected'].draft, false) +}) + +test('unreadable native environment inputs stop before claim and mismatched built fingerprints never submit', (t) => { + const f = fixture(t) + f.addRequest('native-140', 'native', null) + for (const options of [{ MOBILE_FAKE_SECRET: '1' }, { MOBILE_FAKE_FILE: 'unreadable' }]) { + const hidden = f.invoke(['publish'], options) + assert.notEqual(hidden.status, 0) + assert.match(hidden.stderr, /secret EAS environment inputs|native input is unreadable/) + assert.deepEqual(f.state().releases, {}) + assert.equal(f.state().commands.filter(({ tool }) => tool === 'eas').length, 0) + } + const unsupported = f.invoke(['publish'], { MOBILE_FAKE_UNSUPPORTED: '1' }) + assert.notEqual(unsupported.status, 0) + assert.match(unsupported.stderr, /unsupported app build input/) + assert.deepEqual(f.state().releases, {}) + assert.equal(f.state().commands.filter(({ tool }) => tool === 'eas').length, 0) + const mismatch = f.invoke(['publish'], { MOBILE_FAKE_FAIL: 'fingerprint' }) + assert.notEqual(mismatch.status, 0) + assert.match(mismatch.stderr, /build fingerprints differ/) + assert.deepEqual(f.state().submissions, []) + assert.deepEqual(f.state().deploymentStatuses, ['in_progress', 'failure']) +}) + +test('removing a build-time variable during staging cannot inherit a stale value and publish', (t) => { + const f = fixture(t) + f.addRequest('native-140', 'native', null) + const outcome = f.invoke(['publish'], { MOBILE_FAKE_CHANGE: 'remove' }) + assert.notEqual(outcome.status, 0) + assert.match(outcome.stderr, /names, types or contents changed/) + assert.deepEqual(f.state().submissions, []) + assert.equal(f.state().pointer, null) +}) + +test('signing configuration and a matching private key are required before a release is claimed', (t) => { + const f = fixture(t) + f.addRequest('native-140', 'native', null) + const unsigned = f.invoke(['publish'], { MOBILE_FAKE_SIGNING: 'disabled' }) + assert.notEqual(unsigned.status, 0) + assert.match(unsigned.stderr, /pinned OTA signing certificate/) + assert.deepEqual(f.state().releases, {}) + assert.equal(f.invoke(['publish']).status, 0) + f.addRequest('chart-fix', 'ota', 'native-140') + const mismatchedKey = join(f.temporary, 'mismatched-key.pem') + writeFileSync( + mismatchedKey, + generateKeyPairSync('rsa', { + modulusLength: 2048, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + }).privateKey + ) + for (const path of ['', mismatchedKey]) { + const outcome = f.invoke(['publish'], { MOBILE_OTA_SIGNING_KEY_PATH: path }) + assert.notEqual(outcome.status, 0) + assert.match(outcome.stderr, /private key matching the pinned certificate/) + assert.equal(f.state().releases['mobile-release/chart-fix'], undefined) + assert.equal(f.state().commands.filter(({ command }) => command.startsWith('update')).length, 0) + } + for (const fingerprint of [undefined, 'different-certificate']) { + const state = f.state() + const receipt = JSON.parse(state.releases['mobile-release/native-140'].body) + receipt.native.certificateFingerprint = fingerprint + state.releases['mobile-release/native-140'].body = JSON.stringify(receipt) + writeFileSync(f.stateFile, JSON.stringify(state)) + const outcome = f.invoke(['publish']) + assert.notEqual(outcome.status, 0) + assert.match(outcome.stderr, /signing certificate changed; request a native release/) + assert.equal(f.state().releases['mobile-release/chart-fix'], undefined) + } +}) diff --git a/products/mobile/scripts/releases/policy.mjs b/products/mobile/scripts/releases/policy.mjs new file mode 100644 index 000000000000..e06d47f23c73 --- /dev/null +++ b/products/mobile/scripts/releases/policy.mjs @@ -0,0 +1,212 @@ +const REQUEST_PATH = /^products\/mobile\/releases\/requests\/([a-z0-9][a-z0-9-]{0,63})\.json$/ +const ID = /^[a-z0-9][a-z0-9-]{0,63}$/ +const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ + +function assert(condition, message) { + if (!condition) { + throw new Error(message) + } +} + +export function validateRequest(request, filename) { + assert(request && typeof request === 'object' && !Array.isArray(request), 'Request must be a JSON object') + const fields = ['schemaVersion', 'id', 'kind', 'runtimeVersion', 'previousRelease', 'notes'] + assert( + Object.keys(request).length === fields.length && fields.every((key) => Object.hasOwn(request, key)), + 'Unexpected request fields' + ) + assert(request.schemaVersion === 1, 'Unsupported release request schema') + assert(typeof request.id === 'string' && ID.test(request.id), 'Invalid release id') + assert(!filename || filename === `${request.id}.json`, 'Request filename must match its id') + assert(['native', 'ota'].includes(request.kind), 'Release kind must be native or ota') + assert( + typeof request.runtimeVersion === 'string' && VERSION.test(request.runtimeVersion), + 'Runtime must be a stable x.y.z version' + ) + assert( + request.runtimeVersion.split('.').every((part) => Number.isSafeInteger(Number(part))), + 'Runtime version is too large' + ) + assert( + request.previousRelease === null || + (typeof request.previousRelease === 'string' && ID.test(request.previousRelease)), + 'Invalid previous release id' + ) + assert(request.previousRelease !== request.id, 'A release cannot follow itself') + assert( + typeof request.notes === 'string' && request.notes.trim().length > 0 && request.notes.length <= 4000, + 'Release notes must contain 1–4000 characters' + ) + return request +} + +export function selectRequest(changes) { + const requests = changes.filter(({ path }) => path.startsWith('products/mobile/releases/requests/')) + assert( + requests.every(({ path, status }) => REQUEST_PATH.test(path) && status === 'A'), + 'Release requests are append-only JSON files; do not edit, rename, or remove an existing request' + ) + assert( + requests.length <= 1, + 'Only one release request may be added in a push; split the releases into separate merges' + ) + return requests[0]?.path ?? null +} + +export function compareVersions(left, right) { + const a = left.split('.').map(Number) + const b = right.split('.').map(Number) + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) { + return Math.sign(a[i] - b[i]) + } + } + return 0 +} + +export function validatePlan({ request, previous, appVersion, fingerprints, initialRuntimeVersion, isAncestor }) { + validateRequest(request) + assert(request.runtimeVersion === appVersion, 'Request runtime must equal version in app.config.ts') + assert( + request.previousRelease === (previous?.request.id ?? null), + 'Previous release has not completed or production advanced; rerun the missing predecessor before this request' + ) + if (previous) { + assert(previous.state === 'complete', 'Previous release is not complete') + assert(isAncestor(previous.commit), 'Release source must descend from the last completed release') + } + if (request.kind === 'native') { + assert( + compareVersions(appVersion, previous?.native.runtimeVersion ?? initialRuntimeVersion) > 0, + 'A native release must increase version in app.config.ts; build-number increments alone do not change the OTA runtime' + ) + } else { + assert( + previous?.native, + 'The first hosted release must be native to establish a verified compatibility baseline' + ) + assert( + previous.native.runtimeVersion === appVersion, + 'OTA runtime does not match the last successful native release' + ) + assert( + ['ios', 'android'].every( + (platform) => + fingerprints[platform] && fingerprints[platform] === previous.native.fingerprints[platform] + ), + 'Native fingerprint changed; request a native release with a higher app version' + ) + } +} + +export function validateBuilds(builds, runtimeVersion, appIdentifiers) { + assert(Array.isArray(builds) && builds.length === 2, 'Expected one completed EAS build for each platform') + assert( + new Set(builds.map(({ platform }) => platform.toLowerCase())).size === 2, + 'Expected distinct iOS and Android builds' + ) + return builds.map((build) => { + const platform = build.platform.toLowerCase() + assert(['ios', 'android'].includes(platform) && /^[a-f0-9-]{36}$/.test(build.id), 'Invalid EAS build identity') + assert( + build.status === 'FINISHED' && build.distribution === 'STORE', + 'Native build did not finish with store distribution' + ) + assert( + build.appVersion === runtimeVersion && build.runtime?.version === runtimeVersion, + 'EAS binary version/runtime differs from the validated request' + ) + assert( + build.buildProfile === 'production' && + build.updateChannel?.name === 'production' && + build.isForIosSimulator === false, + 'Expected a production device binary on the production channel' + ) + assert( + !appIdentifiers || build.appIdentifier === appIdentifiers[platform], + 'EAS binary app identity differs from the production config' + ) + return { id: build.id, platform, status: build.status, fingerprint: build.fingerprint?.hash ?? null } + }) +} + +export function validateUpdates(updates, runtimeVersion) { + assert(Array.isArray(updates) && updates.length === 2, 'Expected an OTA update for both platforms') + assert(new Set(updates.map(({ platform }) => platform)).size === 2, 'Expected distinct iOS and Android updates') + const group = updates[0]?.group + assert(typeof group === 'string' && /^[a-f0-9-]{36}$/.test(group), 'Missing EAS update group') + assert( + updates.every( + (update) => + ['ios', 'android'].includes(update.platform) && + update.group === group && + update.runtimeVersion === runtimeVersion + ), + 'OTA platforms, group or runtime differ from the request' + ) + return group +} + +export function validateRepublishedUpdates(updates, runtimeVersion) { + assert(Array.isArray(updates), 'Expected raw EAS republished updates') + return validateUpdates( + updates.map(({ platform, group, runtime }) => ({ platform, group, runtimeVersion: runtime?.version })), + runtimeVersion + ) +} + +export function validateNativeEnvironment(variables) { + assert(Array.isArray(variables), 'Cannot inspect the EAS production environment') + const allowed = new Set([ + 'APP_VARIANT', + 'APPLE_TEAM_ID', + 'GOOGLE_SERVICES_JSON', + 'EXPO_PUBLIC_POSTHOG_KEY', + 'EXPO_PUBLIC_POSTHOG_HOST', + 'EXPO_PUBLIC_DEMO_MODE', + 'EXPO_PUBLIC_SIGNUP_LINK', + 'EXPO_PUBLIC_OAUTH_US_CLIENT_ID', + 'EXPO_PUBLIC_OAUTH_EU_CLIENT_ID', + ]) + for (const variable of variables) { + assert(allowed.has(variable.name), 'EAS environment contains an unsupported app build input') + assert( + variable.visibility !== 'SECRET', + 'Hosted release cannot verify secret EAS environment inputs; use readable Sensitive values for native configuration and keep signing keys in EAS credentials' + ) + assert( + variable.type === 'STRING' || (variable.type === 'FILE_BASE64' && variable.name === 'GOOGLE_SERVICES_JSON'), + 'Unsupported native file input; only the materialized GOOGLE_SERVICES_JSON file is supported' + ) + assert( + typeof (variable.type === 'FILE_BASE64' ? variable.valueWithFileContent : variable.value) === 'string', + 'An EAS native input is unreadable; do not establish a partial fingerprint baseline' + ) + } + return variables +} + +export function validateNativeBuildFingerprints(builds, fingerprints) { + assert( + builds.every(({ platform, fingerprint }) => fingerprint && fingerprint === fingerprints[platform]), + 'Completed EAS build fingerprints differ from the validated native inputs; reconcile the EAS environment before submitting' + ) +} + +export async function publishOnce({ existing, commit, request, claim, stage, publish, complete }) { + if (existing) { + assert( + existing.commit === commit && JSON.stringify(existing.request) === JSON.stringify(request), + 'Release id is already bound to different source or metadata' + ) + assert( + existing.state === 'complete', + 'This request was already claimed. Reconcile its EAS results and receipt before retrying; automatic retries could publish twice' + ) + return existing + } + await claim() + const staged = await stage() + const result = await publish(staged) + return complete(staged, result) +} diff --git a/products/mobile/scripts/releases/policy.test.mjs b/products/mobile/scripts/releases/policy.test.mjs new file mode 100644 index 000000000000..d9cecde82092 --- /dev/null +++ b/products/mobile/scripts/releases/policy.test.mjs @@ -0,0 +1,246 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + publishOnce, + selectRequest, + validateBuilds, + validateNativeBuildFingerprints, + validateNativeEnvironment, + validatePlan, + validateRepublishedUpdates, + validateRequest, + validateUpdates, +} from './policy.mjs' + +const request = { + schemaVersion: 1, + id: 'chart-fix', + kind: 'ota', + runtimeVersion: '1.4.0', + previousRelease: 'native-140', + notes: 'Fix the chart legend.', +} +const fingerprints = { ios: 'ios-native-hash', android: 'android-native-hash' } +const previous = { + state: 'complete', + request: { ...request, id: 'native-140', kind: 'native', previousRelease: null }, + commit: 'a'.repeat(40), + native: { commit: 'a'.repeat(40), runtimeVersion: '1.4.0', fingerprints }, +} +const plan = { + request, + previous, + appVersion: '1.4.0', + fingerprints, + initialRuntimeVersion: '1.3.0', + isAncestor: () => true, +} + +test('only the newly added request is selected; old requests do not get swept into a release', () => { + assert.equal(selectRequest([{ path: 'products/mobile/src/chart.ts', status: 'M' }]), null) + assert.equal( + selectRequest([{ path: 'products/mobile/releases/requests/chart-fix.json', status: 'A' }]), + 'products/mobile/releases/requests/chart-fix.json' + ) + for (const status of ['M', 'D', 'R100']) { + assert.throws( + () => selectRequest([{ path: 'products/mobile/releases/requests/chart-fix.json', status }]), + /append-only/ + ) + } + assert.throws( + () => + selectRequest( + ['one', 'two'].map((id) => ({ path: `products/mobile/releases/requests/${id}.json`, status: 'A' })) + ), + /Only one/ + ) +}) + +test('malformed or ambiguous release metadata fails before any publication', () => { + for (const invalid of [ + { ...request, id: '../production' }, + { ...request, runtimeVersion: '1.4.0-beta.1' }, + { ...request, kind: 'store-public' }, + { ...request, previousRelease: request.id }, + { ...request, channel: 'production' }, + { ...request, notes: '' }, + ]) { + assert.throws(() => validateRequest(invalid)) + } + assert.throws(() => validateRequest(request, 'different.json'), /filename/) + assert.deepEqual(validateRequest(request, 'chart-fix.json'), request) +}) + +test('OTA requires the established runtime and matching native inputs on both platforms', () => { + assert.doesNotThrow(() => validatePlan(plan)) + for (const platform of ['ios', 'android']) { + assert.throws( + () => validatePlan({ ...plan, fingerprints: { ...fingerprints, [platform]: 'new-module' } }), + /fingerprint/ + ) + } + assert.throws(() => validatePlan({ ...plan, appVersion: '1.5.0' }), /Request runtime/) + assert.throws( + () => + validatePlan({ + ...plan, + previous: { ...previous, native: { ...previous.native, runtimeVersion: '1.3.0' } }, + }), + /OTA runtime/ + ) + assert.throws( + () => validatePlan({ ...plan, request: { ...request, previousRelease: null }, previous: null }), + /first hosted/ + ) +}) + +test('native releases must increase the public version, including the bootstrap release', () => { + const native = { ...request, kind: 'native', runtimeVersion: '1.5.0' } + assert.doesNotThrow(() => validatePlan({ ...plan, request: native, appVersion: '1.5.0' })) + for (const version of ['1.4.0', '1.3.9']) { + assert.throws( + () => validatePlan({ ...plan, request: { ...native, runtimeVersion: version }, appVersion: version }), + /increase version/ + ) + } + assert.throws( + () => + validatePlan({ + ...plan, + request: { ...native, previousRelease: null, runtimeVersion: '1.3.0' }, + appVersion: '1.3.0', + previous: null, + }), + /increase version/ + ) + assert.doesNotThrow(() => + validatePlan({ ...plan, request: { ...native, previousRelease: null }, appVersion: '1.5.0', previous: null }) + ) +}) + +test('out-of-order, skipped, cancelled and unrelated predecessors never publish', () => { + assert.throws( + () => validatePlan({ ...plan, request: { ...request, previousRelease: 'queued-but-not-released' } }), + /Previous release/ + ) + assert.throws(() => validatePlan({ ...plan, previous: { ...previous, state: 'claimed' } }), /not complete/) + assert.throws(() => validatePlan({ ...plan, isAncestor: () => false }), /descend/) +}) + +test('native and OTA responses must include both correct platform runtimes', () => { + const builds = ['IOS', 'ANDROID'].map((platform, i) => ({ + id: `${i}`.repeat(36), + platform, + status: 'FINISHED', + distribution: 'STORE', + appVersion: '1.4.0', + runtime: { version: '1.4.0' }, + buildProfile: 'production', + updateChannel: { name: 'production' }, + isForIosSimulator: false, + })) + const updates = ['ios', 'android'].map((platform) => ({ platform, group: 'b'.repeat(36), runtimeVersion: '1.4.0' })) + assert.equal(validateBuilds(builds, '1.4.0').length, 2) + assert.equal(validateUpdates(updates, '1.4.0'), 'b'.repeat(36)) + const republished = updates.map(({ platform, group, runtimeVersion }) => ({ + platform, + group, + runtime: { id: 'runtime-example', version: runtimeVersion }, + })) + assert.equal(validateRepublishedUpdates(republished, '1.4.0'), 'b'.repeat(36)) + assert.throws(() => validateRepublishedUpdates(republished, '1.3.0'), /runtime differ/) + assert.throws(() => validateRepublishedUpdates(updates, '1.4.0'), /runtime differ/) + for (const broken of [ + builds.slice(0, 1), + [builds[0], builds[0]], + builds.map((build) => ({ ...build, status: 'ERRORED' })), + builds.map((build) => ({ ...build, runtime: { version: '1.3.0' } })), + ]) { + assert.throws(() => validateBuilds(broken, '1.4.0')) + } + for (const broken of [ + updates.slice(0, 1), + [updates[0], updates[0]], + updates.map((update) => ({ ...update, runtimeVersion: '1.3.0' })), + ]) { + assert.throws(() => validateUpdates(broken, '1.4.0')) + } +}) + +test('a completed rerun returns its receipt without repeating external effects', async () => { + const existing = { state: 'complete', commit: 'c'.repeat(40), request } + let effects = 0 + const effect = () => { + effects++ + } + assert.equal( + await publishOnce({ + existing, + commit: existing.commit, + request, + claim: effect, + stage: effect, + publish: effect, + complete: effect, + }), + existing + ) + assert.equal(effects, 0) + await assert.rejects(() => publishOnce({ existing, commit: 'd'.repeat(40), request }), /different source/) +}) + +test('secret or unmaterialized native files cannot establish a compatibility baseline', () => { + for (const variable of [ + { name: 'GOOGLE_SERVICES_JSON', type: 'FILE_BASE64', visibility: 'SECRET', valueWithFileContent: null }, + { name: 'GOOGLE_SERVICES_JSON', type: 'FILE_BASE64', visibility: 'SENSITIVE' }, + { name: 'OTHER_NATIVE_FILE', type: 'FILE_BASE64', visibility: 'SENSITIVE', valueWithFileContent: 'e30=' }, + { name: 'NATIVE_SWITCH', type: 'STRING', visibility: 'SECRET', value: null }, + { name: 'GITHUB_SHA', type: 'STRING', visibility: 'PUBLIC', value: 'fake' }, + { name: 'GH_TOKEN', type: 'STRING', visibility: 'SENSITIVE', value: 'fake' }, + { name: 'GIT_CONFIG_COUNT', type: 'STRING', visibility: 'PUBLIC', value: '0' }, + { name: 'EXPO_PUBLIC_UNSUPPORTED', type: 'STRING', visibility: 'PUBLIC', value: 'example' }, + { name: 'NODE_OPTIONS', type: 'STRING', visibility: 'PUBLIC', value: '--import=example.mjs' }, + ]) { + assert.throws(() => validateNativeEnvironment([variable])) + } + assert.doesNotThrow(() => + validateNativeEnvironment([ + { name: 'APPLE_TEAM_ID', type: 'STRING', visibility: 'PUBLIC', value: 'EXAMPLETEAM' }, + { + name: 'GOOGLE_SERVICES_JSON', + type: 'FILE_BASE64', + visibility: 'SENSITIVE', + valueWithFileContent: 'e30=', + }, + ]) + ) + assert.throws( + () => validateNativeBuildFingerprints([{ platform: 'ios', fingerprint: null }], fingerprints), + /build fingerprints differ/ + ) +}) + +test('an interrupted publish keeps its claim and a retry cannot submit twice', async () => { + let persisted + let submissions = 0 + const callbacks = { + commit: 'c'.repeat(40), + request, + claim: () => { + persisted = { state: 'claimed', commit: 'c'.repeat(40), request } + }, + stage: () => ({ builds: ['example'] }), + publish: () => { + submissions++ + throw new Error('connection lost after submission') + }, + complete: () => { + throw new Error('must not complete') + }, + } + await assert.rejects(() => publishOnce({ ...callbacks, existing: null }), /connection lost/) + await assert.rejects(() => publishOnce({ ...callbacks, existing: persisted }), /already claimed/) + assert.equal(submissions, 1) +}) diff --git a/products/mobile/scripts/releases/release.mjs b/products/mobile/scripts/releases/release.mjs new file mode 100644 index 000000000000..15c79a96d4cf --- /dev/null +++ b/products/mobile/scripts/releases/release.mjs @@ -0,0 +1,655 @@ +import { execFileSync } from 'node:child_process' +import { createHash, createPrivateKey, X509Certificate } from 'node:crypto' +import { appendFileSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { basename, dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' + +import { + compareVersions, + publishOnce, + selectRequest, + validateBuilds, + validateNativeBuildFingerprints, + validateNativeEnvironment, + validatePlan, + validateRepublishedUpdates, + validateRequest, + validateUpdates, +} from './policy.mjs' + +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const repoRoot = resolve(appRoot, '../..') +const requestRoot = join(appRoot, 'releases/requests') +const config = JSON.parse(readFileSync(join(appRoot, 'releases/config.json'), 'utf8')) +const repo = 'PostHog/posthog' +const pointerRef = 'tags/mobile-production' + +function run(command, args, options = {}) { + return ( + execFileSync(command, args, { + cwd: appRoot, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'inherit'], + ...options, + }) ?? '' + ).trim() +} + +function git(...args) { + return run('git', args, { cwd: repoRoot }) +} + +function api(path, data, method = data ? 'POST' : 'GET') { + const args = ['api', `repos/${repo}/${path}`, '--method', method] + if (data) { + args.push('--input', '-') + } + return JSON.parse(run('gh', args, { input: data ? JSON.stringify(data) : undefined, stdio: 'pipe' }) || 'null') +} + +function optionalApi(path) { + try { + return api(path) + } catch (error) { + if (String(error.stderr).includes('HTTP 404')) { + return null + } + throw new Error(`Cannot read GitHub release state: ${path}`) + } +} + +function readRequest(path) { + return validateRequest(JSON.parse(readFileSync(path, 'utf8')), basename(path)) +} + +function requestFiles() { + try { + return readdirSync(requestRoot).filter((name) => name.endsWith('.json')) + } catch (error) { + if (error.code === 'ENOENT') { + return [] + } + throw error + } +} + +function checkRequests() { + const requests = requestFiles().map((name) => readRequest(join(requestRoot, name))) + const ids = new Set(requests.map(({ id }) => id)) + for (const request of requests) { + if (request.previousRelease !== null && !ids.has(request.previousRelease)) { + throw new Error(`Missing predecessor request ${request.previousRelease}`) + } + const visited = new Set([request.id]) + let previous = request.previousRelease + while (previous !== null) { + if (visited.has(previous)) { + throw new Error('Release request chain contains a cycle') + } + visited.add(previous) + previous = requests.find(({ id }) => id === previous).previousRelease + } + } + if (process.env.MOBILE_RELEASE_BASE) { + const path = plan(process.env.MOBILE_RELEASE_BASE, git('rev-parse', 'HEAD')) + if (path) { + const request = readRequest(join(repoRoot, path)) + const { exp } = expoConfig() + if (request.runtimeVersion !== exp.version || exp.runtimeVersion?.policy !== 'appVersion') { + throw new Error('Release request must match the current app version and appVersion runtime policy') + } + const require = createRequire(join(appRoot, 'package.json')) + const ts = require('typescript') + const source = git('show', `${process.env.MOBILE_RELEASE_BASE}:products/mobile/app.config.ts`) + const versions = [] + const visit = (node) => { + if ( + ts.isPropertyAssignment(node) && + node.name.getText() === 'version' && + ts.isStringLiteral(node.initializer) + ) { + versions.push(node.initializer.text) + } + ts.forEachChild(node, visit) + } + visit(ts.createSourceFile('app.config.ts', source, ts.ScriptTarget.Latest, true)) + if (versions.length !== 1) { + throw new Error('The base app config must have one literal version for release comparison') + } + if ( + request.kind === 'native' ? compareVersions(exp.version, versions[0]) <= 0 : exp.version !== versions[0] + ) { + throw new Error('Native requests must increase app.config.ts version; OTA requests must retain it') + } + } + } + console.log(`Validated ${requests.length} mobile release request(s)`) +} + +function plan(base, head) { + if (!/^[a-f0-9]{40}$/.test(base ?? '') || !/^[a-f0-9]{40}$/.test(head ?? '')) { + throw new Error('Release planning requires explicit base and head commit SHAs') + } + const entries = git('diff', '--name-status', '--no-renames', base, head, '--', 'products/mobile/releases/requests') + .split('\n') + .filter(Boolean) + const path = selectRequest( + entries.map((entry) => { + const [status, changedPath] = entry.split('\t') + return { status, path: changedPath } + }) + ) + const request = path ? readRequest(join(repoRoot, path)) : null + if (process.env.GITHUB_OUTPUT) { + appendFileSync( + process.env.GITHUB_OUTPUT, + `present=${Boolean(path)}\nrequest=${path ?? ''}\nkind=${request?.kind ?? ''}\n` + ) + } + return path +} + +function expoConfig() { + process.env.APP_VARIANT = 'production' + process.env.EXPO_PUBLIC_SIGNUP_LINK = '0' + const require = createRequire(join(appRoot, 'package.json')) + const expoRequire = createRequire(require.resolve('expo/package.json')) + return { exp: expoRequire('@expo/config').getConfig(appRoot).exp, expoRequire } +} + +function productionSigning(exp, kind) { + const updates = exp.updates + if ( + updates?.enabled === false || + updates?.codeSigningCertificate !== './releases/ota-certificate.pem' || + updates?.codeSigningMetadata?.keyid !== 'main' || + updates?.codeSigningMetadata?.alg !== 'rsa-v1_5-sha256' + ) { + throw new Error('Production releases require the pinned OTA signing certificate; see RELEASING.md') + } + let certificate + try { + certificate = new X509Certificate(readFileSync(join(appRoot, updates.codeSigningCertificate))) + } catch { + throw new Error('Production OTA signing certificate is missing or invalid; see RELEASING.md') + } + if ( + certificate.publicKey.asymmetricKeyType !== 'rsa' || + certificate.publicKey.asymmetricKeyDetails.modulusLength < 2048 || + Date.now() < Date.parse(certificate.validFrom) || + Date.now() >= Date.parse(certificate.validTo) + ) { + throw new Error('Production OTA signing certificate must be valid and contain an RSA public key') + } + if (kind === 'native') { + return { privateKeyPath: null, certificateFingerprint: certificate.fingerprint256 } + } + const path = process.env.MOBILE_OTA_SIGNING_KEY_PATH + try { + if (!path || !certificate.checkPrivateKey(createPrivateKey(readFileSync(path)))) { + throw new Error('Missing or mismatched key') + } + } catch { + throw new Error('OTA release requires the private key matching the pinned certificate; see RELEASING.md') + } + return { privateKeyPath: path, certificateFingerprint: certificate.fingerprint256 } +} + +async function nativeFingerprints(expoRequire) { + const { createFingerprintAsync } = expoRequire('@expo/fingerprint') + const fingerprints = {} + for (const platform of ['ios', 'android']) { + fingerprints[platform] = ( + await createFingerprintAsync(appRoot, { + platforms: [platform], + ignorePaths: ['android/**/*', 'ios/**/*'], + silent: true, + }) + ).hash + } + return fingerprints +} + +async function loadProductionEnvironment() { + const fields = 'name type visibility value valueWithFileContent: value(includeFileContent: true)' + const response = await fetch('https://api.expo.dev/graphql', { + method: 'POST', + headers: { Authorization: `Bearer ${process.env.EXPO_TOKEN}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: `query MobileNativeEnvironment($appId: String!, $environment: EnvironmentVariableEnvironment) { app { byId(appId: $appId) { environmentVariablesIncludingSensitive(environment: $environment) { ${fields} } ownerAccount { environmentVariablesIncludingSensitive(environment: $environment) { ${fields} linkedEnvironments(appId: $appId) } } } } }`, + variables: { appId: config.projectId, environment: 'production' }, + }), + signal: AbortSignal.timeout(30000), + }) + const payload = await response.json() + const app = payload.data?.app?.byId + if ( + !response.ok || + payload.errors || + !app || + !Array.isArray(app.environmentVariablesIncludingSensitive) || + !Array.isArray(app.ownerAccount?.environmentVariablesIncludingSensitive) + ) { + throw new Error('Cannot audit the EAS production environment; no release was claimed') + } + const shared = app.ownerAccount.environmentVariablesIncludingSensitive.filter((variable) => + variable.linkedEnvironments?.includes('production') + ) + const variables = [ + ...new Map( + [...shared, ...app.environmentVariablesIncludingSensitive].map((variable) => [variable.name, variable]) + ).values(), + ] + validateNativeEnvironment(variables) + const snapshot = variables + .map((variable) => ({ + name: variable.name, + type: variable.type, + visibility: variable.visibility, + value: variable.type === 'FILE_BASE64' ? variable.valueWithFileContent : variable.value, + })) + .sort((left, right) => left.name.localeCompare(right.name)) + for (const variable of variables) { + if (variable.type === 'FILE_BASE64') { + const path = join(appRoot, 'google-services.json') + const contents = Buffer.from(variable.valueWithFileContent, 'base64') + try { + JSON.parse(contents.toString('utf8')) + } catch { + throw new Error('GOOGLE_SERVICES_JSON must contain valid JSON; file contents are withheld') + } + writeFileSync(path, contents, { mode: 0o600 }) + process.env[variable.name] = path + } else { + process.env[variable.name] = variable.value + } + } + process.env.APP_VARIANT = 'production' + process.env.EXPO_PUBLIC_SIGNUP_LINK = '0' + return createHash('sha256').update(JSON.stringify(snapshot)).digest('hex') +} + +function assertHosted() { + if ( + process.env.GITHUB_ACTIONS !== 'true' || + process.env.GITHUB_EVENT_NAME !== 'push' || + process.env.GITHUB_REF !== 'refs/heads/master' || + process.env.GITHUB_REPOSITORY !== repo + ) { + throw new Error( + 'Production publishing only runs in GitHub Actions on a trusted master push. Create a release request instead' + ) + } + if (git('rev-parse', 'HEAD') !== process.env.GITHUB_SHA) { + throw new Error('Checkout does not match the triggering commit') + } + if (git('status', '--porcelain', '--untracked-files=no')) { + throw new Error('Publishing requires an unchanged checkout') + } +} + +function releaseTag(id) { + return `mobile-release/${id}` +} + +function releaseReceipt(id) { + const release = optionalApi(`releases/tags/${encodeURIComponent(releaseTag(id))}`) + return release ? { release, receipt: JSON.parse(release.body) } : null +} + +function productionState() { + const ref = optionalApi(`git/ref/${pointerRef}`) + if (!ref) { + return null + } + if (ref.object.type !== 'tag') { + throw new Error('Production pointer must be an annotated release receipt tag') + } + const tag = api(`git/tags/${ref.object.sha}`) + const pointer = JSON.parse(tag.message) + const state = releaseReceipt(pointer.id) + if ( + !state || + state.release.draft || + state.receipt.state !== 'complete' || + state.receipt.commit !== tag.object.sha + ) { + throw new Error('Production pointer does not identify a completed release receipt') + } + return state.receipt +} + +function advancePointer(receipt) { + const current = productionState() + if (current?.request.id === receipt.request.id) { + return + } + if ((current?.request.id ?? null) !== receipt.request.previousRelease) { + throw new Error('Production advanced after this completed release; refusing to move its pointer backwards') + } + const tag = api('git/tags', { + tag: 'mobile-production', + message: JSON.stringify({ id: receipt.request.id }), + object: receipt.commit, + type: 'commit', + }) + if (current) { + api(`git/refs/${pointerRef}`, { sha: tag.sha, force: true }, 'PATCH') + } else { + api('git/refs', { ref: `refs/${pointerRef}`, sha: tag.sha }) + } +} + +function saveReceipt(receipt) { + const output = join(process.env.RUNNER_TEMP, 'mobile-release-receipt.json') + writeFileSync(output, `${JSON.stringify(receipt, null, 2)}\n`) + return output +} + +function deploymentStatus(id, state) { + api(`deployments/${id}/statuses`, { state, log_url: process.env.MOBILE_RELEASE_RUN_URL, auto_inactive: false }) +} + +async function publish() { + assertHosted() + checkRequests() + const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')) + const requestPath = plan(event.before, process.env.GITHUB_SHA) + if (!requestPath) { + throw new Error('The triggering push did not add a release request') + } + const request = readRequest(join(repoRoot, requestPath)) + const commit = process.env.GITHUB_SHA + const existing = releaseReceipt(request.id) + if ( + existing?.receipt.state === 'complete' && + existing.receipt.commit === commit && + JSON.stringify(existing.receipt.request) === JSON.stringify(request) + ) { + const current = productionState() + if (!current || current.request.id === request.previousRelease || current.request.id === request.id) { + advancePointer(existing.receipt) + } + saveReceipt(existing.receipt) + console.log('Release already completed; no EAS publication repeated') + return + } + if (existing) { + throw new Error( + 'Request already claimed or bound to other source. Reconcile its EAS results before retrying; see RELEASING.md' + ) + } + const previous = productionState() + for (const name of requestFiles()) { + const sibling = readRequest(join(requestRoot, name)) + if ( + sibling.id !== request.id && + sibling.previousRelease === request.previousRelease && + releaseReceipt(sibling.id) + ) { + throw new Error( + 'Another request sharing this predecessor already has a release receipt. Reconcile its EAS results or repair the production pointer before creating a successor' + ) + } + } + const environmentSnapshot = await loadProductionEnvironment() + const { exp, expoRequire } = expoConfig() + if ( + exp.runtimeVersion?.policy !== 'appVersion' || + exp.extra?.eas?.projectId !== config.projectId || + exp.ios?.runtimeVersion || + exp.android?.runtimeVersion + ) { + throw new Error('Release requires the configured EAS project and one appVersion runtime for both platforms') + } + const { privateKeyPath, certificateFingerprint } = productionSigning(exp, request.kind) + if (request.kind === 'ota' && previous?.native?.certificateFingerprint !== certificateFingerprint) { + throw new Error('OTA signing certificate changed; request a native release with a higher app version') + } + const fingerprints = await nativeFingerprints(expoRequire) + validatePlan({ + request, + previous, + appVersion: exp.version, + fingerprints, + initialRuntimeVersion: config.initialRuntimeVersion, + isAncestor: (ancestor) => + ['ahead', 'identical'].includes(api(`compare/${ancestor}...${commit}?per_page=1`).status), + }) + const eas = JSON.parse(readFileSync(join(appRoot, 'eas.json'), 'utf8')) + if ( + eas.build.production.channel !== 'production' || + eas.build.production.environment !== 'production' || + eas.build.production.env.APP_VARIANT !== 'production' || + eas.build.production.env.EXPO_PUBLIC_SIGNUP_LINK !== '0' || + eas.submit.production.android.track !== 'internal' + ) { + throw new Error('Release profiles must target production builds and the Play internal track') + } + const receipt = { schemaVersion: 1, state: 'claimed', request, commit, runUrl: process.env.MOBILE_RELEASE_RUN_URL } + const message = `mobile-release:${request.id}@${commit}` + let deployment + try { + await publishOnce({ + existing: null, + commit, + request, + claim: () => { + const path = saveReceipt(receipt) + run('gh', [ + 'release', + 'create', + releaseTag(request.id), + '--repo', + repo, + '--target', + commit, + '--draft', + '--prerelease', + '--title', + `Mobile ${request.id}`, + '--notes-file', + path, + ]) + }, + stage: async () => { + deployment = api('deployments', { + ref: commit, + environment: 'mobile-staging', + auto_merge: false, + required_contexts: [], + production_environment: false, + description: `Stage ${request.kind} request ${request.id}`, + }) + deploymentStatus(deployment.id, 'in_progress') + let staged + if (request.kind === 'native') { + const output = run('sh', [ + 'scripts/eas-build.sh', + '--profile', + 'production', + '--platform', + 'all', + '--non-interactive', + '--wait', + '--json', + '--message', + message, + ]) + staged = { + builds: validateBuilds(JSON.parse(output), request.runtimeVersion, { + ios: exp.ios.bundleIdentifier, + android: exp.android.package, + }), + } + validateNativeBuildFingerprints(staged.builds, fingerprints) + } else { + const output = run('eas', [ + 'update', + '--private-key-path', + privateKeyPath, + '--branch', + `mobile-staging-${request.id}`, + '--environment', + 'production', + '--platform', + 'all', + '--non-interactive', + '--json', + '--message', + message, + ]) + staged = { group: validateUpdates(JSON.parse(output), request.runtimeVersion) } + } + if ((await loadProductionEnvironment()) !== environmentSnapshot) { + throw new Error( + 'EAS environment names, types or contents changed during staging; refusing production publication' + ) + } + const stagedFingerprints = await nativeFingerprints(expoRequire) + if (['ios', 'android'].some((platform) => stagedFingerprints[platform] !== fingerprints[platform])) { + throw new Error('Native environment changed during staging; refusing production publication') + } + receipt.staged = staged + receipt.stagingDeployment = deployment.id + run('gh', [ + 'release', + 'edit', + releaseTag(request.id), + '--repo', + repo, + '--notes-file', + saveReceipt(receipt), + ]) + deploymentStatus(deployment.id, 'success') + return staged + }, + publish: (staged) => { + if (request.kind === 'native') { + for (const build of staged.builds) { + run( + 'eas', + [ + 'submit', + '--profile', + 'production', + '--platform', + build.platform, + '--id', + build.id, + '--non-interactive', + '--wait', + ], + { stdio: 'inherit' } + ) + } + return { submittedBuilds: staged.builds } + } + const output = run('eas', [ + 'update:republish', + '--private-key-path', + privateKeyPath, + '--group', + staged.group, + '--destination-channel', + 'production', + '--platform', + 'all', + '--non-interactive', + '--json', + '--message', + message, + ]) + return { productionGroup: validateRepublishedUpdates(JSON.parse(output), request.runtimeVersion) } + }, + complete: (_staged, result) => { + receipt.state = 'complete' + receipt.result = result + receipt.native = + request.kind === 'native' + ? { commit, runtimeVersion: request.runtimeVersion, fingerprints, certificateFingerprint } + : previous.native + run('gh', [ + 'release', + 'edit', + releaseTag(request.id), + '--repo', + repo, + '--draft=false', + '--prerelease', + '--notes-file', + saveReceipt(receipt), + ]) + advancePointer(receipt) + return receipt + }, + }) + appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `Published mobile ${request.kind} request **${request.id}** from \`${commit}\`. [Receipt](https://github.com/${repo}/releases/tag/${releaseTag(request.id)})\n` + ) + } catch (error) { + if (deployment && !receipt.staged) { + deploymentStatus(deployment.id, 'failure') + } + throw error + } +} + +async function main() { + const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + id: { type: 'string' }, + previous: { type: 'string' }, + notes: { type: 'string' }, + initial: { type: 'boolean' }, + base: { type: 'string' }, + head: { type: 'string' }, + }, + }) + const [command, kind] = positionals + if (command === 'check') { + checkRequests() + } else if (command === 'plan') { + checkRequests() + plan(values.base, values.head) + } else if (command === 'request') { + if (Boolean(values.initial) === Boolean(values.previous)) { + throw new Error('Specify --initial for the first native release, or --previous ') + } + const request = validateRequest({ + schemaVersion: 1, + id: values.id, + kind, + runtimeVersion: expoConfig().exp.version, + previousRelease: values.previous ?? null, + notes: values.notes, + }) + if (request.previousRelease === null && kind !== 'native') { + throw new Error('The first hosted release must be native') + } + mkdirSync(requestRoot, { recursive: true }) + writeFileSync(join(requestRoot, `${request.id}.json`), `${JSON.stringify(request, null, 2)}\n`, { flag: 'wx' }) + console.log( + `Created releases/requests/${request.id}.json; review and commit it with the app changes. No release was published` + ) + } else if (command === 'fingerprint') { + const { expoRequire } = expoConfig() + console.log(JSON.stringify(await nativeFingerprints(expoRequire), null, 2)) + } else if (command === 'publish') { + await publish() + } else { + throw new Error( + 'Use release:request, release:check, release:fingerprint or release:test. Publishing is hosted by Mobile Release' + ) + } +} + +main().catch((error) => { + console.error(error.message) + process.exitCode = 1 +}) diff --git a/products/mobile/src/api/actors.ts b/products/mobile/src/api/actors.ts new file mode 100644 index 000000000000..dfb148e1301b --- /dev/null +++ b/products/mobile/src/api/actors.ts @@ -0,0 +1,68 @@ +import { runQuery } from './query' + +// Shapes follow `ActorsQuery`, `InsightActorsQuery` and `ActorsQueryResponse` in `posthog/schema.py`. The web's caller +// is `frontend/src/scenes/trends/persons-modal/personsModalLogic.ts`. + +export const ACTORS_LIMIT = 20 + +export interface ActorsAtPointInput { + /** The insight response's `query.source`, with the dashboard and tile layers applied, so people match the chart. */ + source: unknown + /** The bucket start, as the trends result's `days` lists it. */ + day: string | number + /** The index of the series in the trends query. */ + series: number + breakdown?: string | number | null | (string | number | null)[] + compare?: 'current' | 'previous' | null + /** True for "Watch recordings": each row then carries the sessions that matched. */ + includeRecordings: boolean +} + +export function actorsAtPointQuery(input: ActorsAtPointInput): Record { + const { source, day, series, breakdown, compare, includeRecordings } = input + return { + kind: 'ActorsQuery', + source: { + kind: 'InsightActorsQuery', + source, + day, + series, + ...(breakdown === undefined || breakdown === null ? {} : { breakdown }), + ...(compare ? { compare } : {}), + ...(includeRecordings ? { includeRecordings: true } : {}), + }, + // `matched_recordings` reads the matching events, which the runner selects only with `includeRecordings`. + select: includeRecordings ? ['actor', 'matched_recordings'] : ['actor'], + limit: ACTORS_LIMIT, + } +} + +/** A person row: `{id, distinct_ids, properties, is_identified, created_at}`. */ +export interface ActorApi { + id?: string + distinct_ids?: string[] + properties?: Record + created_at?: string +} + +/** One `matched_recordings` entry. */ +export interface MatchedRecordingApi { + session_id: string + events?: { timestamp?: string; uuid?: string; window_id?: string }[] +} + +export interface ActorsResponseApi { + columns?: unknown[] + /** Rows in `select` order: `[actor]` or `[actor, matched_recordings]`. */ + results?: unknown[][] + hasMore?: boolean | null +} + +/** `POST query/` with `blocking` and no polling. Callers run one actors query at a time. */ +export async function fetchActorsAtPoint(input: ActorsAtPointInput, signal?: AbortSignal): Promise { + return (await runQuery(actorsAtPointQuery(input), { + refresh: 'blocking', + poll: false, + signal, + })) as ActorsResponseApi +} diff --git a/products/mobile/src/api/budget.ts b/products/mobile/src/api/budget.ts new file mode 100644 index 000000000000..2bb1516c7124 --- /dev/null +++ b/products/mobile/src/api/budget.ts @@ -0,0 +1,107 @@ +// The app holds itself to the published API rate limits for these endpoints. The buckets hold 25% of each budget: they +// exist to stop a client bug, such as a refresh loop, and normal use never empties them. They live in memory, so a cold +// start refills them. + +export type BudgetFamily = 'insights' | 'dashboards' | 'query' | 'replay' + +interface BucketSpec { + capacity: number + windowMs: number +} + +const MINUTE_MS = 60_000 +const HOUR_MS = 60 * MINUTE_MS + +/** Published budgets: insights 240/min and 1,200/h; dashboards and shortcuts 480/min and 4,800/h; query 240/min and 1,200/h. */ +export const BUDGETS: Record = { + insights: [ + { capacity: 60, windowMs: MINUTE_MS }, + { capacity: 300, windowMs: HOUR_MS }, + ], + dashboards: [ + { capacity: 120, windowMs: MINUTE_MS }, + { capacity: 1200, windowMs: HOUR_MS }, + ], + query: [ + { capacity: 60, windowMs: MINUTE_MS }, + { capacity: 300, windowMs: HOUR_MS }, + ], + // The session timeline also posts to `query/`, on buckets of its own, so heavy Views use never empties them. + replay: [ + { capacity: 60, windowMs: MINUTE_MS }, + { capacity: 300, windowMs: HOUR_MS }, + ], +} + +interface Bucket { + tokens: number + updatedAt: number +} + +const buckets = new Map() + +function bucketsFor(family: BudgetFamily, now: number): Bucket[] { + let state = buckets.get(family) + if (!state) { + state = BUDGETS[family].map((spec) => ({ tokens: spec.capacity, updatedAt: now })) + buckets.set(family, state) + } + BUDGETS[family].forEach((spec, index) => { + const bucket = state[index] + if (bucket) { + const refill = ((now - bucket.updatedAt) * spec.capacity) / spec.windowMs + bucket.tokens = Math.min(spec.capacity, bucket.tokens + Math.max(0, refill)) + bucket.updatedAt = now + } + }) + return state +} + +/** True when a request of this family could go out now. Takes no token. */ +export function canSpend(family: BudgetFamily, now: number = Date.now()): boolean { + return bucketsFor(family, now).every((bucket) => bucket.tokens >= 1) +} + +/** Takes one token from every bucket of the family, or none when any bucket is empty. */ +export function trySpend(family: BudgetFamily, now: number = Date.now()): boolean { + const state = bucketsFor(family, now) + if (!state.every((bucket) => bucket.tokens >= 1)) { + return false + } + for (const bucket of state) { + bucket.tokens -= 1 + } + return true +} + +export function resetBudgets(): void { + buckets.clear() +} + +const FAMILY_PATH = /^\/api\/(?:projects|environments)\/[^/]+\/(insights|dashboards|file_system_shortcut|query)(?:\/|$)/ + +/** The budget a project path counts against, or null for endpoints outside the Views budgets. */ +export function budgetFamilyFor(path: string): BudgetFamily | null { + const match = FAMILY_PATH.exec(path) + switch (match?.[1]) { + case 'insights': + return 'insights' + case 'dashboards': + case 'file_system_shortcut': + return 'dashboards' + case 'query': + return 'query' + default: + return null + } +} + +export class BudgetExceededError extends Error { + readonly family: BudgetFamily + + constructor(family: BudgetFamily) { + super('The app paused updates for a moment. Try again in a minute.') + this.name = 'BudgetExceededError' + this.family = family + } +} diff --git a/products/mobile/src/api/client.test.ts b/products/mobile/src/api/client.test.ts new file mode 100644 index 000000000000..e5c574365477 --- /dev/null +++ b/products/mobile/src/api/client.test.ts @@ -0,0 +1,109 @@ +import { isAppReloadBlocked } from '@/lib/appReload' + +import { BUDGETS, BudgetExceededError, resetBudgets } from './budget' +import { apiJson, setAuthProvider } from './client' +import { resetServerClock, serverOffsetMs } from './serverClock' +import { createTask, sendTaskRunCommand, startTaskRun } from './tasks' + +describe('api client send path', () => { + let fetchMock: jest.Mock + + beforeEach(() => { + resetBudgets() + resetServerClock() + setAuthProvider({ + getAuth: () => ({ host: 'https://app.example.com', accessToken: 'token', projectId: 2, session: 1 }), + refreshAccessToken: async () => null, + }) + fetchMock = jest.fn(async () => ({ + ok: true, + status: 200, + headers: { get: (name: string) => (name === 'Date' ? 'Wed, 14 Oct 2026 12:00:30 GMT' : null) }, + text: async () => '{}', + })) + globalThis.fetch = fetchMock as unknown as typeof fetch + }) + + afterEach(() => { + jest.useRealTimers() + setAuthProvider(null) + }) + + test('identifies mobile creation, continuation and permission answers on every task action', async () => { + await createTask({ + title: '', + description: 'Example', + origin_product: 'posthog_ai', + initial_permission_mode: 'default', + pending_user_message: 'Example', + }) + await startTaskRun('task-1', { + mode: 'interactive', + pending_user_message: 'Continue', + resume_from_run_id: 'run-1', + }) + await sendTaskRunCommand( + 'task-1', + 'run-1', + { method: 'user_message', params: { content: 'Continue' } }, + { id: 'message-1' } + ) + await sendTaskRunCommand( + 'task-1', + 'run-1', + { method: 'permission_response', params: { requestId: 'question-1', optionId: 'allow' } }, + { id: 'answer-1' } + ) + expect(fetchMock).toHaveBeenCalledTimes(4) + for (const [, init] of fetchMock.mock.calls) { + expect(init).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ 'X-PostHog-Client-Platform': 'mobile' }), + }) + ) + } + }) + + test('keeps the server clock offset from the Date header', async () => { + jest.useFakeTimers({ now: Date.parse('2026-10-14T12:00:00Z') }) + + await apiJson('/api/projects/2/insights/1/') + + expect(serverOffsetMs()).toBe(30_000) + }) + + test.each([false, true])('blocks an app reload until a pending request settles, failure=%s', async (failure) => { + let finish!: (response: unknown) => void + let fail!: (error: Error) => void + fetchMock.mockImplementationOnce( + () => + new Promise((resolve, reject) => { + finish = resolve + fail = reject + }) + ) + const request = apiJson('/api/projects/2/tasks/task-1/', { method: 'PATCH', json: { archived: true } }) + expect(isAppReloadBlocked()).toBe(true) + + if (failure) { + fail(new Error('Offline')) + await expect(request).rejects.toThrow('Offline') + } else { + finish({ ok: true, status: 200, text: async () => '{}' }) + await request + } + expect(isAppReloadBlocked()).toBe(false) + }) + + test('sends nothing once a budget family has used its minute of tokens', async () => { + const perMinute = BUDGETS.insights[0]?.capacity ?? 0 + for (let index = 0; index < perMinute; index += 1) { + await apiJson('/api/projects/2/insights/1/') + } + + await expect(apiJson('/api/projects/2/insights/1/')).rejects.toBeInstanceOf(BudgetExceededError) + expect(fetchMock).toHaveBeenCalledTimes(perMinute) + await expect(apiJson('/api/projects/2/dashboards/1/')).resolves.toEqual({}) + }) +}) diff --git a/products/mobile/src/api/client.ts b/products/mobile/src/api/client.ts new file mode 100644 index 000000000000..9a8899fe7093 --- /dev/null +++ b/products/mobile/src/api/client.ts @@ -0,0 +1,225 @@ +import { fetch as streamingFetch } from 'expo/fetch' + +import { withAppReloadBlocked } from '@/lib/appReload' +import { isRecord } from '@/lib/records' + +import { BudgetExceededError, type BudgetFamily, budgetFamilyFor, trySpend } from './budget' +import { recordServerDate } from './serverClock' + +export interface ApiAuth { + host: string + accessToken: string | null + projectId: number | null + /** Changes when the user signs in or out, but not when a refresh rotates the token. */ + session: number +} + +export interface AuthProvider { + getAuth: () => ApiAuth + refreshAccessToken: () => Promise +} + +let provider: AuthProvider | null = null + +export function setAuthProvider(next: AuthProvider | null): void { + provider = next +} + +export class ApiError extends Error { + readonly status: number + readonly code: string | undefined + /** PostHog error `detail`, which is user-facing copy for most errors. */ + readonly detail: string | undefined + readonly body: unknown + readonly retryAfterSeconds: number | undefined + + constructor(status: number, body: unknown, headers?: Headers) { + const record = isRecord(body) ? body : {} + // Signals and tasks errors use `error` where DRF uses `detail`. + const detail = + typeof record.detail === 'string' + ? record.detail + : typeof record.error === 'string' + ? record.error + : undefined + const code = typeof record.code === 'string' ? record.code : undefined + super(detail ?? code ?? `Request failed with status ${status}`) + this.name = 'ApiError' + this.status = status + this.code = code + this.detail = detail + this.body = body + const retryAfter = headers?.get('Retry-After') + this.retryAfterSeconds = retryAfter ? Number(retryAfter) || undefined : undefined + } +} + +export class NotSignedInError extends Error { + constructor() { + super('Sign in to continue') + this.name = 'NotSignedInError' + } +} + +export type QueryValue = string | number | boolean | null | undefined + +export interface ApiInit extends Omit { + json?: unknown + body?: RequestInit['body'] + headers?: Record + query?: Record + /** Spends this budget instead of the one the path belongs to. */ + budgetFamily?: BudgetFamily +} + +function requireAuth(): ApiAuth { + if (!provider) { + throw new NotSignedInError() + } + const auth = provider.getAuth() + if (!auth.host || !auth.accessToken) { + throw new NotSignedInError() + } + return auth +} + +export function projectPath(path: string): string { + const auth = requireAuth() + if (auth.projectId === null) { + throw new Error('No project selected') + } + return `/api/projects/${auth.projectId}/${path.replace(/^\//, '')}` +} + +export function buildQuery(query: Record | undefined): string { + if (!query) { + return '' + } + const params = new URLSearchParams() + for (const [key, value] of Object.entries(query)) { + if (value !== null && value !== undefined) { + params.set(key, String(value)) + } + } + const encoded = params.toString() + return encoded ? `?${encoded}` : '' +} + +export function apiUrl(path: string, query?: Record): string { + const { host } = requireAuth() + return `${host}${path.startsWith('/') ? path : `/${path}`}${buildQuery(query)}` +} + +/** The web app URL of a page in the current project, or undefined when no session or project is set. */ +export function projectWebUrl(path: string): string | undefined { + try { + return apiUrl(projectPath(path).replace(/^\/api\/projects\//, '/project/')) + } catch { + return undefined + } +} + +function buildInit(init: ApiInit, accessToken: string, accept: string): RequestInit { + const { json, query: _query, budgetFamily: _budgetFamily, headers, ...rest } = init + const merged: Record = { + Accept: accept, + Authorization: `Bearer ${accessToken}`, + // The tasks API reads this to skip warm-sandbox reuse and to label PRs opened from the app. + 'X-PostHog-Client-Platform': 'mobile', + ...headers, + } + let body = rest.body + if (json !== undefined) { + merged['Content-Type'] = 'application/json' + body = JSON.stringify(json) + } + return { ...rest, headers: merged, body } +} + +type Fetcher = (url: string, init: RequestInit) => Promise + +// Test doubles and some web responses have no `Date` header, or no headers at all. +function noteServerDate(response: Response): void { + recordServerDate(response.headers?.get?.('Date')) +} + +function isSameSession(a: ApiAuth, b: ApiAuth): boolean { + return a.session === b.session && a.host === b.host +} + +async function send(fetcher: Fetcher, path: string, init: ApiInit, accept: string): Promise { + const auth = requireAuth() + const url = apiUrl(path, init.query) + const family = init.budgetFamily ?? budgetFamilyFor(path) + if (family && !trySpend(family)) { + throw new BudgetExceededError(family) + } + const sent = auth.accessToken as string + let response = await fetcher(url, buildInit(init, sent, accept)) + noteServerDate(response) + if (response.status === 401 && provider) { + // The retry goes to `url`, which belongs to the session that sent the request. If the user signed out or + // into another account while the request was in flight, the current token belongs to a different session, + // and possibly to a different host. Sending it to `url` would give it to the earlier host. + const current = provider.getAuth() + if (!isSameSession(auth, current)) { + throw new NotSignedInError() + } + // A refresh elsewhere may already have rotated the token this request used. Refreshing + // again would revoke that newer token and 401 every other in-flight request. + const token = + current.accessToken && current.accessToken !== sent + ? current.accessToken + : await provider.refreshAccessToken() + // The session can also change while the refresh is in flight. + if (!token || !isSameSession(auth, provider.getAuth())) { + throw new NotSignedInError() + } + response = await fetcher(url, buildInit(init, token, accept)) + noteServerDate(response) + } + return response +} + +async function parseBody(response: Response): Promise { + const text = await response.text() + if (!text) { + return undefined + } + try { + return JSON.parse(text) as unknown + } catch { + return text + } +} + +/** Sends a request and returns the raw response. Refreshes the token once on a 401. */ +export async function apiFetch(path: string, init: ApiInit = {}): Promise { + return withAppReloadBlocked(() => send((url, options) => fetch(url, options), path, init, 'application/json')) +} + +export async function apiJson(path: string, init: ApiInit = {}): Promise { + return withAppReloadBlocked(async () => { + const response = await apiFetch(path, init) + const body = await parseBody(response) + if (!response.ok) { + throw new ApiError(response.status, body, response.headers) + } + return body as T + }) +} + +/** Uses expo's fetch, which exposes `response.body` as a readable stream on native. */ +export async function apiStream(path: string, init: ApiInit = {}): Promise { + const response = await send( + (url, options) => streamingFetch(url, options as Parameters[1]) as Promise, + path, + init, + // JSON too, so content negotiation never answers 406 when the server replies in JSON, such as with an error. + 'text/event-stream, application/json' + ) + if (!response.ok) { + throw new ApiError(response.status, await parseBody(response), response.headers) + } + return response +} diff --git a/products/mobile/src/api/dashboards.ts b/products/mobile/src/api/dashboards.ts new file mode 100644 index 000000000000..9d3cbcadc3ab --- /dev/null +++ b/products/mobile/src/api/dashboards.ts @@ -0,0 +1,88 @@ +import { apiJson, projectPath } from './client' +import type { InsightApi } from './insights' +import type { PaginatedApi } from './reports' + +// Shapes follow `DashboardBasicSerializer`, `DashboardSerializer` and `DashboardTileSerializer` in +// `products/dashboards/backend/api/dashboard.py`, keeping the fields the app reads. + +export interface DashboardBasicApi { + id: number + name: string | null + description?: string | null + pinned?: boolean + /** This user's last web view. Null for a dashboard the user never viewed; the list then sorts it by creation. */ + last_viewed_at?: string | null + last_refresh?: string | null + created_at?: string | null + deleted?: boolean +} + +export interface DashboardTileApi { + id: number + /** A full `InsightSerializer` with the cached result. Null for text and button tiles. */ + insight: InsightApi | null + text?: { body?: string | null } | null + /** `sm` (12 columns) and `xs` (1 column) grid positions. */ + layouts?: Record | null + /** Tile-level filters. They win over the dashboard and the request override. */ + filters_overrides?: Record | null + deleted?: boolean | null +} + +export interface DashboardApi extends DashboardBasicApi { + tiles?: DashboardTileApi[] | null + /** The saved filters with the request's `filters_override` applied. */ + filters?: Record | null + /** The saved filters alone, also on a read with an override. Null when the dashboard saved none. */ + persisted_filters?: Record | null + /** Shared by every viewer and set by the web's bulk refresh. The app reads it and never writes it. */ + last_refresh?: string | null +} + +export interface DashboardReadOptions { + /** A `DashboardFilter`, sent as JSON. Null reads the saved filters. */ + filtersOverride?: Record | null + signal?: AbortSignal +} + +/** + * `GET dashboards/{id}/?refresh=force_cache`: cached tiles only, no compute. The explicit value keeps paints + * cache-only even if the server default changes. + */ +export function fetchDashboard(id: number, options: DashboardReadOptions = {}): Promise { + const { filtersOverride, signal } = options + return apiJson(projectPath(`dashboards/${id}/`), { + query: { + refresh: 'force_cache', + filters_override: filtersOverride ? JSON.stringify(filtersOverride) : undefined, + }, + signal, + }) +} + +export const DASHBOARD_PAGE_SIZE = 30 +export const RECENT_DASHBOARD_LIMIT = 10 +export const DASHBOARD_SEARCH_LIMIT = 10 + +/** Browse, Dashboards, in the web Views order. */ +export function fetchDashboardPage(offset: number, signal?: AbortSignal): Promise> { + return apiJson>(projectPath('dashboards/'), { + query: { ordering: '-last_viewed_at', limit: DASHBOARD_PAGE_SIZE, offset }, + signal, + }) +} + +/** The dashboards this user viewed on the web, newest first. Rows with no `last_viewed_at` were never viewed. */ +export function fetchRecentDashboards(signal?: AbortSignal): Promise> { + return apiJson>(projectPath('dashboards/'), { + query: { ordering: '-last_viewed_at', limit: RECENT_DASHBOARD_LIMIT }, + signal, + }) +} + +export function searchDashboards(search: string, signal?: AbortSignal): Promise> { + return apiJson>(projectPath('dashboards/'), { + query: { search, limit: DASHBOARD_SEARCH_LIMIT }, + signal, + }) +} diff --git a/products/mobile/src/api/errorTracking.ts b/products/mobile/src/api/errorTracking.ts new file mode 100644 index 000000000000..13369af5d3fb --- /dev/null +++ b/products/mobile/src/api/errorTracking.ts @@ -0,0 +1,279 @@ +import type { + ErrorTrackingIssueListItemApi, + ErrorTrackingIssuesListQueryRequestApi, + ErrorTrackingIssuesListResponseApi, +} from '../../../error_tracking/frontend/generated/api.schemas' +import type { SignalsReportsListParams } from '../../../signals/frontend/generated/api.schemas' + +import { apiJson, projectPath } from './client' +import { type InboxPageApi, type ProjectScope, scopeKey } from './reports' + +// Shapes follow the serializers in `products/error_tracking/backend/presentation/views/`: `query_serializers.py` for +// the compact `error_tracking/query/` reads, `issues.py` for the issue resource and `spike_events.py`. Every endpoint +// here needs `error_tracking:read`, and the writes need `error_tracking:write`. + +export type IssueStatusApi = 'active' | 'resolved' | 'suppressed' | 'archived' | 'pending_release' +export type WritableIssueStatus = 'active' | 'resolved' | 'suppressed' + +/** `ErrorTrackingIssueAssigneeReadSerializer`: a user's numeric id, or a role's UUID. */ +export interface IssueAssigneeApi { + id?: number | string | null + type?: 'user' | 'role' | string | null +} + +/** `ErrorTrackingAggregationsSerializer`. Counts arrive as floats. */ +export interface IssueAggregationsApi { + occurrences?: number + users?: number + sessions?: number + volumeRange?: number[] +} + +export type IssueListItemApi = ErrorTrackingIssueListItemApi +export type IssuesListResponseApi = ErrorTrackingIssuesListResponseApi + +export interface IssueReleaseApi { + version?: string + commit_id?: string + branch?: string +} + +export interface IssueTrendApi { + direction: 'increasing' | 'decreasing' | 'stable' | 'new' + change_percent?: number +} + +export interface IssueSummaryApi extends IssueListItemApi { + impact?: { occurrences?: number; users?: number; sessions?: number } + trend?: IssueTrendApi + sparkline?: number[] + latest_release?: IssueReleaseApi + first_release?: IssueReleaseApi +} + +export interface BreakdownValueApi { + value: string + count: number +} + +/** `ErrorTrackingIssueBreakdownSerializer`. A dimension with no values is left out of `top_values`. */ +export interface IssueBreakdownApi { + occurrences: number + sample_session_ids: string[] + top_values: Partial< + Record<'path' | 'url' | 'screen' | 'browser' | 'os' | 'library' | 'app_version', BreakdownValueApi[]> + > +} + +export interface StackFrameApi { + resolved_name?: string | null + mangled_name?: string | null + function?: string | null + source?: string | null + line?: number | null + in_app?: boolean +} + +export interface ExceptionApi { + type?: string + value?: string + /** `same_as_event` replaces a stack the page already returned; the app reads frames from the first copy only. */ + stacktrace?: { frames?: StackFrameApi[] } | null +} + +export interface IssueEventApi { + uuid?: string + timestamp?: string + properties?: { + $exception_list?: ExceptionApi[] + $session_id?: string | null + $browser?: string | null + $os?: string | null + } +} + +export interface IssueEventsResponseApi { + results: IssueEventApi[] +} + +export interface IssueApi { + id: string + status: IssueStatusApi + name: string | null + description: string | null + first_seen: string | null + assignee: IssueAssigneeApi | null +} + +export interface SpikeEventApi { + issue: { id: string } + detected_at: string +} + +export interface SpikeEventsResponseApi { + results: SpikeEventApi[] +} + +export type ErrorsWindow = '24h' | '7d' + +export const ISSUE_BUCKETS: Record = { '24h': 24, '7d': 28 } +export const ISSUES_PAGE_SIZE = 15 +export const ISSUE_EVENTS_LIMIT = 10 + +export function windowDateRange(window: ErrorsWindow): { date_from: string } { + return { date_from: window === '24h' ? '-24h' : '-7d' } +} + +export interface IssuesListInput { + window: ErrorsWindow + assigneeUserId?: number +} + +export function issuesListBody( + { window, assigneeUserId }: IssuesListInput, + offset = 0 +): ErrorTrackingIssuesListQueryRequestApi { + return { + dateRange: windowDateRange(window), + status: 'active', + orderBy: 'users', + orderDirection: 'DESC', + limit: ISSUES_PAGE_SIZE, + offset, + volumeResolution: 0, + ...(assigneeUserId === undefined ? {} : { assignee: { type: 'user', id: assigneeUserId } }), + } +} + +export function fetchIssues(input: IssuesListInput, signal?: AbortSignal, offset = 0): Promise { + return apiJson(projectPath('error_tracking/query/issues/'), { + method: 'POST', + json: issuesListBody(input, offset), + signal, + }) +} + +export function fetchIssueSummary( + issueId: string, + window: ErrorsWindow, + signal?: AbortSignal +): Promise { + return apiJson(projectPath('error_tracking/query/issue/'), { + method: 'POST', + json: { + issueId, + dateRange: windowDateRange(window), + includeSparkline: true, + volumeResolution: ISSUE_BUCKETS[window], + }, + signal, + }) +} + +/** + * The same endpoint with `includeBreakdown`, as its own request: the breakdown adds an aggregate query, and the server + * fails the whole response when it cannot compute it, so it must not hold up or break the impact numbers. + */ +export async function fetchIssueBreakdown( + issueId: string, + window: ErrorsWindow, + signal?: AbortSignal +): Promise { + const summary = await apiJson( + projectPath('error_tracking/query/issue/'), + { + method: 'POST', + json: { issueId, dateRange: windowDateRange(window), includeBreakdown: true }, + signal, + } + ) + return summary.breakdown ?? null +} + +export function fetchIssueEvents( + issueId: string, + window: ErrorsWindow, + signal?: AbortSignal +): Promise { + return apiJson(projectPath('error_tracking/query/issue_events/'), { + method: 'POST', + json: { + issueId, + dateRange: windowDateRange(window), + limit: ISSUE_EVENTS_LIMIT, + include: ['exception', 'stacktrace', 'environment', 'correlation'], + onlyAppFrames: true, + }, + signal, + }) +} + +export function fetchIssue(issueId: string, signal?: AbortSignal): Promise { + return apiJson(projectPath(`error_tracking/issues/${encodeURIComponent(issueId)}/`), { signal }) +} + +export function updateIssueStatus(issueId: string, status: WritableIssueStatus): Promise { + return apiJson(projectPath(`error_tracking/issues/${encodeURIComponent(issueId)}/`), { + method: 'PATCH', + json: { status }, + }) +} + +/** `null` removes the assignment. */ +export async function assignIssue(issueId: string, userId: number | null): Promise { + await apiJson(projectPath(`error_tracking/issues/${encodeURIComponent(issueId)}/assign/`), { + method: 'PATCH', + json: { assignee: userId === null ? null : { type: 'user', id: userId } }, + }) +} + +export const SPIKE_EVENTS_LIMIT = 100 + +export function fetchSpikeEvents( + issueIds: readonly string[], + dateFrom: string, + signal?: AbortSignal +): Promise { + return apiJson(projectPath('error_tracking/spike_events/'), { + query: { issue_ids: issueIds.join(','), date_from: dateFrom, limit: SPIKE_EVENTS_LIMIT }, + signal, + }) +} + +/** + * `GET signals/reports/` narrowed to reports built from this issue's signals: error tracking signals carry the issue + * id as their `source_id`. Needs `task:read`, which the inbox already uses. + */ +export function fetchIssueReports(issueId: string, signal?: AbortSignal): Promise { + return apiJson(projectPath('signals/reports/'), { + query: { + source_product: 'error_tracking', + source_id: issueId, + limit: 1, + include_source_metadata: false, + } satisfies SignalsReportsListParams, + signal, + }) +} + +/** Query keys. Every key carries the host and project so a project switch never shows stale data. */ +export const errorTrackingKeys = { + all: (scope: ProjectScope | null) => ['error_tracking', ...scopeKey(scope)] as const, + lists: (scope: ProjectScope | null) => [...errorTrackingKeys.all(scope), 'issues'] as const, + list: (scope: ProjectScope | null, input: IssuesListInput) => + [...errorTrackingKeys.lists(scope), input.window, input.assigneeUserId ?? null, 'pages'] as const, + spikes: (scope: ProjectScope | null, window: ErrorsWindow, issueIds: readonly string[]) => + [...errorTrackingKeys.all(scope), 'spikes', window, issueIds.join(',')] as const, + issueAll: (scope: ProjectScope | null, issueId: string) => + [...errorTrackingKeys.all(scope), 'issue', issueId] as const, + issue: (scope: ProjectScope | null, issueId: string) => + [...errorTrackingKeys.issueAll(scope, issueId), 'detail'] as const, + summary: (scope: ProjectScope | null, issueId: string, window: ErrorsWindow) => + [...errorTrackingKeys.issueAll(scope, issueId), 'summary', window] as const, + breakdown: (scope: ProjectScope | null, issueId: string, window: ErrorsWindow) => + [...errorTrackingKeys.issueAll(scope, issueId), 'breakdown', window] as const, + events: (scope: ProjectScope | null, issueId: string, window: ErrorsWindow) => + [...errorTrackingKeys.issueAll(scope, issueId), 'events', window] as const, + reports: (scope: ProjectScope | null, issueId: string) => + [...errorTrackingKeys.issueAll(scope, issueId), 'reports'] as const, +} diff --git a/products/mobile/src/api/insights.test.ts b/products/mobile/src/api/insights.test.ts new file mode 100644 index 000000000000..de9d497817c4 --- /dev/null +++ b/products/mobile/src/api/insights.test.ts @@ -0,0 +1,41 @@ +import { setAuthProvider } from './client' +import { fetchInsightMetadata, getInsightSummary } from './insights' + +describe('insight summary', () => { + beforeEach(() => + setAuthProvider({ + getAuth: () => ({ host: 'https://example.com', accessToken: 'token', projectId: 7, session: 1 }), + refreshAccessToken: async () => null, + }) + ) + afterEach(() => setAuthProvider(null)) + test('only reads the cached insight and falls back to derived name', async () => { + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: async () => JSON.stringify({ name: '', derived_name: 'Count', description: '' }), + }) + await expect(getInsightSummary('a/b')).resolves.toEqual({ name: 'Count', description: null }) + expect(fetch).toHaveBeenCalledWith( + 'https://example.com/api/projects/7/insights/a%2Fb/?refresh=force_cache', + expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer token' }) }) + ) + }) + test('reads only metadata for an all-digit short id without resolving it as a primary key', async () => { + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: async () => JSON.stringify({ results: [{ id: 9, short_id: '1234', name: 'Signups' }] }), + }) + await expect(fetchInsightMetadata('1234')).resolves.toMatchObject({ id: 9, short_id: '1234' }) + expect(fetch).toHaveBeenCalledWith( + 'https://example.com/api/projects/7/insights/?basic=true&short_id=1234&refresh=force_cache', + expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer token' }) }) + ) + }) + test.each([404, 403, 500])('handles status %s', async (status) => { + globalThis.fetch = jest + .fn() + .mockResolvedValue({ ok: false, status, headers: { get: () => null }, text: async () => '{}' }) + if (status === 404) await expect(getInsightSummary('a')).resolves.toBeNull() + else await expect(getInsightSummary('a')).rejects.toMatchObject({ status }) + }) +}) diff --git a/products/mobile/src/api/insights.ts b/products/mobile/src/api/insights.ts new file mode 100644 index 000000000000..2342e96c45d4 --- /dev/null +++ b/products/mobile/src/api/insights.ts @@ -0,0 +1,173 @@ +import { ApiError, apiJson, projectPath } from './client' +import type { PaginatedApi } from './reports' + +// Shapes follow `InsightSerializer` and `InsightBasicSerializer` in +// `products/product_analytics/backend/presentation/insight.py`, keeping the fields the app reads. + +/** + * `force_cache` reads the cache only. `blocking` computes only past `cache_target_age` or on a miss. `force_blocking` + * always computes, so the app sends it only once `next_allowed_client_refresh` has passed. + * Never `async_except_on_cache_miss`: the insight endpoint turns it into a cache-only read. + */ +export type InsightRefresh = 'force_cache' | 'blocking' | 'force_blocking' + +export interface QueryStatusFieldsApi { + id?: string + complete?: boolean | null + error?: boolean | null + /** `rate_limited` when the org's query slots are full. The response then carries `result: null`. */ + error_code?: string | null + error_message?: string | null +} + +export interface ResolvedDateRangeApi { + date_from: string + date_to: string +} + +export interface UserBasicApi { + first_name?: string + last_name?: string + email?: string +} + +export interface InsightApi { + id: number + short_id: string + name: string | null + derived_name: string | null + description?: string | null + /** The query with the dashboard and tile layers applied when the request named `from_dashboard`. */ + query: unknown + result: unknown + /** Also set to now on a rate-limited error response, so only a response with a result may be trusted. */ + last_refresh: string | null + cache_target_age: string | null + next_allowed_client_refresh: string | null + is_cached?: boolean + query_status?: QueryStatusFieldsApi | null + timezone?: string | null + resolved_date_range?: ResolvedDateRangeApi | null + /** SQL insights only: the result's column names, and `[expression, clickhouseType]` per column. */ + columns?: string[] | null + types?: [string, string][] | null + /** SQL insights only: the server cut the result at its limit. */ + hasMore?: boolean | null + created_by?: UserBasicApi | null + created_at?: string | null + last_modified_at?: string | null + deleted?: boolean +} + +export interface InsightBasicApi { + id: number + short_id: string + name: string | null + derived_name: string | null + description?: string | null + query?: unknown + last_refresh?: string | null + last_modified_at?: string | null + last_viewed_at?: string | null + deleted?: boolean +} + +export interface InsightReadOptions { + refresh: InsightRefresh + /** Applies the dashboard's filters and the tile's overrides, and takes a dashboard query slot on a compute. */ + fromDashboard?: number | null + /** A `DashboardFilter`, sent as JSON. */ + filtersOverride?: Record | null + signal?: AbortSignal +} + +/** `GET insights/{id}/`. Takes the numeric id or the `short_id`; an all-digit value is tried as an id first. */ +export function fetchInsight(idOrShortId: number | string, options: InsightReadOptions): Promise { + const { refresh, fromDashboard, filtersOverride, signal } = options + return apiJson(projectPath(`insights/${encodeURIComponent(String(idOrShortId))}/`), { + query: { + refresh, + from_dashboard: fromDashboard ?? undefined, + filters_override: filtersOverride ? JSON.stringify(filtersOverride) : undefined, + }, + signal, + }) +} + +/** `GET insights/?short_id=` with a cache-only read, for an all-digit `short_id` that retrieve resolved as an id. */ +export async function fetchInsightByShortId(shortId: string, signal?: AbortSignal): Promise { + const page = await apiJson>(projectPath('insights/'), { + query: { short_id: shortId, refresh: 'force_cache' }, + signal, + }) + return page.results.find((insight) => insight.short_id === shortId) +} + +const ALL_DIGITS = /^\d+$/ + +/** + * Reads an insight by its numeric id when known, else by `short_id`. Retrieve tries an all-digit `short_id` as a + * primary key first, so a response for another insight falls back to the `short_id` filter once. + */ +export async function readInsight( + target: { id?: number | null; shortId: string }, + options: InsightReadOptions +): Promise { + if (typeof target.id === 'number') { + return fetchInsight(target.id, options) + } + const insight = await fetchInsight(target.shortId, options) + if (insight.short_id === target.shortId || !ALL_DIGITS.test(target.shortId)) { + return insight + } + return (await fetchInsightByShortId(target.shortId, options.signal)) ?? insight +} + +export interface InsightSummary { + name: string | null + description: string | null +} + +export async function getInsightSummary(shortId: string, signal?: AbortSignal): Promise { + try { + const insight = await fetchInsight(shortId, { refresh: 'force_cache', signal }) + return { name: insight.name || insight.derived_name || null, description: insight.description || null } + } catch (error) { + if (error instanceof ApiError && error.status === 404) return null + throw error + } +} + +export const INSIGHT_PAGE_SIZE = 30 +export const INSIGHT_SEARCH_LIMIT = 10 + +export async function fetchInsightMetadata( + shortId: string, + signal?: AbortSignal +): Promise { + const page = await apiJson>(projectPath('insights/'), { + query: { basic: true, short_id: shortId, refresh: 'force_cache' }, + signal, + }) + return page.results.find((insight) => insight.short_id === shortId) +} + +/** Browse, Insights. `basic` drops the results, so a page is small. */ +export function fetchInsightPage(offset: number, signal?: AbortSignal): Promise> { + return apiJson>(projectPath('insights/'), { + query: { basic: true, saved: true, order: '-last_modified_at', limit: INSIGHT_PAGE_SIZE, offset }, + signal, + }) +} + +export function searchInsights(search: string, signal?: AbortSignal): Promise> { + return apiJson>(projectPath('insights/'), { + query: { basic: true, saved: true, search, limit: INSIGHT_SEARCH_LIMIT }, + signal, + }) +} + +/** `POST insights/viewed/` once per insight open, so the web's "Last viewed" includes phone opens. */ +export async function markInsightViewed(insightId: number): Promise { + await apiJson(projectPath('insights/viewed/'), { method: 'POST', json: { insight_ids: [insightId] } }) +} diff --git a/products/mobile/src/api/notificationPreferences.test.ts b/products/mobile/src/api/notificationPreferences.test.ts new file mode 100644 index 000000000000..fe52fb355194 --- /dev/null +++ b/products/mobile/src/api/notificationPreferences.test.ts @@ -0,0 +1,29 @@ +import { apiJson } from './client' +import { + DEFAULT_NOTIFICATION_PREFERENCES, + getNotificationPreferences, + setNotificationPreference, +} from './notificationPreferences' + +jest.mock('./client', () => ({ apiJson: jest.fn() })) + +describe('account notification preferences', () => { + beforeEach(() => jest.resetAllMocks()) + + test('older accounts default to receiving mobile notifications', async () => { + jest.mocked(apiJson).mockResolvedValue({ notification_settings: {} }) + expect(await getNotificationPreferences()).toEqual(DEFAULT_NOTIFICATION_PREFERENCES) + }) + + test('updates only the changed account preference and reads the saved response', async () => { + jest.mocked(apiJson).mockResolvedValue({ notification_settings: { mobile_push_tasks_enabled: false } }) + expect(await setNotificationPreference('mobile_push_tasks_enabled', false)).toEqual({ + ...DEFAULT_NOTIFICATION_PREFERENCES, + mobile_push_tasks_enabled: false, + }) + expect(apiJson).toHaveBeenCalledWith('/api/users/@me/', { + method: 'PATCH', + json: { notification_settings: { mobile_push_tasks_enabled: false } }, + }) + }) +}) diff --git a/products/mobile/src/api/notificationPreferences.ts b/products/mobile/src/api/notificationPreferences.ts new file mode 100644 index 000000000000..025cb96acb0f --- /dev/null +++ b/products/mobile/src/api/notificationPreferences.ts @@ -0,0 +1,31 @@ +import { apiJson } from './client' + +export const DEFAULT_NOTIFICATION_PREFERENCES = { + mobile_push_enabled: true, + mobile_push_tasks_enabled: true, + mobile_push_reports_enabled: true, + mobile_push_report_updates_enabled: true, +} + +export type NotificationPreferences = typeof DEFAULT_NOTIFICATION_PREFERENCES +export type NotificationPreferenceKey = keyof NotificationPreferences + +interface UserNotificationSettings { + notification_settings?: Partial +} + +export async function getNotificationPreferences(signal?: AbortSignal): Promise { + const user = await apiJson('/api/users/@me/', { signal }) + return { ...DEFAULT_NOTIFICATION_PREFERENCES, ...user.notification_settings } +} + +export async function setNotificationPreference( + key: NotificationPreferenceKey, + enabled: boolean +): Promise { + const user = await apiJson('/api/users/@me/', { + method: 'PATCH', + json: { notification_settings: { [key]: enabled } }, + }) + return { ...DEFAULT_NOTIFICATION_PREFERENCES, ...user.notification_settings } +} diff --git a/products/mobile/src/api/pushTokens.ts b/products/mobile/src/api/pushTokens.ts new file mode 100644 index 000000000000..3ada33ae11b5 --- /dev/null +++ b/products/mobile/src/api/pushTokens.ts @@ -0,0 +1,17 @@ +import { apiJson } from './client' + +export type PushPlatform = 'ios' | 'android' + +// Needs `user:write`. The server upserts on (user, token), so a call on every launch only refreshes `last_seen_at`. +export async function registerPushToken( + token: string, + platform: PushPlatform, + init: { signal?: AbortSignal } = {} +): Promise { + await apiJson('/api/users/@me/push_tokens/', { method: 'POST', json: { token, platform }, ...init }) +} + +/** Removes the token for the signed-in user. The server returns 204 even when nothing matched. */ +export async function unregisterPushToken(token: string, init: { signal?: AbortSignal } = {}): Promise { + await apiJson('/api/users/@me/push_tokens/unregister/', { method: 'POST', json: { token }, ...init }) +} diff --git a/products/mobile/src/api/query.test.ts b/products/mobile/src/api/query.test.ts new file mode 100644 index 000000000000..7ccb1fd9f0db --- /dev/null +++ b/products/mobile/src/api/query.test.ts @@ -0,0 +1,142 @@ +import { setAuthProvider } from './client' +import { QueryTimeoutError, queryKeys, type RunQueryOptions, runQuery } from './query' + +const TRENDS = { results: [{ data: [1, 2], days: ['2026-10-12', '2026-10-13'] }] } +const SCOPE = { host: 'https://app.example.com', projectId: 2 } + +describe('query api', () => { + const calls: { url: string; method: string; body: unknown }[] = [] + let replies: unknown[] = [] + + beforeEach(() => { + calls.length = 0 + replies = [] + setAuthProvider({ + getAuth: () => ({ host: 'https://app.example.com', accessToken: 'token', projectId: 2, session: 1 }), + refreshAccessToken: async () => null, + }) + globalThis.fetch = jest.fn(async (url: string, init: RequestInit) => { + calls.push({ url, method: init.method ?? 'GET', body: init.body ? JSON.parse(String(init.body)) : null }) + return { + ok: true, + status: 200, + headers: { get: () => null }, + text: async () => JSON.stringify(replies.shift()), + } as unknown as Response + }) as unknown as typeof fetch + }) + + afterEach(() => { + setAuthProvider(null) + }) + + test('posts the query and returns an inline response without polling', async () => { + replies = [TRENDS] + + await expect(runQuery({ kind: 'TrendsQuery' })).resolves.toEqual(TRENDS) + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + url: 'https://app.example.com/api/projects/2/query/', + method: 'POST', + body: { query: { kind: 'TrendsQuery' }, refresh: 'blocking' }, + }) + }) + + test('polls a running query until it completes and returns its results', async () => { + replies = [ + { query_status: { id: 'q 1', complete: false } }, + { query_status: { id: 'q 1', complete: false } }, + { query_status: { id: 'q 1', complete: true, results: TRENDS } }, + ] + + await expect(runQuery({ kind: 'TrendsQuery' }, { pollIntervalMs: 0 })).resolves.toEqual(TRENDS) + expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([ + 'POST https://app.example.com/api/projects/2/query/', + 'GET https://app.example.com/api/projects/2/query/q%201/', + 'GET https://app.example.com/api/projects/2/query/q%201/', + ]) + }) + + test('returns a running status as it is when polling is off', async () => { + const running = { query_status: { id: 'q', complete: false } } + replies = [running] + + await expect(runQuery({ kind: 'TrendsQuery' }, { refresh: 'force_blocking', poll: false })).resolves.toEqual( + running + ) + expect(calls).toHaveLength(1) + expect(calls[0]?.body).toMatchObject({ refresh: 'force_blocking' }) + }) + + test.each<[string, unknown[], RunQueryOptions, string]>([ + [ + 'a failed status', + [{ query_status: { id: 'q', complete: true, error: true, error_message: 'Bad' } }], + {}, + 'Bad', + ], + ['a query past the deadline', [{ query_status: { id: 'q', complete: false } }], { timeoutMs: 0 }, 'too long'], + ])('rejects %s', async (_name, queued, options, message) => { + replies = queued + await expect(runQuery({ kind: 'TrendsQuery' }, options)).rejects.toThrow(message) + }) + + test('stops polling when the caller aborts', async () => { + replies = [{ query_status: { id: 'q', complete: false } }] + const controller = new AbortController() + const running = runQuery({ kind: 'TrendsQuery' }, { signal: controller.signal, pollIntervalMs: 60_000 }) + await Promise.resolve() + controller.abort() + + await expect(running).rejects.toMatchObject({ name: 'AbortError' }) + expect(calls).toHaveLength(1) + }) + + test('keeps caller cancellation distinct from the deadline during the initial request', async () => { + globalThis.fetch = jest.fn( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) + }) + ) as unknown as typeof fetch + const controller = new AbortController() + const running = runQuery({ kind: 'TrendsQuery' }, { signal: controller.signal, timeoutMs: 1000 }) + controller.abort() + await expect(running).rejects.toMatchObject({ name: 'AbortError' }) + }) + + test.each(['POST', 'GET'])('times out while the %s request is still in flight', async (method) => { + jest.useFakeTimers() + try { + globalThis.fetch = jest.fn((_url: string, init: RequestInit) => { + if (method === 'GET' && init.method === 'POST') { + return Promise.resolve({ + ok: true, + status: 200, + headers: { get: () => null }, + text: async () => JSON.stringify({ query_status: { id: 'q', complete: false } }), + } as unknown as Response) + } + return new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) + }) + }) as unknown as typeof fetch + const running = runQuery({ kind: 'TrendsQuery' }, { pollIntervalMs: 0, timeoutMs: 100 }) + const assertion = expect(running).rejects.toBeInstanceOf(QueryTimeoutError) + await jest.advanceTimersByTimeAsync(100) + await assertion + expect(jest.mocked(globalThis.fetch).mock.calls.map(([, init]) => init?.method ?? 'GET')).toEqual( + method === 'POST' ? ['POST'] : ['POST', 'GET'] + ) + } finally { + jest.useRealTimers() + } + }) + + test('equal queries share a cache key whatever their key order', () => { + expect(queryKeys.run(SCOPE, { kind: 'TrendsQuery', interval: 'day', series: [{ a: 1, b: 2 }] })).toEqual( + queryKeys.run(SCOPE, { series: [{ b: 2, a: 1 }], interval: 'day', kind: 'TrendsQuery' }) + ) + expect(queryKeys.run(SCOPE, { interval: 'day' })).not.toEqual(queryKeys.run(SCOPE, { interval: 'week' })) + }) +}) diff --git a/products/mobile/src/api/query.ts b/products/mobile/src/api/query.ts new file mode 100644 index 000000000000..0ec2ed1b357e --- /dev/null +++ b/products/mobile/src/api/query.ts @@ -0,0 +1,173 @@ +import { randomUUID } from 'expo-crypto' + +import { abortError } from '@/lib/abort' +import { isRecord } from '@/lib/records' +import { sleep } from '@/lib/sleep' + +import type { BudgetFamily } from './budget' +import { apiJson, projectPath } from './client' +import { type ProjectScope, scopeKey } from './reports' + +// Shapes follow `QueryRequest`, `QueryStatusResponse` and `TrendsQueryResponse` in `posthog/schema.py`. + +/** `QueryStatus`. When `complete` is true, `results` holds the whole query response. */ +export interface QueryStatusApi { + id: string + complete?: boolean | null + error?: boolean | null + error_message?: string | null + results?: unknown +} + +/** One series of a trends response. `days` holds the ISO bucket starts, aligned with `data`. */ +export interface TrendsResultApi { + data?: unknown + days?: unknown + labels?: unknown + aggregated_value?: unknown +} + +/** The web accepts both keys, so the app does too. */ +export interface TrendsResponseApi { + results?: TrendsResultApi[] + result?: TrendsResultApi[] +} + +export const QUERY_POLL_INTERVAL_MS = 1000 +export const QUERY_POLL_TIMEOUT_MS = 30_000 + +export class QueryTimeoutError extends Error { + constructor() { + super('The query took too long. Try again later.') + this.name = 'QueryTimeoutError' + } +} + +export class QueryFailedError extends Error { + constructor(message: string | null | undefined) { + super(message || 'The query failed.') + this.name = 'QueryFailedError' + } +} + +function queryStatusOf(body: unknown): QueryStatusApi | undefined { + const status = isRecord(body) ? body.query_status : undefined + return isRecord(status) && typeof status.id === 'string' ? (status as unknown as QueryStatusApi) : undefined +} + +/** `POST query/` has no cache-only read: the server turns a missing or `force_cache` value into `blocking`. */ +export type QueryRefresh = 'blocking' | 'force_blocking' + +export interface RunQueryOptions { + signal?: AbortSignal + pollIntervalMs?: number + timeoutMs?: number + refresh?: QueryRefresh + /** + * False returns a response that still carries a running `query_status` as it is, so the caller keeps its old + * value instead of sending `GET query/{id}/` polls. + */ + poll?: boolean + /** Spends this budget instead of the Views `query` budget. */ + budgetFamily?: BudgetFamily +} + +/** + * `POST query/`, `blocking` unless told otherwise. The server answers inline for most queries; when it hands back a + * running `query_status` instead, this polls `GET query/{id}/` until the status is complete. + */ +export async function runQuery(query: unknown, options: RunQueryOptions = {}): Promise { + const { + signal, + pollIntervalMs = QUERY_POLL_INTERVAL_MS, + timeoutMs = QUERY_POLL_TIMEOUT_MS, + refresh = 'blocking', + poll = true, + budgetFamily, + } = options + const deadline = Date.now() + timeoutMs + const controller = new AbortController() + let timedOut = false + const abortFromCaller = (): void => controller.abort() + signal?.addEventListener('abort', abortFromCaller, { once: true }) + if (signal?.aborted) { + controller.abort() + } + const timer = setTimeout( + () => { + timedOut = true + controller.abort() + }, + Math.max(0, timeoutMs) + ) + try { + const body = await apiJson(projectPath('query/'), { + method: 'POST', + json: { query, client_query_id: randomUUID(), refresh }, + signal: controller.signal, + budgetFamily, + }) + if (signal?.aborted) { + throw abortError() + } + if (timedOut) { + throw new QueryTimeoutError() + } + let status = queryStatusOf(body) + if (!status || status.complete !== false || !poll) { + if (status?.error) { + throw new QueryFailedError(status.error_message) + } + return body + } + while (status.complete !== true) { + if (Date.now() >= deadline) { + throw new QueryTimeoutError() + } + await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())), controller.signal) + const polled: unknown = await apiJson(projectPath(`query/${encodeURIComponent(status.id)}/`), { + signal: controller.signal, + budgetFamily, + }) + if (signal?.aborted) { + throw abortError() + } + if (timedOut) { + throw new QueryTimeoutError() + } + status = queryStatusOf(polled) ?? status + } + if (status.error) { + throw new QueryFailedError(status.error_message) + } + return status.results + } catch (error) { + if (timedOut && !signal?.aborted) { + throw new QueryTimeoutError() + } + throw error + } finally { + clearTimeout(timer) + signal?.removeEventListener('abort', abortFromCaller) + } +} + +/** JSON with object keys sorted, so two equal queries give the same string whatever their key order. */ +export function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item ?? null)).join(',')}]` + } + if (isRecord(value)) { + const entries = Object.keys(value) + .sort() + .filter((key) => value[key] !== undefined) + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + return `{${entries.join(',')}}` + } + return JSON.stringify(value) ?? 'null' +} + +export const queryKeys = { + all: (scope: ProjectScope | null) => ['query', ...scopeKey(scope)] as const, + run: (scope: ProjectScope | null, query: unknown) => [...queryKeys.all(scope), stableStringify(query)] as const, +} diff --git a/products/mobile/src/api/queryClient.ts b/products/mobile/src/api/queryClient.ts new file mode 100644 index 000000000000..ec29a124fe86 --- /dev/null +++ b/products/mobile/src/api/queryClient.ts @@ -0,0 +1,21 @@ +import { focusManager, QueryClient } from '@tanstack/react-query' +import { AppState } from 'react-native' + +// TanStack's default focus listener needs `window` visibility events, which React Native lacks, so the focus state never +// changed on iOS or Android. App state drives it instead. +focusManager.setEventListener((setFocused) => { + const subscription = AppState.addEventListener('change', (state) => setFocused(state === 'active')) + return () => subscription.remove() +}) + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 1, + // Focus refetch stays opt-in per query. With the 30 s stale time, a default of true would refetch the inbox + // and chats on every return to the app. + refetchOnWindowFocus: false, + }, + }, +}) diff --git a/products/mobile/src/api/recordings.ts b/products/mobile/src/api/recordings.ts new file mode 100644 index 000000000000..a338c8cb75c8 --- /dev/null +++ b/products/mobile/src/api/recordings.ts @@ -0,0 +1,157 @@ +import { apiJson, projectPath } from './client' +import { runQuery } from './query' +import { type ProjectScope, scopeKey } from './reports' + +// Shapes follow `SessionRecordingSerializer` and `MinimalPersonSerializer` in +// `posthog/session_recordings/session_recording_api.py` and `posthog/api/person.py`, keeping the fields the app reads. +// Every call here needs `session_recording:read`, except the events timeline, which is a `query:read` HogQL query. + +export interface RecordingPersonApi { + name?: string | null + distinct_ids?: string[] + properties?: Record | null +} + +export interface RecordingApi { + id: string + distinct_id?: string | null + viewed?: boolean + /** Seconds. */ + recording_duration?: number | null + active_seconds?: number | null + start_time?: string | null + end_time?: string | null + click_count?: number | null + keypress_count?: number | null + console_error_count?: number | null + start_url?: string | null + person?: RecordingPersonApi | null + ongoing?: boolean + activity_score?: number | null +} + +export interface RecordingsListApi { + results?: RecordingApi[] + has_next?: boolean + next_cursor?: string | null +} + +export const RECENT_RECORDINGS_LIMIT = 10 +/** The list window, the same as the watch feed's default, so both sides of the merged list cover the same days. */ +export const RECENT_RECORDINGS_FROM = '-7d' + +export async function fetchRecording(sessionId: string, signal?: AbortSignal): Promise { + return apiJson(projectPath(`session_recordings/${encodeURIComponent(sessionId)}/`), { signal }) +} + +/** + * `GET session_recordings/`, newest first: the server's default order is `start_time` + * descending. The REST listing takes `session_recording:read`; the web's `RecordingsQuery` through `query/` would not + * check that scope. + */ +export async function fetchRecentRecordings(signal?: AbortSignal, after?: string): Promise { + return apiJson(projectPath('session_recordings/'), { + query: { limit: RECENT_RECORDINGS_LIMIT, date_from: RECENT_RECORDINGS_FROM, after }, + signal, + }) +} + +export const SESSION_EVENT_COLUMNS = [ + 'timestamp', + 'event', + 'current_url', + 'pathname', + 'screen_name', + 'element_texts', + 'event_type', + 'issue_id', + 'exception_types', + 'exception_values', + 'browser', + 'os', + 'city', +] as const + +/** Page views and clicks are many, so their query is capped; a session past the cap shows its first events. */ +export const SESSION_ACTIVITY_LIMIT = 300 +/** Errors and rage clicks are rare, so their own query keeps them even when page views and clicks hit the cap. */ +export const SESSION_PROBLEMS_LIMIT = 100 +/** Events can arrive a little before the first snapshot or after the last one. */ +const WINDOW_PADDING_MS = 60_000 + +export type SessionEventsPart = 'activity' | 'problems' + +const PART_FILTERS: Record = { + activity: + "AND (event IN ('$pageview', '$screen') OR (event = '$autocapture' AND properties.$event_type = 'click'))", + problems: "AND event IN ('$rageclick', '$exception')", +} + +const PART_LIMITS: Record = { + activity: SESSION_ACTIVITY_LIMIT, + problems: SESSION_PROBLEMS_LIMIT, +} + +/** + * One part of what happened in a session. The session id and the time window go in as `values`, never into the query + * text. The window keeps ClickHouse on the recording's own days instead of the whole events table. + */ +export function sessionEventsQuery( + sessionId: string, + startTime: string, + endTime: string, + part: SessionEventsPart +): Record { + const from = new Date(new Date(startTime).getTime() - WINDOW_PADDING_MS).toISOString() + const to = new Date(new Date(endTime).getTime() + WINDOW_PADDING_MS).toISOString() + return { + kind: 'HogQLQuery', + query: [ + 'SELECT timestamp, event, properties.$current_url, properties.$pathname, properties.$screen_name,', + 'elements_chain_texts, properties.$event_type, properties.$exception_issue_id,', + 'properties.$exception_types, properties.$exception_values, properties.$browser, properties.$os,', + 'properties.$geoip_city_name', + 'FROM events', + 'WHERE properties.$session_id = {session_id}', + 'AND timestamp >= toDateTime({date_from}) AND timestamp <= toDateTime({date_to})', + PART_FILTERS[part], + 'ORDER BY timestamp ASC', + `LIMIT ${PART_LIMITS[part]}`, + ].join(' '), + values: { session_id: sessionId, date_from: from, date_to: to }, + } +} + +export interface SessionEventsResponseApi { + results?: unknown[][] +} + +export interface SessionEventsApi { + activity: SessionEventsResponseApi + problems: SessionEventsResponseApi +} + +/** Both parts through `POST query/`, `blocking`, on the replay budget so Views refreshes never starve them. */ +export async function fetchSessionEvents( + sessionId: string, + startTime: string, + endTime: string, + signal?: AbortSignal +): Promise { + const run = async (part: SessionEventsPart): Promise => + (await runQuery(sessionEventsQuery(sessionId, startTime, endTime, part), { + signal, + budgetFamily: 'replay', + })) as SessionEventsResponseApi + const [activity, problems] = await Promise.all([run('activity'), run('problems')]) + return { activity, problems } +} + +export const recordingKeys = { + all: (scope: ProjectScope | null) => ['recordings', ...scopeKey(scope)] as const, + recent: (scope: ProjectScope | null) => [...recordingKeys.all(scope), 'recent', 'pages'] as const, + detail: (scope: ProjectScope | null, sessionId: string) => + [...recordingKeys.all(scope), 'detail', sessionId] as const, + events: (scope: ProjectScope | null, sessionId: string) => + [...recordingKeys.all(scope), 'events', sessionId] as const, +} diff --git a/products/mobile/src/api/replayVision.test.ts b/products/mobile/src/api/replayVision.test.ts new file mode 100644 index 000000000000..e8a5b129d05d --- /dev/null +++ b/products/mobile/src/api/replayVision.test.ts @@ -0,0 +1,53 @@ +import { setAuthProvider } from './client' +import { sessionEventsQuery } from './recordings' +import { fetchFrameUrl, requestSessionSummary } from './replayVision' + +describe('replay API', () => { + beforeEach(() => + setAuthProvider({ + getAuth: () => ({ host: 'https://example.com', accessToken: 'token', projectId: 7, session: 1 }), + refreshAccessToken: async () => null, + }) + ) + afterEach(() => setAuthProvider(null)) + + test('a frame resolves to the signed URL without following the redirect', async () => { + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 302, + headers: { get: (name: string) => (name === 'Location' ? 'https://storage.example.com/frame.png' : null) }, + }) + await expect(fetchFrameUrl('obs-1', 2)).resolves.toBe('https://storage.example.com/frame.png') + expect(fetch).toHaveBeenCalledWith( + 'https://example.com/api/projects/7/vision/observations/obs-1/thumbnail/?chapter=2', + expect.objectContaining({ redirect: 'manual' }) + ) + }) + + test('summarize runs the built-in summary and reads its outcome for the session', async () => { + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 202, + text: async () => + JSON.stringify({ + scan_id: 'scan-1', + started: 0, + results: [{ session_id: 's-1', scan_outcome: 'skipped_quota' }], + }), + }) + await expect(requestSessionSummary('s-1')).resolves.toBe('skipped_quota') + const init = (fetch as jest.Mock).mock.calls[0][1] as RequestInit + expect(JSON.parse(init.body as string)).toEqual({ built_in: 'summarize_recording', session_ids: ['s-1'] }) + }) + + test('the events query takes the session id as a value, never as query text', () => { + const id = "abc' OR 1=1 --" + const query = sessionEventsQuery(id, '2026-10-21T20:14:00Z', '2026-10-21T20:18:12Z', 'problems') + expect(query.query).not.toContain(id) + expect(query.values).toEqual({ + session_id: id, + date_from: '2026-10-21T20:13:00.000Z', + date_to: '2026-10-21T20:19:12.000Z', + }) + }) +}) diff --git a/products/mobile/src/api/replayVision.ts b/products/mobile/src/api/replayVision.ts new file mode 100644 index 000000000000..f4354a8f0df8 --- /dev/null +++ b/products/mobile/src/api/replayVision.ts @@ -0,0 +1,156 @@ +import { ApiError, apiFetch, apiJson, projectPath } from './client' +import { type ProjectScope, scopeKey } from './reports' + +// Shapes follow `ReplayObservationSerializer`, `InlineScanResponseSerializer` and `WatchFeedResponseSerializer` in +// `products/replay_vision/backend/api/`, keeping the fields the app reads. The summarizer's `model_output` follows +// `SummarizerOutput` in `products/replay_vision/backend/temporal/scanners/summarizer.py`. + +export type ObservationStatusApi = 'pending' | 'running' | 'succeeded' | 'failed' | 'ineligible' + +/** One persisted chapter, on the session clock. `idle` entries come from older scans and are skipped. */ +export interface SummaryChapterApi { + kind?: 'activity' | 'idle' + start_ms: number + end_ms?: number + title: string + thumbnail_ms?: number | null +} + +/** `model_output`. Every scanner type carries `notability` and `key_moment_ms`; a summarizer adds the rest. */ +export interface ModelOutputApi { + scanner_type?: string + title?: string + summary?: string + chapters?: SummaryChapterApi[] + notability?: number | null + notability_reason?: string | null + key_moment_ms?: number | null +} + +export interface ObservationMediaApi { + kind: 'thumbnail' | 'chapter' + position: number +} + +export interface ObservationApi { + id: string + scanner_id: string + session_id: string + status: ObservationStatusApi + scanner_snapshot?: { name?: string; scanner_type?: string } | null + scanner_result?: { model_output?: ModelOutputApi | null } | null + distinct_id?: string | null + recording_subject_email?: string | null + media?: ObservationMediaApi[] + summary_line?: string + /** On `failed` and `ineligible`: `kind:human-readable message`. */ + error_reason?: string + completed_at?: string | null + created_at?: string +} + +export interface ObservationListApi { + results?: ObservationApi[] +} + +export async function fetchSessionObservations(sessionId: string, signal?: AbortSignal): Promise { + return apiJson(projectPath('vision/observations/'), { + query: { session_id: sessionId }, + signal, + }) +} + +export type ScanOutcomeApi = + | 'started' + | 'already_running' + | 'already_scanned' + | 'skipped_limit' + | 'skipped_quota' + | 'skipped_scanner_limit' + | 'failed' + +export interface InlineScanResponseApi { + scan_id: string | null + started: number + results?: { session_id: string; scan_outcome: ScanOutcomeApi }[] +} + +/** + * `POST vision/scanners/inline_scan/` with the built-in `summarize_recording`, the scan the web's Summarize button + * runs, so both share one result. Needs `replay_scanner:write`. Answers 202 whatever the outcome, and 400 when the + * organization has not approved AI data processing. + */ +export async function requestSessionSummary(sessionId: string, signal?: AbortSignal): Promise { + const response = await apiJson(projectPath('vision/scanners/inline_scan/'), { + method: 'POST', + json: { built_in: 'summarize_recording', session_ids: [sessionId] }, + signal, + }) + return response.results?.find((result) => result.session_id === sessionId)?.scan_outcome ?? null +} + +export interface WatchFeedItemApi { + observation: ObservationApi + reason: { kind: string; notability?: number | null; notability_reason?: string | null } +} + +export interface WatchFeedResponseApi { + results?: WatchFeedItemApi[] +} + +export const WATCH_FEED_LIMIT = 5 + +export async function fetchWatchFeed(signal?: AbortSignal): Promise { + return apiJson(projectPath('vision/scanners/watch_feed/'), { + query: { limit: WATCH_FEED_LIMIT, date_from: '-7d' }, + signal, + }) +} + +/** + * The address of one frame of an observation: its thumbnail, or one chapter's frame. The endpoint answers with a + * redirect to a signed object-storage URL. The request does not follow it, so the bearer token never goes to the + * storage host, and the image then loads from the signed URL without credentials. The global fetch on native is + * `expo/fetch`, which hands back the 302 itself for `redirect: 'manual'`. + */ +export async function fetchFrameUrl( + observationId: string, + chapter: number | null, + signal?: AbortSignal +): Promise { + const response = await apiFetch( + projectPath(`vision/observations/${encodeURIComponent(observationId)}/thumbnail/`), + { + query: { chapter }, + redirect: 'manual', + signal, + } + ) + const location = response.headers.get('Location') + if (response.status >= 300 && response.status < 400 && location) { + return location + } + if (response.ok) { + // Local dev and a deployment without object storage serve the bytes from the API itself. + return frameDataUri(await response.arrayBuffer(), response.headers.get('Content-Type') ?? 'image/png') + } + throw new ApiError(response.status, undefined, response.headers) +} + +function frameDataUri(bytes: ArrayBuffer, contentType: string): string { + const view = new Uint8Array(bytes) + let binary = '' + for (let index = 0; index < view.length; index += 0x8000) { + binary += String.fromCharCode(...view.subarray(index, index + 0x8000)) + } + return `data:${contentType};base64,${btoa(binary)}` +} + +export const visionKeys = { + all: (scope: ProjectScope | null) => ['vision', ...scopeKey(scope)] as const, + session: (scope: ProjectScope | null, sessionId: string) => + [...visionKeys.all(scope), 'session', sessionId] as const, + feed: (scope: ProjectScope | null) => [...visionKeys.all(scope), 'feed'] as const, + frame: (scope: ProjectScope | null, observationId: string, chapter: number | null) => + [...visionKeys.all(scope), 'frame', observationId, chapter ?? 'thumbnail'] as const, +} diff --git a/products/mobile/src/api/reports.test.ts b/products/mobile/src/api/reports.test.ts new file mode 100644 index 000000000000..30c6a16f2ffd --- /dev/null +++ b/products/mobile/src/api/reports.test.ts @@ -0,0 +1,101 @@ +import { setAuthProvider } from './client' +import { dismissReport, fetchInboxPage, fetchLatestReviewerArtefact, INBOX_PAGE_SIZE, resolveReport } from './reports' + +describe('reports api', () => { + const urls: string[] = [] + const bodies: unknown[] = [] + + beforeEach(() => { + urls.length = 0 + bodies.length = 0 + setAuthProvider({ + getAuth: () => ({ host: 'https://app.example.com', accessToken: 'token', projectId: 2, session: 1 }), + refreshAccessToken: async () => null, + }) + globalThis.fetch = jest.fn(async (url: string, init?: RequestInit) => { + urls.push(url) + bodies.push(typeof init?.body === 'string' ? JSON.parse(init.body) : undefined) + return { + ok: true, + status: 200, + headers: { get: () => null }, + text: async () => JSON.stringify({ count: 0, next: null, results: [] }), + } as unknown as Response + }) as unknown as typeof fetch + }) + + afterEach(() => { + setAuthProvider(null) + }) + + test.each([0, INBOX_PAGE_SIZE])('the inbox asks for personal reports at offset %i', async (offset) => { + await fetchInboxPage(offset) + + const url = new URL(urls[0] ?? '') + expect(url.pathname).toBe('/api/projects/2/signals/reports/') + expect(Object.fromEntries(url.searchParams)).toEqual({ + view: 'for_you', + sort: 'priority', + limit: String(INBOX_PAGE_SIZE), + offset: String(offset), + }) + }) + + test('finds the newest reviewer assignment beyond the first artefact page', async () => { + const assignment = { id: 'assignment', type: 'suggested_reviewers', content: [{ github_login: 'reviewer' }] } + const pages = [ + { + count: 101, + next: 'https://app.example.com/api/projects/2/signals/reports/report-1/artefacts/?limit=100&offset=100', + results: Array.from({ length: 100 }, (_, index) => ({ id: `newer-${index}`, type: 'note' })), + }, + { count: 101, next: null, results: [assignment] }, + ] + globalThis.fetch = jest.fn(async () => ({ + ok: true, + status: 200, + headers: { get: () => null }, + text: async () => JSON.stringify(pages.shift()), + })) as unknown as typeof fetch + + await expect(fetchLatestReviewerArtefact('report-1')).resolves.toMatchObject(assignment) + const requests = jest.mocked(globalThis.fetch).mock.calls.map(([url]) => new URL(String(url))) + expect(requests.map((url) => url.searchParams.get('offset'))).toEqual([null, '100']) + }) + + test('rejects an artefact page from another origin', async () => { + globalThis.fetch = jest.fn(async () => ({ + ok: true, + status: 200, + headers: { get: () => null }, + text: async () => + JSON.stringify({ count: 101, next: 'https://other.example.com/steal?offset=100', results: [] }), + })) as unknown as typeof fetch + + await expect(fetchLatestReviewerArtefact('report-1')).rejects.toThrow('Invalid report artefact page') + expect(globalThis.fetch).toHaveBeenCalledTimes(1) + }) + + test.each([ + { + name: 'resolve sends the reason and the trimmed note', + send: () => resolveReport('report-1', 'pr_merged', ' Fixed in #12 \n'), + body: { state: 'resolved', dismissal_reason: 'pr_merged', dismissal_note: 'Fixed in #12' }, + }, + { + name: 'dismiss leaves out a blank note', + send: () => dismissReport('report-1', 'other', ' '), + body: { state: 'suppressed', dismissal_reason: 'other' }, + }, + { + name: 'a long note is cut to the API limit', + send: () => dismissReport('report-1', 'analysis_wrong', 'x'.repeat(4100)), + body: { state: 'suppressed', dismissal_reason: 'analysis_wrong', dismissal_note: 'x'.repeat(4000) }, + }, + ])('$name', async ({ send, body }) => { + await send() + + expect(new URL(urls[0] ?? '').pathname).toBe('/api/projects/2/signals/reports/report-1/state/') + expect(bodies[0]).toEqual(body) + }) +}) diff --git a/products/mobile/src/api/reports.ts b/products/mobile/src/api/reports.ts new file mode 100644 index 000000000000..f6a36f0f69f2 --- /dev/null +++ b/products/mobile/src/api/reports.ts @@ -0,0 +1,345 @@ +import type { ReportPriority } from '@/model/types' +import { type DismissReason, type ResolveReason, VERDICT_NOTE_MAX_LENGTH } from '@/model/verdictReasons' +import type { + PullRequestChecksResponseApi, + SignalsReportsListParams, +} from '../../../signals/frontend/generated/api.schemas' + +import { ApiError, apiJson, apiUrl, projectPath } from './client' + +// Shapes follow `products/signals/backend/serializers.py` and `report_read_state.py`, keeping the fields the app reads. + +export interface ReportUserApi { + uuid: string + first_name: string + last_name: string + email: string +} + +export interface ReportAssigneeApi { + claim_id?: string | null + kind: 'user' | 'task' | 'agent' | 'system' + user: ReportUserApi | null + task_id: string | null + agent: string | null + claimed_at: string | null +} + +export interface ReportPullRequestApi { + id?: string | null + url: string + state: 'unknown' | 'draft' | 'open' | 'closed' | 'merged' + merged: boolean + review_decision: 'approved' | 'changes_requested' | 'review_required' | null +} + +/** `ReportMetricSerializer`. `query` is only on the detail response, and null when redacted. */ +export interface ReportMetricApi { + metric_id: string + title: string + kind: string + role: 'primary' | 'supporting' + /** Null means no snapshot for this viewer, never zero. */ + value: number | null + value_at: string | null + /** Trailing per-bucket values, oldest first. */ + series: number[] | null + value_format: 'number' | 'count' | 'percentage' | 'percentage_scaled' | 'duration' | 'currency' + unit: string | null + caption: string | null + /** An `InsightVizNode` with a `TrendsQuery` source. */ + query?: unknown +} + +export interface ReportChartApi { + chart_id: string + title: string + /** An `InsightVizNode`, a `DataVisualizationNode` (a `HogQLQuery` source) or a `SavedInsightNode` (a `shortId`). */ + query: unknown + caption?: string | null + size?: 'small' | 'medium' | 'large' | null +} + +export interface SignalReportApi { + id: string + title: string | null + summary: string | null + summary_lead: string + status: string + signal_count: number + /** Snooze threshold: the report comes back when `signal_count` reaches it. */ + signals_at_run: number + created_at: string + updated_at: string + metrics: ReportMetricApi[] + charts: ReportChartApi[] + suggested_prompts: string[] + priority: string | null + actionability: string | null + already_addressed: boolean | null + is_suggested_reviewer: boolean + source_products: string[] + scout_name: string | null + implementation_pr_url: string | null + pull_requests: ReportPullRequestApi[] + work_state: 'unclaimed' | 'working' | 'in_review' | 'done' + assignee: ReportAssigneeApi | null + /** `ReportRankingSerializer`: outcome head name (such as `pr_merged`) to its probability. Null for non-staff users. */ + ranking?: { scores: Record } | null +} + +export interface PaginatedApi { + count: number + next: string | null + results: T[] +} + +export interface SignalNodeApi { + signal_id: string + content: string + source_product: string + source_type: string + source_id: string + timestamp: string + extra: Record | null +} + +export interface ReportSignalsApi { + signals: SignalNodeApi[] +} + +/** `SignalReportArtefactSerializer`. `content` is parsed JSON; its shape depends on `type` (`artefact_schemas.py`). */ +export interface ReportArtefactApi { + id: string + type: string + content: unknown + created_at: string + actor_kind: 'user' | 'task' | 'agent' | 'system' + actor_agent: string | null + created_by: ReportUserApi | null + task_id: string | null +} + +export interface SuggestedReviewerApi { + github_login: string | null + user_uuid: string | null + github_name: string | null + user: ReportUserApi | null +} + +/** `SuggestedReviewerEntryWriteSerializer`. One of the two keys is required. */ +export interface SuggestedReviewerWriteApi { + user_uuid?: string + github_login?: string +} + +export type AvailableReviewersApi = Record + +export interface ReadStatesApi { + states: Record +} + +export interface ProjectScope { + /** Base URL of the PostHog instance, without a trailing slash. */ + host: string + projectId: number +} + +export function isMissingFeature(error: unknown): boolean { + return error instanceof ApiError && (error.status === 404 || error.status === 403) +} + +export function retryUnlessMissing(failureCount: number, error: unknown): boolean { + return !isMissingFeature(error) && failureCount < 1 +} + +export type ScopeKey = readonly [string, number] | readonly ['none'] + +export function scopeKey(scope: ProjectScope | null): ScopeKey { + return scope ? [scope.host, scope.projectId] : ['none'] +} + +/** Query keys. Every key carries the host and project so a project switch never shows stale data. */ +export const reportKeys = { + all: (scope: ProjectScope | null) => ['signals', ...scopeKey(scope)] as const, + inbox: (scope: ProjectScope | null) => [...reportKeys.all(scope), 'inbox'] as const, + readStates: (scope: ProjectScope | null, ids: readonly string[]) => + [...reportKeys.all(scope), 'read_states', ids.join(',')] as const, + readStatesAll: (scope: ProjectScope | null) => [...reportKeys.all(scope), 'read_states'] as const, + report: (scope: ProjectScope | null, id: string) => [...reportKeys.all(scope), 'report', id] as const, + detail: (scope: ProjectScope | null, id: string) => [...reportKeys.report(scope, id), 'detail'] as const, + artefacts: (scope: ProjectScope | null, id: string) => [...reportKeys.report(scope, id), 'artefacts'] as const, + reviewers: (scope: ProjectScope | null, id: string) => [...reportKeys.artefacts(scope, id), 'reviewers'] as const, + signals: (scope: ProjectScope | null, id: string) => [...reportKeys.report(scope, id), 'signals'] as const, + page: (scope: ProjectScope | null, id: string) => ['today', ...scopeKey(scope), 'page', id] as const, + task: (scope: ProjectScope | null, taskId: string | undefined) => + ['tasks', ...scopeKey(scope), 'task', taskId ?? ''] as const, + availableReviewers: (scope: ProjectScope | null, query: string) => + [...reportKeys.all(scope), 'available_reviewers', query] as const, +} + +export interface InboxPageApi { + results: SignalReportApi[] + count: number + next?: string | null +} + +export const INBOX_PAGE_SIZE = 20 + +export function fetchInboxPage(offset: number, signal?: AbortSignal): Promise { + return apiJson(projectPath('signals/reports/'), { + query: { view: 'for_you', sort: 'priority', limit: INBOX_PAGE_SIZE, offset } satisfies SignalsReportsListParams, + signal, + }) +} + +export function fetchReport(reportId: string, signal?: AbortSignal): Promise { + return apiJson(projectPath(`signals/reports/${encodeURIComponent(reportId)}/`), { signal }) +} + +export function fetchReportSignals(reportId: string, signal?: AbortSignal): Promise { + return apiJson(projectPath(`signals/reports/${encodeURIComponent(reportId)}/signals/`), { + signal, + }) +} + +export const ARTEFACT_PAGE_SIZE = 100 + +/** `GET signals/reports/{id}/artefacts/`, newest first. */ +export function fetchReportArtefacts( + reportId: string, + signal?: AbortSignal, + offset = 0 +): Promise> { + return apiJson>( + projectPath(`signals/reports/${encodeURIComponent(reportId)}/artefacts/`), + { + query: { limit: ARTEFACT_PAGE_SIZE, ...(offset ? { offset } : {}) }, + signal, + } + ) +} + +/** Find the latest full reviewer assignment, which may be older than the first artefact page. */ +export async function fetchLatestReviewerArtefact( + reportId: string, + signal?: AbortSignal +): Promise { + const path = projectPath(`signals/reports/${encodeURIComponent(reportId)}/artefacts/`) + const firstUrl = new URL(apiUrl(path)) + let offset = 0 + for (;;) { + const page = await fetchReportArtefacts(reportId, signal, offset) + const reviewer = page.results.find((artefact) => artefact.type === 'suggested_reviewers') + if (reviewer || !page.next) { + return reviewer ?? null + } + const next = new URL(page.next, firstUrl) + const nextOffset = Number(next.searchParams.get('offset')) + if ( + next.origin !== firstUrl.origin || + next.pathname !== firstUrl.pathname || + !Number.isSafeInteger(nextOffset) || + nextOffset <= offset + ) { + throw new Error('Invalid report artefact page') + } + offset = nextOffset + } +} + +/** `read_state/` accepts at most this many ids per request. */ +export const READ_STATE_BATCH = 100 + +/** Without `read` this only reads the states; with it, it sets them for the current user and returns the new states. */ +export function postReadStates(reportIds: string[], read?: boolean, signal?: AbortSignal): Promise { + return apiJson(projectPath('signals/reports/read_state/'), { + method: 'POST', + json: read === undefined ? { report_ids: reportIds } : { report_ids: reportIds, read }, + signal, + }) +} + +/** `POST signals/reports/{id}/state/` with `potential`: hides the report until `count` more signals arrive. */ +export function snoozeReportForSignals(reportId: string, count: number): Promise { + return apiJson(projectPath(`signals/reports/${encodeURIComponent(reportId)}/state/`), { + method: 'POST', + json: { state: 'potential', snooze_for: count }, + }) +} + +function postVerdict( + reportId: string, + state: 'resolved' | 'suppressed', + reason: ResolveReason | DismissReason, + note = '' +): Promise { + const trimmed = note.trim().slice(0, VERDICT_NOTE_MAX_LENGTH) + return apiJson(projectPath(`signals/reports/${encodeURIComponent(reportId)}/state/`), { + method: 'POST', + json: { state, dismissal_reason: reason, ...(trimmed ? { dismissal_note: trimmed } : {}) }, + }) +} + +/** + * `POST signals/reports/{id}/state/` with `resolved`: marks the report done and closes its open implementation PR. + * A 409 means the report's current status cannot move to resolved; its `error` says why. + */ +export function resolveReport(reportId: string, reason: ResolveReason, note?: string): Promise { + return postVerdict(reportId, 'resolved', reason, note) +} + +/** + * `POST signals/reports/{id}/state/` with `suppressed`: the report leaves the inbox, and the agent that filed it reads + * the note on its next run. A 409 means the report's current status cannot move to suppressed; its `error` says why. + */ +export function dismissReport(reportId: string, reason: DismissReason, note?: string): Promise { + return postVerdict(reportId, 'suppressed', reason, note) +} + +/** `PUT signals/reports/{id}/reviewers/`: replaces the suggested reviewers. Returns the new artefact. */ +export function putReportReviewers(reportId: string, content: SuggestedReviewerWriteApi[]): Promise { + return apiJson(projectPath(`signals/reports/${encodeURIComponent(reportId)}/reviewers/`), { + method: 'PUT', + json: { content }, + }) +} + +/** `SignalReportArtefactWriteResponseSerializer`: the stored row, without the actor fields of the read shape. */ +export interface ReportArtefactWriteApi { + id: string + report_id: string + type: string + content: unknown + created_at: string + task_id: string | null +} + +/** `PriorityAssessment` requires a non-empty explanation. */ +export const MANUAL_PRIORITY_EXPLANATION = 'Priority set by hand from the PostHog mobile app' + +/** `POST signals/reports/{id}/artefacts/` with a `priority_judgment`: the newest one is the report's priority. */ +export function postPriorityJudgment(reportId: string, priority: ReportPriority): Promise { + return apiJson(projectPath(`signals/reports/${encodeURIComponent(reportId)}/artefacts/`), { + method: 'POST', + json: { artefact_type: 'priority_judgment', content: { priority, explanation: MANUAL_PRIORITY_EXPLANATION } }, + }) +} + +export function fetchAvailableReviewers(query: string, signal?: AbortSignal): Promise { + return apiJson(projectPath('signals/reports/available_reviewers/'), { + query: { query: query || undefined }, + signal, + }) +} + +export function fetchReportPullRequestChecks( + reportId: string, + pullRequestId: string, + signal?: AbortSignal +): Promise { + return apiJson( + projectPath(`signals/reports/${encodeURIComponent(reportId)}/pr_checks/`), + { query: { pull_request_id: pullRequestId }, signal } + ) +} diff --git a/products/mobile/src/api/scopeErrors.test.ts b/products/mobile/src/api/scopeErrors.test.ts new file mode 100644 index 000000000000..9914f712efc3 --- /dev/null +++ b/products/mobile/src/api/scopeErrors.test.ts @@ -0,0 +1,42 @@ +import { ApiError } from './client' +import { missingFeatureScope, missingScope, missingViewsScope } from './scopeErrors' + +describe('missingScope', () => { + test.each<[string, unknown, string | null]>([ + [ + 'a missing scope', + new ApiError(403, { detail: "API key missing required scope 'dashboard:read'" }), + 'dashboard:read', + ], + [ + 'a 403 without access', + new ApiError(403, { detail: 'You do not have permission to perform this action.' }), + null, + ], + ['a 404', new ApiError(404, { detail: "API key missing required scope 'dashboard:read'" }), null], + ['a plain error', new Error("API key missing required scope 'insight:read'"), null], + ])('reads %s', (_name, error, expected) => { + expect(missingScope(error)).toBe(expected) + }) + + test.each<[string, string[] | null, string | null]>([ + ['an unknown grant', null, null], + ['every scope', ['dashboard:read', 'insight:read', 'file_system_shortcut:write'], null], + ['write scopes that cover the reads', ['dashboard:write', 'insight:write', 'file_system_shortcut:write'], null], + ['a grant from before Views', ['user:read', 'query:read'], 'dashboard:read'], + [ + 'a read where write is needed', + ['dashboard:read', 'insight:read', 'file_system_shortcut:read'], + 'file_system_shortcut:write', + ], + ])('finds the missing Views scope in %s', (_name, granted, expected) => { + expect(missingViewsScope(granted)).toBe(expected) + }) + + test.each<[string, string[], 'errors' | 'replay', string | null]>([ + ['a write that covers the error tracking read', ['error_tracking:write'], 'errors', null], + ['a grant without the summary scopes', ['session_recording:read'], 'replay', 'replay_scanner:read'], + ])('finds the missing feature scope in %s', (_name, granted, feature, expected) => { + expect(missingFeatureScope(granted, feature)).toBe(expected) + }) +}) diff --git a/products/mobile/src/api/scopeErrors.ts b/products/mobile/src/api/scopeErrors.ts new file mode 100644 index 000000000000..9ea050c435e0 --- /dev/null +++ b/products/mobile/src/api/scopeErrors.ts @@ -0,0 +1,49 @@ +import { ApiError } from './client' + +// `APIScopePermission` in `posthog/permissions.py` answers a token without the scope with 403 and this detail. A 403 +// for anything else, such as a dashboard the user cannot view, has another detail and must not ask for a new sign-in. +const MISSING_SCOPE = /missing required scope '([^']+)'/ + +/** The scope a 403 names as missing, such as `dashboard:read`, or null for any other error. */ +export function missingScope(error: unknown): string | null { + if (!(error instanceof ApiError) || error.status !== 403) { + return null + } + return MISSING_SCOPE.exec(error.detail ?? '')?.[1] ?? null +} + +/** The scopes Views needs on top of the app's others. `file_system_shortcut:write` also grants the read. */ +export const VIEWS_SCOPES = ['dashboard:read', 'insight:read', 'file_system_shortcut:write'] as const + +/** True when the scopes include `scope`. A `:write` scope covers the matching `:read`. */ +export function coversScope(scopes: readonly string[], scope: string): boolean { + const [object, level] = scope.split(':') + return scopes.includes(scope) || (level === 'read' && scopes.includes(`${object}:write`)) +} + +/** The scopes the issues list and issue screen need. The write covers status and assignee changes. */ +export const ERRORS_SCOPES = ['error_tracking:read', 'error_tracking:write'] as const + +/** The scopes the sessions list and session screen need. `replay_scanner` covers the AI summary. */ +export const REPLAY_SCOPES = ['session_recording:read', 'replay_scanner:read', 'replay_scanner:write'] as const + +export type ScopedFeature = 'views' | 'errors' | 'replay' + +export const FEATURE_SCOPES: Record = { + views: VIEWS_SCOPES, + errors: ERRORS_SCOPES, + replay: REPLAY_SCOPES, +} + +/** The first scope of `feature` a grant lacks, or null for an unknown grant. */ +export function missingFeatureScope(granted: readonly string[] | null, feature: ScopedFeature): string | null { + if (!granted) { + return null + } + return FEATURE_SCOPES[feature].find((scope) => !coversScope(granted, scope)) ?? null +} + +/** The first Views scope a grant lacks, or null for an unknown grant. */ +export function missingViewsScope(granted: readonly string[] | null): string | null { + return missingFeatureScope(granted, 'views') +} diff --git a/products/mobile/src/api/serverClock.ts b/products/mobile/src/api/serverClock.ts new file mode 100644 index 000000000000..9ec0e033786b --- /dev/null +++ b/products/mobile/src/api/serverClock.ts @@ -0,0 +1,32 @@ +// The server's refresh windows (`next_allowed_client_refresh`, `cache_target_age`) are server times. A phone clock +// that runs fast would force a compute inside the window, so every comparison with them goes through `serverNow()`. + +let offsetMs = 0 + +/** + * Keeps the offset between the server's `Date` header and the device clock from the latest response. A missing or + * unreadable header keeps the previous offset, so the device clock applies until a server sends one. + */ +export function recordServerDate(header: string | null | undefined, receivedAt: number = Date.now()): void { + if (!header) { + return + } + const server = Date.parse(header) + if (Number.isNaN(server)) { + return + } + offsetMs = server - receivedAt +} + +export function serverOffsetMs(): number { + return offsetMs +} + +/** Epoch ms in server time. */ +export function serverNow(): number { + return Date.now() + offsetMs +} + +export function resetServerClock(): void { + offsetMs = 0 +} diff --git a/products/mobile/src/api/shortcuts.ts b/products/mobile/src/api/shortcuts.ts new file mode 100644 index 000000000000..665820efae17 --- /dev/null +++ b/products/mobile/src/api/shortcuts.ts @@ -0,0 +1,50 @@ +import { ApiError, apiJson, projectPath } from './client' +import type { PaginatedApi } from './reports' + +// Shapes follow `FileSystemShortcutSerializer` in `posthog/api/file_system/file_system_shortcut.py`. These rows are the +// web's "Starred" items: the app uses the default surface, so a pin made here shows as a star on the web. + +export interface FileSystemShortcutApi { + id: string + /** Escaped like the web's `joinPath`: `/` as `\/` and `\` as `\\`. */ + path: string + type: string | null + ref: string | null + href: string | null + order: number | null + created_at: string +} + +/** What `POST file_system_shortcut/` takes. The server returns the existing row on an exact match. */ +export interface ShortcutInput { + path: string + type: string + ref: string | null + href: string +} + +/** One page holds every pin the app shows. A longer list shows a note instead of a second page. */ +export const SHORTCUT_PAGE_SIZE = 100 + +export function fetchShortcuts(signal?: AbortSignal): Promise> { + return apiJson>(projectPath('file_system_shortcut/'), { + query: { limit: SHORTCUT_PAGE_SIZE }, + signal, + }) +} + +export function createShortcut(input: ShortcutInput): Promise { + return apiJson(projectPath('file_system_shortcut/'), { method: 'POST', json: input }) +} + +/** Works for a row in any environment of the project. A 404 means the row is already gone, which is the goal. */ +export async function deleteShortcut(id: string): Promise { + try { + await apiJson(projectPath(`file_system_shortcut/${encodeURIComponent(id)}/`), { method: 'DELETE' }) + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + return + } + throw error + } +} diff --git a/products/mobile/src/api/sse.test.ts b/products/mobile/src/api/sse.test.ts new file mode 100644 index 000000000000..d33f81e64eb3 --- /dev/null +++ b/products/mobile/src/api/sse.test.ts @@ -0,0 +1,65 @@ +import { createSseParser, readSse, type SseEvent } from './sse' + +function parse(chunks: string[]): SseEvent[] { + const events: SseEvent[] = [] + const parser = createSseParser((event) => events.push(event)) + for (const chunk of chunks) { + parser.feed(chunk) + } + return events +} + +describe('sse', () => { + test.each<[string, string[], SseEvent[]]>([ + [ + 'an unnamed frame defaults to message', + ['data: {"a":1}\n\n'], + [{ event: 'message', data: '{"a":1}', id: undefined }], + ], + [ + 'a frame split across chunks', + ['ev', 'ent: keepalive\nda', 'ta: {"type":"keep', 'alive"}\n', '\n'], + [{ event: 'keepalive', data: '{"type":"keepalive"}', id: undefined }], + ], + [ + 'ids attach to their frame', + ['id: log-3\ndata: x\n\ndata: y\n\n'], + [ + { event: 'message', data: 'x', id: 'log-3' }, + { event: 'message', data: 'y', id: undefined }, + ], + ], + ['CRLF line endings', ['id: 1-0\r\ndata: x\r\n\r\n'], [{ event: 'message', data: 'x', id: '1-0' }]], + [ + 'CRLF split between CR and LF keeps the id with its data', + ['id: 1-0\r', '\ndata: x\r', '\n\r', '\n'], + [{ event: 'message', data: 'x', id: '1-0' }], + ], + ['comment lines are ignored', [': ping\ndata: x\n\n'], [{ event: 'message', data: 'x', id: undefined }]], + [ + 'multi-line data joins with newlines', + ['data: a\ndata: b\n\n'], + [{ event: 'message', data: 'a\nb', id: undefined }], + ], + ['a frame without data dispatches nothing', ['event: end\n\n'], []], + ['an unterminated frame waits for its blank line', ['data: x\n'], []], + ])('%s', (_name, chunks, expected) => { + expect(parse(chunks)).toEqual(expected) + }) + + test('readSse decodes a byte stream split mid-character', async () => { + const bytes = new TextEncoder().encode('data: "Größe"\n\n') + const chunks = [bytes.slice(0, 10), bytes.slice(10)] + const reader = { + read: jest.fn(async () => { + const value = chunks.shift() + return value ? { value, done: false } : { value: undefined, done: true } + }), + cancel: jest.fn(async () => undefined), + } + const response = { body: { getReader: () => reader } } as unknown as Response + const events: SseEvent[] = [] + await readSse(response, (event) => events.push(event)) + expect(events).toEqual([{ event: 'message', data: '"Größe"', id: undefined }]) + }) +}) diff --git a/products/mobile/src/api/sse.ts b/products/mobile/src/api/sse.ts new file mode 100644 index 000000000000..fc448aa054ea --- /dev/null +++ b/products/mobile/src/api/sse.ts @@ -0,0 +1,123 @@ +import { abortError } from '@/lib/abort' + +/** One server-sent event. `event` defaults to `message` when the frame names none. */ +export interface SseEvent { + event: string + data: string + id?: string +} + +export interface SseParser { + feed: (chunk: string) => void + reset: () => void +} + +export function createSseParser(onEvent: (event: SseEvent) => void): SseParser { + let buffer = '' + let skipLeadingLf = false + let event = '' + let data: string[] = [] + let id: string | undefined + + const dispatch = (): void => { + if (data.length > 0) { + onEvent({ event: event || 'message', data: data.join('\n'), id }) + } + event = '' + data = [] + id = undefined + } + + const handleLine = (line: string): void => { + if (line === '') { + dispatch() + return + } + if (line.startsWith(':')) { + return + } + const colon = line.indexOf(':') + const field = colon === -1 ? line : line.slice(0, colon) + let value = colon === -1 ? '' : line.slice(colon + 1) + if (value.startsWith(' ')) { + value = value.slice(1) + } + switch (field) { + case 'event': + event = value + break + case 'data': + data.push(value) + break + case 'id': + id = value + break + default: + break + } + } + + return { + feed: (chunk) => { + if (!chunk) { + return + } + // A CRLF split across chunks: the CR already ended the line, so this LF is not a blank line. + buffer += skipLeadingLf && chunk.startsWith('\n') ? chunk.slice(1) : chunk + skipLeadingLf = false + let newline = buffer.search(/\r\n|\n|\r/) + while (newline !== -1) { + const line = buffer.slice(0, newline) + const crAtEnd = buffer[newline] === '\r' && newline === buffer.length - 1 + buffer = buffer.slice(newline + (buffer[newline] === '\r' && buffer[newline + 1] === '\n' ? 2 : 1)) + skipLeadingLf = crAtEnd + handleLine(line) + newline = buffer.search(/\r\n|\n|\r/) + } + }, + reset: () => { + buffer = '' + skipLeadingLf = false + event = '' + data = [] + id = undefined + }, + } +} + +export async function readSse( + response: Response, + onEvent: (event: SseEvent) => void, + signal?: AbortSignal +): Promise { + const body = response.body + if (!body) { + throw new Error('Streaming is not supported by this response') + } + // Expo installs `TextDecoder` on native. The check gives a clear error in a runtime without Expo's globals. + if (typeof TextDecoder === 'undefined') { + throw new Error('TextDecoder is not available in this runtime') + } + const parser = createSseParser(onEvent) + const reader = body.getReader() + const decoder = new TextDecoder() + const abort = (): void => { + void reader.cancel() + } + signal?.addEventListener('abort', abort, { once: true }) + try { + while (true) { + if (signal?.aborted) { + throw abortError() + } + const { value, done } = await reader.read() + if (done) { + parser.feed(decoder.decode()) + break + } + parser.feed(decoder.decode(value, { stream: true })) + } + } finally { + signal?.removeEventListener('abort', abort) + } +} diff --git a/products/mobile/src/api/tasks.test.ts b/products/mobile/src/api/tasks.test.ts new file mode 100644 index 000000000000..8cc709c21663 --- /dev/null +++ b/products/mobile/src/api/tasks.test.ts @@ -0,0 +1,34 @@ +import { setAuthProvider } from './client' +import { listMySessions, SESSION_LIST_LIMIT } from './tasks' + +describe('listMySessions', () => { + afterEach(() => { + setAuthProvider(null) + }) + + test('asks for the user chats and leaves out scout runs', async () => { + setAuthProvider({ + getAuth: () => ({ host: 'https://app.example.com', accessToken: 'token', projectId: 2, session: 1 }), + refreshAccessToken: async () => null, + }) + const fetchMock = jest.fn(async (_url: string) => ({ + ok: true, + status: 200, + headers: { get: () => null }, + text: async () => JSON.stringify({ results: [] }), + })) + globalThis.fetch = fetchMock as unknown as typeof fetch + + await listMySessions(7) + + const url = new URL(fetchMock.mock.calls[0]?.[0] ?? '') + expect(url.pathname).toBe('/api/projects/2/tasks/') + expect(Object.fromEntries(url.searchParams)).toEqual({ + created_by: '7', + ordering: '-last_activity_at', + basic: 'true', + limit: String(SESSION_LIST_LIMIT), + exclude_origin_product: 'signals_scout', + }) + }) +}) diff --git a/products/mobile/src/api/tasks.ts b/products/mobile/src/api/tasks.ts new file mode 100644 index 000000000000..bfe6b2e1f16b --- /dev/null +++ b/products/mobile/src/api/tasks.ts @@ -0,0 +1,421 @@ +import { ApiError, apiFetch, apiJson, apiStream, projectPath } from './client' + +// Shapes follow `products/tasks/backend/presentation/serializers.py`, keeping the fields the app reads. + +export type TaskRunStatusApi = 'not_started' | 'queued' | 'in_progress' | 'completed' | 'failed' | 'cancelled' + +export interface TaskRunOutputApi { + pr_url?: string | null + pr_urls?: string[] | null + head_branches?: string[] | null +} + +/** The part of `TaskRun.state` the app reads. The server stores more keys there. */ +export interface TaskRunStateApi { + resume_from_run_id?: string | null + initial_permission_mode?: string | null +} + +export interface TaskRunApi { + id: string + task: string + status: TaskRunStatusApi + stage: string | null + branch: string | null + error_message: string | null + output: TaskRunOutputApi | null + completed_at: string | null + updated_at?: string + runtime_adapter?: RuntimeAdapterApi | null + model?: string | null + reasoning_effort?: ReasoningEffortApi | null + state?: TaskRunStateApi | null +} + +export interface TaskApi { + id: string + title: string + signal_report: string | null + latest_run: TaskRunApi | null +} + +export interface TaskCreateResponseApi extends TaskApi { + run_error?: string +} + +export interface TaskRunResponseApi extends TaskCreateResponseApi { + /** The run this call created or activated. Older backends only fill `latest_run`. */ + run?: TaskRunApi | null +} + +/** The Inbox "Create PR" task. The server resolves the repository and rejects one the client sets. */ +interface ReportImplementationTaskCreateApi { + title: string + description: string + origin_product: 'signal_report' + signal_report: string + signal_report_task_relationship: 'implementation' +} + +/** + * A PostHog AI chat, as the web side panel creates it. A blank title makes the server name the task from + * `description`. It sends no `branch`: that key would let the create claim a warm run a web composer booted. + */ +export interface ChatTaskCreateApi extends Partial { + title: '' + description: string + origin_product: 'posthog_ai' + initial_permission_mode: PermissionModeApi + pending_user_message: string +} + +/** The Inbox "Discuss" task. Repo-less report discussions are exempt from the PostHog Desktop gate. */ +export interface ReportDiscussionTaskCreateApi extends Partial { + title: string + description: string + origin_product: 'signal_report' + signal_report: string + signal_report_task_relationship: 'discussion' + /** Forwarded to the report's scout. At most 4000 characters. */ + signal_report_discussion_question?: string + pending_user_message: string +} + +/** `TaskCreateSerializer`, in the three shapes the app sends. */ +export type TaskCreateRequestApi = ReportImplementationTaskCreateApi | ChatTaskCreateApi | ReportDiscussionTaskCreateApi + +export type RuntimeAdapterApi = 'claude' | 'codex' + +/** `REASONING_EFFORTS` in `products/tasks/backend/model_catalog.py`. */ +export type ReasoningEffortApi = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultracode' + +/** `INITIAL_PERMISSION_MODE_CHOICES` (Claude) and `CODEX_INITIAL_PERMISSION_MODE_CHOICES` (Codex). */ +export type PermissionModeApi = + | 'default' + | 'acceptEdits' + | 'plan' + | 'bypassPermissions' + | 'auto' + | 'read-only' + | 'full-access' + +/** The pinned model fields of `TaskRunCreateRequestSerializer`. The server needs `runtime_adapter` with `model`. */ +export interface RunRuntimeSelectionApi { + runtime_adapter: RuntimeAdapterApi + model: string + /** Left out for a model with no effort control, which rejects any value. */ + reasoning_effort?: ReasoningEffortApi +} + +/** The run fields the hand-off options control. Without a runtime the server resolves the user's, then the team's, default. */ +export interface HandoffRunFieldsApi extends Partial { + initial_permission_mode?: PermissionModeApi + /** Base branch to check out in the sandbox. Left out for the repository's default branch. */ + branch?: string +} + +/** + * `TaskRunCreateRequestSerializer`. A follow-up on an ended run sets `resume_from_run_id` and no run source; without + * a runtime it keeps the ended run's selection. + */ +export interface TaskRunCreateRequestApi extends HandoffRunFieldsApi { + run_source?: 'signal_report' + signal_report_id?: string + resume_from_run_id?: string + mode: 'interactive' + pending_user_message: string + /** Staged artifact ids from `staged_artifacts/finalize_upload/`. The server copies them into the new run. */ + pending_user_artifact_ids?: string[] +} + +export interface ModelChoiceApi { + runtime_adapter: RuntimeAdapterApi + model: string + display_name: string + /** Ascending. Empty for a model with no effort control. */ + supported_efforts: ReasoningEffortApi[] + /** Ready to display, such as `2×`. Null when the catalogue quotes no rate. */ + cost_multiplier?: string | null +} + +/** `TasksResolvedAIRunDefaultsSerializer`: what a run uses when it pins no runtime. */ +export interface ResolvedAiRunDefaultsApi { + runtime: 'acp' | 'pi' + /** Null when no default is stored or the harness is Pi. */ + runtime_adapter: string | null + model: string | null + reasoning_effort: string | null + source: 'user' | 'team' | 'none' +} + +export interface TaskUserConfigApi { + resolved_ai_run_defaults: ResolvedAiRunDefaultsApi +} + +export interface PermissionResponseParams { + requestId: string + optionId: string + customInput?: string + answers?: Record +} + +export interface UserMessageParams { + content: string + /** Joins the agent's live turn. The server queues the message as an ordinary follow-up when the agent declines. */ + steer?: boolean + /** Artifact ids already on this run's manifest, from `runs/{r}/artifacts/finalize_upload/`. */ + artifact_ids?: string[] +} + +type SessionConfigId = 'model' | 'effort' | 'mode' + +export interface SetConfigOptionParams { + configId: SessionConfigId + value: string +} + +export type TaskRunCommand = + | { method: 'permission_response'; params: PermissionResponseParams } + | { method: 'user_message'; params: UserMessageParams } + | { method: 'set_config_option'; params: SetConfigOptionParams } + | { method: 'cancel' } + +/** JSON-RPC reply from `command/`. HTTP 200 can still carry an `error`. */ +export interface TaskRunCommandResponseApi { + jsonrpc?: string + id?: string | number | null + result?: unknown + error?: { code?: number; message?: string } +} + +export interface TaskRequestOptions { + signal?: AbortSignal + headers?: Record +} + +export function createTask( + body: TaskCreateRequestApi, + options: TaskRequestOptions = {} +): Promise { + return apiJson(projectPath('tasks/'), { method: 'POST', json: body, ...options }) +} + +export function startTaskRun( + taskId: string, + body: TaskRunCreateRequestApi, + options: TaskRequestOptions = {} +): Promise { + return apiJson(projectPath(`tasks/${encodeURIComponent(taskId)}/run/`), { + method: 'POST', + json: body, + ...options, + }) +} + +/** + * Where an upload goes. `staged` holds files on a task whose run does not exist yet, and `run` adds them to a live + * run's manifest. Both take and return the same shapes. + */ +export type ArtifactUploadTarget = { kind: 'staged'; taskId: string } | { kind: 'run'; taskId: string; runId: string } + +/** `TaskStagedArtifactPrepareUploadSerializer` and `TaskRunArtifactPrepareUploadSerializer`, for a user attachment. */ +export interface ArtifactPrepareRequestApi { + name: string + type: 'user_attachment' + source: 'posthog_ai' + size: number + content_type: string +} + +export interface PresignedPostApi { + url: string + fields: Record +} + +/** `TaskStagedArtifactPrepareUploadResponseSerializer`. The array keeps the request's order. */ +export interface PreparedArtifactApi { + id: string + name: string + storage_path: string + content_type?: string + presigned_post: PresignedPostApi +} + +/** `TaskStagedArtifactFinalizeUploadSerializer` and `TaskRunArtifactFinalizeUploadSerializer`. */ +export interface ArtifactFinalizeRequestApi { + id: string + name: string + type: 'user_attachment' + source: 'posthog_ai' + storage_path: string + content_type: string +} + +function artifactUploadPath(target: ArtifactUploadTarget, step: 'prepare_upload' | 'finalize_upload'): string { + const task = `tasks/${encodeURIComponent(target.taskId)}` + return target.kind === 'staged' + ? projectPath(`${task}/staged_artifacts/${step}/`) + : projectPath(`${task}/runs/${encodeURIComponent(target.runId)}/artifacts/${step}/`) +} + +export async function prepareArtifactUpload( + target: ArtifactUploadTarget, + artifacts: ArtifactPrepareRequestApi[], + signal?: AbortSignal +): Promise { + const body = await apiJson<{ artifacts?: PreparedArtifactApi[] }>(artifactUploadPath(target, 'prepare_upload'), { + method: 'POST', + json: { artifacts }, + signal, + }) + return body.artifacts ?? [] +} + +export async function finalizeArtifactUpload( + target: ArtifactUploadTarget, + artifacts: ArtifactFinalizeRequestApi[], + signal?: AbortSignal +): Promise { + await apiJson(artifactUploadPath(target, 'finalize_upload'), { + method: 'POST', + json: { artifacts }, + signal, + }) +} + +/** `PATCH tasks/{id}/`. The server stamps `archived_at` and leaves an archived task out of the default list. */ +export async function setTaskArchived(taskId: string, archived: boolean, signal?: AbortSignal): Promise { + await apiJson(projectPath(`tasks/${encodeURIComponent(taskId)}/`), { + method: 'PATCH', + json: { archived }, + signal, + }) +} + +/** Soft-deletes the task. It does not stop a run that already started. */ +export async function deleteTask(taskId: string, signal?: AbortSignal): Promise { + await apiJson(projectPath(`tasks/${encodeURIComponent(taskId)}/`), { method: 'DELETE', signal }) +} + +/** Every model a run may use, from the live gateway catalogue. Empty when the gateway is unreachable. */ +export async function getTaskModels(signal?: AbortSignal): Promise { + const body = await apiJson<{ models?: ModelChoiceApi[] }>(projectPath('tasks/models/'), { signal }) + return body.models ?? [] +} + +export function getMyTaskConfig(signal?: AbortSignal): Promise { + return apiJson(projectPath('tasks/@me/config/'), { signal }) +} + +export function getTask(taskId: string, signal?: AbortSignal): Promise { + return apiJson(projectPath(`tasks/${encodeURIComponent(taskId)}/`), { signal }) +} + +export function getTaskRun(taskId: string, runId: string, signal?: AbortSignal): Promise { + return apiJson(projectPath(`tasks/${encodeURIComponent(taskId)}/runs/${encodeURIComponent(runId)}/`), { + signal, + }) +} + +/** The stored run log, one JSON record per line. A resumed run's log starts with its ancestors' logs, oldest first. */ +export async function getTaskRunLogs(taskId: string, runId: string, signal?: AbortSignal): Promise { + const response = await apiFetch( + projectPath(`tasks/${encodeURIComponent(taskId)}/runs/${encodeURIComponent(runId)}/logs/`), + { signal } + ) + const text = await response.text() + if (!response.ok) { + throw new ApiError(response.status, parseJsonLine(text), response.headers) + } + return parseJsonLines(text) +} + +function parseJsonLine(line: string): unknown { + try { + return JSON.parse(line) as unknown + } catch { + return undefined + } +} + +export function parseJsonLines(text: string): unknown[] { + const records: unknown[] = [] + for (const line of text.split('\n')) { + const trimmed = line.trim() + if (!trimmed) { + continue + } + const record = parseJsonLine(trimmed) + if (record !== undefined) { + records.push(record) + } + } + return records +} + +// Without `lastEventId` the server replays the durable run log first, which is how a question asked before the app +// connected reaches the decision card. +export function openTaskRunStream( + taskId: string, + runId: string, + options: { lastEventId?: string; signal?: AbortSignal } = {} +): Promise { + return apiStream(projectPath(`tasks/${encodeURIComponent(taskId)}/runs/${encodeURIComponent(runId)}/stream/`), { + signal: options.signal, + headers: options.lastEventId ? { 'Last-Event-ID': options.lastEventId } : undefined, + }) +} + +export function sendTaskRunCommand( + taskId: string, + runId: string, + command: TaskRunCommand, + options: { id: string; signal?: AbortSignal } +): Promise { + return apiJson( + projectPath(`tasks/${encodeURIComponent(taskId)}/runs/${encodeURIComponent(runId)}/command/`), + { + method: 'POST', + json: { + jsonrpc: '2.0', + id: options.id, + method: command.method, + ...('params' in command ? { params: command.params } : {}), + }, + signal: options.signal, + } + ) +} + +/** `TaskBasicApi` in `products/tasks/frontend/generated/api.schemas.ts`, the `basic=true` list shape. */ +export interface TaskSummaryApi { + id: string + title: string + /** `TaskOriginProduct`, such as `posthog_ai` for a chat or `signal_report` for a report hand-off. */ + origin_product: string + archived: boolean + latest_run: Pick | null + created_at: string | null + updated_at: string | null + last_activity_at: string | null +} + +export const SESSION_LIST_LIMIT = 30 + +// Scout runs are headless, not conversations. The web PostHog AI task list leaves them out the same way. +const SCOUT_ORIGIN_PRODUCT = 'signals_scout' + +/** `created_by` filters on the numeric user id from `users/@me/`, not the UUID. */ +export async function listMySessions(userId: number, signal?: AbortSignal): Promise { + const body = await apiJson<{ results?: TaskSummaryApi[] }>(projectPath('tasks/'), { + query: { + created_by: userId, + ordering: '-last_activity_at', + basic: true, + limit: SESSION_LIST_LIMIT, + exclude_origin_product: SCOUT_ORIGIN_PRODUCT, + }, + signal, + }) + return body.results ?? [] +} diff --git a/products/mobile/src/api/today.ts b/products/mobile/src/api/today.ts new file mode 100644 index 000000000000..3a97356e17ff --- /dev/null +++ b/products/mobile/src/api/today.ts @@ -0,0 +1,57 @@ +import { apiJson, projectPath } from './client' + +// Shapes follow `products/today/backend/presentation/serializers.py`. The page returns 404 when the project or person +// has no Today (flag off, no AI data processing approval, no AI credits); the report screen then shows signals instead. + +export interface RecordingTargetApi { + session_id: string + /** The finding's time in the recording, as MM:SS. */ + offset: string | null + seek_seconds: number | null +} + +/** Quiet lines are stack frames. */ +export interface PreviewLineApi { + text: string + quiet: boolean +} + +export interface SignalPreviewApi { + block: PreviewLineApi[] + facts: string[] +} + +export interface SignalViewApi { + signal_id: string + source_product: string + source_type: string + content: string + timestamp: string + extra: Record + headline: string + lead: string + meta: string + recording: RecordingTargetApi | null + preview: SignalPreviewApi | null +} + +export interface ImpactNumberApi { + key: string + value: string + sentence: string +} + +export interface ReportPageApi { + /** The summary's opening paragraph, as markdown. */ + lead: string + /** The impact section cut to whole sentences, as markdown. Empty when it states no measurement. */ + impact_sentence: string + /** At most 3 signals, newest first, one per source first. */ + evidence: SignalViewApi[] + impact_numbers: ImpactNumberApi[] + last_seen: string | null +} + +export function fetchReportPage(reportId: string, signal?: AbortSignal): Promise { + return apiJson(projectPath(`today/reports/${encodeURIComponent(reportId)}/page/`), { signal }) +} diff --git a/products/mobile/src/api/users.ts b/products/mobile/src/api/users.ts new file mode 100644 index 000000000000..757e3c5f71e3 --- /dev/null +++ b/products/mobile/src/api/users.ts @@ -0,0 +1,49 @@ +import { apiJson } from './client' + +export interface TeamApi { + id: number + name: string + project_id?: number +} + +export interface OrganizationApi { + id: string + name: string + teams: TeamApi[] + /** Null means not approved, as the server treats anything but `true`. */ + is_ai_data_processing_approved?: boolean | null + membership_level?: number | null +} + +export interface OrganizationBasicApi { + id: string + name: string + is_active?: boolean | null +} + +export interface MeApi { + /** The numeric user id. Some list filters, such as `tasks/?created_by=`, take it instead of the UUID. */ + id: number + uuid: string + distinct_id: string + email: string + anonymize_data?: boolean | null + first_name: string + last_name: string + team: TeamApi | null + organization: OrganizationApi | null + organizations: OrganizationBasicApi[] +} + +/** Needs `user:read`. Also the check that a new token or key works. */ +export function fetchMe(init: { signal?: AbortSignal } = {}): Promise { + return apiJson('/api/users/@me/', init) +} + +/** Needs `organization:read`. */ +export function fetchOrganization( + organizationId: string, + init: { signal?: AbortSignal } = {} +): Promise { + return apiJson(`/api/organizations/${encodeURIComponent(organizationId)}/`, init) +} diff --git a/products/mobile/src/api/webAnalytics.ts b/products/mobile/src/api/webAnalytics.ts new file mode 100644 index 000000000000..ea6f39599f6d --- /dev/null +++ b/products/mobile/src/api/webAnalytics.ts @@ -0,0 +1,60 @@ +import type { QueryStatusFieldsApi, ResolvedDateRangeApi } from './insights' +import { type QueryRefresh, runQuery } from './query' + +// Shapes follow `WebOverviewQuery`, `WebOverviewItem` and `CachedWebOverviewQueryResponse` in `posthog/schema.py`. + +export interface WebOverviewItemApi { + /** `visitors`, `views`, `sessions`, `session duration`, `bounce rate`, and revenue or conversion keys. */ + key: string + /** `unit`, `duration_s`, `percentage` or `currency`. */ + kind: string + value?: number | null + previous?: number | null + changeFromPreviousPct?: number | null + isIncreaseBad?: boolean | null +} + +export interface WebOverviewResponseApi { + results?: WebOverviewItemApi[] + last_refresh?: string | null + cache_target_age?: string | null + next_allowed_client_refresh?: string | null + is_cached?: boolean + timezone?: string | null + resolved_date_range?: ResolvedDateRangeApi | null + query_status?: QueryStatusFieldsApi | null +} + +export interface WebOverviewQueryOptions { + dateFrom: string | null + dateTo: string | null + explicitDate?: boolean + compare: boolean + interval?: 'hour' | 'day' | 'week' | 'month' + filterTestAccounts?: boolean +} + +/** The web's overview tile query without its property filters, so a phone read with the web's range can share its cache. */ +export function webOverviewQuery(options: WebOverviewQueryOptions): Record { + const { dateFrom, dateTo, explicitDate, compare, interval = 'day', filterTestAccounts = false } = options + return { + kind: 'WebOverviewQuery', + properties: [], + dateRange: { date_from: dateFrom, date_to: dateTo, ...(explicitDate ? { explicitDate: true } : {}) }, + interval, + compareFilter: { compare }, + filterTestAccounts, + } +} + +/** One `POST query/` with no polling: a response that still runs keeps the old value until the next due check. */ +export async function fetchWebOverview( + query: Record, + options: { refresh: QueryRefresh; signal?: AbortSignal } +): Promise { + return (await runQuery(query, { + refresh: options.refresh, + poll: false, + signal: options.signal, + })) as WebOverviewResponseApi +} diff --git a/products/mobile/src/auth/BootSplash.test.tsx b/products/mobile/src/auth/BootSplash.test.tsx new file mode 100644 index 000000000000..42096bfe9c88 --- /dev/null +++ b/products/mobile/src/auth/BootSplash.test.tsx @@ -0,0 +1,93 @@ +import { act, fireEvent, render, screen } from '@testing-library/react-native' +import * as SplashScreen from 'expo-splash-screen' + +import { track } from '@/lib/analytics' + +import RootLayout from '../../app/_layout' +import { useAuthStore } from './authStore' +import { SPLASH_TIMEOUT_MS, useBootGate } from './bootGate' + +jest.mock('expo-router', () => { + const { createElement } = jest.requireActual('react') + const { View } = jest.requireActual('react-native') + const segments = ['(tabs)', 'inbox'] + return { + Stack: Object.assign(() => createElement(View, { testID: 'app-screens' }), { Screen: () => null }), + useSegments: () => segments, + } +}) +jest.mock('expo-splash-screen', () => ({ + preventAutoHideAsync: jest.fn(async () => undefined), + setOptions: jest.fn(), + hideAsync: jest.fn(async () => undefined), +})) +jest.mock('react-native-gesture-handler', () => ({ GestureHandlerRootView: require('react-native').View })) +jest.mock('react-native-keyboard-controller', () => ({ KeyboardProvider: require('react-native').View })) +jest.mock( + 'react-native-safe-area-context', + () => jest.requireActual('react-native-safe-area-context/jest/mock').default +) +jest.mock('@/auth/authStore', () => { + const { create } = jest.requireActual('zustand') + return { + installAuthProvider: jest.fn(), + useAuthStore: create(() => ({ status: 'loading', restore: async () => undefined })), + } +}) +jest.mock('@/auth/useAuthRedirect', () => ({ useAuthRedirect: jest.fn() })) +jest.mock('@/demo/demoMode', () => ({ useDemoHydrated: () => true })) +jest.mock('@/ui/appearanceStore', () => ({ + ...jest.requireActual('@/ui/appearanceStore'), + useAppearanceHydrated: () => true, +})) +jest.mock('@/features/ai/AiApprovalDialog', () => ({ AiApprovalDialog: () => null })) +jest.mock('@/features/updates/useAutomaticUpdates', () => ({ useAutomaticUpdates: jest.fn() })) +jest.mock('@/features/chat/draftPersistence', () => ({})) +jest.mock('@/features/hedgehog/useHedgehogWidgetSync', () => ({ useHedgehogWidgetSync: jest.fn() })) +jest.mock('@/features/notifications/pushRegistration', () => ({ configureNotifications: jest.fn() })) +jest.mock('@/features/notifications/useNotificationRouting', () => ({ useNotificationRouting: jest.fn() })) +jest.mock('@/features/notifications/usePushRegistration', () => ({ usePushRegistration: jest.fn() })) +jest.mock('@/lib/analytics', () => ({ track: jest.fn() })) +jest.mock('@/lib/posthogClient', () => ({ initAnalytics: jest.fn() })) +jest.mock('@/lib/useScreenTracking', () => ({ useScreenTracking: jest.fn() })) +jest.mock('@/ui/RouteErrorBoundary', () => ({ RouteErrorBoundary: jest.fn() })) +jest.mock('@/ui/icons/AnimatedLogomark', () => ({ AnimatedLogomark: require('react-native').View })) + +describe('boot splash handoff', () => { + beforeEach(() => { + jest.useFakeTimers() + jest.clearAllMocks() + useAuthStore.setState({ status: 'loading' }) + useBootGate.setState({ firstScreenReady: false }) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + test.each(['first_screen', 'timeout'] as const)( + 'reveals the animated splash before the session loads and keeps it until %s', + (reason) => { + render() + fireEvent(screen.getByTestId('boot-splash'), 'layout') + + expect(SplashScreen.hideAsync).toHaveBeenCalledTimes(1) + expect(screen.getByLabelText('Loading PostHog')).toBeTruthy() + expect(screen.queryByTestId('app-screens')).toBeNull() + + act(() => useAuthStore.setState({ status: 'signed_in' })) + expect(screen.getByTestId('app-screens')).toBeTruthy() + expect(screen.getByTestId('boot-splash')).toBeTruthy() + + act(() => { + if (reason === 'first_screen') { + useBootGate.getState().markFirstScreenReady() + } else { + jest.advanceTimersByTime(SPLASH_TIMEOUT_MS) + } + }) + expect(screen.queryByTestId('boot-splash')).toBeNull() + expect(track).toHaveBeenCalledWith('mobile_splash_hidden', expect.objectContaining({ reason })) + } + ) +}) diff --git a/products/mobile/src/auth/BootSplash.tsx b/products/mobile/src/auth/BootSplash.tsx new file mode 100644 index 000000000000..343b5cd802f4 --- /dev/null +++ b/products/mobile/src/auth/BootSplash.tsx @@ -0,0 +1,29 @@ +import type { ReactElement } from 'react' +import { View } from 'react-native' + +import { AnimatedLogomark } from '@/ui/icons/AnimatedLogomark' +import { createStyles } from '@/ui/theme' + +export function BootSplash(): ReactElement { + const styles = useStyles() + return ( + + + + ) +} + +const useStyles = createStyles(({ colors }) => ({ + screen: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.background, + }, +})) diff --git a/products/mobile/src/auth/RegionSelect.tsx b/products/mobile/src/auth/RegionSelect.tsx new file mode 100644 index 000000000000..066cd8c496fd --- /dev/null +++ b/products/mobile/src/auth/RegionSelect.tsx @@ -0,0 +1,151 @@ +import { type ReactElement, useRef, useState } from 'react' +import { Modal, Text, useWindowDimensions, View } from 'react-native' + +import { FloatingCard } from '@/ui/FloatingCard' +import { CheckIcon } from '@/ui/icons/CheckIcon' +import { ChevronDownIcon } from '@/ui/icons/ChevronDownIcon' +import { Pressable44 } from '@/ui/Pressable44' +import { createStyles, useColors } from '@/ui/theme' +import { iconSizes, radii, type } from '@/ui/tokens' +import { useScreenInsets } from '@/ui/useScreenInsets' + +import { CLOUD_REGIONS, type CloudRegion } from './regions' + +interface RegionSelectProps { + value: CloudRegion['id'] + onChange: (region: CloudRegion['id']) => void + disabled: boolean +} + +interface Anchor { + x: number + y: number + width: number + height: number +} + +export function RegionSelect({ value, onChange, disabled }: RegionSelectProps): ReactElement { + const colors = useColors() + const styles = useStyles() + const trigger = useRef(null) + const [anchor, setAnchor] = useState(null) + const { height: windowHeight } = useWindowDimensions() + const insets = useScreenInsets() + const selected = CLOUD_REGIONS.find((region) => region.id === value) ?? CLOUD_REGIONS[0] + const close = (): void => setAnchor(null) + const open = (): void => { + trigger.current?.measureInWindow((x, y, width, height) => setAnchor({ x, y, width, height })) + } + + return ( + <> + + + {selected.flag} {selected.label} + + + + {anchor ? ( + + + + + {CLOUD_REGIONS.map((region) => ( + { + onChange(region.id) + close() + }} + accessibilityRole="radio" + accessibilityLabel={region.label} + accessibilityState={{ checked: region.id === value }} + aria-checked={region.id === value} + pressedBackground={colors.sunken} + style={styles.option} + testID={`region-${region.id}`} + > + + {region.flag} {region.label} + + {region.id === value ? ( + + ) : null} + + ))} + + + + + ) : null} + + ) +} + +const useStyles = createStyles(({ colors }) => ({ + trigger: { + minHeight: 48, + paddingHorizontal: 16, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + borderWidth: 1, + borderColor: colors.border, + borderRadius: radii.card, + backgroundColor: colors.surface, + }, + label: { + ...type.body, + color: colors.ink, + flex: 1, + }, + overlay: { + flex: 1, + }, + menu: { + padding: 6, + borderWidth: 1, + borderColor: colors.border, + borderRadius: radii.card, + }, + option: { + minHeight: 48, + paddingHorizontal: 10, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + borderRadius: radii.menuRow, + }, +})) diff --git a/products/mobile/src/auth/SignInField.tsx b/products/mobile/src/auth/SignInField.tsx new file mode 100644 index 000000000000..7724bea1f8e4 --- /dev/null +++ b/products/mobile/src/auth/SignInField.tsx @@ -0,0 +1,73 @@ +import type { ReactElement } from 'react' +import { Text, TextInput, type TextInputProps, View } from 'react-native' + +import { createStyles, useTheme } from '@/ui/theme' +import { inputReset, radii, type } from '@/ui/tokens' + +interface SignInFieldProps extends Pick { + label: string + secret?: boolean + hint?: string + editable?: boolean + testID?: string +} + +export function SignInField({ + label, + secret = false, + hint, + editable = true, + testID, + ...input +}: SignInFieldProps): ReactElement { + const { colors, scheme } = useTheme() + const styles = useStyles() + return ( + + {label.toUpperCase()} + + {hint ? {hint} : null} + + ) +} + +const useStyles = createStyles(({ colors }) => ({ + field: { + gap: 8, + }, + label: { + color: colors.tertiary, + paddingHorizontal: 4, + }, + input: { + ...inputReset, + height: 48, + borderRadius: radii.pill, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + paddingHorizontal: 18, + ...type.input, + color: colors.ink, + }, + disabled: { + color: colors.tertiary, + }, + hint: { + ...type.metaSmall, + color: colors.secondary, + paddingHorizontal: 4, + }, +})) diff --git a/products/mobile/src/auth/SignInScreen.tsx b/products/mobile/src/auth/SignInScreen.tsx new file mode 100644 index 000000000000..e84ca8222b5e --- /dev/null +++ b/products/mobile/src/auth/SignInScreen.tsx @@ -0,0 +1,453 @@ +import { getCalendars, getLocales } from 'expo-localization' +import { useRouter } from 'expo-router' +import { type ReactElement, useState } from 'react' +import { ActivityIndicator, Linking, ScrollView, Text, View } from 'react-native' + +import { useDemoMode } from '@/demo/demoMode' +import { currentDefaultPageHref, defaultPageHref, settleDefaultPage } from '@/features/settings/defaultPageStore' +import { track } from '@/lib/analytics' +import { toError } from '@/lib/errors' +import { openLegalLink, PRIVACY_POLICY_URL, TERMS_URL } from '@/lib/legalLinks' +import { useAsyncAction } from '@/lib/useAsyncAction' +import { EnvironmentRibbon } from '@/ui/EnvironmentRibbon' +import { PostHogLogo } from '@/ui/icons/PostHogLogo' +import { Pressable44 } from '@/ui/Pressable44' +import { createStyles, useColors } from '@/ui/theme' +import { layout, radii, type } from '@/ui/tokens' +import { bottomOffset, useScreenInsets } from '@/ui/useScreenInsets' + +import { useAuthStore } from './authStore' +import { OAuthError } from './oauth' +import { RegionSelect } from './RegionSelect' +import { + CLOUD_REGIONS, + type CloudRegion, + DEFAULT_CUSTOM_CLIENT_ID, + defaultCloudRegion, + type RegionId, + SHOW_SIGNUP_LINK, + signupUrl, + validateHost, +} from './regions' +import { SignInField } from './SignInField' +import { signInFailureReason } from './signInErrors' + +export function SignInScreen(): ReactElement { + const colors = useColors() + const styles = useStyles() + const router = useRouter() + const insets = useScreenInsets() + const signIn = useAuthStore((state) => state.signIn) + const signedOutReason = useAuthStore((state) => state.signedOutReason) + const setDemo = useDemoMode((state) => state.setEnabled) + const [cloudRegion, setCloudRegion] = useState(() => { + const { region, host } = useAuthStore.getState() + return defaultCloudRegion( + getLocales()[0]?.regionCode ?? null, + getCalendars()[0]?.timeZone ?? null, + host ? region : undefined + ) + }) + const [selfHosted, setSelfHosted] = useState(() => useAuthStore.getState().region === 'custom') + const region: RegionId = selfHosted ? 'custom' : cloudRegion + const [customHost, setCustomHost] = useState(() => { + const { region: lastRegion, host } = useAuthStore.getState() + return lastRegion === 'custom' && host ? host : '' + }) + const [clientId, setClientId] = useState(() => { + const { region: lastRegion, clientId: lastClientId } = useAuthStore.getState() + return lastRegion === 'custom' && lastClientId ? lastClientId : DEFAULT_CUSTOM_CLIENT_ID + }) + const [error, setError] = useState<{ message: string; retryable: boolean } | null>(null) + + const selected = CLOUD_REGIONS.find((option) => option.id === cloudRegion) + const customHostCheck = selfHosted ? validateHost(customHost) : null + const formHost = customHostCheck ? (customHostCheck.ok ? customHostCheck.host : null) : (selected?.host ?? null) + + const submit = useAsyncAction(async () => { + setError(null) + const host = selfHosted ? customHost : (selected?.host ?? '') + const method = 'oauth' + track('mobile_sign_in_started', { method, region, after_expiry: signedOutReason === 'expired' }) + try { + await signIn(host, selfHosted ? clientId : (selected?.clientId ?? '')) + if (useDemoMode.getState().enabled) { + track('mobile_demo_ended', { from: 'sign_in' }) + } + setDemo(false) + router.replace(defaultPageHref(settleDefaultPage())) + } catch (caught) { + // Retrying cannot help when this platform has no OAuth sign-in. + const retryable = !(caught instanceof OAuthError && caught.kind === 'unsupported') + track('mobile_sign_in_failed', { + method, + region, + reason: signInFailureReason(caught, method), + retryable, + }) + setError({ message: toError(caught).message, retryable }) + } + }) + + const changeRegion = (next: RegionId): void => { + if (next !== region) { + track('mobile_sign_in_region_changed', { region: next }) + } + setError(null) + setSelfHosted(next === 'custom') + if (next !== 'custom') { + setCloudRegion(next) + } + } + + const tryDemo = (): void => { + track('mobile_demo_started', { from: 'sign_in' }) + setDemo(true) + router.replace(currentDefaultPageHref()) + } + + const createAccount = (): void => { + if (!selected?.host) { + return + } + track('mobile_create_account_opened', { region }) + Linking.openURL(signupUrl(selected.host)).catch(() => undefined) + } + + return ( + + + + + + + + + Log in to + @PostHog + + Let's go ship something. + + {selfHosted ? ( + <> + + + + ) : ( + + DATA REGION + + + )} + {signedOutReason === 'expired' && !error ? ( + + Your session ended. Log in again to continue. + + ) : null} + {error ? ( + + {error.message} + {error.retryable ? ( + void submit.run()} + disabled={submit.pending} + pressedBackground={colors.sunken} + style={styles.retry} + testID="sign-in-retry" + > + Try again + + ) : null} + + ) : null} + + + void submit.run()} + disabled={submit.pending} + accessibilityState={{ busy: submit.pending, disabled: submit.pending }} + pressedBackground={colors.accentHover} + style={styles.primaryPressable} + testID="sign-in" + > + {submit.pending ? ( + + ) : ( + Log in + )} + + + + changeRegion(selfHosted ? cloudRegion : 'custom')} + disabled={submit.pending} + pressedStyle={styles.linkPressed} + style={styles.link} + testID="sign-in-self-hosted" + > + + {selfHosted ? 'Use PostHog Cloud instead' : 'Use a self-hosted instance'} + + + + + + Try the demo + + + {SHOW_SIGNUP_LINK && !selfHosted ? ( + + + New to PostHog? Create an account → + + + ) : null} + + + openLegalLink('privacy', PRIVACY_POLICY_URL, 'sign_in')} + accessibilityRole="link" + pressedBackground={colors.pressed} + style={styles.legalLink} + testID="sign-in-privacy" + > + Privacy policy + + + · + + openLegalLink('terms', TERMS_URL, 'sign_in')} + accessibilityRole="link" + pressedBackground={colors.pressed} + style={styles.legalLink} + testID="sign-in-terms" + > + Terms + + + + ) +} + +const useStyles = createStyles(({ colors }) => ({ + screen: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + flexGrow: 1, + paddingHorizontal: layout.detailGutter, + }, + main: { + flexGrow: 1, + justifyContent: 'center', + paddingVertical: 24, + }, + ribbon: { + alignSelf: 'center', + marginBottom: 24, + }, + brand: { + alignItems: 'center', + marginBottom: 28, + }, + card: { + backgroundColor: colors.surface, + borderRadius: radii.softCard, + borderWidth: 1, + borderColor: colors.hairline, + padding: 24, + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + title: { + ...type.headline, + color: colors.ink, + }, + titleChip: { + color: colors.blue, + backgroundColor: colors.blueHighlight, + borderRadius: radii.markSmall, + paddingHorizontal: 6, + overflow: 'hidden', + }, + sub: { + ...type.body, + color: colors.muted, + textAlign: 'center', + marginTop: 6, + }, + form: { + marginTop: 28, + gap: 16, + }, + field: { + gap: 8, + }, + label: { + color: colors.tertiary, + paddingHorizontal: 4, + }, + notice: { + ...type.metaSmall, + color: colors.muted, + paddingHorizontal: 4, + }, + errorBox: { + gap: 10, + alignItems: 'flex-start', + padding: 12, + borderRadius: radii.card, + backgroundColor: colors.sunken, + }, + errorText: { + ...type.metaSmall, + color: colors.ink, + }, + retry: { + height: 36, + paddingHorizontal: 14, + borderRadius: radii.pill, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + alignItems: 'center', + justifyContent: 'center', + }, + retryText: { + ...type.buttonSmall, + color: colors.ink, + }, + primary: { + marginTop: 20, + height: 52, + borderRadius: radii.card, + backgroundColor: colors.accent, + overflow: 'hidden', + }, + primaryPending: { + opacity: 0.8, + }, + primaryPressable: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + primaryText: { + ...type.button, + color: colors.onAccent, + }, + divider: { + marginVertical: 20, + borderTopWidth: 1, + borderStyle: 'dashed', + borderColor: colors.border, + }, + secondary: { + height: 48, + borderRadius: radii.card, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + alignItems: 'center', + justifyContent: 'center', + }, + secondaryText: { + ...type.button, + color: colors.ink, + }, + links: { + marginTop: 6, + }, + link: { + height: 44, + alignSelf: 'center', + alignItems: 'center', + justifyContent: 'center', + }, + linkPressed: { + opacity: 0.6, + }, + linkText: { + ...type.buttonSmall, + color: colors.secondary, + }, + footer: { + marginTop: 20, + minHeight: 44, + borderRadius: radii.card, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 12, + }, + footerText: { + ...type.metaSmall, + color: colors.muted, + textAlign: 'center', + }, + footerLink: { + ...type.bodySmallStrong, + fontSize: 14, + color: colors.ink, + }, + legal: { + marginTop: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 4, + }, + legalLink: { + minHeight: 44, + paddingHorizontal: 8, + borderRadius: radii.card, + alignItems: 'center', + justifyContent: 'center', + }, + legalText: { + ...type.meta, + color: colors.secondary, + }, +})) diff --git a/products/mobile/src/auth/account.test.ts b/products/mobile/src/auth/account.test.ts new file mode 100644 index 000000000000..45f587aee97a --- /dev/null +++ b/products/mobile/src/auth/account.test.ts @@ -0,0 +1,22 @@ +import type { MeApi } from '@/api/users' + +import { buildAccount } from './account' + +describe('account analytics privacy', () => { + test.each([true, false, null, undefined])('keeps anonymization safe for preference %s', (preference) => { + const me: MeApi = { + id: 1, + uuid: 'example-user', + distinct_id: 'example-user', + email: 'person@example.com', + first_name: 'Example', + last_name: 'Person', + team: null, + organization: null, + organizations: [], + anonymize_data: preference, + } + + expect(buildAccount(me, []).user.anonymizeData).toBe(preference !== false) + }) +}) diff --git a/products/mobile/src/auth/account.ts b/products/mobile/src/auth/account.ts new file mode 100644 index 000000000000..d444eaa636e6 --- /dev/null +++ b/products/mobile/src/auth/account.ts @@ -0,0 +1,131 @@ +import { ApiError, NotSignedInError } from '@/api/client' +import { fetchMe, fetchOrganization, type MeApi, type OrganizationApi } from '@/api/users' + +export interface AuthUser { + /** Undefined in a session persisted before the app stored it, until the next account reload. */ + id?: number + uuid: string + distinctId: string + email: string + /** Unknown in older persisted sessions until the account reloads; analytics must withhold email. */ + anonymizeData?: boolean + name: string +} + +export interface AuthOrganization { + id: string + name: string + /** Undefined in a session persisted before the app stored it, until the next account reload. */ + aiDataProcessingApproved?: boolean + membershipLevel?: OrganizationApi['membership_level'] +} + +export interface AuthProject { + id: number + name: string + organizationId: string + organizationName: string +} + +export interface Account { + user: AuthUser + organizations: AuthOrganization[] + /** Grouped by organization, the user's current organization first, then by name. */ + projects: AuthProject[] + webTeamId: number | null +} + +function displayName(me: MeApi): string { + return `${me.first_name ?? ''} ${me.last_name ?? ''}`.trim() || me.email +} + +export function buildAccount(me: MeApi, organizations: OrganizationApi[]): Account { + const projects: AuthProject[] = [] + for (const organization of organizations) { + const teams = [...(organization.teams ?? [])].sort((a, b) => a.name.localeCompare(b.name)) + for (const team of teams) { + projects.push({ + id: team.id, + name: team.name, + organizationId: organization.id, + organizationName: organization.name, + }) + } + } + return { + user: { + id: me.id, + uuid: me.uuid, + distinctId: me.distinct_id, + email: me.email, + anonymizeData: me.anonymize_data !== false, + name: displayName(me), + }, + organizations: organizations.map((organization) => ({ + id: organization.id, + name: organization.name, + aiDataProcessingApproved: + organization.is_ai_data_processing_approved === undefined + ? undefined + : organization.is_ai_data_processing_approved === true, + membershipLevel: organization.membership_level, + })), + projects, + webTeamId: me.team?.id ?? null, + } +} + +// An organization that fails to load (for example one that enforces SSO this token does not satisfy) is left out +// instead of failing sign in. +async function loadAccountFrom( + readMe: () => Promise, + readOrganization: (organizationId: string) => Promise +): Promise { + const me = await readMe() + const current = me.organization + const others = me.organizations.filter( + (organization) => organization.is_active !== false && organization.id !== current?.id + ) + const results = await Promise.allSettled(others.map((organization) => readOrganization(organization.id))) + const loaded = results.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : [])) + return buildAccount(me, current ? [current, ...loaded] : loaded) +} + +export function loadAccount(): Promise { + return loadAccountFrom(fetchMe, fetchOrganization) +} + +/** Checks a candidate sign-in without changing the active API client's host or credentials. */ +export function loadAccountWithCredentials(host: string, accessToken: string): Promise { + const read = async (path: string): Promise => { + const response = await fetch(`${host}${path}`, { + headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` }, + }) + if (!response.ok) { + if (response.status === 401) { + throw new NotSignedInError() + } + let body: unknown + try { + body = await response.json() + } catch { + body = undefined + } + throw new ApiError(response.status, body, response.headers) + } + return (await response.json()) as T + } + return loadAccountFrom( + () => read('/api/users/@me/'), + (organizationId) => read(`/api/organizations/${encodeURIComponent(organizationId)}/`) + ) +} + +export function pickProjectId(projects: AuthProject[], candidates: (number | null | undefined)[]): number | null { + for (const candidate of candidates) { + if (candidate !== null && candidate !== undefined && projects.some((project) => project.id === candidate)) { + return candidate + } + } + return projects[0]?.id ?? null +} diff --git a/products/mobile/src/auth/authStore.ts b/products/mobile/src/auth/authStore.ts new file mode 100644 index 000000000000..c3804e8cc9ba --- /dev/null +++ b/products/mobile/src/auth/authStore.ts @@ -0,0 +1,583 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { create } from 'zustand' +import { createJSONStorage, persist } from 'zustand/middleware' + +import { ApiError, NotSignedInError, setAuthProvider } from '@/api/client' +import { queryClient } from '@/api/queryClient' +import { identify, resetAnalytics, track } from '@/lib/analytics' +import { toError } from '@/lib/errors' +import { createPersistHydrationGate } from '@/lib/persistHydration' + +import { + type Account, + type AuthOrganization, + type AuthProject, + type AuthUser, + loadAccount, + loadAccountWithCredentials, + pickProjectId, +} from './account' +import { authorize, OAuthError, refreshTokens, revokeToken } from './oauth' +import { type RegionId, regionForHost, validateHost } from './regions' +import { AccountLoadError, accountLoadKind, SignInInputError } from './signInErrors' +import { + clearTokens, + commitSessionTokens, + getSessionIdentity, + getSessionTokens, + loadTokens, + type SessionTokens, + saveRefreshedTokens, + sessionGeneration, + setSessionTokens, +} from './tokens' + +export type { AuthOrganization, AuthProject, AuthUser } from './account' + +export type AuthStatus = 'loading' | 'signed_out' | 'signed_in' +export type AuthMethod = 'oauth' | 'personal_api_key' +export type ProjectSwitchSource = 'nav_menu' | 'settings' | 'push' + +export interface AuthState { + status: AuthStatus + signedIn: boolean + /** The region of the last sign-in. Kept after sign out so the sign-in screen starts there. */ + region: RegionId + host: string | null + clientId: string | null + method: AuthMethod | null + user: AuthUser | null + organizations: AuthOrganization[] + projects: AuthProject[] + projectId: number | null + projectName: string | null + accountLoading: boolean + accountError: string | null + signedOutReason: 'expired' | null + restore: () => Promise + signIn: (host: string, clientId: string) => Promise + /** Development builds only. The personal API key never refreshes. */ + signInWithPersonalApiKey: (host: string, key: string) => Promise + selectProject: (projectId: number, from: ProjectSwitchSource) => void + reloadAccount: () => Promise + signOut: () => Promise +} + +/** Refresh at launch when the access token has less than this left, so the first requests do not all 401. */ +const RESTORE_REFRESH_MARGIN_MS = 60_000 + +type SignOutTask = () => Promise void)> +type SessionEndTask = () => void | Promise + +const signOutTasks = new Set() +const sessionEndTasks = new Set() + +/** Registers work that must run while the session is still valid, before sign out drops the token. */ +export function addSignOutTask(task: SignOutTask): () => void { + signOutTasks.add(task) + return () => { + signOutTasks.delete(task) + } +} + +/** + * Registers device cleanup for every end of a session: sign out and a session that expired. It runs after the token + * is gone, so it must not call the API. Features use it to drop per-user state without the auth store importing them. + */ +export function addSessionEndTask(task: SessionEndTask): () => void { + sessionEndTasks.add(task) + return () => { + sessionEndTasks.delete(task) + } +} + +const SIGNED_OUT_ACCOUNT = { + status: 'signed_out', + signedIn: false, + method: null, + user: null, + organizations: [], + projects: [], + projectId: null, + projectName: null, + accountLoading: false, + accountError: null, +} satisfies Partial + +function projectNameFor(projects: AuthProject[], projectId: number | null): string | null { + return projects.find((project) => project.id === projectId)?.name ?? null +} + +function accountLoadMessage(error: unknown, method: AuthMethod): string { + if (error instanceof NotSignedInError) { + return method === 'personal_api_key' + ? "PostHog didn't accept that key. Check that it is a personal API key with the user:read, organization:read and project:read scopes." + : "PostHog didn't accept the new session. Try signing in again." + } + if (error instanceof ApiError && error.status === 403) { + return `PostHog didn't share your account details with the app. ${error.detail ?? 'Check the key or app scopes and try again.'}` + } + if (error instanceof ApiError) { + return `Couldn't load your account. ${error.message}` + } + if (error instanceof TypeError) { + return "Couldn't reach PostHog. Check your connection and try again." + } + return toError(error).message +} + +/** In-flight work that callers of one session share. A caller from another session starts its own. */ +interface SessionTask { + session: number + promise: Promise +} + +let restoring: Promise | null = null +let reloading: SessionTask | null = null +let refreshing: SessionTask | null = null +let signInIntent = 0 +let sessionTransitions: Promise = Promise.resolve() +const hydration = createPersistHydrationGate() + +function serializeSessionTransition(work: () => Promise): Promise { + const result = sessionTransitions.then(work, work) + sessionTransitions = result.then( + () => undefined, + () => undefined + ) + return result +} + +async function runSignOutTasks(): Promise<(() => void)[]> { + const recoveries: (() => void)[] = [] + try { + for (const task of signOutTasks) { + const recover = await task() + if (recover) { + recoveries.push(recover) + } + } + return recoveries + } catch (error) { + for (const recover of recoveries) { + recover() + } + throw error + } +} + +export const useAuthStore = create()( + persist( + (set, get) => { + const accountFields = (account: Account, preferProjectId: number | null): Partial => { + const projectId = pickProjectId(account.projects, [preferProjectId, account.webTeamId]) + return { + user: account.user, + organizations: account.organizations, + projects: account.projects, + projectId, + projectName: projectNameFor(account.projects, projectId), + accountError: null, + } + } + + const applyAccount = (account: Account, preferProjectId: number | null): void => { + set(accountFields(account, preferProjectId)) + } + + const completeSignIn = async ( + tokens: SessionTokens, + method: AuthMethod, + host: string, + clientId: string | null, + intent: number + ): Promise => { + let account: Account + try { + account = await loadAccountWithCredentials(host, tokens.accessToken) + } catch (error) { + throw new AccountLoadError(accountLoadKind(error), accountLoadMessage(error, method)) + } + await serializeSessionTransition(async () => { + if (intent !== signInIntent) { + throw new OAuthError('cancelled') + } + const previous = get() + const previousSession = sessionGeneration() + let recoveries: (() => void)[] = [] + if (previous.status === 'signed_in') { + recoveries = await runSignOutTasks() + } + try { + if (intent !== signInIntent) { + throw new OAuthError('cancelled') + } + if (previous.status === 'signed_in') { + await Promise.allSettled([...sessionEndTasks].map(async (task) => task())) + } + const previousTokens = getSessionTokens() + const region = regionForHost(host) + const committed = await commitSessionTokens( + tokens, + { host, clientId, method, userUuid: account.user.uuid }, + () => intent === signInIntent && sessionGeneration() === previousSession, + () => { + queryClient.clear() + const preferProjectId = + previous.user?.uuid === account.user.uuid ? previous.projectId : null + set({ + ...accountFields(account, preferProjectId), + status: 'signed_in', + signedIn: true, + region, + host, + clientId, + method, + signedOutReason: null, + }) + } + ) + if (!committed) { + throw new OAuthError('cancelled') + } + if (previous.status === 'signed_in') { + queryClient.clear() + } + identify(account.user.distinctId) + track('mobile_signed_in', { method, region }) + if ( + previousTokens?.kind === 'oauth' && + previousTokens.refreshToken && + previous.host && + previous.clientId + ) { + void revokeToken({ + host: previous.host, + clientId: previous.clientId, + token: previousTokens.refreshToken, + }) + } + } catch (error) { + if (sessionGeneration() === previousSession) { + for (const recover of recoveries) { + recover() + } + } + throw error + } + }) + } + + return { + status: 'loading', + signedIn: false, + region: 'us', + host: null, + clientId: null, + method: null, + user: null, + organizations: [], + projects: [], + projectId: null, + projectName: null, + accountLoading: false, + accountError: null, + signedOutReason: null, + + restore: () => { + restoring ??= (async () => { + await waitForHydration() + const tokens = await loadTokens() + const owner = getSessionIdentity() + if (!tokens || !owner || !validateHost(owner.host).ok) { + setSessionTokens(null) + set(SIGNED_OUT_ACCOUNT) + return + } + const metadata = get() + const sameAccount = + metadata.host === owner.host && + metadata.clientId === owner.clientId && + metadata.method === owner.method && + metadata.user?.uuid === owner.userUuid + set({ + ...(!sameAccount ? SIGNED_OUT_ACCOUNT : {}), + status: 'loading', + signedIn: false, + host: owner.host, + clientId: owner.clientId, + method: owner.method, + region: regionForHost(owner.host), + }) + if ( + tokens.kind === 'oauth' && + tokens.expiresAt !== null && + tokens.expiresAt - Date.now() < RESTORE_REFRESH_MARGIN_MS + ) { + try { + if ((await refreshAccessToken()) === null) { + return + } + } catch { + // Offline or a server error: keep the session. The next 401 retries the refresh. + } + } + const { user } = get() + if (!user) { + await get().reloadAccount() + } + set({ status: 'signed_in', signedIn: true }) + const restored = get().user + if (restored) { + identify(restored.distinctId) + } + track('mobile_session_restored', { method: get().method }) + if (user) { + void get().reloadAccount() + } + })() + return restoring + }, + + signIn: async (host, clientId) => { + const validation = validateHost(host) + if (!validation.ok) { + throw new SignInInputError(validation.error) + } + const trimmedClientId = clientId.trim() + if (!trimmedClientId) { + throw new SignInInputError('Enter the OAuth client ID of the PostHog app on this instance.') + } + const intent = ++signInIntent + const tokens = await authorize({ host: validation.host, clientId: trimmedClientId }) + try { + await completeSignIn(tokens, 'oauth', validation.host, trimmedClientId, intent) + } catch (error) { + if (tokens.refreshToken) { + void revokeToken({ + host: validation.host, + clientId: trimmedClientId, + token: tokens.refreshToken, + }) + } + throw error + } + }, + + signInWithPersonalApiKey: async (host, key) => { + if (!__DEV__) { + throw new SignInInputError('Personal API keys only work in development builds.') + } + const validation = validateHost(host) + if (!validation.ok) { + throw new SignInInputError(validation.error) + } + const trimmedKey = key.trim() + if (!trimmedKey) { + throw new SignInInputError('Paste a personal API key.') + } + const intent = ++signInIntent + await completeSignIn( + { + kind: 'personal_api_key', + accessToken: trimmedKey, + refreshToken: null, + expiresAt: null, + grantedScopes: null, + requestedScopes: null, + }, + 'personal_api_key', + validation.host, + null, + intent + ) + }, + + selectProject: (projectId, from) => { + const { projects, projectId: current } = get() + const project = projects.find((candidate) => candidate.id === projectId) + if (!project || projectId === current) { + return + } + set({ projectId, projectName: project.name }) + track('mobile_project_switched', { from }) + // Every cached query belongs to the previous project. Reset drops the data, so no + // screen shows the old project's reports, and refetches what is on screen. + void queryClient.resetQueries() + }, + + reloadAccount: () => { + const session = sessionGeneration() + if (reloading?.session === session) { + return reloading.promise + } + const promise = (async (): Promise => { + if (get().status === 'signed_out') { + return + } + set({ accountLoading: true }) + try { + const account = await loadAccount() + // The account belongs to the earlier session. The new session loads its own. + if (sessionGeneration() !== session) { + return + } + const before = get().projectId + applyAccount(account, before) + if (before !== null && get().projectId !== before) { + void queryClient.resetQueries() + } + set({ accountLoading: false }) + } catch (error) { + // The failure belongs to the earlier session, so it must not show on the new one. + if (sessionGeneration() !== session) { + return + } + if (error instanceof NotSignedInError) { + set({ accountLoading: false }) + return + } + set({ + accountLoading: false, + accountError: accountLoadMessage(error, get().method ?? 'oauth'), + }) + track('mobile_account_reload_failed', { reason: accountLoadKind(error) }) + } + })().finally(() => { + if (reloading?.promise === promise) { + reloading = null + } + }) + reloading = { session, promise } + return promise + }, + + signOut: async () => { + ++signInIntent + await serializeSessionTransition(async () => { + const { host, clientId, status } = get() + if (status === 'signed_out') { + return + } + if (status === 'signed_in') { + await runSignOutTasks() + } + const tokens = getSessionTokens() + if (tokens?.kind === 'oauth' && tokens.refreshToken && host && clientId) { + await revokeToken({ host, clientId, token: tokens.refreshToken }) + } + await endSession('user') + }) + }, + } + }, + { + name: 'posthog-mobile-auth', + version: 1, + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + region: state.region, + host: state.host, + clientId: state.clientId, + method: state.method, + user: state.user, + organizations: state.organizations, + projects: state.projects, + projectId: state.projectId, + projectName: state.projectName, + }), + onRehydrateStorage: () => () => hydration.settle(), + } + ) +) + +function waitForHydration(): Promise { + return hydration.wait() +} + +async function endSession(reason: 'user' | 'expired'): Promise { + // Before the reset, so the event belongs to the user who signed out. + track('mobile_signed_out', { reason, method: useAuthStore.getState().method }) + reloading = null + refreshing = null + await clearTokens() + queryClient.clear() + resetAnalytics() + await Promise.allSettled([...sessionEndTasks].map(async (task) => task())) + useAuthStore.setState({ ...SIGNED_OUT_ACCOUNT, signedOutReason: reason === 'expired' ? 'expired' : null }) +} + +async function refreshOnce(): Promise { + const session = sessionGeneration() + const { status, host, clientId } = useAuthStore.getState() + const tokens = getSessionTokens() + // A 401 during sign in means the new credentials are bad. Sign in reports that itself. + if (status === 'signed_out' || !tokens) { + return null + } + if (!tokens.refreshToken || !host || !clientId) { + await endSession('expired') + return null + } + let next: SessionTokens + try { + next = await refreshTokens({ + host, + clientId, + refreshToken: tokens.refreshToken, + grantedScopes: tokens.grantedScopes, + requestedScopes: tokens.requestedScopes, + }) + } catch (error) { + // A rejection from the earlier host says nothing about the current session, so it must not end it. + if (sessionGeneration() !== session) { + return null + } + if (error instanceof OAuthError && error.kind === 'session_expired') { + await endSession('expired') + return null + } + throw error + } + if (sessionGeneration() !== session) { + // The user signed out or in again while the refresh was in flight. The caller sends the result to the + // earlier host, so it gets no token. The rotated refresh token is valid at that host but stored nowhere, + // so revoke it there. + if (next.refreshToken) { + void revokeToken({ host, clientId, token: next.refreshToken }) + } + return null + } + return (await saveRefreshedTokens(next, session)) ? next.accessToken : null +} + +// Concurrent callers share one request, because each refresh rotates the token and a second request with the old +// one would race it. A caller from a later session never joins, because the earlier refresh returns no token. +export function refreshAccessToken(): Promise { + const session = sessionGeneration() + if (refreshing?.session === session) { + return refreshing.promise + } + const promise = refreshOnce().finally(() => { + if (refreshing?.promise === promise) { + refreshing = null + } + }) + refreshing = { session, promise } + return promise +} + +export function installAuthProvider(): void { + setAuthProvider({ + getAuth: () => { + const state = useAuthStore.getState() + return { + host: state.host ?? '', + accessToken: getSessionTokens()?.accessToken ?? null, + projectId: state.projectId, + session: sessionGeneration(), + } + }, + refreshAccessToken, + }) +} diff --git a/products/mobile/src/auth/bootGate.test.ts b/products/mobile/src/auth/bootGate.test.ts new file mode 100644 index 000000000000..3eecfc296624 --- /dev/null +++ b/products/mobile/src/auth/bootGate.test.ts @@ -0,0 +1,19 @@ +import { type SplashGateInput, shouldHideSplash } from './bootGate' + +const base: SplashGateInput = { sessionReady: true, firstScreenReady: false, segments: [], timedOut: false } + +describe('shouldHideSplash', () => { + test.each<[string, Partial, boolean]>([ + ['holds while the index route redirects', {}, false], + ['holds while the inbox tab loads', { segments: ['(tabs)', 'inbox'] }, false], + ['hides once the inbox is ready', { segments: ['(tabs)', 'inbox'], firstScreenReady: true }, true], + ['hides on the chats tab without waiting', { segments: ['(tabs)', 'chats'] }, true], + ['hides on sign in without waiting', { segments: ['sign-in'] }, true], + ['hides on a deep link to a report', { segments: ['report', '[id]'] }, true], + ['hides when the inbox times out', { segments: ['(tabs)', 'inbox'], timedOut: true }, true], + ['holds before the session loads', { sessionReady: false, firstScreenReady: true, timedOut: true }, false], + ['holds before the session loads on sign in', { sessionReady: false, segments: ['sign-in'] }, false], + ])('%s', (_name, input, expected) => { + expect(shouldHideSplash({ ...base, ...input })).toBe(expected) + }) +}) diff --git a/products/mobile/src/auth/bootGate.ts b/products/mobile/src/auth/bootGate.ts new file mode 100644 index 000000000000..47ba9d45bc53 --- /dev/null +++ b/products/mobile/src/auth/bootGate.ts @@ -0,0 +1,40 @@ +import { create } from 'zustand' + +// A hung request must not trap the user on the splash; after this the first screen shows its own loading state. +export const SPLASH_TIMEOUT_MS = 8_000 + +interface BootGateState { + firstScreenReady: boolean + markFirstScreenReady: () => void +} + +export const useBootGate = create()((set) => ({ + firstScreenReady: false, + markFirstScreenReady: () => set({ firstScreenReady: true }), +})) + +export interface SplashGateInput { + sessionReady: boolean + firstScreenReady: boolean + /** The route's file segments from `useSegments()`, so group names such as `(tabs)` are included. */ + segments: readonly string[] + timedOut: boolean +} + +function isGroup(segment: string): boolean { + return segment.startsWith('(') && segment.endsWith(')') +} + +// The index route has no segment and redirects to the default page, so it and the inbox hold the splash until the +// inbox loads. Chats and Views show their own loading state at once. +function holdsSplash(segments: readonly string[]): boolean { + const first = segments.find((segment) => !isGroup(segment)) + return first === undefined || first === 'inbox' +} + +export function shouldHideSplash({ sessionReady, firstScreenReady, segments, timedOut }: SplashGateInput): boolean { + if (!sessionReady) { + return false + } + return firstScreenReady || timedOut || !holdsSplash(segments) +} diff --git a/products/mobile/src/auth/oauth.ts b/products/mobile/src/auth/oauth.ts new file mode 100644 index 000000000000..13c75eb30aa3 --- /dev/null +++ b/products/mobile/src/auth/oauth.ts @@ -0,0 +1,281 @@ +import { AuthRequest, type AuthSessionResult, makeRedirectUri } from 'expo-auth-session' +import { Platform } from 'react-native' + +import { appScheme } from '@/lib/environment' + +import type { SessionTokens } from './tokens' + +// The grant is not narrowed to one project (no `required_access_level`), so the project switcher can list every +// project the user has. The server drops any scope outside the OAuth application's ceiling, so a scope added here +// also needs adding to that application. +export const OAUTH_SCOPES = [ + 'openid', + 'profile', + 'email', + 'user:read', + 'user:write', + 'organization:read', + 'project:read', + 'task:read', + 'task:write', + 'conversation:write', + 'today:read', + 'query:read', + 'dashboard:read', + 'insight:read', + 'file_system_shortcut:write', + 'error_tracking:read', + 'error_tracking:write', + 'session_recording:read', + 'replay_scanner:read', + 'replay_scanner:write', +] as const + +const REQUEST_TIMEOUT_MS = 20_000 +const REVOKE_TIMEOUT_MS = 5_000 + +/** + * `posthog://callback` in store builds, `posthog-dev://callback` and `posthog-preview://callback` in internal builds. + * Internal schemes belong to separate non-first-party OAuth applications so sign-in requires consent. + */ +export function redirectUri(): string { + return makeRedirectUri({ scheme: appScheme, path: 'callback' }) +} + +export type OAuthErrorKind = + | 'cancelled' + | 'denied' + | 'in_progress' + | 'state_mismatch' + | 'network' + | 'rejected' + | 'missing_scope' + | 'server' + | 'session_expired' + | 'unsupported' + +const MESSAGES: Record = { + cancelled: 'Sign in was canceled before it finished.', + denied: "Access wasn't granted. Sign in again and allow access to continue.", + in_progress: 'A sign-in page is already open. Finish or close it, then try again.', + state_mismatch: "Sign in couldn't be verified. Try again.", + network: "Couldn't reach PostHog. Check your connection and try again.", + rejected: "PostHog didn't accept the sign-in. Try again, and check the host and client ID if it keeps happening.", + missing_scope: "This app's OAuth registration is missing a permission. Ask an admin to update it.", + server: 'PostHog had a problem signing you in. Try again in a moment.', + session_expired: 'Your session ended. Sign in again.', + unsupported: 'Sign in works in the iOS and Android apps. Open the app to log in.', +} + +/** A sign-in or token failure. `message` is user-facing copy; `detail` is the server's own text. */ +export class OAuthError extends Error { + readonly kind: OAuthErrorKind + readonly detail: string | undefined + + constructor(kind: OAuthErrorKind, detail?: string) { + super(kind === 'rejected' && detail ? `${MESSAGES.rejected} The server said: ${detail}` : MESSAGES[kind]) + this.name = 'OAuthError' + this.kind = kind + this.detail = detail + } +} + +interface TokenResponse { + access_token: string + refresh_token?: string + expires_in?: number + /** Space-separated. Present whenever the server knows the request's scopes. */ + scope?: string +} + +function isTokenResponse(value: unknown): value is TokenResponse { + return ( + typeof value === 'object' && + value !== null && + typeof (value as Record).access_token === 'string' + ) +} + +function scopesOf(response: TokenResponse): string[] | null { + return typeof response.scope === 'string' ? response.scope.split(/\s+/).filter(Boolean) : null +} + +function toSessionTokens( + response: TokenResponse, + previous: { refreshToken: string | null; grantedScopes: string[] | null; requestedScopes: string[] | null } +): SessionTokens { + return { + kind: 'oauth', + accessToken: response.access_token, + // The server rotates refresh tokens for first-party apps; keep the old one only if it sends none. + refreshToken: response.refresh_token ?? previous.refreshToken, + expiresAt: typeof response.expires_in === 'number' ? Date.now() + response.expires_in * 1000 : null, + // A refresh never widens a grant, so a refresh response without `scope` keeps the scopes of the sign-in. + grantedScopes: scopesOf(response) ?? previous.grantedScopes, + requestedScopes: previous.requestedScopes, + } +} + +async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + return await fetch(url, { ...init, signal: controller.signal }) + } catch (error) { + throw new OAuthError('network', error instanceof Error ? error.message : undefined) + } finally { + clearTimeout(timer) + } +} + +async function readOAuthError(response: Response): Promise<{ error?: string; description?: string }> { + try { + const body = (await response.json()) as Record + return { + error: typeof body.error === 'string' ? body.error : undefined, + description: typeof body.error_description === 'string' ? body.error_description : undefined, + } + } catch { + return {} + } +} + +async function postToken(host: string, body: Record): Promise { + return fetchWithTimeout( + `${host}/oauth/token`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + }, + REQUEST_TIMEOUT_MS + ) +} + +function authResultError(result: AuthSessionResult): OAuthError { + if (result.type === 'cancel' || result.type === 'dismiss') { + return new OAuthError('cancelled') + } + if (result.type === 'locked') { + return new OAuthError('in_progress') + } + if (result.type === 'error') { + const code = result.error?.code ?? result.params.error + const detail = result.error?.description ?? result.params.error_description + if (code === 'state_mismatch') { + return new OAuthError('state_mismatch') + } + if (code === 'access_denied') { + return new OAuthError('denied', detail) + } + if (code === 'invalid_scope') { + return new OAuthError('missing_scope', detail) + } + return new OAuthError('rejected', detail ?? code ?? undefined) + } + return new OAuthError('rejected', 'The sign-in page returned no authorization code.') +} + +export async function authorize({ host, clientId }: { host: string; clientId: string }): Promise { + if (Platform.OS === 'web') { + throw new OAuthError('unsupported') + } + const request = new AuthRequest({ + clientId, + redirectUri: redirectUri(), + scopes: [...OAUTH_SCOPES], + usePKCE: true, + }) + let result: AuthSessionResult + try { + result = await request.promptAsync({ authorizationEndpoint: `${host}/oauth/authorize` }) + } catch (error) { + throw new OAuthError('network', error instanceof Error ? error.message : undefined) + } + // `promptAsync` compares the returned state with the one it sent and reports a mismatch as an error. + const code = result.type === 'success' ? result.params.code : undefined + if (!code) { + throw authResultError(result) + } + if (!request.codeVerifier) { + throw new OAuthError('rejected', 'The sign-in request lost its PKCE verifier.') + } + const response = await postToken(host, { + grant_type: 'authorization_code', + code, + redirect_uri: request.redirectUri, + client_id: clientId, + code_verifier: request.codeVerifier, + }) + if (!response.ok) { + const { error, description } = await readOAuthError(response) + if (error === 'invalid_scope') { + throw new OAuthError('missing_scope', description) + } + throw new OAuthError(response.status >= 500 ? 'server' : 'rejected', description ?? error) + } + const body: unknown = await response.json() + if (!isTokenResponse(body)) { + throw new OAuthError('rejected', 'The token response had no access token.') + } + return toSessionTokens(body, { refreshToken: null, grantedScopes: null, requestedScopes: [...OAUTH_SCOPES] }) +} + +// The server rotates the refresh token on every call and revokes the whole family when an old one is reused after a +// short grace period, so callers must store the result before the next refresh. +export async function refreshTokens({ + host, + clientId, + refreshToken, + grantedScopes = null, + requestedScopes = null, +}: { + host: string + clientId: string + refreshToken: string + grantedScopes?: string[] | null + requestedScopes?: string[] | null +}): Promise { + const response = await postToken(host, { + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: clientId, + }) + if (response.status === 400 || response.status === 401 || response.status === 403) { + const { error, description } = await readOAuthError(response) + throw new OAuthError('session_expired', description ?? error) + } + if (!response.ok) { + throw new OAuthError( + response.status >= 500 ? 'server' : 'rejected', + `Token refresh failed with ${response.status}` + ) + } + const body: unknown = await response.json() + if (!isTokenResponse(body)) { + throw new OAuthError('rejected', 'The token response had no access token.') + } + return toSessionTokens(body, { refreshToken, grantedScopes, requestedScopes }) +} + +// Revocation follows RFC 7009, which takes a form body. Failures are ignored because sign out must work offline. +export async function revokeToken({ + host, + clientId, + token, +}: { + host: string + clientId: string + token: string +}): Promise { + const body = new URLSearchParams({ token, token_type_hint: 'refresh_token', client_id: clientId }).toString() + try { + await fetchWithTimeout( + `${host}/oauth/revoke`, + { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body }, + REVOKE_TIMEOUT_MS + ) + } catch { + // The token expires on its own; the device has already forgotten it. + } +} diff --git a/products/mobile/src/auth/regions.test.ts b/products/mobile/src/auth/regions.test.ts new file mode 100644 index 000000000000..b5532e176a18 --- /dev/null +++ b/products/mobile/src/auth/regions.test.ts @@ -0,0 +1,104 @@ +import { defaultCloudRegion, type RegionId, validateHost } from './regions' + +describe('defaultCloudRegion', () => { + it.each<[string | null, string | null, RegionId | undefined, 'us' | 'eu']>([ + ['DE', 'Europe/Berlin', undefined, 'eu'], + ['GB', 'Europe/London', undefined, 'eu'], + ['CY', 'Asia/Nicosia', undefined, 'eu'], + ['fi', null, undefined, 'eu'], + ['US', 'America/New_York', undefined, 'us'], + ['AU', 'Australia/Sydney', undefined, 'us'], + [null, 'Europe/Paris', undefined, 'eu'], + [null, null, undefined, 'us'], + ['DE', 'Europe/Berlin', 'us', 'us'], + ['US', 'America/New_York', 'eu', 'eu'], + ['DE', 'Europe/Berlin', 'custom', 'eu'], + ])('device region %s, timezone %s and saved region %s select %s', (regionCode, timeZone, saved, expected) => { + expect(defaultCloudRegion(regionCode, timeZone, saved)).toBe(expected) + }) +}) + +describe('regions', () => { + const originalUsClientId = process.env.EXPO_PUBLIC_OAUTH_US_CLIENT_ID + const originalEuClientId = process.env.EXPO_PUBLIC_OAUTH_EU_CLIENT_ID + + afterEach(() => { + if (originalUsClientId === undefined) { + delete process.env.EXPO_PUBLIC_OAUTH_US_CLIENT_ID + } else { + process.env.EXPO_PUBLIC_OAUTH_US_CLIENT_ID = originalUsClientId + } + if (originalEuClientId === undefined) { + delete process.env.EXPO_PUBLIC_OAUTH_EU_CLIENT_ID + } else { + process.env.EXPO_PUBLIC_OAUTH_EU_CLIENT_ID = originalEuClientId + } + jest.dontMock('@/lib/environment') + }) + + test.each(['production', 'development', 'preview'] as const)( + '%s builds select the matching cloud OAuth registration', + (variant) => { + jest.isolateModules(() => { + jest.doMock('@/lib/environment', () => ({ + ...jest.requireActual('@/lib/environment'), + buildVariant: variant, + })) + process.env.EXPO_PUBLIC_OAUTH_US_CLIENT_ID = 'internal-us-client' + process.env.EXPO_PUBLIC_OAUTH_EU_CLIENT_ID = 'internal-eu-client' + const { CLOUD_REGIONS } = jest.requireActual('./regions') + expect(CLOUD_REGIONS.map((region) => region.clientId)).toEqual( + variant === 'production' + ? ['a5TY7w9IjFYfes6dkPgZe6envclWw3bm2UD8ZTlm', '1A7vO138Fh5sYmJislicN4F5HnttI6urmFttxPDU'] + : ['internal-us-client', 'internal-eu-client'] + ) + }) + } + ) + + test('an unconfigured internal build has no production cloud client fallback', () => { + jest.isolateModules(() => { + jest.doMock('@/lib/environment', () => ({ + ...jest.requireActual('@/lib/environment'), + buildVariant: 'preview', + })) + delete process.env.EXPO_PUBLIC_OAUTH_US_CLIENT_ID + delete process.env.EXPO_PUBLIC_OAUTH_EU_CLIENT_ID + const { CLOUD_REGIONS } = jest.requireActual('./regions') + expect(CLOUD_REGIONS.map((region) => region.clientId)).toEqual(['', '']) + }) + }) + + test.each([ + ['us.posthog.com', 'https://us.posthog.com'], + ['https://posthog.example.com/project/2/', 'https://posthog.example.com'], + ['http://localhost:8010', 'http://localhost:8010'], + ['http://127.0.0.1:8000', 'http://127.0.0.1:8000'], + ['http://[::1]:8010', 'http://[::1]:8010'], + ['http://[0:0:0:0:0:0:0:1]:8010', 'http://[::1]:8010'], + ])('accepts %s as %s', (input, host) => { + expect(validateHost(input)).toEqual({ ok: true, host }) + }) + + test.each([ + ['an empty value', ' '], + ['plain http to a remote host', 'http://posthog.example.com'], + ['another loopback address outside the native allowlist', 'http://127.0.0.2:8010'], + ['the emulator host alias', 'http://10.0.2.2:8010'], + ['a localhost subdomain', 'http://app.localhost:8010'], + ['an IPv4-mapped IPv6 address', 'http://[::ffff:127.0.0.1]:8010'], + ['a non-web scheme', 'ftp://posthog.example.com'], + ['a script URL', 'javascript:alert(1)'], + ])('rejects %s', (_name, input) => { + expect(validateHost(input)).toMatchObject({ ok: false }) + }) + + test.each([ + ['http://localhost:8010', false], + ['http://127.0.0.1:8000', false], + ['http://[::1]:8010', false], + ['https://localhost:8010', true], + ])('a store build given %s accepts it: %s', (input, ok) => { + expect(validateHost(input, false)).toMatchObject({ ok }) + }) +}) diff --git a/products/mobile/src/auth/regions.ts b/products/mobile/src/auth/regions.ts new file mode 100644 index 000000000000..cecf9cc221e0 --- /dev/null +++ b/products/mobile/src/auth/regions.ts @@ -0,0 +1,168 @@ +import { buildVariant, isInternalBuild, isLoopbackHost } from '@/lib/environment' + +export type RegionId = 'us' | 'eu' | 'custom' + +export interface Region { + id: RegionId + label: string + host: string + clientId: string +} + +export interface CloudRegion extends Region { + id: Exclude + flag: string +} + +// Internal callback schemes require separate apps with consent enabled. Never fall back to a first-party app. +export const US_CLIENT_ID = + buildVariant === 'production' + ? 'a5TY7w9IjFYfes6dkPgZe6envclWw3bm2UD8ZTlm' + : (process.env.EXPO_PUBLIC_OAUTH_US_CLIENT_ID?.trim() ?? '') +export const EU_CLIENT_ID = + buildVariant === 'production' + ? '1A7vO138Fh5sYmJislicN4F5HnttI6urmFttxPDU' + : (process.env.EXPO_PUBLIC_OAUTH_EU_CLIENT_ID?.trim() ?? '') +// The OAuth app that `setup_tasks_oauth` creates for a local stack, so a dev build signs in there without typing an id. +export const DEV_CLIENT_ID = 'DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ' + +export const CLOUD_REGIONS = [ + { + id: 'us', + label: 'United States', + flag: '\u{1F1FA}\u{1F1F8}', + host: 'https://us.posthog.com', + clientId: US_CLIENT_ID, + }, + { + id: 'eu', + label: 'European Union', + flag: '\u{1F1EA}\u{1F1FA}', + host: 'https://eu.posthog.com', + clientId: EU_CLIENT_ID, + }, +] as const satisfies readonly CloudRegion[] + +// The id `custom` is persisted in AsyncStorage by the auth store, so it keeps its name while the label changes. +export const REGIONS: readonly Region[] = [ + ...CLOUD_REGIONS, + { id: 'custom', label: 'Self-hosted', host: '', clientId: '' }, +] + +const EUROPEAN_REGION_CODES = new Set([ + 'AD', + 'AL', + 'AT', + 'AX', + 'BA', + 'BE', + 'BG', + 'BY', + 'CH', + 'CY', + 'CZ', + 'DE', + 'DK', + 'EE', + 'ES', + 'FI', + 'FO', + 'FR', + 'GB', + 'GG', + 'GI', + 'GR', + 'HR', + 'HU', + 'IE', + 'IM', + 'IS', + 'IT', + 'JE', + 'LI', + 'LT', + 'LU', + 'LV', + 'MC', + 'MD', + 'ME', + 'MK', + 'MT', + 'NL', + 'NO', + 'PL', + 'PT', + 'RO', + 'RS', + 'RU', + 'SE', + 'SI', + 'SJ', + 'SK', + 'SM', + 'TR', + 'UA', + 'VA', + 'XK', +]) + +export function defaultCloudRegion( + regionCode: string | null, + timeZone: string | null, + previousRegion?: RegionId +): CloudRegion['id'] { + if (previousRegion && previousRegion !== 'custom') { + return previousRegion + } + if (regionCode) { + return EUROPEAN_REGION_CODES.has(regionCode.toUpperCase()) ? 'eu' : 'us' + } + return timeZone?.startsWith('Europe/') ? 'eu' : 'us' +} + +// Store review may not allow a link that leaves the app to create an account, so a build can hide it. +export const SHOW_SIGNUP_LINK = process.env.EXPO_PUBLIC_SIGNUP_LINK !== '0' + +export function signupUrl(host: string): string { + return `${host}/signup` +} + +export function regionForHost(host: string): RegionId { + return REGIONS.find((region) => region.host !== '' && region.host === host)?.id ?? 'custom' +} + +export type HostValidation = { ok: true; host: string } | { ok: false; error: string } + +// The dev app exists only on a developer's machine, so store builds start the Client ID field empty. +export const DEFAULT_CUSTOM_CLIENT_ID = isInternalBuild ? DEV_CLIENT_ID : '' + +// Tokens travel to this host, so plain http is only accepted for loopback addresses, which is also the only place the +// server allows http redirects. Only internal builds accept it, because a local stack is a developer's setup. +export function validateHost(input: string, allowLoopbackHttp: boolean = isInternalBuild): HostValidation { + const trimmed = input.trim().replace(/\/+$/, '') + if (!trimmed) { + return { ok: false, error: 'Enter the address of your PostHog instance.' } + } + const withScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}` + let url: URL + try { + url = new URL(withScheme) + } catch { + return { ok: false, error: "That doesn't look like a web address. Try something like posthog.example.com." } + } + if (!url.hostname) { + return { ok: false, error: "That doesn't look like a web address. Try something like posthog.example.com." } + } + if (url.protocol === 'http:' && !(allowLoopbackHttp && isLoopbackHost(url.hostname))) { + return { + ok: false, + error: allowLoopbackHttp + ? 'Use an https address. Plain http only works for localhost.' + : 'Use an https address, for example https://posthog.example.com.', + } + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return { ok: false, error: 'Use an https address, for example https://posthog.example.com.' } + } + return { ok: true, host: `${url.protocol}//${url.host}` } +} diff --git a/products/mobile/src/auth/sessionRestore.test.ts b/products/mobile/src/auth/sessionRestore.test.ts new file mode 100644 index 000000000000..33e8ca2409ef --- /dev/null +++ b/products/mobile/src/auth/sessionRestore.test.ts @@ -0,0 +1,103 @@ +import * as SecureStore from 'expo-secure-store' +import { installAuthProvider, useAuthStore } from './authStore' +import { loadTokens, saveTokens, setSessionTokens } from './tokens' + +jest.mock('@react-native-async-storage/async-storage', () => + require('@react-native-async-storage/async-storage/jest/async-storage-mock') +) +jest.mock('expo-secure-store', () => { + const items = new Map() + return { + getItemAsync: async (key: string) => items.get(key) ?? null, + setItemAsync: async (key: string, value: string) => { + items.set(key, value) + }, + deleteItemAsync: async (key: string) => { + items.delete(key) + }, + } +}) + +const tokens = { + kind: 'oauth' as const, + accessToken: 'example-access', + refreshToken: 'example-refresh', + expiresAt: 0, + grantedScopes: null, + requestedScopes: null, +} +const host = 'https://current.example.com' + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }) +} + +describe('stored session identity', () => { + test('rejects credentials without a stored destination', async () => { + await SecureStore.setItemAsync('posthog_mobile_session', JSON.stringify(tokens)) + expect(await loadTokens()).toBeNull() + }) + + test('restores and refreshes using the secure identity when cached metadata belongs to an earlier account', async () => { + const requests: string[] = [] + globalThis.fetch = jest.fn(async (url: string) => { + requests.push(url) + if (url.endsWith('/oauth/token')) { + return json({ + access_token: 'example-rotated-access', + refresh_token: 'example-rotated-refresh', + expires_in: 3600, + }) + } + return json({ + id: 1, + uuid: 'current-user', + distinct_id: 'current-user', + email: 'current@example.com', + first_name: '', + last_name: '', + team: null, + organization: null, + organizations: [], + }) + }) as unknown as typeof fetch + await saveTokens(tokens, { host, clientId: 'example-client', method: 'oauth', userUuid: 'current-user' }) + useAuthStore.setState({ + status: 'loading', + signedIn: false, + host: 'https://previous.example.com', + clientId: 'previous-client', + method: 'oauth', + user: { + uuid: 'previous-user', + distinctId: 'previous-user', + email: 'previous@example.com', + name: 'Previous', + }, + projectId: 9, + projects: [ + { + id: 9, + name: 'Previous project', + organizationId: 'previous-org', + organizationName: 'Previous organization', + }, + ], + }) + setSessionTokens(null) + installAuthProvider() + await useAuthStore.getState().restore() + expect(requests).toEqual([`${host}/oauth/token`, `${host}/api/users/@me/`]) + expect(useAuthStore.getState()).toMatchObject({ + host, + clientId: 'example-client', + user: { uuid: 'current-user' }, + projectId: null, + projects: [], + }) + expect(JSON.parse((await SecureStore.getItemAsync('posthog_mobile_session')) ?? '{}')).toMatchObject({ + identity: { host, userUuid: 'current-user' }, + tokens: { accessToken: 'example-rotated-access' }, + }) + }) +}) diff --git a/products/mobile/src/auth/signInErrors.test.ts b/products/mobile/src/auth/signInErrors.test.ts new file mode 100644 index 000000000000..ea9f608f6e79 --- /dev/null +++ b/products/mobile/src/auth/signInErrors.test.ts @@ -0,0 +1,33 @@ +import { ApiError, NotSignedInError } from '@/api/client' + +import type { AuthMethod } from './authStore' +import { OAuthError } from './oauth' +import { + AccountLoadError, + accountLoadKind, + type SignInFailureReason, + SignInInputError, + signInFailureReason, +} from './signInErrors' + +jest.mock('expo/fetch', () => ({ fetch: jest.fn() })) +jest.mock('expo-auth-session', () => ({ AuthRequest: jest.fn(), makeRedirectUri: () => 'posthog://callback' })) + +function accountError(cause: unknown): AccountLoadError { + return new AccountLoadError(accountLoadKind(cause), 'Shown to the user') +} + +describe('signInFailureReason', () => { + test.each<[string, unknown, AuthMethod, SignInFailureReason]>([ + ['an invalid host', new SignInInputError('Enter a host'), 'oauth', 'invalid_input'], + ['a cancelled browser', new OAuthError('cancelled'), 'oauth', 'cancelled'], + ['a rejected personal API key', accountError(new NotSignedInError()), 'personal_api_key', 'invalid_key'], + ['a rejected OAuth session', accountError(new NotSignedInError()), 'oauth', 'account_rejected'], + ['a 403 on the account', accountError(new ApiError(403, {})), 'oauth', 'account_forbidden'], + ['a 500 on the account', accountError(new ApiError(500, {})), 'oauth', 'account_load'], + ['no connection for the account', accountError(new TypeError('Network request failed')), 'oauth', 'network'], + ['anything else', new Error('boom'), 'oauth', 'unknown'], + ])('%s', (_name, error, method, expected) => { + expect(signInFailureReason(error, method)).toBe(expected) + }) +}) diff --git a/products/mobile/src/auth/signInErrors.ts b/products/mobile/src/auth/signInErrors.ts new file mode 100644 index 000000000000..bb322d8e057b --- /dev/null +++ b/products/mobile/src/auth/signInErrors.ts @@ -0,0 +1,68 @@ +import { ApiError, NotSignedInError } from '@/api/client' + +import type { AuthMethod } from './authStore' +import { OAuthError, type OAuthErrorKind } from './oauth' + +export class SignInInputError extends Error { + constructor(message: string) { + super(message) + this.name = 'SignInInputError' + } +} + +export type AccountLoadKind = 'rejected' | 'forbidden' | 'api_error' | 'network' | 'unknown' + +export function accountLoadKind(error: unknown): AccountLoadKind { + if (error instanceof NotSignedInError) { + return 'rejected' + } + if (error instanceof ApiError) { + return error.status === 403 ? 'forbidden' : 'api_error' + } + if (error instanceof TypeError) { + return 'network' + } + return 'unknown' +} + +/** The new credentials worked for the token exchange, but the account bootstrap failed. `message` is user-facing. */ +export class AccountLoadError extends Error { + readonly kind: AccountLoadKind + + constructor(kind: AccountLoadKind, message: string) { + super(message) + this.name = 'AccountLoadError' + this.kind = kind + } +} + +export type SignInFailureReason = + | Exclude + | 'invalid_input' + | 'invalid_key' + | 'account_rejected' + | 'account_forbidden' + | 'account_load' + | 'unknown' + +const ACCOUNT_REASONS: Record = { + rejected: 'account_rejected', + forbidden: 'account_forbidden', + api_error: 'account_load', + network: 'network', + unknown: 'unknown', +} + +/** A fixed enum for analytics, so no error text leaves the device. */ +export function signInFailureReason(error: unknown, method: AuthMethod): SignInFailureReason { + if (error instanceof SignInInputError) { + return 'invalid_input' + } + if (error instanceof OAuthError) { + return error.kind === 'session_expired' ? 'unknown' : error.kind + } + if (error instanceof AccountLoadError) { + return error.kind === 'rejected' && method === 'personal_api_key' ? 'invalid_key' : ACCOUNT_REASONS[error.kind] + } + return 'unknown' +} diff --git a/products/mobile/src/auth/tokenRefresh.test.ts b/products/mobile/src/auth/tokenRefresh.test.ts new file mode 100644 index 000000000000..8c8e44c7f96d --- /dev/null +++ b/products/mobile/src/auth/tokenRefresh.test.ts @@ -0,0 +1,437 @@ +import * as SecureStore from 'expo-secure-store' +import { apiJson, NotSignedInError, projectPath } from '@/api/client' + +import { addSessionEndTask, addSignOutTask, installAuthProvider, useAuthStore } from './authStore' +import { OAuthError } from './oauth' +import { getSessionIdentity, getSessionTokens, loadTokens, saveTokens, setSessionTokens } from './tokens' + +jest.mock('@react-native-async-storage/async-storage', () => + require('@react-native-async-storage/async-storage/jest/async-storage-mock') +) +jest.mock('expo/fetch', () => ({ fetch: jest.fn() })) +jest.mock('expo-auth-session', () => ({ + AuthRequest: jest.fn().mockImplementation(() => ({ + redirectUri: 'posthog://callback', + codeVerifier: 'fake-verifier', + promptAsync: async () => ({ type: 'success', params: { code: 'fake-code' } }), + })), + makeRedirectUri: () => 'posthog://callback', +})) +jest.mock('expo-secure-store', () => { + const items = new Map() + return { + getItemAsync: async (key: string) => items.get(key) ?? null, + setItemAsync: async (key: string, value: string) => { + items.set(key, value) + }, + deleteItemAsync: async (key: string) => { + items.delete(key) + }, + } +}) + +const HOST = 'https://us.posthog.com' +const OLD_HOST = 'https://old-instance.example.com' +const NEW_HOST = 'https://new-instance.example.com' + +function accountResponse(uuid: string): Response { + return json(200, { + id: 7, + uuid, + distinct_id: uuid, + email: `${uuid}@example.com`, + first_name: '', + last_name: '', + team: null, + organization: null, + organizations: [], + }) +} + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) +} + +function authorization(init: RequestInit | undefined): string | undefined { + return (init?.headers as Record | undefined)?.Authorization +} + +describe('token refresh through the API client', () => { + const fetchMock = jest.fn, [string, RequestInit | undefined]>() + + beforeEach(async () => { + globalThis.fetch = fetchMock as unknown as typeof fetch + fetchMock.mockReset() + installAuthProvider() + useAuthStore.setState({ + status: 'signed_in', + signedIn: true, + host: HOST, + clientId: 'test-client', + projectId: 2, + signedOutReason: null, + }) + await saveTokens( + { + kind: 'oauth', + accessToken: 'old-access', + refreshToken: 'old-refresh', + expiresAt: Date.now() + 60_000, + grantedScopes: ['user:read', 'dashboard:read'], + requestedScopes: ['user:read', 'dashboard:read', 'insight:read'], + }, + { host: HOST, clientId: 'test-client', method: 'oauth', userUuid: 'example-user' } + ) + }) + + test('concurrent 401s share one refresh, store the rotated pair and retry with the new token', async () => { + const tokenBodies: unknown[] = [] + fetchMock.mockImplementation(async (url, init) => { + if (url === `${HOST}/oauth/token`) { + tokenBodies.push(JSON.parse(String(init?.body))) + // Answer on a later task, so both 401 handlers are already waiting on this refresh. + await new Promise((resolve) => setTimeout(resolve, 0)) + return json(200, { access_token: 'new-access', refresh_token: 'new-refresh', expires_in: 604800 }) + } + return authorization(init) === 'Bearer new-access' ? json(200, { url }) : json(401, { detail: 'expired' }) + }) + + const results = await Promise.all([ + apiJson<{ url: string }>('/api/users/@me/'), + apiJson<{ url: string }>(projectPath('signals/reports/')), + ]) + + expect(results).toEqual([{ url: `${HOST}/api/users/@me/` }, { url: `${HOST}/api/projects/2/signals/reports/` }]) + expect(tokenBodies).toEqual([ + { grant_type: 'refresh_token', refresh_token: 'old-refresh', client_id: 'test-client' }, + ]) + setSessionTokens(null) + expect(await loadTokens()).toMatchObject({ + accessToken: 'new-access', + refreshToken: 'new-refresh', + grantedScopes: ['user:read', 'dashboard:read'], + requestedScopes: ['user:read', 'dashboard:read', 'insight:read'], + }) + expect(useAuthStore.getState().status).toBe('signed_in') + }) + + test.each([ + { + name: 'a rejected refresh token signs the user out', + token: async (): Promise => json(400, { error: 'invalid_grant' }), + error: NotSignedInError, + status: 'signed_out', + reason: 'expired', + accessToken: undefined, + }, + { + name: 'a network failure keeps the session for a later retry', + token: async (): Promise => { + throw new TypeError('Network request failed') + }, + error: OAuthError, + status: 'signed_in', + reason: null, + accessToken: 'old-access', + }, + ])('$name', async ({ token, error, status, reason, accessToken }) => { + fetchMock.mockImplementation(async (url) => (url === `${HOST}/oauth/token` ? token() : json(401, {}))) + + await expect(apiJson('/api/users/@me/')).rejects.toBeInstanceOf(error) + + expect(useAuthStore.getState()).toMatchObject({ status, signedOutReason: reason }) + expect(getSessionTokens()?.accessToken).toBe(accessToken) + }) + + test.each([ + { name: 'API response', held: `${OLD_HOST}/api/users/@me/`, answer: (): Response => json(401, {}) }, + { + name: 'token refresh', + held: `${OLD_HOST}/oauth/token`, + answer: (): Response => + json(200, { access_token: 'rotated-access', refresh_token: 'rotated-refresh', expires_in: 3600 }), + }, + ])('an old host $name held across signing in elsewhere never gets the new session', async ({ held, answer }) => { + const sent: { url: string; credentials: string }[] = [] + let release: (response: Response) => void = () => undefined + let reached: () => void = () => undefined + const heldReached = new Promise((resolve) => { + reached = resolve + }) + let holding = true + fetchMock.mockImplementation(async (url, init) => { + sent.push({ url, credentials: `${authorization(init)} ${String(init?.body)}` }) + if (url === held && holding) { + holding = false + reached() + return new Promise((resolve) => { + release = resolve + }) + } + if (url === `${OLD_HOST}/api/users/@me/`) { + return json(401, {}) + } + if (url === `${OLD_HOST}/oauth/revoke`) { + return json(200, {}) + } + if (url === `${HOST}/oauth/token`) { + return json(200, { access_token: 'next-access', refresh_token: 'next-refresh', expires_in: 3600 }) + } + if (url === `${HOST}/api/users/@me/` && authorization(init) === 'Bearer next-access') { + return json(200, { + id: 7, + uuid: 'next-user', + distinct_id: 'next-user', + email: 'next-user@example.com', + first_name: '', + last_name: '', + team: null, + organization: null, + organizations: [], + }) + } + throw new Error(`Unexpected request to ${url}`) + }) + useAuthStore.setState({ host: OLD_HOST, clientId: 'old-client', method: 'oauth' }) + + const reload = useAuthStore.getState().reloadAccount() + await heldReached + await useAuthStore.getState().signOut() + await useAuthStore.getState().signIn(HOST, 'next-client') + release(answer()) + await reload + + expect( + sent.filter(({ url, credentials }) => url.startsWith(OLD_HOST) && credentials.includes('next-')) + ).toEqual([]) + expect(await apiJson('/api/users/@me/')).toMatchObject({ uuid: 'next-user' }) + expect(useAuthStore.getState()).toMatchObject({ + status: 'signed_in', + host: HOST, + user: { uuid: 'next-user' }, + accountError: null, + }) + expect(getSessionTokens()).toMatchObject({ accessToken: 'next-access', refreshToken: 'next-refresh' }) + }) + + test('a candidate host and token stay separate until its account is loaded', async () => { + let releaseMe: (response: Response) => void = () => undefined + let reachedMe: () => void = () => undefined + const meStarted = new Promise((resolve) => { + reachedMe = resolve + }) + fetchMock.mockImplementation(async (url) => { + if (url === `${NEW_HOST}/oauth/token`) { + return json(200, { access_token: 'candidate', refresh_token: 'candidate-refresh', expires_in: 3600 }) + } + if (url === `${NEW_HOST}/api/users/@me/`) { + reachedMe() + return new Promise((resolve) => { + releaseMe = resolve + }) + } + if (url === `${HOST}/oauth/revoke`) { + return json(200, {}) + } + throw new Error(`Unexpected request to ${url}`) + }) + + const signingIn = useAuthStore.getState().signIn(NEW_HOST, 'candidate-client') + await meStarted + expect(useAuthStore.getState().host).toBe(HOST) + expect(getSessionTokens()?.accessToken).toBe('old-access') + releaseMe(accountResponse('candidate-user')) + await signingIn + expect(useAuthStore.getState()).toMatchObject({ host: NEW_HOST, user: { uuid: 'candidate-user' } }) + expect(getSessionTokens()?.accessToken).toBe('candidate') + }) + + test('a failed candidate or canceled sign-in leaves the active host and credentials intact', async () => { + const { AuthRequest } = jest.requireMock('expo-auth-session') as { AuthRequest: jest.Mock } + AuthRequest.mockImplementationOnce(() => ({ + promptAsync: async () => ({ type: 'cancel' }), + })) + await expect(useAuthStore.getState().signIn(NEW_HOST, 'candidate-client')).rejects.toBeInstanceOf(OAuthError) + + fetchMock.mockImplementation(async (url) => { + if (url === `${NEW_HOST}/api/users/@me/`) { + return json(403, { detail: 'Account unavailable' }) + } + throw new Error(`Unexpected request to ${url}`) + }) + await expect(useAuthStore.getState().signInWithPersonalApiKey(NEW_HOST, 'candidate-key')).rejects.toThrow() + expect(useAuthStore.getState().host).toBe(HOST) + expect(getSessionTokens()?.accessToken).toBe('old-access') + }) + + test('failed session cleanup keeps the old credentials and a retry can sign out', async () => { + const cleanup = jest.fn().mockRejectedValueOnce(new Error('Offline')).mockResolvedValue(undefined) + const removeCleanup = addSignOutTask(cleanup) + fetchMock.mockResolvedValue(json(200, {})) + try { + await expect(useAuthStore.getState().signOut()).rejects.toThrow('Offline') + expect(useAuthStore.getState()).toMatchObject({ status: 'signed_in', host: HOST }) + expect(getSessionTokens()?.accessToken).toBe('old-access') + await useAuthStore.getState().signOut() + expect(cleanup).toHaveBeenCalledTimes(2) + expect(useAuthStore.getState().status).toBe('signed_out') + } finally { + removeCleanup() + } + }) + + test('a failed account switch commit reopens the old session cleanup', async () => { + const recover = jest.fn() + const cleanup = jest.fn().mockResolvedValue(recover) + const removeCleanup = addSignOutTask(cleanup) + fetchMock.mockImplementation(async (url) => { + if (url === `${NEW_HOST}/api/users/@me/`) { + return accountResponse('candidate-user') + } + return json(200, {}) + }) + const storeWrite = jest + .spyOn(SecureStore, 'setItemAsync') + .mockRejectedValueOnce(new Error('Storage unavailable')) + try { + await expect(useAuthStore.getState().signInWithPersonalApiKey(NEW_HOST, 'candidate-key')).rejects.toThrow( + 'Storage unavailable' + ) + expect(cleanup).toHaveBeenCalledTimes(1) + expect(recover).toHaveBeenCalledTimes(1) + expect(useAuthStore.getState().host).toBe(HOST) + expect(getSessionTokens()?.accessToken).toBe('old-access') + } finally { + storeWrite.mockRestore() + removeCleanup() + } + }) + + test('a later concurrent sign-in wins without publishing the first candidate', async () => { + let releaseFirst: (response: Response) => void = () => undefined + let firstStarted: () => void = () => undefined + const started = new Promise((resolve) => { + firstStarted = resolve + }) + fetchMock.mockImplementation(async (url, init) => { + if (url === `${NEW_HOST}/api/users/@me/`) { + if (authorization(init) === 'Bearer first-key') { + firstStarted() + return new Promise((resolve) => { + releaseFirst = resolve + }) + } + return accountResponse('second-user') + } + throw new Error(`Unexpected request to ${url}`) + }) + const first = useAuthStore.getState().signInWithPersonalApiKey(NEW_HOST, 'first-key') + await started + await useAuthStore.getState().signInWithPersonalApiKey(NEW_HOST, 'second-key') + releaseFirst(accountResponse('first-user')) + await expect(first).rejects.toBeInstanceOf(OAuthError) + expect(useAuthStore.getState().user?.uuid).toBe('second-user') + expect(getSessionTokens()?.accessToken).toBe('second-key') + }) + + test('account switch finishes old session cleanup before publishing the new account', async () => { + let finishCleanup: () => void = () => undefined + let cleanupStarted: () => void = () => undefined + const started = new Promise((resolve) => { + cleanupStarted = resolve + }) + const removeCleanup = addSessionEndTask( + () => + new Promise((resolve) => { + cleanupStarted() + finishCleanup = resolve + }) + ) + fetchMock.mockImplementation(async (url) => { + if (url === `${NEW_HOST}/api/users/@me/`) { + return accountResponse('candidate-user') + } + return json(200, {}) + }) + try { + const signingIn = useAuthStore.getState().signInWithPersonalApiKey(NEW_HOST, 'candidate-key') + await started + expect(useAuthStore.getState().host).toBe(HOST) + expect(getSessionTokens()?.accessToken).toBe('old-access') + finishCleanup() + await signingIn + expect(useAuthStore.getState().host).toBe(NEW_HOST) + } finally { + removeCleanup() + } + }) + + test('a candidate canceled during SecureStore write never replaces the later sign-in', async () => { + fetchMock.mockImplementation(async (url, init) => { + if (url === `${NEW_HOST}/api/users/@me/`) { + return accountResponse(authorization(init) === 'Bearer first-key' ? 'first-user' : 'second-user') + } + throw new Error(`Unexpected request to ${url}`) + }) + const originalWrite = SecureStore.setItemAsync + let finishWrite: () => void = () => undefined + let writeStarted: () => void = () => undefined + const started = new Promise((resolve) => { + writeStarted = resolve + }) + const storeWrite = jest.spyOn(SecureStore, 'setItemAsync').mockImplementationOnce( + (key, value) => + new Promise((resolve) => { + writeStarted() + finishWrite = () => void originalWrite(key, value).then(resolve) + }) + ) + try { + const first = useAuthStore.getState().signInWithPersonalApiKey(NEW_HOST, 'first-key') + await started + const second = useAuthStore.getState().signInWithPersonalApiKey(NEW_HOST, 'second-key') + finishWrite() + await expect(first).rejects.toBeInstanceOf(OAuthError) + await second + expect(useAuthStore.getState().user?.uuid).toBe('second-user') + setSessionTokens(null) + expect((await loadTokens())?.accessToken).toBe('second-key') + expect(getSessionIdentity()).toMatchObject({ + host: NEW_HOST, + userUuid: 'second-user', + method: 'personal_api_key', + }) + } finally { + storeWrite.mockRestore() + } + }) + + test('two sign-out requests share one cleanup and leave one signed-out session', async () => { + let finishCleanup: () => void = () => undefined + let cleanupStarted: () => void = () => undefined + const started = new Promise((resolve) => { + cleanupStarted = resolve + }) + const cleanup = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + cleanupStarted() + finishCleanup = resolve + }) + ) + const removeCleanup = addSignOutTask(cleanup) + fetchMock.mockResolvedValue(json(200, {})) + try { + const first = useAuthStore.getState().signOut() + const second = useAuthStore.getState().signOut() + await started + expect(cleanup).toHaveBeenCalledTimes(1) + finishCleanup() + await Promise.all([first, second]) + expect(cleanup).toHaveBeenCalledTimes(1) + expect(useAuthStore.getState().status).toBe('signed_out') + } finally { + removeCleanup() + } + }) +}) diff --git a/products/mobile/src/auth/tokens.ts b/products/mobile/src/auth/tokens.ts new file mode 100644 index 000000000000..b141e17d7317 --- /dev/null +++ b/products/mobile/src/auth/tokens.ts @@ -0,0 +1,247 @@ +import * as SecureStore from 'expo-secure-store' +import { Platform } from 'react-native' + +export interface SessionTokens { + kind: 'oauth' | 'personal_api_key' + accessToken: string + refreshToken: string | null + /** Epoch ms when the access token stops working, or null when it does not expire. */ + expiresAt: number | null + /** + * The scopes the server granted, from the token response. Null for a personal API key and for a session stored + * before the app recorded them, where only a 403 tells which scope is missing. + */ + grantedScopes: string[] | null + /** + * The scopes the sign-in asked for. A scope asked for but not granted is outside the OAuth application's ceiling, + * so signing in again gets the same grant. Null for a personal API key and for a session stored before the app + * recorded them. + */ + requestedScopes: string[] | null +} + +export interface SessionIdentity { + readonly host: string + readonly clientId: string | null + readonly method: SessionTokens['kind'] + readonly userUuid: string +} + +interface SessionEnvelope { + version: 1 + identity: SessionIdentity + tokens: SessionTokens +} + +let identity: SessionIdentity | null = null + +export function getSessionIdentity(): SessionIdentity | null { + return identity +} + +function storedSession(tokens: SessionTokens, owner: SessionIdentity | null): string { + if (!owner || owner.method !== tokens.kind) { + throw new Error('Session identity is required to persist credentials') + } + return JSON.stringify({ version: 1, identity: owner, tokens } satisfies SessionEnvelope) +} + +const STORAGE_KEY = 'posthog_mobile_session' + +let current: SessionTokens | null = null +let generation = 0 +let storageQueue: Promise = Promise.resolve() + +function serializeStorage(work: () => Promise): Promise { + const result = storageQueue.then(work, work) + storageQueue = result.then( + () => undefined, + () => undefined + ) + return result +} + +export function getSessionTokens(): SessionTokens | null { + return current +} + +/** + * Identifies the session that `getSessionTokens` belongs to. It changes on sign in, sign out and restore, but not when + * a refresh rotates the tokens. Async work captures it before it awaits and compares after, so that a response from + * an earlier session never reads, stores or sends the tokens of a later one. + */ +export function sessionGeneration(): number { + return generation +} + +/** + * Changes the in-memory tokens only and starts a new session generation. Call `saveTokens` to keep them across + * launches. + */ +export function setSessionTokens(tokens: SessionTokens | null, owner: SessionIdentity | null = null): void { + identity = owner ? Object.freeze({ ...owner }) : null + current = tokens + generation += 1 +} + +// expo-secure-store has no web implementation. The web build is a dev and preview target, so it keeps tokens in +// localStorage in dev builds and in memory otherwise, where a reload signs out. +const webMemory = new Map() + +function webStorage(): Storage | null { + if (!__DEV__ || typeof localStorage === 'undefined') { + return null + } + return localStorage +} + +async function readItem(key: string): Promise { + if (Platform.OS === 'web') { + return webStorage()?.getItem(key) ?? webMemory.get(key) ?? null + } + return SecureStore.getItemAsync(key) +} + +async function writeItem(key: string, value: string): Promise { + if (Platform.OS === 'web') { + const storage = webStorage() + if (storage) { + storage.setItem(key, value) + } else { + webMemory.set(key, value) + } + return + } + await SecureStore.setItemAsync(key, value) +} + +async function deleteItem(key: string): Promise { + if (Platform.OS === 'web') { + webStorage()?.removeItem(key) + webMemory.delete(key) + return + } + await SecureStore.deleteItemAsync(key) +} + +function scopesOf(value: unknown): string[] | null { + return Array.isArray(value) && value.every((scope) => typeof scope === 'string') ? value : null +} + +function toSessionTokens(value: unknown): SessionTokens | null { + if (typeof value !== 'object' || value === null) { + return null + } + const record = value as Record + const valid = + (record.kind === 'oauth' || record.kind === 'personal_api_key') && + typeof record.accessToken === 'string' && + (record.refreshToken === null || typeof record.refreshToken === 'string') && + (record.expiresAt === null || typeof record.expiresAt === 'number') + // Sessions stored before the app recorded scopes have no `grantedScopes` or `requestedScopes`, which read as unknown. + return valid + ? ({ + ...record, + grantedScopes: scopesOf(record.grantedScopes), + requestedScopes: scopesOf(record.requestedScopes), + } as SessionTokens) + : null +} + +export async function loadTokens(): Promise { + let raw: string | null = null + try { + raw = await readItem(STORAGE_KEY) + } catch { + raw = null + } + let parsed: unknown = null + try { + parsed = raw ? JSON.parse(raw) : null + } catch { + parsed = null + } + const envelope = parsed as Partial | null + const owner = envelope?.identity + const tokens = envelope?.version === 1 ? toSessionTokens(envelope.tokens) : null + const validOwner = + owner && + typeof owner.host === 'string' && + owner.host.length > 0 && + typeof owner.userUuid === 'string' && + owner.userUuid.length > 0 && + owner.method === tokens?.kind && + (owner.method === 'oauth' + ? typeof owner.clientId === 'string' && owner.clientId.length > 0 + : owner.clientId === null) + current = validOwner ? tokens : null + identity = validOwner ? Object.freeze({ ...owner }) : null + generation += 1 + return current +} + +/** + * Keeps the generation, because a refresh saves its rotated tokens into the same session. A new session calls + * `setSessionTokens` first. + */ +export async function saveTokens(tokens: SessionTokens, owner: SessionIdentity | null = identity): Promise { + await serializeStorage(async () => { + await writeItem(STORAGE_KEY, storedSession(tokens, owner)) + current = tokens + identity = owner ? Object.freeze({ ...owner }) : null + }) +} + +/** Persists and publishes a new session in one storage turn, after its account has loaded. */ +export async function commitSessionTokens( + tokens: SessionTokens, + owner: SessionIdentity, + canPublish: () => boolean, + publish: () => void +): Promise { + return serializeStorage(async () => { + if (!canPublish()) { + return false + } + await writeItem(STORAGE_KEY, storedSession(tokens, owner)) + if (!canPublish()) { + // A sign out or later sign-in started while SecureStore was writing. Restore the current session before + // the queued transition can run, so a relaunch never pairs the earlier candidate with the later host. + if (current) { + await writeItem(STORAGE_KEY, storedSession(current, identity)) + } else { + await deleteItem(STORAGE_KEY) + } + return false + } + setSessionTokens(tokens, owner) + publish() + return true + }) +} + +/** A refresh queued behind a new sign-in must not overwrite the new session's stored token. */ +export async function saveRefreshedTokens(tokens: SessionTokens, expectedGeneration: number): Promise { + return serializeStorage(async () => { + if (generation !== expectedGeneration) { + return false + } + await writeItem(STORAGE_KEY, storedSession(tokens, identity)) + if (generation !== expectedGeneration) { + return false + } + current = tokens + return true + }) +} + +export async function clearTokens(): Promise { + current = null + identity = null + generation += 1 + try { + await serializeStorage(() => deleteItem(STORAGE_KEY)) + } catch { + // A failed delete leaves a token that the next restore tries and the server rejects. + } +} diff --git a/products/mobile/src/auth/useAuthRedirect.ts b/products/mobile/src/auth/useAuthRedirect.ts new file mode 100644 index 000000000000..40a7f57b66ec --- /dev/null +++ b/products/mobile/src/auth/useAuthRedirect.ts @@ -0,0 +1,22 @@ +import { useRootNavigationState, useRouter, useSegments } from 'expo-router' +import { useEffect } from 'react' + +import { useDemoMode } from '@/demo/demoMode' + +import { useAuthStore } from './authStore' + +// Covers a refresh token the server rejects in the middle of a session, not only the explicit sign-out buttons. +export function useAuthRedirect(): void { + const router = useRouter() + const segments = useSegments() + const navigationReady = Boolean(useRootNavigationState()?.key) + const status = useAuthStore((state) => state.status) + const demo = useDemoMode((state) => state.enabled) + const onSignIn = segments[0] === 'sign-in' + + useEffect(() => { + if (navigationReady && status === 'signed_out' && !demo && !onSignIn) { + router.replace('/sign-in') + } + }, [navigationReady, status, demo, onSignIn, router]) +} diff --git a/products/mobile/src/auth/useProjectScope.ts b/products/mobile/src/auth/useProjectScope.ts new file mode 100644 index 000000000000..d18fcdf8706c --- /dev/null +++ b/products/mobile/src/auth/useProjectScope.ts @@ -0,0 +1,15 @@ +import { useMemo } from 'react' + +import type { ProjectScope } from '@/api/reports' + +import { useAuthStore } from './authStore' + +export function useProjectScope(): ProjectScope | null { + const signedIn = useAuthStore((state) => state.signedIn) + const host = useAuthStore((state) => state.host) + const projectId = useAuthStore((state) => state.projectId) + return useMemo( + () => (signedIn && host && projectId !== null ? { host: host.replace(/\/$/, ''), projectId } : null), + [signedIn, host, projectId] + ) +} diff --git a/products/mobile/src/auth/useViewer.ts b/products/mobile/src/auth/useViewer.ts new file mode 100644 index 000000000000..4cc5f2436ae4 --- /dev/null +++ b/products/mobile/src/auth/useViewer.ts @@ -0,0 +1,20 @@ +import { DEMO_PROJECT_NAME } from '@/demo/briefings' +import { useDemoMode } from '@/demo/demoMode' +import { DEMO_ME } from '@/demo/fixtures' + +import { useAuthStore } from './authStore' + +export interface Viewer { + name: string + projectName: string +} + +export function useViewer(): Viewer { + const demo = useDemoMode((state) => state.enabled) + const projectName = useAuthStore((state) => state.projectName) + const name = useAuthStore((state) => state.user?.name) + if (demo) { + return { name: DEMO_ME.name, projectName: DEMO_PROJECT_NAME } + } + return { name: name ?? '', projectName: projectName ?? '' } +} diff --git a/products/mobile/src/demo/briefings.ts b/products/mobile/src/demo/briefings.ts new file mode 100644 index 000000000000..b47de8bbf070 --- /dev/null +++ b/products/mobile/src/demo/briefings.ts @@ -0,0 +1,1151 @@ +import { MCP_VIA, POSTHOG_AGENT } from '@/model/agent' +import type { Accent, Fact, MetricValueFormat, ReportPriority, SourceProduct } from '@/model/types' + +export type DemoAgo = { hours: number } | { days: number } + +export type DemoKind = 'done' | 'open' | 'decision' | 'pr_ready' + +/** `[text with **bold**, when, via?]`. `when` is "Today 9:04 AM" or "Mon 7:21 AM". */ +export type DemoActivity = readonly [text: string, when: string, via?: string] + +/** The primary metric. */ +export interface DemoObservation { + title: string + format: MetricValueFormat + unit?: string + caption?: string + /** Daily values in the metric's own unit, oldest first. */ + values: readonly number[] + filters: number +} + +export type DemoSupportingMetric = readonly [title: string, value: number, format: MetricValueFormat, unit?: string] + +export interface DemoChart { + id: string + title: string + caption?: string + format?: MetricValueFormat + unit?: string + /** Daily values, oldest first. A chart without values is one the app only links to. */ + values?: readonly number[] +} + +/** A signal behind the report. `ago` is the time before the demo anchor. */ +export interface DemoSignal { + source: SourceProduct + ago: DemoAgo + content: string + sessionId?: string + ticket?: number +} + +export interface DemoBriefing { + id: string + title: string + sourceProduct: SourceProduct + sourceLabel: string + ago: DemoAgo + kind: DemoKind + accent: Accent + headNum: string + headRest: string + lede: string + /** Markdown blocks of the full summary, joined with blank lines. */ + summary: readonly string[] + /** Newest first, as the signals API returns them. */ + signals: readonly DemoSignal[] + trace?: { error: string; frames: readonly (readonly [fn: string, loc: string])[]; footer: string } + quotes?: readonly (readonly [text: string, who: string])[] + replay?: { meta: string; count: number } + facts: readonly (readonly [key: string, title: string, sub: string, tone: Fact['tone']])[] + observation: DemoObservation + supporting?: readonly DemoSupportingMetric[] + charts?: readonly DemoChart[] + activity: readonly DemoActivity[] + contextNote: string + prompt?: string + handedStage: string + owner?: { kind: 'user' | 'task'; name: string } + priority?: ReportPriority +} + +export const DEMO_USER_NAME = 'Robin Calloway' +export const DEMO_PROJECT_NAME = 'Hedgebox' +export const DEMO_AGENT = POSTHOG_AGENT + +export const DEMO_DECISION_REPORT_ID = 'demo-report-ui-errors' + +export const DEMO_DECISION = { + id: 'demo-decision-chart-legend', + question: 'Found the cause in ChartLegend. Two ways to fix it. Paused until you pick one.', + options: [ + { id: 'guard', title: 'Guard the undefined label', meta: 'One-line fix, ships today' }, + { + id: 'backfill', + title: 'Backfill labels in the API', + meta: 'Fixes the root cause · touches the API, needs backend review', + }, + ], +} as const + +export function demoDecisionMeta(answer: string): string { + if (/backfill|api/i.test(answer)) { + return 'Backfilling labels in the API · PR against the API first, flagged for backend review' + } + if (/sana/i.test(answer)) { + return 'Asking Sana in the thread · keeps going on the UI side' + } + return 'Guarding the undefined label in ChartLegend · PR in about 15 min' +} + +export const DEMO_BRIEFINGS: readonly DemoBriefing[] = [ + { + id: 'demo-guest-404s', + priority: 'P2', + title: 'Shared report links returned 404 for guests', + sourceProduct: 'signals_scout', + sourceLabel: 'Report', + ago: { hours: 2 }, + kind: 'done', + accent: 'resolved', + headNum: '0', + headRest: 'guest 404s in the last six hours', + lede: '1,204 yesterday. Fixed in deploy c71b20 this morning.', + summary: [ + '## Problem', + 'Shared report links returned 404 for every guest without a session. Commit 9be4f2 moved the share token check behind the session middleware on Sunday night, so `resolveShareToken` never ran for invited viewers. Signed-in members were not affected, which is why nobody on the team saw it.', + '## Impact', + '1,204 failed opens on Monday, from 94 guests in 23 orgs. The peak was 310 an hour at 9 AM, when weekly reports go out. No paid seat was locked out, but several customers had forwarded the links to their own clients, who saw an error page with our logo on it.', + '## Solution', + 'Sana moved the token check back in front of the session middleware and added a test that opens a shared link with no cookies. The fix shipped in deploy c71b20 at 8:10 AM. Guest 404s are back to the baseline of expired tokens, about 20 a day. Nothing else to do unless that baseline moves.', + ], + signals: [ + { + source: 'logs', + ago: { hours: 1 }, + content: + 'Guest 404s on /shared/:token are back to baseline after deploy c71b20. 20 in the last 6 hours, all from expired tokens.', + }, + { + source: 'session_replay', + ago: { hours: 26 }, + sessionId: 'demo-guest-404s-session', + content: + 'An invited viewer opens a shared report link and lands on a 404 page after 8 seconds. They leave without signing in.', + }, + { + source: 'error_tracking', + ago: { hours: 27 }, + content: + 'HTTP 404 on GET /shared/:token for requests with no session\n```\nloadSharedReport in src/scenes/SharedReport.tsx line 31\nresolveShareToken in src/server/share.ts line 57\n```', + }, + ], + trace: { + error: 'HTTP 404 · GET /shared/:token · no session', + frames: [ + ['resolveShareToken', 'share.ts:57'], + ['loadSharedReport', 'SharedReport.tsx:31'], + ], + footer: 'Introduced in 9be4f2 · Sun 11:40 PM · Sana · fixed in c71b20 at 8:10 AM', + }, + replay: { meta: '404 at 0:08 · 0:15 total', count: 12 }, + facts: [ + ['Where', 'Shared report links', '/shared/:token · invited viewers only', 'default'], + ['Since', 'Mon 7:15 AM · peak 310 an hour', 'Fixed 8:10 AM by Sana', 'default'], + ['Impact', '94 guests in 23 orgs', 'All invited viewers. No paid seat was locked out.', 'default'], + ['Owner', 'Sana', '', 'default'], + ], + observation: { + title: 'Guest 404s on shared links', + format: 'count', + values: [20, 20, 360, 840, 1200, 1040, 880, 660, 400, 180, 20, 20, 20, 20], + filters: 1, + }, + activity: [ + ['PostHog created the report', 'Mon 7:21 AM'], + [`${DEMO_USER_NAME} changed assignee to **Sana Patel**`, 'Mon 7:40 AM'], + [ + 'Claude Code linked commit **c71b20** and changed status from **Open** to **Resolved**', + 'Today 8:10 AM', + MCP_VIA, + ], + ], + contextNote: 'Sends the request log and the c71b20 diff as context', + handedStage: 'Reading share.ts · a PR will land here to review', + owner: { kind: 'user', name: 'Sana Patel' }, + }, + { + id: DEMO_DECISION_REPORT_ID, + priority: 'P0', + title: '3,721 errors in the report UI since 9 AM', + sourceProduct: 'error_tracking', + sourceLabel: 'Error', + ago: { hours: 1 }, + kind: 'decision', + accent: 'alert', + headNum: '3,721', + headRest: "errors since this morning's deploy", + lede: 'One TypeError in ChartLegend.', + summary: [ + '## Problem', + 'A TypeError in `ChartLegend` breaks every report with a custom legend. Commit a3f9e1, "Refactor legend series mapping", maps legend entries by index. When a series has no label, its entry is undefined and `ChartLegend.tsx:142` reads `label` from it. The chart area stays blank while the rest of the page works, so most people reload once and then leave.', + '## Impact', + '3,721 errors since 9:04 AM, 12 minutes after the deploy. 612 users in 51 orgs hit it. Chrome 130 is 94% of the errors, because most report traffic comes from it. Report views with an error went from 0.2% to 4.2%. Acme Corp and Northwind renew this quarter, and both use custom legends on their weekly reports.', + '[Report views with an error](chart:error-share-21d)', + '## Solution', + 'There are two ways to fix it. Guard the undefined label in `ChartLegend` and fall back to the series name. That is a one-line change that can ship today. Or backfill labels in the API, so no series reaches the client without one. That fixes the root cause, but it touches the API and needs a backend review. The agent paused until you pick one.', + ], + signals: [ + { + source: 'error_tracking', + ago: { hours: 0.2 }, + content: + "TypeError: Cannot read properties of undefined (reading 'label')\n```\nrenderReport in src/scenes/reports/ReportPage.tsx line 52\nReportChart in src/scenes/reports/ReportChart.tsx line 88\nChartLegend in src/scenes/reports/ChartLegend.tsx line 142\n```\n3,721 occurrences since 9:04 AM. First seen 12 minutes after deploy a3f9e1.", + }, + { + source: 'session_replay', + ago: { hours: 1.5 }, + sessionId: 'demo-report-ui-errors-session', + content: + 'Opens the weekly revenue report. The chart area stays blank. The user reloads twice and leaves after 40 seconds.', + }, + { + source: 'conversations', + ago: { hours: 2 }, + ticket: 8840, + content: 'Our weekly report shows an empty box where the chart used to be. It worked on Friday.', + }, + { + source: 'github', + ago: { hours: 2.8 }, + content: + 'Commit a3f9e1 "Refactor legend series mapping" by Sana Patel, deployed at 8:52 AM. It changes ChartLegend.tsx and legendSeries.ts.', + }, + ], + trace: { + error: "TypeError: Cannot read properties of undefined (reading 'label')", + frames: [ + ['ChartLegend', 'ChartLegend.tsx:142'], + ['ReportChart', 'ReportChart.tsx:88'], + ], + footer: 'Introduced in a3f9e1 · 8:52 AM · Sana · "Refactor legend series mapping"', + }, + facts: [ + ['Where', 'Reports with a custom legend', 'Route /reports/:id · Chrome 130, 94%', 'default'], + ['Since', '9:04 AM · 12 min after the deploy', 'Report views down 31% by 11:30 AM', 'default'], + ['Impact', '612 users in 51 orgs', 'Acme Corp and Northwind renew this quarter', 'default'], + ['Owner', 'Nobody assigned', '', 'alert'], + ], + observation: { + title: 'TypeErrors in ChartLegend', + format: 'count', + caption: 'Since the 8:52 AM deploy', + values: [90, 60, 120, 90, 60, 90, 1440, 2280, 2760, 3120, 2940, 3360, 3600, 3300], + filters: 1, + }, + supporting: [ + ['Affected users', 612, 'count'], + ['Organizations', 51, 'count'], + ['Report views with an error', 4.2, 'percentage'], + ], + charts: [ + { + id: 'error-share-21d', + title: 'Report views with an error', + format: 'percentage', + values: [ + 0.2, 0.1, 0.3, 0.2, 0.2, 0.1, 0.2, 0.3, 0.2, 0.1, 0.2, 0.2, 0.3, 0.2, 0.1, 0.2, 0.2, 0.3, 0.2, 2.8, + 4.2, + ], + }, + { id: 'errors-by-release', title: 'Errors by release', caption: 'A SQL table the app links to' }, + ], + activity: [ + ['PostHog created the report', 'Today 9:04 AM'], + [`${DEMO_USER_NAME} handed off to **Claude Code**`, 'Today 9:12 AM'], + ['Claude Code changed status from **Open** to **Investigating**', 'Today 9:13 AM', MCP_VIA], + ['Claude Code asked for a decision and changed status to **Waiting on you**', 'Today 11:38 AM', MCP_VIA], + ], + contextNote: 'Sends the trace, the a3f9e1 diff and 38 replays as context', + prompt: "Fix the TypeError in ChartLegend.tsx:142 (reading 'label' of undefined). Started after deploy a3f9e1 at 8:52 AM; 3,721 errors since 9 AM.", + handedStage: 'Reading ChartLegend.tsx · a PR will land here to review', + owner: { kind: 'task', name: 'Claude Code' }, + }, + { + id: 'demo-report-load-time', + priority: 'P1', + title: 'Report load time is up 2.3s for every user', + sourceProduct: 'pganalyze', + sourceLabel: 'pganalyze', + ago: { days: 2 }, + kind: 'open', + accent: 'alert', + headNum: '+2.3s', + headRest: 'slower report loads for every user', + lede: "p95 is 4.1s since Monday's deploy. Was 1.8s.", + summary: [ + '## Problem', + 'Report pages wait on a new server call before the first paint. Commit 7d2c04, "Move legend rendering to the server", added `GET /api/report/:id/legend`, and `ReportChart` now calls it once per legend item instead of once per report. Each call runs the same series query again, so a report with 11 series runs it 11 times.', + '## Impact', + 'p95 load time went from 1.8s to 4.1s on Monday at 3:05 PM and has stayed there. The step lines up with the deploy in the [p95 load time for the last 30 days](chart:load-time-30d). Every user sees it, on about 4,800 report views a day. It is worst for reports with 5 or more series, and Globex averages 11 series per report. Report views are down 12% since Tuesday.', + '[Load time by browser](chart:load-time-by-browser)', + '## Solution', + 'Fetch the legend once per report and pass it to each legend item, or cache the series query per request on the server. The first change is smaller and keeps the server rendering in place. Claude Code picked this up at 9:12 AM and is reading `ReportChart.tsx`.', + ], + signals: [ + { + source: 'pganalyze', + ago: { hours: 1 }, + content: + 'Query `select_series_for_legend` ran 52,800 times in the last 24 hours, up from 4,800. Mean time 410ms. It is 68% of the total database time on the primary.\n```\nSELECT series.id, series.label, series.color\nFROM report_series series\nWHERE series.report_id = $1\nORDER BY series.position\n```', + }, + { + source: 'session_replay', + ago: { hours: 20 }, + sessionId: 'demo-report-load-time-session', + content: + 'A spinner shows on the report page for 4.6 seconds before the chart draws. The user scrolls past the empty chart and opens the next report.', + }, + { + source: 'analytics', + ago: { hours: 30 }, + content: + 'p95 time from $pageview to report_loaded on /reports/:id crossed 3s on Monday at 3:05 PM. It was under 2s for the 30 days before.', + }, + { + source: 'github', + ago: { days: 2 }, + content: + 'Commit 7d2c04 "Move legend rendering to the server" by Priya Nair, deployed Monday at 2:30 PM.', + }, + ], + trace: { + error: 'Slow request · GET /api/report/:id/legend · blocks first paint · 1.9s per call', + frames: [ + ['renderLegend', 'server/legend.ts:88'], + ['getReport', 'server/report.ts:140'], + ['ReportPage', 'ReportPage.tsx:52'], + ], + footer: 'Introduced in 7d2c04 · Mon 2:30 PM · Priya · "Move legend rendering to the server"', + }, + replay: { meta: 'spinner for 4.6s · 0:58 total', count: 54 }, + facts: [ + ['Where', 'Every report page', '/reports/:id · worst with 5+ series', 'default'], + ['Since', 'Mon 3:05 PM · p95 crossed 3s', 'Report views down 12% by Tuesday', 'default'], + ['Impact', 'Every user · 4,800 views a day', 'Worst for Globex, 11 series on average', 'default'], + ['Owner', 'Claude Code', 'Picked up 9:12 AM', 'agent'], + ], + observation: { + title: 'p95 report load time', + format: 'duration', + unit: 'ms', + values: [1700, 1768, 1632, 1836, 1700, 1768, 1666, 3264, 3740, 3536, 4012, 3808, 4080, 3944], + filters: 0, + }, + supporting: [ + ['Report views a day', 4800, 'count'], + ['p50 load time', 1900, 'duration', 'ms'], + ['Series per report', 11, 'number'], + ], + charts: [ + { + id: 'load-time-30d', + title: 'p95 load time, last 30 days', + caption: 'The step up is the Monday deploy', + format: 'duration', + unit: 'ms', + values: [ + 1720, 1680, 1750, 1810, 1690, 1740, 1700, 1780, 1730, 1660, 1710, 1800, 1760, 1690, 1740, 1720, + 1780, 1750, 1700, 1770, 1730, 1690, 1760, 3260, 3740, 3540, 4010, 3810, 4080, 3940, + ], + }, + { id: 'load-time-by-browser', title: 'Load time by browser', caption: 'A breakdown the app links to' }, + ], + activity: [ + ['PostHog created the report', 'Mon 3:05 PM'], + [`${DEMO_USER_NAME} handed off to **Claude Code**`, 'Today 9:12 AM'], + ['Claude Code changed status from **Open** to **In progress**', 'Today 9:12 AM', MCP_VIA], + ], + contextNote: 'Sends the slow-request trace, the 7d2c04 diff and 54 replays as context', + prompt: 'Report p95 load time rose from 1.8s to 4.1s after deploy 7d2c04. ReportChart fetches /api/series once per legend item. Fix it.', + handedStage: 'Reading ReportChart.tsx · a PR will land here to review', + }, + { + id: 'demo-resolve-button', + title: 'PR #4182 removed the Resolve button', + sourceProduct: 'github', + sourceLabel: 'GitHub', + ago: { days: 2 }, + kind: 'open', + accent: 'alert', + headNum: '0', + headRest: "Resolve clicks since Monday's deploy", + lede: '1,900 a week before. The button is gone from the issue header.', + summary: [ + '## Problem', + 'PR #4182, "Simplify issue header", removed `ResolveButton` from `IssueHeader`. The PR moved the header actions into an overflow menu, but Resolve was left out of the menu items. There is no way to resolve an issue from its page now. The keyboard shortcut still works, which is why a few resolves still come through.', + '## Impact', + "Resolve clicks went from about 1,900 a week to 6 a day since Monday's deploy at 2:30 PM. 340 users in 60 orgs triage issues, and all of them are affected. Three support tickets so far ask where the button went. Open issue counts are going up in the orgs that triage every day.", + '## Solution', + 'Add Resolve back to the issue header, as its own button or as the first item in the new overflow menu. A button matches what people look for in the replays: they hover where it used to be, then open the menu and close it again. Add a test that the header shows Resolve for an open issue.', + ], + signals: [ + { + source: 'conversations', + ago: { hours: 20 }, + ticket: 8871, + content: "Where did the resolve button go? I can't close issues anymore unless I use the shortcut.", + }, + { + source: 'session_replay', + ago: { hours: 26 }, + sessionId: 'demo-resolve-button-session', + content: + 'Hovers over the issue header where Resolve used to be, opens the overflow menu, closes it, and leaves the issue open.', + }, + { + source: 'analytics', + ago: { hours: 40 }, + content: + 'resolve_clicked fell from 270 a day to 6 a day after Monday 2:30 PM. Resolves from the keyboard shortcut are unchanged.', + }, + { + source: 'github', + ago: { hours: 49 }, + content: + 'PR #4182 "Simplify issue header" by Priya Nair, merged Monday at 2:22 PM. It removes ResolveButton from IssueHeader.tsx.', + }, + ], + trace: { + error: 'Regression · ResolveButton no longer rendered in IssueHeader', + frames: [ + ['IssueHeader', 'IssueHeader.tsx:40'], + ['IssuePage', 'IssuePage.tsx:118'], + ], + footer: 'Introduced in 7d2c04 · Mon 2:30 PM · Priya · PR #4182 "Simplify issue header"', + }, + replay: { meta: 'hunts for Resolve · 0:19', count: 21 }, + facts: [ + ['Where', 'Issue header', '/issues/:id · all browsers', 'default'], + ['Since', 'Mon 2:31 PM', '3 support tickets since: "Where did the resolve button go?"', 'default'], + ['Impact', '340 users in 60 orgs', 'Everyone who triages issues', 'default'], + ['Owner', 'Nobody assigned', '', 'alert'], + ], + observation: { + title: 'Resolve clicks', + format: 'count', + values: [288, 312, 264, 330, 276, 300, 294, 6, 6, 6, 6, 6, 6, 6], + filters: 1, + }, + activity: [ + ['PostHog created the report', 'Mon 2:31 PM'], + ['PostHog linked **PR #4182** as the likely cause', 'Mon 2:31 PM'], + ['PostHog attached **3 support tickets**', 'Tue 10:15 AM'], + ], + contextNote: 'Sends the #4182 diff, click data and 21 replays as context', + prompt: 'PR #4182 removed ResolveButton from IssueHeader.tsx. Restore it; it was clicked 1,900 times a week.', + handedStage: 'Reading IssueHeader.tsx · a PR will land here to review', + }, + { + id: 'demo-report-emails', + title: 'Scheduled report emails stopped on Sunday', + sourceProduct: 'signals_scout', + sourceLabel: 'Report', + ago: { days: 3 }, + kind: 'open', + accent: 'alert', + headNum: '0 of 860', + headRest: 'scheduled report emails delivered since Sunday', + lede: 'The digest job has been paused for two days. Nobody paused it on purpose.', + summary: [ + '## Problem', + 'The scheduled report digest has not sent since Saturday. The digest worker restarted on Sunday at 5:58 AM during a node pool upgrade, and the `report_digest` queue was not registered again when it started. Jobs are still added every morning, but no worker picks them up. Nothing fails, so nothing alerts.', + '## Impact', + "0 of 860 scheduled emails were delivered since Sunday: 0 of 410 on Sunday and 0 of 450 on Monday. The recipients are in 140 orgs. Northwind's Monday exec digest goes to 40 people, and two of them have already asked their admin where it is. The next run is in 15 hours.", + '## Solution', + 'Register `report_digest` in `workers/index.ts` with the other queues. Add a startup check that fails the deploy when a scheduled queue has no consumer, so the next restart cannot drop a queue without a warning. After the fix, run the two missed digests once, so recipients get Sunday and Monday before the next scheduled run.', + ], + signals: [ + { + source: 'logs', + ago: { hours: 10 }, + content: + 'Queue report_digest has no consumer.\n```\nqueue=report_digest waiting=860 active=0 oldest_job_age=50h\nqueue=alerts_slack waiting=0 active=4\nqueue=export_csv waiting=2 active=3\n```', + }, + { + source: 'logs', + ago: { hours: 60 }, + content: + 'Worker digest-7f4c restarted at Sun 5:58 AM after the node pool upgrade. Registered queues: alerts_slack, export_csv, insights_refresh.', + }, + { + source: 'analytics', + ago: { hours: 70 }, + content: 'digest_email_sent fell to 0 on Sunday. 410 were expected.', + }, + ], + trace: { + error: 'Job report_digest · last success Sat 6:00 AM · queue not registered', + frames: [ + ['scheduleDigest', 'workers/digest.ts:22'], + ['registerQueues', 'workers/index.ts:9'], + ], + footer: 'Worker restarted Sun 5:58 AM · node pool upgrade · the digest queue was not re-registered', + }, + facts: [ + ['Where', 'Digest worker', 'report_digest queue', 'default'], + ['Since', 'Sun 6:00 AM · 0 of 410', 'Mon 6:00 AM · 0 of 450 · next run in 15 hours', 'default'], + ['Impact', '860 recipients in 140 orgs', "Northwind's Monday exec digest goes to 40 people", 'default'], + ['Owner', 'Nobody assigned', '', 'alert'], + ], + observation: { + title: 'Scheduled report emails sent', + format: 'count', + values: [88, 92, 90, 94, 86, 96, 90, 92, 2, 2, 2, 2, 2, 2], + filters: 0, + }, + activity: [ + ['PostHog created the report', 'Sun 6:14 AM'], + ['PostHog changed priority from **Medium** to **High**', 'Mon 6:05 AM'], + ], + contextNote: 'Sends the worker log and the queue config as context', + prompt: 'The report_digest queue stopped running after the worker restart on Sunday 5:58 AM. Re-register it in workers/index.ts.', + handedStage: 'Reading workers/index.ts · a PR will land here to review', + }, + { + id: 'demo-rage-clicks', + title: 'Rage clicks on Apply filter tripled', + sourceProduct: 'analytics', + sourceLabel: 'Product Analytics', + ago: { days: 2 }, + kind: 'pr_ready', + accent: 'alert', + headNum: '1,140', + headRest: 'rage clicks on Apply filter in 24 hours', + lede: 'Was 380 a day. 82% are Safari.', + summary: [ + '## Problem', + 'Apply filter does nothing in Safari 17 when a date range is typed instead of picked. The new date picker from commit 9a11f3 passes the typed text to `parseRange`, which calls `new Date()` on a format Safari does not parse. It returns Invalid Date, `applyFilter` drops the range without a message, and people click Apply again.', + '## Impact', + '1,140 rage clicks on Apply filter in 24 hours, up from about 380 a day. 82% are in Safari. 410 users in 38 orgs are affected, and 60 of them are at Acme Corp. Filter usage in Safari is down 19% since Monday, so some people have stopped trying.', + '## Solution', + 'Parse typed ranges with the same date library the picker uses. When a range cannot be parsed, show an inline error under the field instead of ignoring it. Codex opened PR #4190 with the fix and a test for typed ranges, and checked it in Safari 17 on staging. It needs a review before it can merge.', + ], + signals: [ + { + source: 'session_replay', + ago: { hours: 5 }, + sessionId: 'demo-rage-clicks-session', + content: + 'Types "Mar 3 - Mar 9" in the date range field and clicks Apply 6 times. The results do not change.', + }, + { + source: 'analytics', + ago: { hours: 12 }, + content: '$rageclick on the Apply filter button: 1,140 in 24 hours. 82% are in Safari 17.', + }, + { + source: 'error_tracking', + ago: { hours: 30 }, + content: + 'Console warning: Invalid Date passed to applyFilter\n```\nparseRange in src/components/DateRangeInput.tsx line 31\napplyFilter in src/components/FilterBar.tsx line 77\n```', + }, + ], + trace: { + error: 'Console warning · Invalid Date passed to applyFilter', + frames: [ + ['applyFilter', 'FilterBar.tsx:77'], + ['parseRange', 'DateRangeInput.tsx:31'], + ], + footer: 'Introduced in 9a11f3 · Mon 1:40 PM · Sana · "New date picker"', + }, + replay: { meta: 'clicks Apply 6 times · 0:44', count: 60 }, + facts: [ + ['Where', 'Filter bar · Safari 17', 'Typed date ranges, not picked', 'default'], + ['Since', 'Mon 2:10 PM', 'Filter usage down 19% in Safari', 'default'], + ['Impact', '410 users in 38 orgs', 'Acme Corp is 60 of them', 'default'], + ['Owner', 'Codex', 'PR #4190 opened 9:41 AM', 'agent'], + ], + observation: { + title: 'Rage clicks on Apply filter', + format: 'count', + caption: 'Mostly Safari 17', + values: [340, 380, 320, 400, 360, 340, 380, 720, 960, 1040, 1120, 1200, 1080, 1160], + filters: 2, + }, + supporting: [ + ['Affected users', 410, 'count'], + ['Share on Safari', 82, 'percentage'], + ], + activity: [ + ['PostHog created the report', 'Mon 2:10 PM'], + [`${DEMO_USER_NAME} handed off to **Codex**`, 'Today 9:30 AM'], + ['Codex changed status from **Open** to **In progress**', 'Today 9:31 AM', MCP_VIA], + ['Codex opened **PR #4190** and changed status to **Ready for review**', 'Today 9:41 AM', MCP_VIA], + ], + contextNote: 'Sends the console warnings, the 9a11f3 diff and 60 replays as context', + prompt: 'Apply filter does nothing in Safari 17 when a date range is typed. parseRange in DateRangeInput.tsx:31 returns Invalid Date. Fix it.', + handedStage: 'Reading DateRangeInput.tsx · a PR will land here to review', + owner: { kind: 'task', name: 'Codex' }, + }, + { + id: 'demo-copy-to-clipboard', + title: '7 customers asked to copy reports to clipboard', + sourceProduct: 'dashboard', + sourceLabel: 'Dashboards', + ago: { days: 6 }, + kind: 'open', + accent: 'opportunity', + headNum: '7', + headRest: 'customers asked to copy reports to the clipboard', + lede: '3 support tickets and 4 survey replies, in five days. They are screenshotting reports into Slack and Notion.', + summary: [ + '## Problem', + 'Customers want to copy a report chart as an image and paste it into Slack, Notion or a standup doc. Today they take screenshots, which crop badly and lose the legend. Export already renders a PNG on the server for scheduled emails, but there is no way to get that image from the report page.', + '## Impact', + '7 customers asked in five days: 3 in support tickets and 4 in in-app survey replies. They are in 6 orgs worth $184k ARR, and 2 of the requests came in this morning from Enterprise accounts. Northwind renews in November. Screenshot keypresses on report pages were 1,320 last week, up 40%.', + '## Solution', + 'Add a Copy image button next to Export on the report page. It can call the existing PNG export and write the result to the clipboard, so no new rendering code is needed. Keep CSV export as it is. The scope fits in one PR, plus a short spec for the clipboard permission prompt in Safari.', + ], + signals: [ + { + source: 'zendesk', + ago: { hours: 3 }, + ticket: 8857, + content: + 'Can we get a copy to clipboard option on reports? We paste charts into Notion every week, and screenshots look blurry on our screens.', + }, + { + source: 'analytics', + ago: { hours: 5 }, + content: + 'Survey "What would make reports easier to share?": 4 of 31 replies ask to copy a chart as an image.', + }, + { + source: 'zendesk', + ago: { hours: 60 }, + ticket: 8830, + content: 'We share these charts in Slack every day. A copy button would save us a lot of cropping.', + }, + { + source: 'zendesk', + ago: { hours: 100 }, + ticket: 8812, + content: + 'I just want to paste the chart into our Monday standup doc. Right now I screenshot it and it looks terrible.', + }, + ], + quotes: [ + [ + '"I just want to paste the chart into our Monday standup doc. Right now I screenshot it and it looks terrible."', + 'Priya K · Northwind · ticket #8812', + ], + [ + '"Export to CSV is fine but I need the picture. Copy image would be perfect."', + 'Tom A · Acme Corp · in-app survey', + ], + ], + facts: [ + ['Who', 'Priya K, Tom A and 5 more', 'Northwind, Acme Corp, Globex, Initech', 'default'], + ['Since', 'Thursday', '2 more this morning, both Enterprise', 'default'], + ['Worth', '$184k ARR across 6 orgs', 'Northwind renews in November', 'default'], + ['Workaround', '1,320 screenshot keypresses', 'On report pages last week, up 40%', 'default'], + ['Owner', 'Nobody assigned', '', 'alert'], + ], + observation: { + title: 'Clipboard requests', + format: 'count', + values: [0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 3, 2, 3, 0], + filters: 0, + }, + activity: [ + ['PostHog created the report', 'Thu 4:52 PM'], + ['PostHog attached **2 survey replies**', 'Today 8:20 AM'], + ], + contextNote: 'Sends the 7 quotes, the orgs and the Export code path as context', + prompt: '7 customers asked to copy a report as an image to the clipboard. Export already renders a PNG server-side. Scope a Copy image button on top of it.', + handedStage: 'Reading the Export code path · a spec will land here to review', + }, + { + id: 'demo-checkout-dead-clicks', + title: 'Upgrade button does nothing on small screens', + sourceProduct: 'session_replay', + sourceLabel: 'Session replay', + ago: { hours: 4 }, + kind: 'open', + accent: 'alert', + headNum: '38%', + headRest: 'of upgrade attempts end in a dead click', + lede: 'Only below 768px wide. The cookie banner covers the button.', + summary: [ + '## Problem', + "On screens narrower than 768px, the new cookie banner sits on top of the Upgrade plan button on the billing page. The banner has a higher z-index than the page footer and spans the full width, so a tap on Upgrade lands on the banner's empty area and does nothing. The banner shipped on Saturday at 10:20 AM.", + '## Impact', + '38% of upgrade attempts on small screens now end in a dead click, up from about 2%. 212 users in 180 orgs tried to upgrade and could not, all of them on the free plan. Upgrades from mobile are down 44%. iOS Safari is 71% of the affected sessions.', + '## Solution', + 'On small screens, give the banner a bottom margin equal to the height of the sticky footer, or move the Upgrade button above the banner. Both keep the banner visible and the button reachable. Check the result against the 87 attached replays, which cover iPhone and Android widths.', + ], + signals: [ + { + source: 'session_replay', + ago: { hours: 2 }, + sessionId: 'demo-checkout-dead-clicks-session', + content: + 'Taps Upgrade plan 4 times on an iPhone and nothing happens. Scrolls down, accepts cookies, and the button works on the next tap.', + }, + { + source: 'replay_vision', + ago: { hours: 6 }, + sessionId: 'demo-checkout-dead-clicks-session-2', + content: + 'The cookie banner covers the Upgrade plan button at 390px wide. 87 sessions show the same layout.', + }, + { + source: 'analytics', + ago: { hours: 20 }, + content: + 'upgrade_clicked with no billing_checkout_started within 10 seconds: 38% on viewports under 768px, 1.6% on wider screens.', + }, + ], + replay: { meta: 'taps Upgrade 4 times · 0:31', count: 87 }, + facts: [ + ['Where', 'Billing page · Upgrade plan', 'Viewports under 768px · iOS Safari 71%', 'default'], + ['Since', 'Sat 10:20 AM · new cookie banner', 'Upgrades from mobile down 44%', 'default'], + ['Impact', '212 users in 180 orgs', 'All on the free plan, trying to pay', 'default'], + ['Owner', 'Nobody assigned', '', 'alert'], + ], + observation: { + title: 'Upgrade attempts that end in a dead click', + format: 'percentage', + caption: 'Screens below 768px', + values: [1.9, 1.3, 2.6, 1.9, 1.3, 1.9, 1.6, 2.2, 20.5, 29.4, 25.6, 33.3, 38.4, 30.7], + filters: 2, + }, + activity: [ + ['PostHog created the report', 'Sun 9:02 AM'], + ['PostHog attached **87 replays**', 'Today 6:40 AM'], + ], + contextNote: 'Sends 87 replays, the banner component and the billing page layout as context', + prompt: 'The cookie banner covers the Upgrade plan button on viewports under 768px. Dead clicks on it went from 1% to 38% of upgrade attempts. Fix the stacking so the button stays reachable.', + handedStage: 'Reading CookieBanner.tsx · a PR will land here to review', + }, + { + id: 'demo-ai-summary-cost', + title: 'AI summaries cost 4x more since Thursday', + sourceProduct: 'llm_analytics', + sourceLabel: 'AI observability', + ago: { days: 1 }, + kind: 'open', + accent: 'alert', + headNum: '$1,860', + headRest: 'spent on report summaries yesterday', + lede: 'Was $460 a day. Each call now sends the full event list, not the top 50.', + summary: [ + '## Problem', + 'Report summaries send every event in the report to the model. Commit e40b9d, "Include all events in summary context", removed the top-50 cap in `buildSummaryPrompt`. Input grew from about 9,800 to 41,200 tokens per call, and latency grew with it.', + '## Impact', + 'Summary spend was $1,860 yesterday, against about $460 a day before Thursday. That is $1,400 a day over budget. p95 latency went from 3.2s to 8.9s, so people wait longer for every summary. Answer quality is unchanged at 4.3, which means the extra context does not make the summaries better. Most of the extra tokens are page views and clicks that the summaries never mention.', + '## Solution', + 'Restore the top-50 cap, ranked by the same relevance score as before. If the change was meant to fix missing context on long reports, cap by tokens instead of by count, for example at 12,000 input tokens. Mateo owns `ai/summary.ts` and can confirm which of the two was intended.', + ], + signals: [ + { + source: 'llm_analytics', + ago: { hours: 4 }, + content: + 'summarize_report input tokens: p50 41,200, up from 9,800. Output tokens are unchanged at 310.', + }, + { + source: 'llm_analytics', + ago: { hours: 20 }, + content: 'Generation cost for summarize_report was $1,860 yesterday, across 3,100 calls.', + }, + { + source: 'github', + ago: { hours: 70 }, + content: + 'Commit e40b9d "Include all events in summary context" by Mateo Ruiz, deployed Thursday at 3:15 PM.\n```\n- const events = rankEvents(report.events).slice(0, 50)\n+ const events = report.events\n```', + }, + ], + trace: { + error: 'Prompt size · summarize_report · 41,200 input tokens per call (was 9,800)', + frames: [ + ['buildSummaryPrompt', 'ai/summary.ts:64'], + ['summarizeReport', 'ai/summary.ts:21'], + ], + footer: 'Introduced in e40b9d · Thu 3:15 PM · Mateo · "Include all events in summary context"', + }, + facts: [ + ['Where', 'Report summaries', 'summarize_report · 9,400 calls a day', 'default'], + ['Since', 'Thu 3:40 PM', 'Latency p95 up from 3.2s to 8.9s', 'default'], + ['Impact', '$1,400 a day over budget', 'Answer quality score unchanged at 4.3', 'default'], + ['Owner', 'Mateo', '', 'default'], + ], + observation: { + title: 'Report summary spend', + format: 'currency', + unit: 'USD', + values: [465, 434, 480.5, 449.5, 465, 418.5, 465, 449.5, 480.5, 1178, 1736, 1612, 1798, 1860], + filters: 1, + }, + supporting: [ + ['Summary calls a day', 3100, 'count'], + ['Cost per call', 0.6, 'currency', 'USD'], + ['Answer quality score', 4.3, 'number'], + ], + charts: [ + { + id: 'tokens-per-call', + title: 'Input tokens per summary call', + caption: 'The full event list goes in since Thursday', + values: [2100, 2080, 2150, 2120, 2090, 2110, 2060, 2130, 2100, 8350, 8420, 8390, 8460, 8410], + }, + ], + activity: [ + ['PostHog created the report', 'Fri 8:10 AM'], + [`${DEMO_USER_NAME} changed assignee to **Mateo Ruiz**`, 'Fri 9:25 AM'], + ['PostHog changed priority from **Medium** to **High**', 'Today 7:00 AM'], + ], + contextNote: 'Sends 20 traces, the e40b9d diff and the daily cost breakdown as context', + prompt: 'summarize_report input grew from 9,800 to 41,200 tokens after e40b9d. buildSummaryPrompt in ai/summary.ts:64 now sends every event. Restore the top-50 cap without losing answer quality.', + handedStage: 'Reading ai/summary.ts · a PR will land here to review', + owner: { kind: 'user', name: 'Mateo Ruiz' }, + }, + { + id: 'demo-export-timeouts', + title: 'CSV exports timed out for large projects', + sourceProduct: 'logs', + sourceLabel: 'Logs', + ago: { hours: 5 }, + kind: 'done', + accent: 'resolved', + headNum: '0', + headRest: 'export timeouts since the fix this morning', + lede: '640 on Monday. Exports over 200k rows now stream instead of buffering.', + summary: [ + '## Problem', + 'CSV exports over 200k rows timed out after 300 seconds. The row cap went from 100k to 1M on Monday, but `writeRows` still built the whole file in memory before the upload. Large exports ran out of time while they buffered, and they failed without a partial file. Exports under the old cap finished in less than a minute, so the buffering was never a problem before.', + '## Impact', + '640 timeouts on Monday, from 58 users in 31 orgs. 9 of them retried more than five times, and two opened support tickets. Exports under 200k rows were not affected.', + '## Solution', + 'Marcus changed `writeRows` to stream rows to storage in chunks of 10k, so memory stays flat and the upload starts at once. The fix shipped in 5c8e7a at 7:45 AM, and there have been no timeouts since. The 1M row cap stays. A 1M row export now finishes in about 90 seconds, well under the limit.', + ], + signals: [ + { + source: 'logs', + ago: { hours: 4 }, + content: 'export_csv finished 1,000,000 rows in 94s. No timeouts in the last 4 hours.', + }, + { + source: 'logs', + ago: { hours: 60 }, + content: + 'WorkerTimeout in export_csv after 300s\n```\nrunExport in exports/run.ts line 48\nwriteRows in exports/csv.ts line 112\n```', + }, + { + source: 'conversations', + ago: { hours: 70 }, + ticket: 8790, + content: "Our export of last quarter's events fails every time. Smaller date ranges work.", + }, + ], + trace: { + error: 'WorkerTimeout · export_csv · exceeded 300s', + frames: [ + ['writeRows', 'exports/csv.ts:112'], + ['runExport', 'exports/run.ts:48'], + ], + footer: 'Started when the row cap went from 100k to 1M · fixed in 5c8e7a at 7:45 AM', + }, + facts: [ + ['Where', 'CSV exports over 200k rows', 'export_csv worker', 'default'], + ['Since', 'Mon 11:00 AM', 'Fixed 7:45 AM by Marcus', 'default'], + ['Impact', '58 users in 31 orgs', '9 retried more than five times', 'default'], + ['Owner', 'Marcus', '', 'default'], + ], + observation: { + title: 'CSV export timeouts', + format: 'count', + values: [10, 200, 440, 600, 480, 360, 420, 300, 220, 60, 10, 10, 10, 10], + filters: 1, + }, + activity: [ + ['PostHog created the report', 'Mon 11:24 AM'], + [`${DEMO_USER_NAME} changed assignee to **Marcus Lee**`, 'Mon 12:05 PM'], + ['Marcus Lee changed status from **Open** to **Resolved**', 'Today 7:50 AM'], + ], + contextNote: 'Sends the worker logs and the 5c8e7a diff as context', + handedStage: 'Reading exports/csv.ts · a PR will land here to review', + owner: { kind: 'user', name: 'Marcus Lee' }, + }, + { + id: 'demo-dark-mode-requests', + priority: 'P3', + title: '12 customers asked for dark mode in shared dashboards', + sourceProduct: 'zendesk', + sourceLabel: 'Zendesk', + ago: { days: 4 }, + kind: 'open', + accent: 'opportunity', + headNum: '12', + headRest: 'requests for dark mode on shared dashboards', + lede: 'Most of them put dashboards on office TVs. The white background is too bright at night.', + summary: [ + '## Problem', + "Shared dashboard links always use the light theme. The app has a dark theme, but public links do not read the viewer's preference and have no toggle. Customers who put dashboards on office TVs and ops walls find the white background too bright at night.", + '## Impact', + '12 requests in two weeks, 5 of them in the last four days. They come from 9 orgs worth $96k ARR, and 3 of those are in a renewal quarter. 4 tickets mention a dark reader browser extension as the workaround, which breaks the chart colors.', + '## Solution', + 'Add a theme option to shared links: light, dark, or follow the system. Save it with the share settings, so a link on a TV stays dark. The dark theme tokens already exist, so most of the work is passing the setting to the public page and checking the chart colors on the dark background.', + ], + signals: [ + { + source: 'zendesk', + ago: { hours: 20 }, + ticket: 9066, + content: + 'We run dashboards on a wall display in our network operations room. Please add dark mode to shared links.', + }, + { + source: 'zendesk', + ago: { hours: 50 }, + ticket: 9051, + content: 'The app has dark mode, so why does the public link ignore it?', + }, + { + source: 'zendesk', + ago: { hours: 80 }, + ticket: 9034, + content: 'Our ops wall runs 24/7. The shared dashboard is the only bright screen in the room.', + }, + ], + quotes: [ + [ + '"Our ops wall runs 24/7. The shared dashboard is the only bright screen in the room."', + 'Dana R · Initech · ticket #9034', + ], + ['"The app has dark mode, so why does the public link ignore it?"', 'Lee M · Globex · ticket #9051'], + ], + facts: [ + ['Who', 'Dana R, Lee M and 10 more', 'Initech, Globex, Umbrella, Hooli', 'default'], + ['Since', '2 weeks ago', '5 in the last four days', 'default'], + ['Worth', '$96k ARR across 9 orgs', '3 are in a renewal quarter', 'default'], + ['Workaround', 'Browser extensions', '4 tickets mention a dark reader plugin', 'default'], + ['Owner', 'Nobody assigned', '', 'alert'], + ], + observation: { + title: 'Dark mode requests', + format: 'count', + values: [0, 2, 0, 0, 2, 0, 4, 0, 0, 2, 4, 0, 6, 2], + filters: 0, + }, + activity: [ + ['PostHog created the report', 'Wed 2:18 PM'], + ['PostHog attached **3 more tickets**', 'Fri 11:02 AM'], + ], + contextNote: 'Sends the 12 tickets, the orgs and the shared dashboard theme code as context', + prompt: '12 customers asked for dark mode on shared dashboard links. The app already has a dark theme. Scope a theme option for shared links.', + handedStage: 'Reading the shared dashboard theme · a spec will land here to review', + }, + { + id: 'demo-webhook-health', + title: 'Slack alert webhook failed for 3 hours', + sourceProduct: 'health_checks', + sourceLabel: 'Health check', + ago: { days: 1 }, + kind: 'done', + accent: 'resolved', + headNum: '1,312', + headRest: 'alerts retried and delivered', + lede: 'The signing secret expired on Saturday. Rotated, and the queue drained in 20 minutes.', + summary: [ + '## Problem', + 'The Slack alert webhook rejected every delivery for 3 hours, because its signing secret expired on Saturday at 11:00 PM. The secret had a fixed 12 month expiry and no reminder. Each delivery got HTTP 401 `invalid_signature`, and the queue kept retrying. Nobody was paged, because the delivery check only alerted after 6 hours of failures.', + '## Impact', + '1,312 alerts were late by up to 3 hours, for 74 orgs that send alerts to Slack. No alerts were dropped, because the retry queue held them until the secret was rotated. Alerts by email and webhook were not affected. Most of them were low priority.', + '## Solution', + 'Marcus rotated the secret on Sunday at 2:05 AM, and the queue drained in 20 minutes. To stop this from happening again, add a health check that warns 14 days before the secret expires, and page on call when Slack deliveries fail for more than 10 minutes.', + ], + signals: [ + { + source: 'health_checks', + ago: { hours: 20 }, + content: 'Slack delivery check is passing. The alerts_slack queue is empty.', + }, + { + source: 'logs', + ago: { hours: 24 }, + content: + 'HTTP 401 from Slack\n```\nPOST https://hooks.slack.example.com/services/T000/B000 status=401 error=invalid_signature\nflushQueue in alerts/queue.ts line 70\ndeliverAlert in alerts/slack.ts line 35\n```', + }, + { + source: 'health_checks', + ago: { hours: 26 }, + content: 'Slack delivery check has failed for 15 minutes. 420 alerts are waiting in alerts_slack.', + }, + ], + trace: { + error: 'HTTP 401 · POST hooks.slack.example.com · invalid_signature', + frames: [ + ['deliverAlert', 'alerts/slack.ts:35'], + ['flushQueue', 'alerts/queue.ts:70'], + ], + footer: 'Secret expired Sat 11:00 PM · rotated Sun 2:05 AM by Marcus', + }, + facts: [ + ['Where', 'Slack alert delivery', 'alerts_slack queue', 'default'], + ['Since', 'Sat 11:00 PM → Sun 2:05 AM', 'No alerts were dropped', 'default'], + ['Impact', '1,312 alerts late by up to 3 hours', '74 orgs, all on Slack alerts', 'default'], + ['Owner', 'Marcus', '', 'default'], + ], + observation: { + title: 'Failed Slack deliveries', + format: 'count', + values: [12, 12, 12, 12, 420, 720, 624, 48, 12, 12, 12, 12, 12, 12], + filters: 1, + }, + activity: [ + ['PostHog created the report', 'Sat 11:06 PM'], + ['Marcus Lee changed status from **Open** to **Resolved**', 'Sun 2:30 AM'], + ], + contextNote: 'Sends the delivery log and the secret rotation history as context', + handedStage: 'Reading alerts/slack.ts · a PR will land here to review', + owner: { kind: 'user', name: 'Marcus Lee' }, + }, + { + id: 'demo-funnel-step-crash', + title: 'Funnels with 10+ steps crash the editor', + sourceProduct: 'error_tracking', + sourceLabel: 'Error', + ago: { hours: 6 }, + kind: 'open', + accent: 'alert', + headNum: '214', + headRest: 'editor crashes on long funnels since Friday', + lede: 'One RangeError when the step list renders. Funnels under 10 steps are fine.', + summary: [ + '## Problem', + 'The funnel editor crashes when a funnel has 10 or more steps. Commit b17d52, "Drag to reorder funnel steps", added `reorderSteps`, which calls itself again when the order changes. With 10 or more steps, the order comparison sorts "10" before "2", so the order never settles and the call stack overflows.', + '## Impact', + '214 crashes since Friday at 4:30 PM, from 96 users in 41 orgs. The page goes blank and unsaved changes are lost, so people rebuild long funnels from the start. Saved funnels with 10 or more steps are down 60% since the deploy. Most of them are on growth teams that model long onboarding flows.', + '## Solution', + 'Compare step positions as numbers, and update the order once per drag instead of during render. Add a test with a 12 step funnel. Drag to reorder must keep working for funnels of every length, so check 2, 9 and 15 steps by hand too.', + ], + signals: [ + { + source: 'error_tracking', + ago: { hours: 1 }, + content: + 'RangeError: Maximum call stack size exceeded\n```\nFunnelEditor in src/scenes/funnels/FunnelEditor.tsx line 66\nuseStepOrder in src/scenes/funnels/useStepOrder.ts line 18\nreorderSteps in src/scenes/funnels/FunnelSteps.tsx line 203\nreorderSteps in src/scenes/funnels/FunnelSteps.tsx line 203\n```', + }, + { + source: 'session_replay', + ago: { hours: 3 }, + sessionId: 'demo-funnel-step-crash-session', + content: 'Adds a tenth step to a funnel. The page goes blank and the unsaved funnel is lost.', + }, + { + source: 'github', + ago: { hours: 70 }, + content: 'Commit b17d52 "Drag to reorder funnel steps" by Priya Nair, deployed Friday at 4:05 PM.', + }, + ], + trace: { + error: 'RangeError: Maximum call stack size exceeded', + frames: [ + ['reorderSteps', 'FunnelSteps.tsx:203'], + ['useStepOrder', 'useStepOrder.ts:18'], + ['FunnelEditor', 'FunnelEditor.tsx:66'], + ], + footer: 'Introduced in b17d52 · Fri 4:05 PM · Priya · "Drag to reorder funnel steps"', + }, + replay: { meta: 'adds step 10, page goes blank · 0:22', count: 33 }, + facts: [ + ['Where', 'Funnel editor', '/insights/new · 10 or more steps', 'default'], + ['Since', 'Fri 4:30 PM', 'Saved funnels with 10+ steps down 60%', 'default'], + ['Impact', '96 users in 41 orgs', 'Unsaved changes are lost on crash', 'default'], + ['Owner', 'Nobody assigned', '', 'alert'], + ], + observation: { + title: 'Editor crashes on long funnels', + format: 'count', + values: [2, 2, 2, 2, 30, 56, 40, 24, 36, 80, 104, 96, 120, 88], + filters: 2, + }, + activity: [ + ['PostHog created the report', 'Fri 4:30 PM'], + ['PostHog linked commit **b17d52** as the likely cause', 'Fri 4:31 PM'], + ['PostHog attached **33 replays**', 'Today 8:45 AM'], + ], + contextNote: 'Sends the trace, the b17d52 diff and 33 replays as context', + prompt: 'reorderSteps in FunnelSteps.tsx:203 recurses without end on funnels with 10 or more steps. Started after b17d52. Fix it and keep drag to reorder working.', + handedStage: 'Reading FunnelSteps.tsx · a PR will land here to review', + }, + { + id: 'demo-onboarding-drop', + title: 'Fewer new projects finish setup since the new wizard', + sourceProduct: 'analytics', + sourceLabel: 'Product Analytics', + ago: { days: 5 }, + kind: 'open', + accent: 'alert', + headNum: '−18%', + headRest: 'of new projects send a first event', + lede: 'Was 64%, now 46%. The drop is at the new "Pick your framework" step.', + summary: [ + '## Problem', + 'Fewer new projects finish setup since the new setup wizard reached 100% on Tuesday at 10:00 AM. The drop is at step 2 of 4, "Pick your framework". The step shows 40 frameworks in one long list with no search, and the most common choices are below the fold on a laptop screen.', + '## Impact', + 'The share of new projects that send a first event fell from 64% to 46%. That is about 300 projects a week, mostly self-serve signups. Signups are unchanged, so all of the loss is in setup. 61% of the people who leave do it on "Pick your framework".', + '## Solution', + 'Add search to the framework list and show the 6 most used frameworks first. Add a "Skip for now" link that goes straight to the snippet, so people who do not know their framework can still finish. Ines owns the wizard. The 45 attached replays show people scrolling the list and leaving.', + ], + signals: [ + { + source: 'session_replay', + ago: { hours: 10 }, + sessionId: 'demo-onboarding-drop-session', + content: + 'Scrolls the framework list for 70 seconds, scrolls back up, and closes the tab without picking one.', + }, + { + source: 'analytics', + ago: { hours: 30 }, + content: + 'Funnel from signup to first event: 46% this week, 64% in the 4 weeks before. Step 2 conversion fell from 91% to 39%.', + }, + { + source: 'analytics', + ago: { hours: 100 }, + content: 'Feature flag new-setup-wizard rolled out to 100% on Tuesday at 10:00 AM.', + }, + ], + replay: { meta: 'scrolls the framework list, leaves · 1:12', count: 45 }, + facts: [ + ['Where', 'Setup · Pick your framework', 'Step 2 of 4', 'default'], + ['Since', 'Tue 10:00 AM · new wizard at 100%', 'Signups unchanged', 'default'], + ['Impact', 'About 300 projects a week', 'Mostly self-serve signups', 'default'], + ['Owner', 'Ines', '', 'default'], + ], + observation: { + title: 'New projects that finish setup', + format: 'percentage', + caption: 'Projects that send a first event', + values: [62.4, 64.8, 60, 66, 63.6, 61.2, 64.8, 48, 45.6, 46.8, 44.4, 45.6, 43.2, 45], + filters: 1, + }, + supporting: [ + ['New projects a week', 300, 'count'], + ['Drop-off at Pick your framework', 61, 'percentage'], + ], + activity: [ + ['PostHog created the report', 'Wed 9:30 AM'], + [`${DEMO_USER_NAME} changed assignee to **Ines Moreau**`, 'Wed 10:12 AM'], + ], + contextNote: 'Sends the setup funnel, 45 replays and the wizard step config as context', + prompt: 'Setup completion fell from 64% to 46% after the new wizard. Most drop-off is at "Pick your framework". Find why and propose a fix.', + handedStage: 'Reading the setup wizard · a proposal will land here to review', + owner: { kind: 'user', name: 'Ines Moreau' }, + }, +] diff --git a/products/mobile/src/demo/chats.ts b/products/mobile/src/demo/chats.ts new file mode 100644 index 000000000000..125849cabfb6 --- /dev/null +++ b/products/mobile/src/demo/chats.ts @@ -0,0 +1,64 @@ +import type { ChatListItem, ChatOrigin, ChatSessionStatus } from '@/model/types' + +const HOUR_MS = 3_600_000 +const DAY_MS = 24 * HOUR_MS + +type DemoWhen = { hoursAgo: number } | { daysAgo: number } | { yesterdayAt: [hours: number, minutes: number] } + +interface DemoSession { + id: string + title: string + when: DemoWhen + status: ChatSessionStatus + origin?: ChatOrigin + pullRequestUrl?: string +} + +// `sessionThreads.ts` looks these sessions up by id, so renaming an id here leaves its row without a thread. +const DEMO_SESSIONS: DemoSession[] = [ + { + id: 'demo-session-flaky-test', + title: 'Fix the flaky checkout test', + when: { hoursAgo: 1 }, + status: 'completed', + origin: 'chat', + pullRequestUrl: 'https://github.com/example/app/pull/482', + }, + { + id: 'demo-session-dark-mode', + title: 'Add dark mode to settings', + when: { yesterdayAt: [11, 0] }, + status: 'working', + origin: 'chat', + }, + { id: 'demo-session-webhooks', title: 'Migrate billing webhooks', when: { daysAgo: 4 }, status: 'failed' }, +] + +function resolveWhen(when: DemoWhen, anchor: Date): Date { + if ('hoursAgo' in when) { + return new Date(anchor.getTime() - when.hoursAgo * HOUR_MS) + } + if ('daysAgo' in when) { + return new Date(anchor.getTime() - when.daysAgo * DAY_MS) + } + const [hours, minutes] = when.yesterdayAt + return new Date(anchor.getFullYear(), anchor.getMonth(), anchor.getDate() - 1, hours, minutes) +} + +function toItem(session: DemoSession, anchor: Date): ChatListItem { + return { + id: `session:${session.id}`, + kind: 'session', + sourceId: session.id, + origin: session.origin ?? 'session', + title: session.title, + updatedAt: resolveWhen(session.when, anchor).toISOString(), + status: session.status, + pullRequestUrl: session.pullRequestUrl ?? null, + } +} + +export function buildDemoChatList(anchor: Date): ChatListItem[] { + const items = DEMO_SESSIONS.map((session) => toItem(session, anchor)) + return items.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)) +} diff --git a/products/mobile/src/demo/demoMode.test.ts b/products/mobile/src/demo/demoMode.test.ts new file mode 100644 index 000000000000..2665d258e93f --- /dev/null +++ b/products/mobile/src/demo/demoMode.test.ts @@ -0,0 +1,78 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { renderHook, waitFor } from '@testing-library/react-native' +import * as React from 'react' + +type DemoStore = typeof import('./demoMode').useDemoMode + +const globals = globalThis as unknown as { __DEV__: boolean } +const originalDev = globals.__DEV__ +const originalDemoEnv = process.env.EXPO_PUBLIC_DEMO_MODE + +describe('demo availability', () => { + afterEach(async () => { + globals.__DEV__ = originalDev + if (originalDemoEnv === undefined) { + delete process.env.EXPO_PUBLIC_DEMO_MODE + } else { + process.env.EXPO_PUBLIC_DEMO_MODE = originalDemoEnv + } + await AsyncStorage.clear() + }) + + test.each<[string, boolean, string | undefined, boolean | null, boolean]>([ + ['release restores a saved demo session', false, undefined, true, true], + ['release ignores the demo environment flag', false, '1', null, false], + ['release restores a saved demo session and ignores the environment flag', false, '1', true, true], + ['development starts in demo from the environment', true, '1', null, true], + ['development respects a saved exit from demo', true, '1', false, false], + ['development restores a demo session', true, undefined, true, true], + ['development defaults to live data', true, undefined, null, false], + ])('%s', async (_name, isDev, demoEnv, savedEnabled, expectedEnabled) => { + if (demoEnv === undefined) { + delete process.env.EXPO_PUBLIC_DEMO_MODE + } else { + process.env.EXPO_PUBLIC_DEMO_MODE = demoEnv + } + if (savedEnabled !== null) { + await AsyncStorage.setItem( + 'posthog-mobile-demo', + JSON.stringify({ state: { enabled: savedEnabled }, version: 0 }) + ) + } + globals.__DEV__ = isDev + let store: DemoStore | undefined + jest.isolateModules(() => { + store = jest.requireActual('./demoMode').useDemoMode + }) + if (!store) { + throw new Error('Demo store did not load') + } + await store.persist.rehydrate() + expect(store.getState().enabled).toBe(expectedEnabled) + + store.getState().setEnabled(true) + expect(store.getState().enabled).toBe(true) + }) + + test('a failed storage read settles the demo routing gate with live data', async () => { + globals.__DEV__ = true + const read = jest.spyOn(AsyncStorage, 'getItem').mockRejectedValueOnce(new Error('Storage unavailable')) + let demo: typeof import('./demoMode') | undefined + try { + jest.doMock('react', () => React) + jest.isolateModules(() => { + demo = jest.requireActual('./demoMode') + }) + if (!demo) { + throw new Error('Demo store did not load') + } + const loaded = demo + const hook = renderHook(() => loaded.useDataMode()) + await waitFor(() => expect(hook.result.current).toBe('live')) + hook.unmount() + } finally { + jest.dontMock('react') + read.mockRestore() + } + }) +}) diff --git a/products/mobile/src/demo/demoMode.ts b/products/mobile/src/demo/demoMode.ts new file mode 100644 index 000000000000..37f32234740c --- /dev/null +++ b/products/mobile/src/demo/demoMode.ts @@ -0,0 +1,226 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { useSyncExternalStore } from 'react' +import { create } from 'zustand' +import { createJSONStorage, persist } from 'zustand/middleware' +import { createPersistHydrationGate } from '@/lib/persistHydration' +import { MCP_VIA } from '@/model/agent' +import type { ActivityEvent, DecisionAnswer, ReportPriority } from '@/model/types' +import { + DISMISSAL_REASON_OPTIONS, + type DismissReason, + RESOLVE_REASON_OPTIONS, + type ResolveReason, +} from '@/model/verdictReasons' + +import { DEMO_AGENT, DEMO_USER_NAME } from './briefings' +import { DEMO_PEOPLE } from './fixtures' + +const REFRESH_MS = 450 +const DEMO_FROM_ENV = __DEV__ && process.env.EXPO_PUBLIC_DEMO_MODE === '1' + +export type DemoLogTag = 'handoff' | 'decision' | 'snooze' | 'reviewers' | 'priority' | 'verdict' + +export interface DemoLogEntry extends ActivityEvent { + tag: DemoLogTag +} + +export interface DemoMutableState { + /** Epoch ms that fixture times are relative to. */ + anchor: number + handed: Record + decisions: Record + snoozed: Record + closed: Record + /** An explicit value overrides the fixture's read state. */ + read: Record + reviewers: Record + priorities: Record + /** Demo session ids the person archived from the Chats list. */ + archivedChats: Record + log: Record + isRefreshing: boolean +} + +export interface DemoSnoozeLabels { + row: string + activity: string +} + +export interface DemoModeState extends DemoMutableState { + enabled: boolean + setEnabled: (enabled: boolean) => void + reset: () => void + refresh: () => void + handOff: (reportId: string) => void + answer: (reportId: string, answer: DecisionAnswer) => void + undoAnswer: (reportId: string) => void + snooze: (reportId: string, labels: DemoSnoozeLabels) => void + resolve: (reportId: string, reason: ResolveReason) => void + dismiss: (reportId: string, reason: DismissReason) => void + setReviewers: (reportId: string, uuids: string[]) => void + setPriority: (reportId: string, priority: ReportPriority) => void + markRead: (reportId: string) => void + markUnread: (reportId: string) => void + archiveChat: (taskId: string) => void + unarchiveChat: (taskId: string) => void +} + +function freshState(): DemoMutableState { + return { + anchor: Date.now(), + handed: {}, + decisions: {}, + snoozed: {}, + closed: {}, + read: {}, + reviewers: {}, + priorities: {}, + archivedChats: {}, + log: {}, + isRefreshing: false, + } +} + +let entryCounter = 0 +const hydration = createPersistHydrationGate() + +function entry(tag: DemoLogTag, text: string, via?: string): DemoLogEntry { + entryCounter += 1 + return { id: `demo-log-${entryCounter}`, tag, text, at: new Date().toISOString(), ...(via ? { via } : {}) } +} + +function appendLog( + log: DemoMutableState['log'], + reportId: string, + ...entries: DemoLogEntry[] +): DemoMutableState['log'] { + return { ...log, [reportId]: [...(log[reportId] ?? []), ...entries] } +} + +// Mirrors the live activity line for a dismissal artefact. "Something else…" has no label there. +function verdictSentence(verb: 'resolved' | 'dismissed', reason: ResolveReason | DismissReason): string { + const options: readonly { value: string; label: string }[] = + verb === 'resolved' ? RESOLVE_REASON_OPTIONS : DISMISSAL_REASON_OPTIONS + const label = reason === 'other' ? undefined : options.find((option) => option.value === reason)?.label + return label ? `${DEMO_USER_NAME} ${verb} the report as **${label}**` : `${DEMO_USER_NAME} ${verb} the report` +} + +function reviewerSentence(uuids: string[]): string { + const names = uuids.map((uuid) => DEMO_PEOPLE.find((person) => person.uuid === uuid)?.name ?? 'someone') + if (names.length === 0) { + return `${DEMO_USER_NAME} removed the assignee` + } + return `${DEMO_USER_NAME} changed assignee to **${names.join(', ')}**` +} + +export const useDemoMode = create()( + persist( + (set) => ({ + enabled: DEMO_FROM_ENV, + ...freshState(), + setEnabled: (enabled) => set({ enabled, ...freshState() }), + reset: () => set(freshState()), + refresh: () => { + set({ isRefreshing: true }) + setTimeout(() => set({ ...freshState(), isRefreshing: false }), REFRESH_MS) + }, + handOff: (reportId) => + set((state) => ({ + handed: { ...state.handed, [reportId]: new Date().toISOString() }, + log: appendLog( + state.log, + reportId, + entry('handoff', `${DEMO_USER_NAME} handed off to **${DEMO_AGENT.name}**`), + entry('handoff', `${DEMO_AGENT.name} changed status from **Open** to **In progress**`, MCP_VIA) + ), + })), + answer: (reportId, answer) => + set((state) => ({ + decisions: { ...state.decisions, [reportId]: answer }, + log: appendLog( + state.log, + reportId, + entry('decision', `${DEMO_USER_NAME} chose **${answer.text}**`), + entry( + 'decision', + `${DEMO_AGENT.name} changed status from **Waiting on you** to **In progress**`, + MCP_VIA + ) + ), + })), + undoAnswer: (reportId) => + set((state) => { + const { [reportId]: _removed, ...decisions } = state.decisions + const kept = (state.log[reportId] ?? []).filter((item) => item.tag !== 'decision') + return { decisions, log: { ...state.log, [reportId]: kept } } + }), + snooze: (reportId, labels) => + set((state) => ({ + snoozed: { ...state.snoozed, [reportId]: labels.row }, + log: appendLog( + state.log, + reportId, + entry('snooze', `${DEMO_USER_NAME} snoozed until **${labels.activity}**`) + ), + })), + resolve: (reportId, reason) => + set((state) => ({ + closed: { ...state.closed, [reportId]: 'resolved' }, + log: appendLog(state.log, reportId, entry('verdict', verdictSentence('resolved', reason))), + })), + dismiss: (reportId, reason) => + set((state) => ({ + closed: { ...state.closed, [reportId]: 'dismissed' }, + log: appendLog(state.log, reportId, entry('verdict', verdictSentence('dismissed', reason))), + })), + setReviewers: (reportId, uuids) => + set((state) => ({ + reviewers: { ...state.reviewers, [reportId]: uuids }, + log: appendLog(state.log, reportId, entry('reviewers', reviewerSentence(uuids))), + })), + setPriority: (reportId, priority) => + set((state) => ({ + priorities: { ...state.priorities, [reportId]: priority }, + log: appendLog( + state.log, + reportId, + entry('priority', `${DEMO_USER_NAME} changed priority to **${priority}**`) + ), + })), + markRead: (reportId) => + set((state) => (state.read[reportId] ? state : { read: { ...state.read, [reportId]: true } })), + markUnread: (reportId) => + set((state) => + state.read[reportId] === false ? state : { read: { ...state.read, [reportId]: false } } + ), + archiveChat: (taskId) => set((state) => ({ archivedChats: { ...state.archivedChats, [taskId]: true } })), + unarchiveChat: (taskId) => + set((state) => { + const { [taskId]: _removed, ...archivedChats } = state.archivedChats + return { archivedChats } + }), + }), + { + name: 'posthog-mobile-demo', + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ enabled: state.enabled }), + onRehydrateStorage: () => () => hydration.settle(), + } + ) +) + +/** True once the persisted `enabled` flag has loaded, so routing does not flash the sign-in screen. */ +export function useDemoHydrated(): boolean { + return useSyncExternalStore(hydration.subscribe, hydration.isHydrated, hydration.isHydrated) +} + +export type DataMode = 'loading' | 'demo' | 'live' + +export function useDataMode(): DataMode { + const hydrated = useDemoHydrated() + const enabled = useDemoMode((state) => state.enabled) + if (!hydrated) { + return 'loading' + } + return enabled ? 'demo' : 'live' +} diff --git a/products/mobile/src/demo/derive.ts b/products/mobile/src/demo/derive.ts new file mode 100644 index 000000000000..d317736ddedb --- /dev/null +++ b/products/mobile/src/demo/derive.ts @@ -0,0 +1,76 @@ +import type { InboxItem, ReportView } from '@/model/types' + +import { DEMO_AGENT, demoDecisionMeta } from './briefings' +import type { DemoLogEntry, DemoMutableState } from './demoMode' +import { DEMO_PEOPLE, type DemoFixture, type DemoFixtures } from './fixtures' + +export type DemoView = Pick< + DemoMutableState, + 'handed' | 'decisions' | 'snoozed' | 'closed' | 'read' | 'reviewers' | 'priorities' | 'log' +> + +function lastIndexOfTag(log: DemoLogEntry[], tag: DemoLogEntry['tag']): number { + return log.map((entry) => entry.tag).lastIndexOf(tag) +} + +export function deriveDemoReport(view: DemoView, fixture: DemoFixture): ReportView { + const base = fixture.report + const id = base.id + const handedAt = view.handed[id] + const reviewerIds = view.reviewers[id] + const log = view.log[id] ?? [] + const priority = view.priorities[id] ?? base.priority + const report: ReportView = { + ...base, + ...(priority ? { priority } : {}), + activity: [...base.activity, ...log.map(({ tag: _tag, ...event }) => event)], + } + const handed = Boolean(handedAt) && base.work.state !== 'done' + if (handed) { + report.task = { + taskId: `${id}-handoff-task`, + runId: `${id}-handoff-run`, + status: 'in_progress', + stage: fixture.briefing.handedStage, + phase: 'working', + connection: 'connected', + } + report.work = { state: 'working', assignee: { kind: 'task', name: DEMO_AGENT.name } } + report.owner = { kind: 'agent', name: DEMO_AGENT.name } + } + if (reviewerIds) { + report.reviewers = DEMO_PEOPLE.filter((person) => reviewerIds.includes(person.uuid)).map( + ({ uuid, name, email }) => ({ uuid, name, email }) + ) + // Whichever of hand-off and reassign came last owns the report, as the log records them in order. + if (!handed || lastIndexOfTag(log, 'reviewers') > lastIndexOfTag(log, 'handoff')) { + const first = DEMO_PEOPLE.find((person) => person.uuid === reviewerIds[0]) + report.owner = first ? { kind: 'user', name: first.name } : { kind: 'none' } + } + } + return report +} + +export function deriveDemoInbox(view: DemoView, fixtures: DemoFixtures): InboxItem[] { + const open = fixtures.list.filter(({ item }) => !view.closed[item.reportId]) + return open.map(({ item, briefing }) => { + const id = item.reportId + const next: InboxItem = { ...item, read: view.read[id] ?? item.read } + const decision = view.decisions[id] + if (decision) { + next.needsInput = false + next.progressLabel = decision.meta ?? demoDecisionMeta(decision.text) + } + if (view.handed[id] && item.state !== 'done') { + next.agent = { ...DEMO_AGENT } + next.progressLabel = briefing.handedStage + } + const snoozeLabel = view.snoozed[id] + if (snoozeLabel && item.state !== 'done') { + next.state = 'snoozed' + next.snoozeLabel = snoozeLabel + next.needsInput = false + } + return next + }) +} diff --git a/products/mobile/src/demo/fixtures.test.ts b/products/mobile/src/demo/fixtures.test.ts new file mode 100644 index 000000000000..a2648797ad3a --- /dev/null +++ b/products/mobile/src/demo/fixtures.test.ts @@ -0,0 +1,59 @@ +import { formatActivityTime, isoDay } from '@/lib/dates' + +import { DEMO_BRIEFINGS } from './briefings' +import { buildDemoFixtures, DEMO_DECISION_QUESTION } from './fixtures' +import { demoSessionLog, demoSessionRunId } from './sessionThreads' + +// One anchor per weekday at 3 PM local, so "Today" and every weekday label are exercised. +const ANCHORS = [4, 5, 6, 7, 8, 9, 10].map((day) => new Date(2026, 9, day, 15, 0)) + +describe('demo fixtures', () => { + test.each(ANCHORS.map((anchor) => [anchor.toDateString(), anchor]))( + 'prototype activity times round-trip on %s', + (_label, anchor) => { + const fixtures = buildDemoFixtures(anchor) + const rendered = fixtures.list.flatMap(({ report }) => + report.activity.map((event) => formatActivityTime(event.at, anchor)) + ) + const expected = DEMO_BRIEFINGS.flatMap((briefing) => briefing.activity.map(([, when]) => when)) + expect(rendered).toEqual(expected) + } + ) + + test('every inbox row opens a report with a complete detail view', () => { + const fixtures = buildDemoFixtures(ANCHORS[0] as Date) + expect(fixtures.list).toHaveLength(14) + for (const { item, report } of fixtures.list) { + expect(fixtures.byId[item.reportId]?.report).toBe(report) + expect(report.title).toBe(item.title) + expect(report.headline.rest).not.toBe('') + expect(report.facts.length).toBeGreaterThan(0) + expect(report.prompt).not.toBe('') + expect(report.summary).toContain('## Problem') + expect(report.signals.length).toBeGreaterThanOrEqual(2) + const signalTimes = report.signals.map((signal) => Date.parse(signal.at)) + expect(signalTimes).toEqual([...signalTimes].sort((a, b) => b - a)) + if (report.task) { + expect(demoSessionLog(report.task.taskId)?.title).toBe(report.title) + expect(demoSessionRunId(report.task.taskId)).toBe(report.task.runId) + } + const observation = report.observation + if (observation?.data.kind !== 'static') { + throw new Error(`${report.id} has no static observation`) + } + const { points } = observation.data.series + expect(points).toHaveLength(14) + expect(points[13]?.at).toBe(isoDay(ANCHORS[0] as Date)) + expect(points.every((point) => Number.isFinite(point.value))).toBe(true) + for (const chart of report.charts) { + expect(chart.data === undefined || chart.data.kind === 'static').toBe(true) + } + } + const reports = fixtures.list.map(({ report }) => report) + expect(reports.filter((report) => report.supportingMetrics.length >= 2).length).toBeGreaterThanOrEqual(4) + expect(reports.filter((report) => report.charts.some((chart) => chart.data)).length).toBeGreaterThanOrEqual(2) + expect(reports.filter((report) => report.charts.some((chart) => !chart.data)).length).toBeGreaterThanOrEqual(2) + const waiting = fixtures.list.filter(({ item }) => item.needsInput) + expect(waiting.map(({ report }) => report.task?.pendingDecision)).toEqual([DEMO_DECISION_QUESTION]) + }) +}) diff --git a/products/mobile/src/demo/fixtures.ts b/products/mobile/src/demo/fixtures.ts new file mode 100644 index 000000000000..312447833c19 --- /dev/null +++ b/products/mobile/src/demo/fixtures.ts @@ -0,0 +1,437 @@ +import { dailyBuckets } from '@/lib/dates' +import { sourceInfo } from '@/model/sources' +import type { + ActivityEvent, + DecisionQuestion, + EvidenceBlock, + InboxItem, + InboxReason, + MetricChartKind, + MetricSeries, + MetricValueFormat, + MetricView, + Person, + ReportChartView, + ReportOwner, + ReportSignalView, + ReportView, + TaskRunView, + WorkView, +} from '@/model/types' + +import { + DEMO_AGENT, + DEMO_BRIEFINGS, + DEMO_DECISION, + type DemoAgo, + type DemoBriefing, + type DemoChart, + type DemoKind, + type DemoSupportingMetric, +} from './briefings' + +export interface DemoFixture { + item: InboxItem + report: ReportView + briefing: DemoBriefing +} + +export interface DemoFixtures { + anchor: Date + list: DemoFixture[] + byId: Record +} + +export const DEMO_DECISION_QUESTION: DecisionQuestion = { + id: DEMO_DECISION.id, + question: DEMO_DECISION.question, + options: DEMO_DECISION.options.map((option) => ({ ...option })), +} + +export const DEMO_PEOPLE: Person[] = [ + { + uuid: 'demo-user-sana', + name: 'Sana Patel', + email: 'sana@example.com', + role: 'Frontend · Reports', + color: '#1D4AFF', + }, + { + uuid: 'demo-user-marcus', + name: 'Marcus Lee', + email: 'marcus@example.com', + role: 'Platform · on call', + color: '#F54E00', + }, + { + uuid: 'demo-user-ines', + name: 'Ines Moreau', + email: 'ines@example.com', + role: 'Product · Analytics', + color: '#2F8F5B', + }, + { + uuid: 'demo-user-tomas', + name: 'Mateo Ruiz', + email: 'tomas@example.com', + role: 'Backend · Jobs', + color: '#7A4DD6', + }, + { + uuid: 'demo-user-priya', + name: 'Priya Nair', + email: 'priya@example.com', + role: 'Frontend · Dashboards', + color: '#C2410C', + }, + { + uuid: 'demo-user-jonah', + name: 'Jonah Weiss', + email: 'jonah@example.com', + role: 'Support engineering', + color: '#0E7490', + }, + { + uuid: 'demo-user-robin', + name: 'Robin Calloway', + email: 'robin@example.com', + role: 'Frontend · Reports', + color: '#1F1F1C', + }, +] + +export const DEMO_ME = DEMO_PEOPLE[6] as Person + +const WHEN_PATTERN = /^(Today|Sun|Mon|Tue|Wed|Thu|Fri|Sat) (\d{1,2}):(\d{2}) (AM|PM)$/ +const WEEKDAY_INDEX: Record = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 } + +// A weekday means the most recent such day before today, one to seven days back. +export function parseDemoWhen(when: string, anchor: Date): Date { + const match = WHEN_PATTERN.exec(when) + if (!match) { + throw new Error(`Unrecognized demo time "${when}"`) + } + const [, day = '', hourText = '', minuteText = '', meridiem = ''] = match + const hour12 = Number(hourText) % 12 + const hours = meridiem === 'PM' ? hour12 + 12 : hour12 + const date = new Date(anchor.getFullYear(), anchor.getMonth(), anchor.getDate(), hours, Number(minuteText)) + if (day !== 'Today') { + const target = WEEKDAY_INDEX[day] ?? 0 + const back = (anchor.getDay() - target + 7) % 7 || 7 + date.setDate(date.getDate() - back) + } + return date +} + +function agoToDate(ago: DemoAgo, anchor: Date): Date { + const ms = 'hours' in ago ? ago.hours * 3_600_000 : ago.days * 86_400_000 + return new Date(anchor.getTime() - ms) +} + +const REASON_BY_KIND: Record = { + done: 'claimed_by_you', + decision: 'waiting_for_you', + pr_ready: 'your_pull_request', + open: 'urgent_for_project', +} + +function evidenceFor(briefing: DemoBriefing): EvidenceBlock[] { + const blocks: EvidenceBlock[] = [] + if (briefing.trace) { + blocks.push({ + kind: 'trace', + error: briefing.trace.error, + frames: briefing.trace.frames.map(([fn, loc]) => ({ fn, loc })), + footer: briefing.trace.footer, + }) + } + if (briefing.quotes) { + blocks.push({ kind: 'quotes', quotes: briefing.quotes.map(([text, who]) => ({ text, who })) }) + } + if (briefing.replay) { + const sessionId = `${briefing.id}-session` + blocks.push({ + kind: 'replay', + sessionId, + meta: briefing.replay.meta, + count: briefing.replay.count, + url: demoReplayUrl(sessionId), + }) + } + return blocks +} + +function demoReplayUrl(sessionId: string): string { + return `https://example.com/replay/${sessionId}` +} + +function signalsFor(briefing: DemoBriefing, anchor: Date): ReportSignalView[] { + return briefing.signals.map((signal, index) => ({ + id: `${briefing.id}-signal-${index}`, + sourceProduct: signal.source, + sourceLabel: sourceInfo(signal.source).label, + content: signal.content, + at: agoToDate(signal.ago, anchor).toISOString(), + ...(signal.sessionId ? { sessionId: signal.sessionId, replayUrl: demoReplayUrl(signal.sessionId) } : {}), + ...(signal.ticket !== undefined ? { ticketNumber: signal.ticket } : {}), + })) +} + +function workFor(briefing: DemoBriefing): WorkView { + switch (briefing.kind) { + case 'done': + return { state: 'done', assignee: briefing.owner } + case 'decision': + return { + state: 'working', + assignee: { kind: 'task', name: DEMO_AGENT.name }, + pullRequests: [ + { + url: 'https://github.com/example/app/pull/4191', + number: 4191, + title: 'Guard missing chart labels', + state: 'draft', + }, + ], + } + case 'pr_ready': + return { + state: 'in_review', + assignee: { kind: 'task', name: DEMO_AGENT.name }, + pullRequests: [ + { + url: 'https://github.com/example/app/pull/4190', + number: 4190, + title: 'Parse typed date ranges before applyFilter', + state: 'open', + reviewDecision: 'review_required', + filesChanged: 2, + additions: 18, + deletions: 4, + checksPassed: true, + note: 'Safari 17 verified on staging', + }, + { + url: 'https://github.com/example/app/pull/4188', + number: 4188, + title: 'Add coverage for typed date ranges', + state: 'merged', + }, + { + url: 'https://github.com/example/app/pull/4189', + number: 4189, + title: 'Update filter input handling', + state: 'closed', + }, + { + url: 'https://github.com/example/app/pull/4192', + number: 4192, + title: 'Verify filter keyboard behavior', + state: 'unknown', + }, + ], + } + case 'open': + return { state: 'unclaimed' } + } +} + +function taskFor(briefing: DemoBriefing): TaskRunView | undefined { + if (briefing.kind === 'decision') { + return { + taskId: `${briefing.id}-task`, + runId: `${briefing.id}-run`, + status: 'in_progress', + stage: 'Waiting on you', + phase: 'idle', + connection: 'connected', + pendingDecision: DEMO_DECISION_QUESTION, + } + } + if (briefing.kind === 'pr_ready') { + return { + taskId: `${briefing.id}-task`, + runId: `${briefing.id}-run`, + status: 'in_progress', + phase: 'working', + connection: 'connected', + stage: 'Checking the filter regression tests', + } + } + return undefined +} + +function activityFor(briefing: DemoBriefing, anchor: Date): ActivityEvent[] { + return briefing.activity.map(([text, when, via], index) => ({ + id: `${briefing.id}-activity-${index}`, + text, + at: parseDemoWhen(when, anchor).toISOString(), + ...(via ? { via } : {}), + })) +} + +export const DEMO_WINDOW_LABEL = 'Last 14 days' + +function dailySeries(values: readonly number[], anchor: Date): MetricSeries { + const days = dailyBuckets(anchor, values.length) + return { interval: 'day', points: values.map((value, index) => ({ at: days[index] ?? '', value })) } +} + +// The fixtures have no query, so they follow the live rule for counts and leave currency as a sum. +function demoChartKind(format: MetricValueFormat): MetricChartKind { + return format === 'count' || format === 'currency' ? 'bar' : 'line' +} + +/** A count adds up over the window; a rate, a duration or an average reads as its latest bucket. */ +function windowValue(values: readonly number[], format: MetricValueFormat): number { + const adds = format === 'count' || format === 'currency' + return adds ? values.reduce((sum, value) => sum + value, 0) : (values[values.length - 1] ?? 0) +} + +function observationFor(briefing: DemoBriefing, anchor: Date): MetricView { + const { title, format, unit, caption, values, filters } = briefing.observation + const value = Math.round(windowValue(values, format) * 10) / 10 + return { + metricId: `${briefing.id}-observation`, + role: 'primary', + title, + ...(caption ? { caption } : {}), + format: { valueFormat: format, unit: unit ?? null }, + chart: demoChartKind(format), + interval: 'day', + snapshot: { value, at: anchor.toISOString(), series: [...values] }, + data: { kind: 'static', series: dailySeries(values, anchor), value }, + windowLabel: DEMO_WINDOW_LABEL, + filterCount: filters, + openUrl: `https://example.com/insights/${briefing.id}-observation`, + } +} + +function supportingFor(briefing: DemoBriefing, anchor: Date): MetricView[] { + return (briefing.supporting ?? []).map(([title, value, format, unit]: DemoSupportingMetric, index) => ({ + metricId: `${briefing.id}-supporting-${index}`, + role: 'supporting', + title, + format: { valueFormat: format, unit: unit ?? null }, + chart: demoChartKind(format), + interval: 'day', + snapshot: { value, at: anchor.toISOString(), series: [] }, + data: { kind: 'static', series: { interval: 'day', points: [] }, value }, + windowLabel: DEMO_WINDOW_LABEL, + filterCount: 0, + })) +} + +function chartsFor(briefing: DemoBriefing, anchor: Date): ReportChartView[] { + return (briefing.charts ?? []).map((chart: DemoChart) => { + const format = chart.format ?? 'number' + return { + chartId: chart.id, + title: chart.title, + ...(chart.caption ? { caption: chart.caption } : {}), + ...(chart.values + ? { + data: { + kind: 'static' as const, + series: dailySeries(chart.values, anchor), + value: windowValue(chart.values, format), + }, + } + : {}), + format: { valueFormat: format, unit: chart.unit ?? null }, + chart: 'line', + interval: 'day', + openUrl: `https://example.com/insights/${chart.id}`, + } + }) +} + +/** Fact rows the report screen shows in its byline and chips, so they stay out of the facts list. */ +const META_FACT_KEYS: ReadonlySet = new Set(['Source', 'Since', 'Owner']) + +function factRow(briefing: DemoBriefing, key: string): DemoBriefing['facts'][number] | undefined { + return briefing.facts.find(([rowKey]) => rowKey === key) +} + +// Since rows carry a detail after " · " ("Mon 7:15 AM · peak 310 an hour"); the byline keeps only the moment. +function sinceFor(briefing: DemoBriefing): string | undefined { + const title = factRow(briefing, 'Since')?.[1] + return title ? (title.split(' · ')[0] ?? title) : undefined +} + +function ownerFor(briefing: DemoBriefing): ReportOwner { + const row = factRow(briefing, 'Owner') + if (!row || row[3] === 'alert') { + return { kind: 'none' } + } + return { kind: row[3] === 'agent' ? 'agent' : 'user', name: row[3] === 'agent' ? DEMO_AGENT.name : row[1] } +} + +function reportFor(briefing: DemoBriefing, anchor: Date): ReportView { + const since = sinceFor(briefing) + const task = taskFor(briefing) + return { + id: briefing.id, + title: briefing.title, + sourceProduct: briefing.sourceProduct, + sourceLabel: briefing.sourceLabel, + accent: briefing.accent, + headline: { number: briefing.headNum, rest: briefing.headRest }, + lede: briefing.lede, + summary: briefing.summary.join('\n\n'), + evidence: evidenceFor(briefing), + ...(since ? { since } : {}), + owner: ownerFor(briefing), + facts: briefing.facts + .filter(([key]) => !META_FACT_KEYS.has(key)) + .map(([key, title, sub, tone]) => ({ key, title, tone, ...(sub ? { sub } : {}) })), + observation: observationFor(briefing, anchor), + supportingMetrics: supportingFor(briefing, anchor), + charts: chartsFor(briefing, anchor), + signals: signalsFor(briefing, anchor), + activity: activityFor(briefing, anchor), + work: workFor(briefing), + ...(task ? { task } : {}), + ...(briefing.priority ? { priority: briefing.priority } : {}), + reviewers: [], + prompt: briefing.prompt ?? briefing.title, + contextNote: briefing.contextNote, + } +} + +function itemFor(briefing: DemoBriefing, anchor: Date): InboxItem { + const hasAgent = briefing.kind === 'decision' || briefing.kind === 'pr_ready' + const task = taskFor(briefing) + const pullRequest = workFor(briefing).pullRequests?.[0] + const pullRequestUrl = pullRequest?.url + const pullRequestState = pullRequest?.state + return { + id: briefing.id, + reportId: briefing.id, + title: briefing.title, + sourceProduct: briefing.sourceProduct, + sourceLabel: briefing.sourceLabel, + sources: [briefing.sourceProduct], + accent: briefing.accent, + createdAt: agoToDate(briefing.ago, anchor).toISOString(), + updatedAt: agoToDate(briefing.ago, anchor).toISOString(), + modelScore: null, + reason: REASON_BY_KIND[briefing.kind], + state: briefing.kind === 'done' ? 'done' : 'open', + needsInput: briefing.kind === 'decision', + read: false, + ...(hasAgent ? { agent: { ...DEMO_AGENT } } : {}), + ...(task?.stage ? { progressLabel: task.stage } : {}), + ...(pullRequestUrl ? { pullRequestUrl } : {}), + ...(pullRequestState ? { pullRequestState } : {}), + } +} + +export function buildDemoFixtures(anchor: Date): DemoFixtures { + const list = DEMO_BRIEFINGS.map((briefing) => ({ + briefing, + item: itemFor(briefing, anchor), + report: reportFor(briefing, anchor), + })) + return { anchor, list, byId: Object.fromEntries(list.map((fixture) => [fixture.report.id, fixture])) } +} diff --git a/products/mobile/src/demo/hooks.ts b/products/mobile/src/demo/hooks.ts new file mode 100644 index 000000000000..fc5c466b0df8 --- /dev/null +++ b/products/mobile/src/demo/hooks.ts @@ -0,0 +1,67 @@ +import { useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' + +import { errorQuery, successQuery } from '@/model/queryStates' +import type { ChatListItem, InboxItem, QueryState, ReportView } from '@/model/types' + +import { buildDemoChatList } from './chats' +import { useDemoMode } from './demoMode' +import { type DemoView, deriveDemoInbox, deriveDemoReport } from './derive' +import { buildDemoFixtures, type DemoFixtures } from './fixtures' + +function useDemoView(): DemoView { + return useDemoMode( + useShallow((state) => ({ + handed: state.handed, + decisions: state.decisions, + snoozed: state.snoozed, + closed: state.closed, + read: state.read, + reviewers: state.reviewers, + priorities: state.priorities, + log: state.log, + })) + ) +} + +export function useDemoFixtures(): DemoFixtures { + const anchor = useDemoMode((state) => state.anchor) + return useMemo(() => buildDemoFixtures(new Date(anchor)), [anchor]) +} + +export function useDemoInboxQuery(): QueryState { + const fixtures = useDemoFixtures() + const view = useDemoView() + const isRefreshing = useDemoMode((state) => state.isRefreshing) + const refresh = useDemoMode((state) => state.refresh) + const items = useMemo(() => deriveDemoInbox(view, fixtures), [view, fixtures]) + return successQuery(items, { isRefetching: isRefreshing, refetch: refresh }) +} + +export function useDemoReportQuery(reportId: string): QueryState { + const fixtures = useDemoFixtures() + const view = useDemoView() + const isRefreshing = useDemoMode((state) => state.isRefreshing) + const refresh = useDemoMode((state) => state.refresh) + const fixture = fixtures.byId[reportId] + const report = useMemo(() => (fixture ? deriveDemoReport(view, fixture) : undefined), [view, fixture]) + if (!report) { + return errorQuery( + new Error("This report isn't in the demo. Go back to the inbox and pick another one."), + refresh + ) + } + return successQuery(report, { isRefetching: isRefreshing, refetch: refresh }) +} + +export function useDemoChatsQuery(): QueryState { + const anchor = useDemoMode((state) => state.anchor) + const isRefreshing = useDemoMode((state) => state.isRefreshing) + const refresh = useDemoMode((state) => state.refresh) + const archivedChats = useDemoMode((state) => state.archivedChats) + const items = useMemo( + () => buildDemoChatList(new Date(anchor)).filter((item) => !archivedChats[item.sourceId]), + [anchor, archivedChats] + ) + return successQuery(items, { isRefetching: isRefreshing, refetch: refresh }) +} diff --git a/products/mobile/src/demo/sessionThreads.test.ts b/products/mobile/src/demo/sessionThreads.test.ts new file mode 100644 index 000000000000..5d4aa72fb014 --- /dev/null +++ b/products/mobile/src/demo/sessionThreads.test.ts @@ -0,0 +1,67 @@ +import { + historyFrames, + initialSessionThread, + type SessionThreadState, + sessionThreadReducer, +} from '@/features/chat/session/sessionReducer' +import { decisionQuestionFor } from '@/features/handoff/permissionRequest' +import { initialRunState, reduceFrame } from '@/features/handoff/runStream' +import type { RunStreamState } from '@/features/handoff/types' + +import { buildDemoChatList } from './chats' +import { buildDemoFixtures } from './fixtures' +import { type DemoSessionLog, demoSessionLog, demoSessionRunId } from './sessionThreads' + +const ANCHOR = new Date(2026, 9, 7, 15, 0) +const SESSIONS = [ + ...buildDemoChatList(ANCHOR), + ...buildDemoFixtures(ANCHOR).list.flatMap(({ report }) => + report.task + ? [ + { + sourceId: report.task.taskId, + title: report.title, + pullRequestUrl: report.work.pullRequests?.[0]?.url ?? report.work.pullRequest?.url ?? null, + }, + ] + : [] + ), +] + +function fold(taskId: string): { log: DemoSessionLog; thread: SessionThreadState; live: RunStreamState } { + const log = demoSessionLog(taskId) + if (!log) { + throw new Error(`No demo log for ${taskId}`) + } + const runId = demoSessionRunId(taskId) + const frames = historyFrames(log.entries, runId, false) + const thread = sessionThreadReducer(initialSessionThread(), { type: 'frames', frames }) + let live = initialRunState(taskId, runId, log.status) + for (const { frame, context } of frames) { + live = reduceFrame(live, frame, context).state + } + return { log, thread, live } +} + +describe('demo session logs', () => { + test.each(SESSIONS.map((item) => [item.sourceId, item.title, item.pullRequestUrl]))( + 'the demo chats list row %s opens a stored session with the same title and PR', + (sourceId, title, pullRequestUrl) => { + const { log, thread, live } = fold(sourceId) + expect(log.title).toBe(title) + expect(thread.items[0]?.kind).toBe('user') + expect(thread.items.some((item) => item.kind === 'tool')).toBe(true) + expect(live.prUrl ?? null).toBe(pullRequestUrl) + } + ) + + test('the in-progress session waits on a two-option question, and the failed one shows its error', () => { + const { live } = fold('demo-session-dark-mode') + expect(live.pending?.kind).toBe('question') + expect(live.pending && decisionQuestionFor(live.pending).options).toHaveLength(2) + + const failed = fold('demo-session-webhooks') + expect(failed.thread.items.at(-1)?.kind).toBe('error') + expect(failed.live.status).toBe('failed') + }) +}) diff --git a/products/mobile/src/demo/sessionThreads.ts b/products/mobile/src/demo/sessionThreads.ts new file mode 100644 index 000000000000..444d5511efc6 --- /dev/null +++ b/products/mobile/src/demo/sessionThreads.ts @@ -0,0 +1,256 @@ +import type { TaskRunStatus } from '@/model/types' +import { DEMO_BRIEFINGS, DEMO_DECISION } from './briefings' + +// Stored run logs for the demo sessions in `chats.ts`, shaped like `GET tasks/{id}/runs/{run}/logs/` lines. + +export interface DemoSessionLog { + title: string + status: TaskRunStatus + entries: unknown[] +} + +function notification(method: string, params: Record): Record { + return { type: 'notification', notification: { jsonrpc: '2.0', method, params } } +} + +function update(update: Record): Record { + return notification('session/update', { sessionId: 'demo-session', update }) +} + +function userMessage(text: string): Record { + return update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text } }) +} + +function thought(text: string): Record { + return update({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text } }) +} + +function agentMessage(text: string): Record { + return update({ sessionUpdate: 'agent_message', content: { type: 'text', text } }) +} + +function tool( + toolCallId: string, + toolName: string, + kind: string, + title: string, + status: 'completed' | 'failed' = 'completed' +): Record[] { + return [ + update({ + sessionUpdate: 'tool_call', + toolCallId, + title, + kind, + status: 'pending', + _meta: { claudeCode: { toolName } }, + }), + update({ sessionUpdate: 'tool_call_update', toolCallId, status }), + ] +} + +function runStarted(runId: string): Record { + return notification('_posthog/run_started', { runId }) +} + +const TURN_COMPLETE = notification('_posthog/turn_complete', {}) + +const FLAKY_TEST_PR = 'https://github.com/example/app/pull/482' + +const LOGS: Record = { + 'demo-session-flaky-test': { + title: 'Fix the flaky checkout test', + status: 'completed', + entries: [ + runStarted('demo-run-flaky-test'), + userMessage( + 'The checkout test fails about one run in five on CI. Find out why and fix it so it passes every time.' + ), + thought('The failure is intermittent, so a timing problem is likely. I will read the test first.'), + ...tool('demo-tc-read-test', 'Read', 'read', 'Read /repo/web/tests/checkout.spec.ts'), + ...tool('demo-tc-run-test', 'Bash', 'execute', 'pnpm test checkout.spec.ts --repeat 20'), + thought('4 of 20 runs failed. Each failure clicks Pay before the shared folder quota loads.'), + ...tool('demo-tc-read-page', 'Read', 'read', 'Read /repo/web/src/checkout/PayButton.tsx'), + ...tool('demo-tc-edit-test', 'Edit', 'edit', 'Edit /repo/web/tests/checkout.spec.ts'), + ...tool('demo-tc-rerun-test', 'Bash', 'execute', 'pnpm test checkout.spec.ts --repeat 50'), + agentMessage( + [ + 'The test clicked **Pay** before the storage quota request finished, so the button was sometimes still disabled.', + '', + '- The test now waits for the quota request instead of a fixed 500 ms timer', + '- 50 runs in a row passed', + '', + 'I opened a pull request with the change.', + ].join('\n') + ), + { + type: 'task_run_state', + run_id: 'demo-run-flaky-test', + status: 'completed', + stage: null, + error_message: null, + output: { pr_url: FLAKY_TEST_PR, pr_urls: [FLAKY_TEST_PR] }, + }, + TURN_COMPLETE, + ], + }, + 'demo-session-dark-mode': { + title: 'Add dark mode to settings', + status: 'in_progress', + entries: [ + runStarted('demo-run-dark-mode'), + userMessage('Add a dark mode switch to the Hedgebox settings page.'), + thought('Settings already has an appearance section. I will check how the theme is stored.'), + ...tool('demo-tc-find-theme', 'Grep', 'search', 'Grep theme /repo/web/src/settings'), + ...tool('demo-tc-read-settings', 'Read', 'read', 'Read /repo/web/src/settings/AppearanceSection.tsx'), + agentMessage( + 'The app stores the theme per device. Before I add the switch, I need to know where the choice should live.' + ), + notification('_posthog/permission_request', { + requestId: 'demo-req-dark-mode', + options: [ + { optionId: 'option_0', kind: 'allow_once', name: 'Save it per account' }, + { optionId: 'option_1', kind: 'allow_once', name: 'Keep it per device' }, + ], + toolCall: { + toolCallId: 'demo-tc-question', + title: 'Where should the dark mode choice live?', + kind: 'other', + _meta: { + claudeCode: { toolName: 'AskUserQuestion' }, + codeToolKind: 'question', + questions: [ + { + question: 'Where should the dark mode choice live?', + header: 'Theme', + multiSelect: false, + options: [ + { + label: 'Save it per account', + description: 'The same theme on every device you sign in on', + }, + { label: 'Keep it per device', description: 'No server change' }, + ], + }, + ], + }, + }, + }), + ], + }, + 'demo-session-webhooks': { + title: 'Migrate billing webhooks', + status: 'failed', + entries: [ + runStarted('demo-run-webhooks'), + userMessage('Move the billing webhooks from the old handler to the new events endpoint.'), + thought('I will find the old handler and list the events it takes.'), + ...tool('demo-tc-find-handler', 'Grep', 'search', 'Grep billing_webhook /repo/api'), + ...tool('demo-tc-read-handler', 'Read', 'read', 'Read /repo/api/billing/webhooks.py'), + ...tool('demo-tc-run-migrations', 'Bash', 'execute', 'python manage.py migrate billing', 'failed'), + notification('_posthog/error', { + message: "The sandbox couldn't reach the billing test database, so the migration did not run.", + }), + { + type: 'task_run_state', + run_id: 'demo-run-webhooks', + status: 'failed', + stage: null, + error_message: "The sandbox couldn't reach the billing test database, so the migration did not run.", + output: null, + }, + ], + }, +} + +/** A stored demo session, keyed by the task ids in `chats.ts`. Undefined for an id the demo does not know. */ +export function demoSessionLog(taskId: string): DemoSessionLog | undefined { + if (LOGS[taskId]) { + return LOGS[taskId] + } + const reportId = taskId.replace(/(?:-handoff)?-task$/, '') + const briefing = DEMO_BRIEFINGS.find((item) => item.id === reportId) + if (!briefing) { + return undefined + } + const runId = demoSessionRunId(taskId) + const decision = briefing.kind === 'decision' && !taskId.endsWith('-handoff-task') + const pullRequests = decision + ? ['https://github.com/example/app/pull/4191'] + : briefing.kind === 'pr_ready' + ? [4190, 4188, 4189, 4192].map((number) => `https://github.com/example/app/pull/${number}`) + : [] + return { + title: briefing.title, + status: 'in_progress', + entries: [ + runStarted(runId), + userMessage(briefing.prompt ?? briefing.title), + ...tool(`${reportId}-read`, 'Read', 'read', 'Read the report and related source'), + agentMessage( + decision ? DEMO_DECISION.question : 'You can review the task activity or send a follow-up here.' + ), + { + type: 'task_run_state', + run_id: runId, + status: 'in_progress', + stage: briefing.handedStage, + output: { pr_url: pullRequests[0] ?? null, pr_urls: pullRequests }, + }, + ...(decision + ? [ + notification('_posthog/permission_request', { + requestId: DEMO_DECISION.id, + options: DEMO_DECISION.options.map((option, index) => ({ + optionId: `option_${index}`, + kind: 'allow_once', + name: option.title, + })), + toolCall: { + toolCallId: `${reportId}-question`, + title: DEMO_DECISION.question, + kind: 'other', + _meta: { + claudeCode: { toolName: 'AskUserQuestion' }, + codeToolKind: 'question', + questions: [ + { + question: DEMO_DECISION.question, + multiSelect: false, + options: DEMO_DECISION.options.map((option) => ({ + label: option.title, + description: option.meta, + })), + }, + ], + }, + }, + }), + ] + : [TURN_COMPLETE]), + ], + } +} + +/** The run id the demo session's log names, so its frames count as the session's own run. */ +export function demoSessionRunId(taskId: string): string { + if (taskId.endsWith('-task')) { + return `${taskId.slice(0, -5)}-run` + } + return `demo-run-${taskId.replace(/^demo-session-/, '')}` +} + +export const DEMO_REPLY = [ + "This is the demo, so I can't look at your data.", + '', + 'When you sign in, PostHog AI can:', + '', + '- explain what **caused** a report', + '- check **who is affected** with a quick query', + '- draft a **status update** you can paste anywhere', + '', + 'Turn off the demo in Settings, then sign in to ask about your own reports.', +].join('\n') + +/** What the demo agent says after an answered decision. */ +export const DEMO_ANSWER_REPLY = 'Thanks. I will continue with that choice.' diff --git a/products/mobile/src/demo/views.test.ts b/products/mobile/src/demo/views.test.ts new file mode 100644 index 000000000000..55a2ffb09aaf --- /dev/null +++ b/products/mobile/src/demo/views.test.ts @@ -0,0 +1,31 @@ +import { insightDisplay, insightRenderState } from '@/features/views/snapshotChart' +import { insightSnapshot } from '@/model/adapters/insightSnapshot' + +import { DEMO_DASHBOARD_SQL, demoDashboardResponse } from './views' + +jest.mock('@react-native-async-storage/async-storage', () => + require('@react-native-async-storage/async-storage/jest/async-storage-mock') +) + +const ANCHOR = Date.parse('2026-10-14T12:00:00Z') + +describe('demo views', () => { + test('draws every tile of the SQL dashboard on the phone', () => { + const dashboard = demoDashboardResponse(DEMO_DASHBOARD_SQL, null, ANCHOR, () => undefined) + + const tiles = (dashboard?.tiles ?? []).map((tile) => { + const snapshot = tile.insight + ? insightSnapshot(tile.insight, undefined, { readAt: ANCHOR, serverNow: ANCHOR }) + : undefined + return snapshot && [snapshot.name, insightDisplay(snapshot), insightRenderState(snapshot)] + }) + + expect(tiles).toEqual([ + ['Top pages this week', 'table', 'ready'], + ['Signups vs activations by day', 'chart', 'ready'], + ['Revenue by plan', 'bars', 'ready'], + ['Paying accounts', 'number', 'ready'], + ['Signups by country', 'table', 'ready'], + ]) + }) +}) diff --git a/products/mobile/src/demo/views.ts b/products/mobile/src/demo/views.ts new file mode 100644 index 000000000000..7e14b4698af2 --- /dev/null +++ b/products/mobile/src/demo/views.ts @@ -0,0 +1,1043 @@ +import { create } from 'zustand' + +import type { ActorsAtPointInput, ActorsResponseApi } from '@/api/actors' +import type { DashboardApi, DashboardBasicApi, DashboardTileApi } from '@/api/dashboards' +import type { InsightApi, InsightBasicApi } from '@/api/insights' +import type { FileSystemShortcutApi, ShortcutInput } from '@/api/shortcuts' +import type { WebOverviewResponseApi } from '@/api/webAnalytics' +import { asRecord } from '@/lib/records' +import type { DeviceRecentEntry, PinDisplay } from '@/model/views' + +import { useDemoMode } from './demoMode' + +// Views demo data. Every name, number and email is invented. Responses have the API shapes, so demo mode runs through +// the same adapters, refresh rules and status line as live mode. Ids are stable because the Argent flow selects by them. + +const MINUTE = 60_000 +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR + +/** How old the demo numbers are when the demo starts: "Updated 42 min ago". */ +const DEMO_DATA_AGE_MS = 42 * MINUTE +/** A demo read takes this long, so "Updating…" shows. */ +export const DEMO_READ_MS = 450 + +export const DEMO_DASHBOARD_KPIS = 'demo-dash-kpis' +export const DEMO_DASHBOARD_GROWTH = 'demo-dash-growth' +export const DEMO_DASHBOARD_SQL = 'demo-dash-sql' +export const DEMO_INSIGHT_SIGNUPS = 'demo-ins-signups' +export const DEMO_INSIGHT_DAU = 'demo-ins-dau' +export const DEMO_INSIGHT_CHECKOUT = 'demo-ins-checkout' +export const DEMO_INSIGHT_RETENTION = 'demo-ins-retention' +export const DEMO_INSIGHT_BROWSERS = 'demo-ins-browsers' +export const DEMO_INSIGHT_SQL_PAGES = 'demo-ins-sql-pages' +export const DEMO_INSIGHT_SQL_SIGNUPS = 'demo-ins-sql-signups' +export const DEMO_INSIGHT_SQL_REVENUE = 'demo-ins-sql-revenue' +export const DEMO_INSIGHT_SQL_COUNTRIES = 'demo-ins-sql-countries' + +type JsonRecord = Record + +type DemoSqlCell = string | number + +/** A SQL result: `[column, ClickHouse type]` per column, and the rows for a window. */ +interface DemoSqlResult { + types: [string, string][] + rows: (window: DemoWindow, anchor: Date) => DemoSqlCell[][] +} + +interface DemoInsightDef { + id: number + shortId: string + name: string + description: string + query: JsonRecord + /** The value for one day; `daysBack` is 0 for today. */ + perDay?: (daysBack: number, weekday: number) => number + /** A big number over a window of this many days. */ + aggregate?: (windowDays: number) => number + /** A SQL insight's columns and rows. */ + sql?: DemoSqlResult + /** Funnel steps over 30 days, scaled to the window. `medianSeconds` is the time from the step before. */ + funnel?: { name: string; count: number; medianSeconds: number | null }[] + /** Weekly cohorts: the size of an average cohort and the percent that comes back in each week after. */ + retention?: { cohortSize: number; curve: number[] } + /** Totals per breakdown value: the value's share of `perDay` summed over the window. */ + breakdown?: { value: string; share: number }[] +} + +function noise(day: number, salt: number): number { + const value = Math.sin((day + 1) * 12.9898 + salt * 78.233) * 43_758.5453 + return value - Math.floor(value) +} + +/** Daily values around `base` with quieter weekends and one clear dip nine days back. */ +function daily(base: number, salt: number, spread = 0.16): (daysBack: number, weekday: number) => number { + return (daysBack, weekday) => { + const weekend = weekday === 0 || weekday === 6 ? 0.72 : 1 + const dip = daysBack === 9 ? 0.58 : 1 + const trend = 1 - daysBack * 0.002 + return Math.round(base * weekend * dip * trend * (1 - spread / 2 + noise(daysBack, salt) * spread)) + } +} + +function trendsQuery(options: { + event: string + math?: string + display: string + dateFrom: string + axisFormat?: string +}): JsonRecord { + const { event, math, display, dateFrom, axisFormat } = options + return { + kind: 'InsightVizNode', + source: { + kind: 'TrendsQuery', + interval: 'day', + series: [{ kind: 'EventsNode', event, name: event, ...(math ? { math } : {}) }], + trendsFilter: { display, ...(axisFormat ? { aggregationAxisFormat: axisFormat } : {}) }, + dateRange: { date_from: dateFrom, date_to: null }, + }, + } +} + +function sqlQuery(query: string, extra: JsonRecord = {}, dateFrom?: string): JsonRecord { + return { + kind: 'DataVisualizationNode', + source: { + kind: 'HogQLQuery', + query, + ...(dateFrom ? { filters: { dateRange: { date_from: dateFrom, date_to: null } } } : {}), + }, + ...extra, + } +} + +const DEMO_SQL_PAGES: DemoSqlCell[][] = [ + ['example.com/', 12_000, 8400], + ['example.com/pricing', 6400, 4100], + ['example.com/docs/getting-started', 5200, 3000], + ['example.com/blog/hello-world', 3800, 2900], + ['example.com/signup', 3000, 2600], + ['example.com/changelog', 2200, 1500], + ['example.com/about', 1600, 1300], + ['example.com/careers', 900, 700], +] + +// Six columns, wider than a phone, so the table pins its first column. `conversion` is a fraction, as SQL returns it. +const DEMO_SQL_COUNTRIES: DemoSqlCell[][] = [ + ['United States', 4200, 1890, 0.45, 340, '2023-02-01'], + ['Germany', 1800, 900, 0.5, 280, '2023-03-15'], + ['United Kingdom', 1500, 600, 0.4, 410, '2023-02-20'], + ['India', 1300, 390, 0.3, 520, '2023-06-01'], + ['Brazil', 900, 315, 0.35, 460, '2024-01-10'], + ['France', 800, 360, 0.45, 300, '2023-04-05'], + ['Canada', 700, 280, 0.4, 350, '2023-05-12'], + ['Japan', 500, 250, 0.5, 600, '2024-09-30'], +] + +const signupsPerDay = daily(128, 1) + +const DEMO_INSIGHTS: DemoInsightDef[] = [ + { + id: 9101, + shortId: DEMO_INSIGHT_SIGNUPS, + name: 'Signups', + description: 'New accounts per day.', + query: trendsQuery({ event: 'user signed up', display: 'ActionsLineGraph', dateFrom: '-14d' }), + perDay: daily(128, 1), + }, + { + id: 9102, + shortId: DEMO_INSIGHT_DAU, + name: 'Daily active users', + description: 'People who viewed a page each day.', + query: trendsQuery({ event: '$pageview', math: 'dau', display: 'ActionsLineGraph', dateFrom: '-14d' }), + perDay: daily(1840, 2, 0.1), + }, + { + id: 9103, + shortId: 'demo-ins-wau', + name: 'Weekly active users', + description: 'People active in the last seven days.', + query: trendsQuery({ event: '$pageview', math: 'weekly_active', display: 'BoldNumber', dateFrom: '-7d' }), + aggregate: (windowDays) => Math.round(6420 * Math.min(1.6, Math.max(0.4, windowDays / 8) ** 0.35)), + }, + { + id: 9104, + shortId: 'demo-ins-pageviews', + name: 'Pageviews', + description: 'Every page view on the marketing site and the app.', + query: trendsQuery({ event: '$pageview', display: 'ActionsBar', dateFrom: '-7d' }), + perDay: daily(9600, 4), + }, + { + id: 9105, + shortId: 'demo-ins-paying', + name: 'Paying accounts', + description: 'Accounts on a paid plan today. The SQL has no date filter.', + query: { + kind: 'DataVisualizationNode', + display: 'BoldNumber', + source: { kind: 'HogQLQuery', query: "select count() from groups where properties.plan != 'free'" }, + }, + sql: { types: [['count()', 'UInt64']], rows: () => [[312]] }, + }, + { + id: 9106, + shortId: DEMO_INSIGHT_CHECKOUT, + name: 'Checkout funnel', + description: 'From the pricing page to a paid plan.', + query: { + kind: 'InsightVizNode', + source: { + kind: 'FunnelsQuery', + series: [ + { kind: 'EventsNode', event: 'viewed pricing' }, + { kind: 'EventsNode', event: 'started checkout' }, + { kind: 'EventsNode', event: 'paid' }, + ], + dateRange: { date_from: '-30d', date_to: null }, + }, + }, + funnel: [ + { name: 'Viewed pricing', count: 4210, medianSeconds: null }, + { name: 'Started checkout', count: 1600, medianSeconds: 380 }, + { name: 'Entered payment', count: 928, medianSeconds: 145 }, + { name: 'Paid', count: 590, medianSeconds: 52 }, + ], + }, + { + id: 9109, + shortId: DEMO_INSIGHT_RETENTION, + name: 'Weekly retention', + description: 'People who signed up, then viewed a page in the weeks after.', + query: { + kind: 'InsightVizNode', + source: { + kind: 'RetentionQuery', + retentionFilter: { + period: 'Week', + totalIntervals: 8, + targetEntity: { id: 'user signed up', name: 'user signed up', type: 'events' }, + returningEntity: { id: '$pageview', name: '$pageview', type: 'events' }, + }, + dateRange: { date_from: '-56d', date_to: null }, + }, + }, + retention: { cohortSize: 430, curve: [100, 46, 34, 29, 26, 24, 23, 22] }, + }, + { + id: 9111, + shortId: DEMO_INSIGHT_SQL_PAGES, + name: 'Top pages this week', + description: 'The most viewed pages in the last seven days. The SQL has its own date filter.', + query: sqlQuery( + "select properties.$pathname as page, count() as views, count(distinct person_id) as visitors from events where event = '$pageview' and timestamp > now() - interval 7 day group by page order by views desc limit 8", + { + display: 'ActionsTable', + tableSettings: { + columns: [ + { column: 'views', settings: { formatting: { style: 'number', decimalPlaces: 0 } } }, + { column: 'visitors', settings: { formatting: { style: 'number', decimalPlaces: 0 } } }, + ], + }, + } + ), + sql: { + types: [ + ['page', 'String'], + ['views', 'UInt64'], + ['visitors', 'UInt64'], + ], + rows: () => DEMO_SQL_PAGES, + }, + }, + { + id: 9112, + shortId: DEMO_INSIGHT_SQL_SIGNUPS, + name: 'Signups vs activations by day', + description: 'New accounts per day, and the ones that created a dashboard.', + query: sqlQuery( + "select toDate(timestamp) as day, countIf(event = 'user signed up') as signups, countIf(event = 'dashboard created') as activations from events where {filters} group by day order by day", + { display: 'ActionsLineGraph' }, + '-14d' + ), + sql: { + types: [ + ['day', 'Date'], + ['signups', 'UInt64'], + ['activations', 'UInt64'], + ], + rows: (window, anchor) => { + const today = startOfDay(anchor) + return windowDays(window).map((day) => { + const daysBack = Math.round((today.getTime() - day.getTime()) / DAY) + const signups = signupsPerDay(daysBack, day.getDay()) + return [isoDay(day), signups, Math.round(signups * (0.32 + noise(daysBack, 13) * 0.12))] + }) + }, + }, + }, + { + id: 9114, + shortId: DEMO_INSIGHT_SQL_COUNTRIES, + name: 'Signups by country', + description: 'Signups, activation and time to activate per country.', + query: sqlQuery( + "select properties.$geoip_country_name as country, count() as signups, countIf(person.properties.activated) as activated, activated / signups as conversion, toUInt64(median(properties.seconds_to_activate)) as median_seconds, min(toDate(timestamp)) as first_seen from events where event = 'user signed up' group by country order by signups desc limit 8", + { + display: 'ActionsTable', + tableSettings: { + columns: [ + { column: 'signups', settings: { formatting: { style: 'number', decimalPlaces: 0 } } }, + { column: 'activated', settings: { formatting: { style: 'number', decimalPlaces: 0 } } }, + { column: 'conversion', settings: { formatting: { style: 'percent', decimalPlaces: 1 } } }, + { column: 'median_seconds', settings: { formatting: { suffix: ' s' } } }, + ], + }, + } + ), + sql: { + types: [ + ['country', 'String'], + ['signups', 'UInt64'], + ['activated', 'UInt64'], + ['conversion', 'Float64'], + ['median_seconds', 'UInt64'], + ['first_seen', 'Date'], + ], + rows: () => DEMO_SQL_COUNTRIES, + }, + }, + { + id: 9113, + shortId: DEMO_INSIGHT_SQL_REVENUE, + name: 'Revenue by plan', + description: 'Monthly recurring revenue per plan.', + query: sqlQuery( + 'select properties.plan as plan, sum(properties.mrr) as mrr from groups group by plan order by mrr desc', + { + display: 'ActionsBarValue', + chartSettings: { + xAxis: { column: 'plan' }, + yAxis: [ + { + column: 'mrr', + settings: { formatting: { prefix: '$', style: 'number', decimalPlaces: 0 } }, + }, + ], + }, + } + ), + sql: { + types: [ + ['plan', 'String'], + ['mrr', 'Float64'], + ], + rows: () => [ + ['Enterprise', 48_000], + ['Scale', 24_000], + ['Teams', 12_000], + ['Starter', 3000], + ], + }, + }, + { + id: 9110, + shortId: DEMO_INSIGHT_BROWSERS, + name: 'Pageviews by browser', + description: 'Page views in the window, per browser.', + query: { + kind: 'InsightVizNode', + source: { + kind: 'TrendsQuery', + interval: 'day', + series: [{ kind: 'EventsNode', event: '$pageview', name: '$pageview' }], + breakdownFilter: { breakdown: '$browser', breakdown_type: 'event' }, + trendsFilter: { display: 'ActionsBarValue' }, + dateRange: { date_from: '-14d', date_to: null }, + }, + }, + perDay: daily(9600, 4), + breakdown: [ + { value: 'Chrome', share: 0.47 }, + { value: 'Safari', share: 0.26 }, + { value: 'Firefox', share: 0.1 }, + { value: 'Edge', share: 0.07 }, + { value: 'Samsung Internet', share: 0.04 }, + { value: '$$_posthog_breakdown_other_$$', share: 0.06 }, + ], + }, + { + id: 9107, + shortId: 'demo-ins-activation', + name: 'Activation rate', + description: 'Share of new accounts that created a dashboard in their first week.', + query: trendsQuery({ + event: 'dashboard created', + display: 'ActionsLineGraph', + dateFrom: '-30d', + axisFormat: 'percentage', + }), + perDay: (daysBack, weekday) => Math.round(daily(240, 7, 0.2)(daysBack, weekday) / 10) / 10 + 2, + }, + { + id: 9108, + shortId: 'demo-ins-tickets', + name: 'New support tickets', + description: 'Tickets opened per day.', + query: trendsQuery({ event: 'ticket created', display: 'ActionsBar', dateFrom: '-14d' }), + perDay: daily(42, 8, 0.3), + }, +] + +interface DemoTileDef { + id: number + insight?: string + text?: string + overrides?: JsonRecord +} + +interface DemoDashboardDef { + id: number + ref: string + name: string + description: string + dateFrom: string | null + tiles: DemoTileDef[] +} + +const DEMO_DASHBOARDS: DemoDashboardDef[] = [ + { + id: 9001, + ref: DEMO_DASHBOARD_KPIS, + name: 'Product KPIs', + description: 'The numbers for the weekly product review.', + dateFrom: '-14d', + tiles: [ + { id: 1, insight: DEMO_INSIGHT_DAU }, + { id: 2, insight: 'demo-ins-pageviews' }, + { id: 3, insight: 'demo-ins-wau' }, + { id: 4, insight: DEMO_INSIGHT_SIGNUPS }, + { id: 5, insight: 'demo-ins-paying' }, + { id: 7, insight: DEMO_INSIGHT_BROWSERS }, + { id: 8, insight: DEMO_INSIGHT_CHECKOUT }, + { id: 9, insight: DEMO_INSIGHT_RETENTION }, + { id: 6, text: 'Review these on Monday. Revenue numbers live on the Growth dashboard.' }, + ], + }, + { + id: 9002, + ref: DEMO_DASHBOARD_GROWTH, + name: 'Growth', + description: 'Activation and conversion.', + dateFrom: '-30d', + tiles: [ + { id: 1, insight: 'demo-ins-activation' }, + { id: 2, insight: DEMO_INSIGHT_SIGNUPS }, + { id: 3, insight: DEMO_INSIGHT_CHECKOUT }, + { id: 4, insight: DEMO_INSIGHT_SQL_PAGES }, + ], + }, + { + id: 9005, + ref: DEMO_DASHBOARD_SQL, + name: 'SQL views', + description: 'Tables and charts written in SQL.', + dateFrom: '-14d', + tiles: [ + { id: 1, insight: DEMO_INSIGHT_SQL_PAGES }, + { id: 2, insight: DEMO_INSIGHT_SQL_SIGNUPS }, + { id: 3, insight: DEMO_INSIGHT_SQL_REVENUE }, + { id: 4, insight: 'demo-ins-paying' }, + { id: 5, insight: DEMO_INSIGHT_SQL_COUNTRIES }, + ], + }, + { + id: 9003, + ref: 'demo-dash-marketing', + name: 'Marketing site', + description: 'Traffic to the public site.', + dateFrom: '-7d', + tiles: [ + { id: 1, insight: 'demo-ins-pageviews' }, + { id: 2, insight: DEMO_INSIGHT_DAU, overrides: { date_from: '-30d' } }, + ], + }, + { + id: 9004, + ref: 'demo-dash-support', + name: 'Support', + description: 'Ticket volume.', + dateFrom: '-14d', + tiles: [ + { id: 1, insight: 'demo-ins-tickets' }, + { id: 2, text: 'Weekend tickets wait until Monday.' }, + ], + }, +] + +function insightDef(shortId: string): DemoInsightDef | undefined { + return DEMO_INSIGHTS.find((insight) => insight.shortId === shortId || `#${insight.id}` === shortId) +} + +function dashboardDef(ref: string): DemoDashboardDef | undefined { + return DEMO_DASHBOARDS.find((dashboard) => dashboard.ref === ref) +} + +function startOfDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()) +} + +function addDays(date: Date, days: number): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days) +} + +function isoDay(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` +} + +function offsetSuffix(date: Date): string { + const minutes = -date.getTimezoneOffset() + const sign = minutes >= 0 ? '+' : '-' + const absolute = Math.abs(minutes) + return `${sign}${String(Math.floor(absolute / 60)).padStart(2, '0')}:${String(absolute % 60).padStart(2, '0')}` +} + +function parseDay(value: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value) + return match ? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])) : null +} + +interface DemoWindow { + start: Date + end: Date +} + +/** The days a range covers in the demo, like the server resolves them: "Last 7 days" is 8 days ending today. */ +function demoWindow(dateFrom: string | null, dateTo: string | null, anchor: Date): DemoWindow { + const today = startOfDay(anchor) + const weekStart = addDays(today, -((today.getDay() + 6) % 7)) + const window = ((): DemoWindow => { + if (!dateFrom) { + return { start: addDays(today, -7), end: today } + } + const days = /^-(\d+)d$/.exec(dateFrom) + if (days) { + return { start: addDays(today, -Number(days[1])), end: today } + } + const hours = /^-(\d+)h$/.exec(dateFrom) + if (hours) { + return { start: addDays(today, -Math.ceil(Number(hours[1]) / 24)), end: today } + } + switch (dateFrom) { + case 'dStart': + return { start: today, end: today } + case '-1dStart': + return { start: addDays(today, -1), end: addDays(today, -1) } + case 'wStart': + return { start: weekStart, end: today } + case '-1wStart': + return { start: addDays(weekStart, -7), end: addDays(weekStart, -1) } + case 'mStart': + return { start: new Date(today.getFullYear(), today.getMonth(), 1), end: today } + case '-1mStart': + return { + start: new Date(today.getFullYear(), today.getMonth() - 1, 1), + end: new Date(today.getFullYear(), today.getMonth(), 0), + } + case 'yStart': + return { start: new Date(today.getFullYear(), 0, 1), end: today } + case 'all': + return { start: addDays(today, -180), end: today } + } + const start = parseDay(dateFrom) ?? addDays(today, -7) + const end = (dateTo ? parseDay(dateTo) : null) ?? today + return { start, end } + })() + const end = window.end > today ? today : window.end + return { start: window.start > end ? end : window.start, end } +} + +function windowDays(window: DemoWindow): Date[] { + const days: Date[] = [] + for (let day = window.start; day <= window.end; day = addDays(day, 1)) { + days.push(day) + } + return days +} + +function trendsEntry( + def: DemoInsightDef, + window: DemoWindow, + anchor: Date, + compareLabel: 'current' | 'previous' | null +): JsonRecord { + const today = startOfDay(anchor) + const days = windowDays(window) + const math = ((def.query.source as JsonRecord).series as JsonRecord[])[0]?.math + const base = { + action: { order: 0, math: math ?? 'total' }, + label: def.name, + ...(compareLabel ? { compare: true, compare_label: compareLabel } : {}), + } + if (def.aggregate) { + return { ...base, aggregated_value: def.aggregate(days.length) } + } + // Today has data only up to the anchor time, so its bucket is in progress. + const todayShare = Math.max(0.15, (anchor.getTime() - today.getTime()) / DAY) + const data = days.map((day) => { + const daysBack = Math.round((today.getTime() - day.getTime()) / DAY) + const value = def.perDay?.(daysBack, day.getDay()) ?? 0 + return daysBack === 0 ? Math.round(value * todayShare * 10) / 10 : value + }) + return { + ...base, + days: days.map(isoDay), + labels: days.map(isoDay), + data, + count: data.reduce((total, value) => total + value, 0), + } +} + +function previousWindow(window: DemoWindow): DemoWindow { + const length = windowDays(window).length + return { start: addDays(window.start, -length), end: addDays(window.end, -length) } +} + +function funnelResult(def: DemoInsightDef, window: DemoWindow): JsonRecord[] { + const scale = windowDays(window).length / 31 + return (def.funnel ?? []).map((step, order) => ({ + action_id: step.name, + name: step.name, + custom_name: null, + order, + type: 'events', + people: [], + count: Math.max(1, Math.round(step.count * scale)), + average_conversion_time: step.medianSeconds === null ? null : Math.round(step.medianSeconds * 1.6), + median_conversion_time: step.medianSeconds, + })) +} + +/** Like the server: every cohort answers all eight weeks, and weeks that have not started yet count 0. */ +function retentionResult(def: DemoInsightDef, window: DemoWindow, anchor: Date): JsonRecord[] { + const retention = def.retention + if (!retention) { + return [] + } + const weeks = retention.curve.length + const today = startOfDay(anchor) + const lastStart = addDays(window.end, -window.end.getDay()) + return Array.from({ length: weeks }, (_, cohort) => { + const start = addDays(lastStart, (cohort - weeks + 1) * 7) + const size = Math.round(retention.cohortSize * (0.9 + noise(cohort, 11) * 0.2)) + const values = retention.curve.map((percent, week) => { + const started = addDays(start, week * 7) <= today + const jitter = week === 0 ? 1 : 0.92 + noise(cohort * 8 + week, 12) * 0.16 + return { count: started ? Math.round(size * (percent / 100) * jitter) : 0, label: `Week ${week}` } + }) + return { + date: `${isoDay(start)}T00:00:00${offsetSuffix(start)}`, + label: `Week ${cohort}`, + values, + breakdown_value: null, + } + }) +} + +function retentionWindow(def: DemoInsightDef, window: DemoWindow): DemoWindow { + const weeks = def.retention?.curve.length ?? 1 + const lastStart = addDays(window.end, -window.end.getDay()) + return { start: addDays(lastStart, -(weeks - 1) * 7), end: window.end } +} + +function breakdownResult(def: DemoInsightDef, window: DemoWindow, anchor: Date): JsonRecord[] { + const total = (trendsEntry(def, window, anchor, null).count as number | undefined) ?? 0 + return (def.breakdown ?? []).map((item) => ({ + action: { order: 0, math: 'total' }, + label: item.value, + breakdown_value: item.value, + aggregated_value: Math.round(total * item.share), + count: 0, + data: [], + days: [], + })) +} + +function demoResult(def: DemoInsightDef, window: DemoWindow, anchor: Date, compare: boolean): unknown { + if (def.sql) { + return def.sql.rows(window, anchor) + } + if (def.funnel) { + return funnelResult(def, window) + } + if (def.retention) { + return retentionResult(def, window, anchor) + } + if (def.breakdown) { + return breakdownResult(def, window, anchor) + } + if (!compare) { + return [trendsEntry(def, window, anchor, null)] + } + return [trendsEntry(def, window, anchor, 'current'), trendsEntry(def, previousWindow(window), anchor, 'previous')] +} + +interface DemoFreshness { + last_refresh: string + cache_target_age: string + next_allowed_client_refresh: string + is_cached: boolean +} + +/** Before a demo refresh the numbers are 42 minutes old and may be forced; after one, the next force waits 3 minutes. */ +function demoFreshness(anchor: number, refreshedAt: number | undefined): DemoFreshness { + const lastRefresh = refreshedAt ?? anchor - DEMO_DATA_AGE_MS + const window = refreshedAt ? 3 * MINUTE : 15 * MINUTE + return { + last_refresh: new Date(lastRefresh).toISOString(), + cache_target_age: new Date(lastRefresh + 6 * HOUR).toISOString(), + next_allowed_client_refresh: new Date(lastRefresh + window).toISOString(), + is_cached: refreshedAt === undefined || Date.now() - refreshedAt > DEMO_READ_MS * 4, + } +} + +function resolvedRange(window: DemoWindow): { date_from: string; date_to: string } { + const suffix = offsetSuffix(window.end) + return { + date_from: `${isoDay(window.start)}T00:00:00${suffix}`, + date_to: `${isoDay(window.end)}T23:59:59${suffix}`, + } +} + +function sourceOf(query: JsonRecord): JsonRecord { + return (query.source as JsonRecord | undefined) ?? query +} + +function savedDateRange(query: JsonRecord): JsonRecord | undefined { + const source = sourceOf(query) + return (source.dateRange ?? (source.filters as JsonRecord | undefined)?.dateRange) as JsonRecord | undefined +} + +function withRange(query: JsonRecord, dateFrom: string | null, dateTo: string | null): JsonRecord { + const source = sourceOf(query) + const dateRange = { date_from: dateFrom, date_to: dateTo } + // Only a SQL insight with `{filters}` takes the dashboard range; the server runs any other SQL as written. + if (source.kind === 'HogQLQuery' && typeof source.query === 'string' && source.query.includes('{filters}')) { + return { ...query, source: { ...source, filters: { ...(source.filters as JsonRecord), dateRange } } } + } + if (query.kind !== 'InsightVizNode') { + return query + } + return { ...query, source: { ...source, dateRange } } +} + +/** The fields the server adds to a SQL insight's response. */ +function sqlFields(def: DemoInsightDef): Pick { + if (!def.sql) { + return {} + } + return { columns: def.sql.types.map(([column]) => column), types: def.sql.types, hasMore: false } +} + +export interface DemoInsightRequest { + shortId: string + dashboardRef: string | null + override: JsonRecord | null + /** The tile's own filters, which win over the dashboard and the override. */ + tileOverrides?: JsonRecord | null + /** Device ms of this item's last forced demo read. */ + refreshedAt?: number +} + +const DEMO_AUTHOR = { first_name: 'Sam', last_name: 'Rivera', email: 'sam@example.com' } + +/** A demo `GET insights/{id}/` answer, or undefined for an id the demo does not have (a 404). */ +export function demoInsightResponse(request: DemoInsightRequest, anchor: number): InsightApi | undefined { + const def = insightDef(request.shortId) + if (!def) { + return undefined + } + const dashboard = request.dashboardRef ? dashboardDef(request.dashboardRef) : undefined + // The server's layers: the tile's own dates win, then the request override, then the dashboard, then the insight. + const layer: JsonRecord = request.tileOverrides?.date_from + ? request.tileOverrides + : request.override?.date_from + ? request.override + : dashboard?.dateFrom + ? { date_from: dashboard.dateFrom, date_to: null } + : (savedDateRange(def.query) ?? {}) + const dateFrom = typeof layer.date_from === 'string' ? layer.date_from : null + const dateTo = typeof layer.date_to === 'string' ? layer.date_to : null + const requested = demoWindow(dateFrom, dateTo, new Date(anchor)) + // Retention shows a fixed number of cohorts that end in the requested window, so its range starts earlier. + const window = def.retention ? retentionWindow(def, requested) : requested + return { + id: def.id, + short_id: def.shortId, + name: def.name, + derived_name: null, + description: def.description, + query: withRange(def.query, dateFrom, dateTo), + result: demoResult(def, window, new Date(anchor), false), + ...sqlFields(def), + ...demoFreshness(anchor, request.refreshedAt), + timezone: 'UTC', + resolved_date_range: resolvedRange(window), + created_by: DEMO_AUTHOR, + created_at: '2026-03-03T10:00:00Z', + } +} + +/** A demo `POST query/` answer for "Compare to previous period" on an insight. */ +export function demoCompareResponse( + query: JsonRecord, + shortId: string, + anchor: number, + refreshedAt?: number +): JsonRecord { + const def = insightDef(shortId) + const range = (query.dateRange as JsonRecord | undefined) ?? {} + const window = demoWindow( + (range.date_from as string | null) ?? null, + (range.date_to as string | null) ?? null, + new Date(anchor) + ) + return { + results: def ? demoResult(def, window, new Date(anchor), asRecord(query.compareFilter)?.compare === true) : [], + ...demoFreshness(anchor, refreshedAt), + timezone: 'UTC', + resolved_date_range: resolvedRange(window), + } +} + +/** A demo `GET dashboards/{id}/?refresh=force_cache` answer, or undefined for a dashboard the demo does not have. */ +export function demoDashboardResponse( + ref: string, + override: JsonRecord | null, + anchor: number, + refreshedAt: (shortId: string) => number | undefined +): DashboardApi | undefined { + const def = dashboardDef(ref) + if (!def) { + return undefined + } + const tiles = def.tiles.map((tile, index): DashboardTileApi => { + const layouts = { sm: { x: 0, y: index * 4, w: 12, h: 4 } } + if (!tile.insight) { + return { id: tile.id, insight: null, text: { body: tile.text ?? '' }, layouts } + } + const insight = demoInsightResponse( + { + shortId: tile.insight, + dashboardRef: ref, + override, + tileOverrides: tile.overrides ?? null, + refreshedAt: refreshedAt(tile.insight), + }, + anchor + ) + return { id: tile.id, insight: insight ?? null, layouts, filters_overrides: tile.overrides ?? null } + }) + return { + id: def.id, + name: def.name, + description: def.description, + filters: def.dateFrom ? { date_from: def.dateFrom, date_to: null } : {}, + last_refresh: new Date(anchor - DEMO_DATA_AGE_MS).toISOString(), + tiles, + } +} + +export function demoDashboardRows(): DashboardBasicApi[] { + return DEMO_DASHBOARDS.map((dashboard) => ({ + id: dashboard.id, + name: dashboard.name, + description: dashboard.description, + last_viewed_at: null, + })) +} + +/** Demo dashboards are opened by `ref`, so Browse rows carry the ref instead of the numeric id. */ +export function demoDashboardRefFor(id: number): string | undefined { + return DEMO_DASHBOARDS.find((dashboard) => dashboard.id === id)?.ref +} + +export function demoInsightRows(): InsightBasicApi[] { + return DEMO_INSIGHTS.map((insight) => ({ + id: insight.id, + short_id: insight.shortId, + name: insight.name, + derived_name: null, + description: insight.description, + last_modified_at: null, + })) +} + +const WEB_METRICS: { key: string; kind: string; perWeek: number; previous: number; isIncreaseBad?: boolean }[] = [ + { key: 'visitors', kind: 'unit', perWeek: 8412, previous: 7930 }, + { key: 'views', kind: 'unit', perWeek: 31_207, previous: 30_118 }, + { key: 'sessions', kind: 'unit', perWeek: 10_906, previous: 10_450 }, + { key: 'session duration', kind: 'duration_s', perWeek: 194, previous: 188 }, + { key: 'bounce rate', kind: 'percentage', perWeek: 41.6, previous: 43.2, isIncreaseBad: true }, +] + +/** A demo web analytics overview for the query's range. Counts scale with the window; durations and rates do not. */ +export function demoWebOverviewResponse( + query: JsonRecord, + anchor: number, + refreshedAt?: number +): WebOverviewResponseApi { + const range = (query.dateRange as JsonRecord | undefined) ?? {} + const window = demoWindow( + (range.date_from as string | null) ?? null, + (range.date_to as string | null) ?? null, + new Date(anchor) + ) + const scale = windowDays(window).length / 8 + return { + results: WEB_METRICS.map((metric) => { + const counts = metric.kind === 'unit' + const value = counts ? Math.round(metric.perWeek * scale) : metric.perWeek + const previous = counts ? Math.round(metric.previous * scale) : metric.previous + return { + key: metric.key, + kind: metric.kind, + value, + previous, + changeFromPreviousPct: Math.round(((value - previous) / previous) * 1000) / 10, + isIncreaseBad: metric.isIncreaseBad ?? false, + } + }), + ...demoFreshness(anchor, refreshedAt), + timezone: 'UTC', + resolved_date_range: resolvedRange(window), + } +} + +const DEMO_ACTORS: { id: string; email: string; recordings: string[] }[] = [ + { id: 'demo-person-1', email: 'avery@example.com', recordings: ['demo-recording-1'] }, + { id: 'demo-person-2', email: 'blake@example.com', recordings: ['demo-recording-2', 'demo-recording-3'] }, + { id: 'demo-person-3', email: 'casey@example.com', recordings: [] }, + { id: 'demo-person-4', email: 'devon@example.com', recordings: [] }, + { id: 'demo-person-5', email: 'emery@example.com', recordings: [] }, +] + +/** Five people at any point, two of them with recordings. */ +export function demoActorsResponse(input: Pick): ActorsResponseApi { + return { + results: DEMO_ACTORS.map((actor) => { + const person = { id: actor.id, distinct_ids: [actor.id], properties: { email: actor.email } } + return input.includeRecordings + ? [person, actor.recordings.map((session_id) => ({ session_id, events: [] }))] + : [person] + }), + hasMore: false, + } +} + +function shortcut(id: string, path: string, type: string, ref: string | null, href: string, order: number) { + return { id, path, type, ref, href, order, created_at: '2026-01-05T09:00:00Z' } satisfies FileSystemShortcutApi +} + +const DEFAULT_SHORTCUTS: FileSystemShortcutApi[] = [ + shortcut('demo-pin-kpis', 'Product KPIs', 'dashboard', DEMO_DASHBOARD_KPIS, `/dashboard/${DEMO_DASHBOARD_KPIS}`, 0), + shortcut('demo-pin-signups', 'Signups', 'insight', DEMO_INSIGHT_SIGNUPS, `/insights/${DEMO_INSIGHT_SIGNUPS}`, 1), + shortcut('demo-pin-web', 'Web analytics', 'web_analytics', null, '/web', 2), + shortcut('demo-pin-notes', 'Launch notes', 'notebook', 'demo-nb-launch', '/notebooks/demo-nb-launch', 3), +] + +const DEFAULT_DISPLAY: Record = { + 'demo-pin-kpis': { mode: 'number', tileId: 1, insightId: 9102, insightShortId: DEMO_INSIGHT_DAU }, + 'demo-pin-signups': { mode: 'chart' }, +} + +function defaultRecent(now: number): DeviceRecentEntry[] { + return [ + { + kind: 'dashboard', + ref: DEMO_DASHBOARD_GROWTH, + name: 'Growth', + viewedAt: new Date(now - 2 * HOUR).toISOString(), + }, + { + kind: 'insight', + ref: DEMO_INSIGHT_CHECKOUT, + name: 'Checkout funnel', + viewedAt: new Date(now - 26 * HOUR).toISOString(), + }, + ] +} + +interface DemoViewsState { + shortcuts: FileSystemShortcutApi[] + display: Record + rank: Record + recent: DeviceRecentEntry[] + /** Device ms of each item's last forced demo read, keyed by the freshness key. */ + refreshedAt: Record + addShortcut: (input: ShortcutInput) => FileSystemShortcutApi + removeShortcuts: (ids: readonly string[]) => void + setDisplay: (shortcutId: string, display: PinDisplay) => void + setOrder: (shortcutIds: readonly string[]) => void + recordRecent: (entry: DeviceRecentEntry) => void + markRefreshed: (key: string) => void + reset: () => void +} + +function freshDemoViews(): Pick { + return { + shortcuts: DEFAULT_SHORTCUTS, + display: DEFAULT_DISPLAY, + rank: {}, + recent: defaultRecent(Date.now()), + refreshedAt: {}, + } +} + +let demoPinCounter = 0 + +/** The demo's pins, their arrangement and the phone's Recent, in memory. They reset when demo mode turns on or off. */ +export const useDemoViews = create()((set) => ({ + ...freshDemoViews(), + addShortcut: (input) => { + demoPinCounter += 1 + // A pin from a dashboard screen names the dashboard by id, but demo dashboards open by ref. + const ref = + input.type === 'dashboard' && input.ref ? (demoDashboardRefFor(Number(input.ref)) ?? input.ref) : input.ref + const row: FileSystemShortcutApi = { + ...input, + ref, + href: ref === input.ref ? input.href : `/dashboard/${ref}`, + id: `demo-pin-added-${demoPinCounter}`, + order: DEFAULT_SHORTCUTS.length + demoPinCounter, + created_at: new Date().toISOString(), + } + set((state) => ({ shortcuts: [...state.shortcuts, row] })) + return row + }, + removeShortcuts: (ids) => set((state) => ({ shortcuts: state.shortcuts.filter((row) => !ids.includes(row.id)) })), + setDisplay: (shortcutId, display) => set((state) => ({ display: { ...state.display, [shortcutId]: display } })), + setOrder: (shortcutIds) => set({ rank: Object.fromEntries(shortcutIds.map((id, index) => [id, index])) }), + recordRecent: (entry) => + set((state) => ({ + recent: [entry, ...state.recent.filter((item) => item.kind !== entry.kind || item.ref !== entry.ref)].slice( + 0, + 20 + ), + })), + markRefreshed: (key) => set((state) => ({ refreshedAt: { ...state.refreshedAt, [key]: Date.now() } })), + reset: () => set(freshDemoViews()), +})) + +useDemoMode.subscribe((state, previous) => { + if (state.enabled !== previous.enabled) { + useDemoViews.getState().reset() + } +}) + +/** The demo's time anchor, so every Views fixture lines up with the inbox fixtures. */ +export function demoAnchor(): number { + return useDemoMode.getState().anchor +} diff --git a/products/mobile/src/features/ai/AiApprovalDialog.tsx b/products/mobile/src/features/ai/AiApprovalDialog.tsx new file mode 100644 index 000000000000..b75215403735 --- /dev/null +++ b/products/mobile/src/features/ai/AiApprovalDialog.tsx @@ -0,0 +1,62 @@ +import type { ReactElement } from 'react' +import { ActivityIndicator, Linking, Modal, View } from 'react-native' +import { GestureHandlerRootView } from 'react-native-gesture-handler' + +import { AiConsentSheet } from '@/features/chat/AiConsentSheet' +import { AiOrgNotice } from '@/features/chat/AiOrgNotice' +import { PRIVACY_POLICY_URL } from '@/features/chat/aiAccess' +import { BottomSheet } from '@/ui/BottomSheet' +import { OverlayHost } from '@/ui/OverlayPortal' +import { createStyles } from '@/ui/theme' + +import { cancelAiApproval, checkAiApproval, useAiApprovalStore } from './aiApproval' + +export function AiApprovalDialog(): ReactElement | null { + const request = useAiApprovalStore((state) => state.request) + const styles = useStyles() + const access = request?.access + if (!request || !access || access.kind === 'ready') { + return null + } + return ( + + + + {access.kind === 'needs_consent' ? ( + void checkAiApproval(true)} + onNotNow={cancelAiApproval} + onOpenPrivacyPolicy={() => void Linking.openURL(PRIVACY_POLICY_URL).catch(() => undefined)} + /> + ) : ( + + + void Linking.openURL(access.settingsUrl as string).catch(() => undefined) + : null + } + onCheckAgain={() => void checkAiApproval()} + checking={request.checking} + onCancel={cancelAiApproval} + /> + + )} + {request.checking ? ( + + + + ) : null} + + + + ) +} + +const useStyles = createStyles(() => ({ root: { flex: 1 }, progress: { position: 'absolute', top: 60, right: 24 } })) diff --git a/products/mobile/src/features/ai/aiApproval.test.ts b/products/mobile/src/features/ai/aiApproval.test.ts new file mode 100644 index 000000000000..78ebdea58bbb --- /dev/null +++ b/products/mobile/src/features/ai/aiApproval.test.ts @@ -0,0 +1,169 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native' + +import { fetchOrganization, type OrganizationApi } from '@/api/users' +import { useAuthStore } from '@/auth/authStore' +import { useDemoMode } from '@/demo/demoMode' +import { aiConsentKey, useAiConsentStore, waitForAiConsent } from '@/features/chat/aiConsentStore' + +import { cancelAiApproval, checkAiApproval, requestAiApproval, useAiApproval, useAiApprovalStore } from './aiApproval' + +jest.mock('@/api/users', () => ({ fetchOrganization: jest.fn() })) + +const HOST = 'https://app.example.com' +const USER = { id: 1, uuid: 'person-1', distinctId: 'distinct-1', email: 'person@example.com', name: 'Person' } +const ORG: OrganizationApi = { + id: 'org-1', + name: 'Example', + teams: [{ id: 7, name: 'Project' }], + is_ai_data_processing_approved: true, + membership_level: 1, +} +const fetchOrg = jest.mocked(fetchOrganization) + +beforeEach(async () => { + await waitForAiConsent() + useDemoMode.setState({ enabled: false }) + useAiConsentStore.getState().clear() + useAuthStore.setState({ + status: 'signed_in', + signedIn: true, + host: HOST, + user: USER, + projectId: 7, + accountError: null, + organizations: [{ id: ORG.id, name: ORG.name, aiDataProcessingApproved: true, membershipLevel: 1 }], + projects: [{ id: 7, name: 'Project', organizationId: ORG.id, organizationName: ORG.name }], + }) + fetchOrg.mockReset().mockResolvedValue(ORG) +}) +afterEach(cancelAiApproval) + +async function shown(kind: string): Promise { + await waitFor(() => expect(useAiApprovalStore.getState().request?.access?.kind).toBe(kind)) +} + +test.each(['chat', 'handoff', 'replay'] as const)( + '%s waits for individual approval, then rechecks organization approval', + async (action) => { + const result = requestAiApproval(action) + await shown('needs_consent') + expect(useAiConsentStore.getState().accepted).toEqual({}) + await checkAiApproval(true) + await expect(result).resolves.toBe(true) + expect(fetchOrg).toHaveBeenCalledTimes(2) + expect(useAiConsentStore.getState().accepted[aiConsentKey(HOST, USER.uuid)]).toBe(true) + } +) + +test.each([1, 8, 15, null, undefined] as const)( + 'only confirmed admins and owners get organization settings (%s)', + async (membership) => { + fetchOrg.mockResolvedValue({ ...ORG, membership_level: membership, is_ai_data_processing_approved: false }) + const result = requestAiApproval('handoff') + await shown('org_blocked') + const privileged = membership === 8 || membership === 15 + expect(useAiApprovalStore.getState().request?.access).toMatchObject({ + canApprove: privileged, + settingsUrl: privileged ? `${HOST}/project/7/settings/organization-details#organization-ai-consent` : null, + }) + cancelAiApproval() + await expect(result).resolves.toBe(false) + } +) + +test('freshly revoked approval blocks even with cached approval and individual consent', async () => { + useAiConsentStore.getState().accept(aiConsentKey(HOST, USER.uuid)) + fetchOrg.mockResolvedValue({ ...ORG, is_ai_data_processing_approved: false }) + const result = requestAiApproval('chat') + await shown('org_blocked') + cancelAiApproval() + await expect(result).resolves.toBe(false) +}) + +test.each(['failed', 'unknown', 'removed_project'] as const)( + 'cannot use cached approval after %s fresh verification', + async (scenario) => { + useAiConsentStore.getState().accept(aiConsentKey(HOST, USER.uuid)) + if (scenario === 'failed') fetchOrg.mockRejectedValue(new Error('Offline')) + if (scenario === 'unknown') fetchOrg.mockResolvedValue({ ...ORG, is_ai_data_processing_approved: undefined }) + if (scenario === 'removed_project') fetchOrg.mockResolvedValue({ ...ORG, teams: [] }) + const result = requestAiApproval('replay') + await shown('org_unknown') + cancelAiApproval() + await expect(result).resolves.toBe(false) + } +) + +test.each(['project', 'user', 'host', 'signed_out'] as const)( + 'changing %s cancels the pending action without approving the new account', + async (change) => { + const result = requestAiApproval('handoff') + await shown('needs_consent') + if (change === 'project') useAuthStore.setState({ projectId: 8 }) + if (change === 'user') useAuthStore.setState({ user: { ...USER, uuid: 'person-2' } }) + if (change === 'host') useAuthStore.setState({ host: 'https://eu.example.com' }) + if (change === 'signed_out') useAuthStore.setState({ status: 'signed_out' }) + await checkAiApproval(true) + await expect(result).resolves.toBe(false) + expect(useAiConsentStore.getState().accepted).toEqual({}) + expect(useAiApprovalStore.getState().request).toBeNull() + } +) + +test('revocation while the disclosure is open cannot be accepted', async () => { + const result = requestAiApproval('handoff') + await shown('needs_consent') + fetchOrg.mockResolvedValue({ ...ORG, is_ai_data_processing_approved: false }) + await checkAiApproval(true) + await shown('org_blocked') + expect(useAiConsentStore.getState().accepted).toEqual({}) + cancelAiApproval() + await expect(result).resolves.toBe(false) +}) + +test('aborting an action cancels it even while its organization read is pending', async () => { + let resolveRead!: (organization: OrganizationApi) => void + fetchOrg.mockImplementation( + () => + new Promise((resolve) => { + resolveRead = resolve + }) + ) + const controller = new AbortController() + const result = requestAiApproval('chat', controller.signal) + controller.abort() + await expect(result).resolves.toBe(false) + resolveRead(ORG) + await Promise.resolve() + expect(useAiApprovalStore.getState().request).toBeNull() +}) + +test('demo actions require no consent or network request', async () => { + useDemoMode.setState({ enabled: true }) + await expect(requestAiApproval('replay')).resolves.toBe(true) + expect(fetchOrg).not.toHaveBeenCalled() +}) + +test.each(['unmount', 'context'] as const)( + 'leaving the originating screen cancels disclosure on %s', + async (change) => { + const { result, unmount, rerender } = renderHook( + ({ context }: { context: string }) => useAiApproval('handoff', context), + { + initialProps: { context: 'report-1' }, + } + ) + let outcome: Promise = Promise.resolve(true) + act(() => { + outcome = result.current.request() + }) + await shown('needs_consent') + if (change === 'unmount') unmount() + else rerender({ context: 'report-2' }) + await act(async () => { + await expect(outcome).resolves.toBe(false) + }) + expect(useAiApprovalStore.getState().request).toBeNull() + expect(useAiConsentStore.getState().accepted).toEqual({}) + } +) diff --git a/products/mobile/src/features/ai/aiApproval.ts b/products/mobile/src/features/ai/aiApproval.ts new file mode 100644 index 000000000000..4c48ac2393fb --- /dev/null +++ b/products/mobile/src/features/ai/aiApproval.ts @@ -0,0 +1,216 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { create } from 'zustand' + +import { fetchOrganization } from '@/api/users' + +import { addSessionEndTask, useAuthStore } from '@/auth/authStore' +import { sessionGeneration } from '@/auth/tokens' +import { useDemoMode } from '@/demo/demoMode' +import { orgAiSettingsUrl, type ProjectOrganization, projectOrganization } from '@/features/chat/aiAccess' +import { aiConsentKey, useAiConsentStore, waitForAiConsent } from '@/features/chat/aiConsentStore' + +export type AiAction = 'chat' | 'handoff' | 'replay' + +export type AiAccess = + | { kind: 'org_unknown' } + | { kind: 'org_blocked'; organizationName: string | null; settingsUrl: string | null; canApprove: boolean } + | { kind: 'needs_consent' } + | { kind: 'ready' } + +export function currentAiIdentity(): string | null { + const { status, host, user, projectId } = useAuthStore.getState() + return status === 'signed_in' && host && user && projectId !== null + ? JSON.stringify([host, user.uuid, projectId, sessionGeneration()]) + : null +} + +export function currentAiAccess(verifiedOrganization?: ProjectOrganization): AiAccess { + const auth = useAuthStore.getState() + const { host, user, projectId } = auth + if (!currentAiIdentity() || !host || !user || projectId === null) { + return { kind: 'org_unknown' } + } + const organization = verifiedOrganization ?? projectOrganization(auth.organizations, auth.projects, projectId) + if (organization.status === 'unknown') { + return { kind: 'org_unknown' } + } + if (organization.status === 'not_approved') { + return { + kind: 'org_blocked', + organizationName: organization.name, + canApprove: organization.canApprove, + settingsUrl: organization.canApprove ? orgAiSettingsUrl({ host, projectId }) : null, + } + } + return useAiConsentStore.getState().accepted[aiConsentKey(host, user.uuid)] + ? { kind: 'ready' } + : { kind: 'needs_consent' } +} + +export async function refreshAiOrganization(identity: string, signal?: AbortSignal): Promise { + const auth = useAuthStore.getState() + const project = auth.projects.find((candidate) => candidate.id === auth.projectId) + if (!project || currentAiIdentity() !== identity) { + throw new Error('No current project') + } + const fresh = await fetchOrganization(project.organizationId, { signal }) + if (currentAiIdentity() !== identity || signal?.aborted) { + throw new Error('Account or project changed') + } + if (fresh.id !== project.organizationId || !fresh.teams.some((team) => team.id === project.id)) { + throw new Error('Project access changed') + } + useAuthStore.setState((state) => ({ + organizations: state.organizations.map((organization) => + organization.id === fresh.id + ? { + ...organization, + aiDataProcessingApproved: + fresh.is_ai_data_processing_approved === undefined + ? undefined + : fresh.is_ai_data_processing_approved === true, + membershipLevel: fresh.membership_level, + } + : organization + ), + })) + return { + name: fresh.name, + status: + fresh.is_ai_data_processing_approved === undefined + ? 'unknown' + : fresh.is_ai_data_processing_approved === true + ? 'approved' + : 'not_approved', + canApprove: fresh.membership_level === 8 || fresh.membership_level === 15, + } +} + +interface ApprovalRequest { + identity: string + action: AiAction + access: AiAccess | null + checking: boolean +} + +interface AiApprovalState { + request: ApprovalRequest | null +} + +export const useAiApprovalStore = create()(() => ({ request: null })) + +let pending: { request: ApprovalRequest; signal?: AbortSignal; finish: (approved: boolean) => void } | null = null + +export function cancelAiApproval(): void { + pending?.finish(false) +} + +export async function checkAiApproval(accept = false): Promise { + const operation = pending + if (!operation || operation.request.checking) { + return + } + operation.request = { ...operation.request, checking: true } + useAiApprovalStore.setState({ request: operation.request }) + try { + const organization = await refreshAiOrganization(operation.request.identity, operation.signal) + await waitForAiConsent() + if (pending !== operation) { + return + } + if (currentAiIdentity() !== operation.request.identity || useDemoMode.getState().enabled) { + operation.finish(false) + return + } + let access = currentAiAccess(organization) + if (accept && access.kind === 'needs_consent') { + const { host, user } = useAuthStore.getState() + if (host && user) { + useAiConsentStore.getState().accept(aiConsentKey(host, user.uuid)) + } + access = currentAiAccess(organization) + } + if (access.kind === 'ready') { + operation.finish(true) + return + } + operation.request = { ...operation.request, access, checking: false } + } catch { + if (pending !== operation) { + return + } + operation.request = { ...operation.request, access: { kind: 'org_unknown' }, checking: false } + } + if (pending === operation) { + useAiApprovalStore.setState({ request: operation.request }) + } +} + +/** Refresh approval before every AI action, including messages that waited in a queue. */ +export async function requestAiApproval(action: AiAction, signal?: AbortSignal): Promise { + if (signal?.aborted) { + return false + } + if (useDemoMode.getState().enabled) { + return true + } + const identity = currentAiIdentity() + if (!identity || pending) { + return false + } + return new Promise((resolve) => { + const request: ApprovalRequest = { identity, action, access: null, checking: false } + const abort = (): void => operation.finish(false) + const unsubscribe = useAuthStore.subscribe(() => { + if (currentAiIdentity() !== identity) { + operation.finish(false) + } + }) + const operation = { + request, + signal, + finish: (approved: boolean): void => { + if (pending !== operation) { + return + } + pending = null + unsubscribe() + signal?.removeEventListener('abort', abort) + useAiApprovalStore.setState({ request: null }) + resolve(approved && currentAiIdentity() === identity) + }, + } + pending = operation + signal?.addEventListener('abort', abort, { once: true }) + void checkAiApproval() + }) +} + +addSessionEndTask(cancelAiApproval) + +export function useAiApproval( + action: AiAction, + context: string +): { request: () => Promise; pending: boolean; signal: AbortSignal } { + const identity = useAuthStore((state) => + JSON.stringify([state.host, state.projectId, state.user?.uuid, state.status]) + ) + const lifetime = useMemo(() => ({ identity, context, controller: new AbortController() }), [identity, context]) + const inFlight = useRef(false) + const [checking, setChecking] = useState(false) + useEffect(() => () => lifetime.controller.abort(), [lifetime]) + const request = useCallback(async (): Promise => { + if (inFlight.current || lifetime.controller.signal.aborted) { + return false + } + inFlight.current = true + setChecking(true) + try { + return await requestAiApproval(action, lifetime.controller.signal) + } finally { + inFlight.current = false + setChecking(false) + } + }, [action, lifetime]) + return { request, pending: checking, signal: lifetime.controller.signal } +} diff --git a/products/mobile/src/features/chat/AiConsentSheet.tsx b/products/mobile/src/features/chat/AiConsentSheet.tsx new file mode 100644 index 000000000000..7494eb68b8f5 --- /dev/null +++ b/products/mobile/src/features/chat/AiConsentSheet.tsx @@ -0,0 +1,146 @@ +import type { ReactElement } from 'react' +import { ActivityIndicator, Text, View } from 'react-native' + +import { BottomSheet } from '@/ui/BottomSheet' +import { Eyebrow } from '@/ui/Eyebrow' +import { ExternalLinkIcon } from '@/ui/icons/ExternalLinkIcon' +import { Pressable44 } from '@/ui/Pressable44' +import { createStyles, useColors } from '@/ui/theme' +import { iconSizes, radii, type } from '@/ui/tokens' + +interface AiConsentSheetProps { + visible: boolean + pending?: boolean + onContinue: () => void + /** Also runs when the sheet is dragged down or the scrim is tapped. */ + onNotNow: () => void + onOpenPrivacyPolicy: () => void +} + +/** The shared disclosure for chat, task handoff and replay summarization. */ +export function AiConsentSheet({ + visible, + pending = false, + onContinue, + onNotNow, + onOpenPrivacyPolicy, +}: AiConsentSheetProps): ReactElement { + const colors = useColors() + const styles = useStyles() + return ( + + + + Before you use PostHog AI + + + What is sent + + Your messages, attached reports, files and photos, selected session recordings, and project data + used by the agent. The data sent depends on the action you choose. + + + + Where it goes + + PostHog AI, which uses third-party model providers such as Anthropic and OpenAI to process it. + + + + Privacy policy + + + + + Not now + + + {pending ? ( + + ) : ( + Continue + )} + + + + + ) +} + +const useStyles = createStyles(({ colors }) => ({ + content: { + paddingTop: 16, + gap: 16, + }, + title: { + ...type.sheetTitle, + color: colors.ink, + }, + section: { + gap: 6, + }, + body: { + ...type.bodySmall, + color: colors.muted, + }, + link: { + alignSelf: 'flex-start', + flexDirection: 'row', + alignItems: 'center', + gap: 6, + minHeight: 32, + marginLeft: -8, + paddingHorizontal: 8, + borderRadius: radii.tile, + }, + linkText: { + ...type.bodySmallStrong, + color: colors.blue, + }, + actions: { + flexDirection: 'row', + gap: 10, + marginTop: 4, + }, + button: { + flex: 1, + height: 48, + borderRadius: radii.pill, + alignItems: 'center', + justifyContent: 'center', + }, + primary: { + backgroundColor: colors.ink, + }, + primaryText: { + ...type.button, + color: colors.onInk, + }, + secondary: { + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + }, + secondaryText: { + ...type.button, + color: colors.ink, + }, +})) diff --git a/products/mobile/src/features/chat/AiOrgNotice.tsx b/products/mobile/src/features/chat/AiOrgNotice.tsx new file mode 100644 index 000000000000..7653a87126bf --- /dev/null +++ b/products/mobile/src/features/chat/AiOrgNotice.tsx @@ -0,0 +1,151 @@ +import type { ReactElement } from 'react' +import { ActivityIndicator, Text, View } from 'react-native' + +import { ExternalLinkIcon } from '@/ui/icons/ExternalLinkIcon' +import { Pressable44 } from '@/ui/Pressable44' +import { createStyles, useColors } from '@/ui/theme' +import { iconSizes, radii, type } from '@/ui/tokens' + +interface AiOrgNoticeProps { + /** `not_approved`: an admin has not allowed third-party AI. `unknown`: the app could not load the setting. */ + reason: 'not_approved' | 'unknown' + organizationName: string | null + canApprove?: boolean + onCancel?: () => void + /** Null hides Open settings. */ + onOpenSettings: (() => void) | null + onCheckAgain: () => void + checking: boolean +} + +function copyFor( + reason: AiOrgNoticeProps['reason'], + organizationName: string | null, + canApprove: boolean +): { title: string; body: string } { + if (reason === 'unknown') { + return { + title: "Couldn't check PostHog AI access", + body: 'Check your connection, then try again.', + } + } + return { + title: `PostHog AI is off for ${organizationName ?? 'this organization'}`, + body: canApprove + ? 'As an admin or owner, you can allow third-party AI services in organization settings.' + : 'Ask an admin or owner to allow third-party AI services in organization settings, then check again.', + } +} + +/** Takes the composer's place while the organization does not allow PostHog AI, as the web blocks its chat. */ +export function AiOrgNotice({ + reason, + organizationName, + canApprove = false, + onCancel, + onOpenSettings, + onCheckAgain, + checking, +}: AiOrgNoticeProps): ReactElement { + const colors = useColors() + const styles = useStyles() + const copy = copyFor(reason, organizationName, canApprove) + return ( + + + {copy.title} + + {copy.body} + + {onOpenSettings ? ( + + + Open settings + + ) : null} + + {checking ? ( + + ) : ( + Check again + )} + + + {onCancel ? ( + + Not now + + ) : null} + + ) +} + +const useStyles = createStyles(({ colors }) => ({ + notice: { + marginHorizontal: 16, + marginTop: 8, + marginBottom: 8, + padding: 16, + gap: 6, + borderRadius: radii.card, + borderWidth: 1, + borderColor: colors.hairline, + backgroundColor: colors.background, + }, + title: { + ...type.bodyStrong, + color: colors.ink, + }, + body: { + ...type.bodySmall, + color: colors.muted, + }, + cancel: { alignItems: 'center', justifyContent: 'center', height: 44 }, + actions: { + flexDirection: 'row', + gap: 10, + marginTop: 10, + }, + button: { + flex: 1, + height: 44, + flexDirection: 'row', + gap: 8, + borderRadius: radii.pill, + alignItems: 'center', + justifyContent: 'center', + }, + primary: { + backgroundColor: colors.ink, + }, + primaryText: { + ...type.buttonSmall, + color: colors.onInk, + }, + secondary: { + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + }, + secondaryText: { + ...type.buttonSmall, + color: colors.ink, + }, +})) diff --git a/products/mobile/src/features/chat/ChatAccessGate.test.tsx b/products/mobile/src/features/chat/ChatAccessGate.test.tsx new file mode 100644 index 000000000000..23ffe6458274 --- /dev/null +++ b/products/mobile/src/features/chat/ChatAccessGate.test.tsx @@ -0,0 +1,147 @@ +import { act, fireEvent, render, screen } from '@testing-library/react-native' +import { Linking, Text } from 'react-native' + +import { fetchOrganization } from '@/api/users' +import { useAuthStore } from '@/auth/authStore' +import { useDemoMode } from '@/demo/demoMode' +import { OverlayHost } from '@/ui/OverlayPortal' + +import { aiConsentKey, useAiConsentStore } from './aiConsentStore' +import { ChatAccessGate } from './ChatAccessGate' + +jest.mock('@/api/users', () => ({ fetchOrganization: jest.fn() })) + +jest.mock('react-native-worklets', () => jest.requireActual('react-native-worklets/src/mock')) +jest.mock('react-native-reanimated', () => ({ + ...jest.requireActual('react-native-reanimated/mock'), + useReducedMotion: () => true, +})) +jest.mock( + 'react-native-safe-area-context', + () => jest.requireActual('react-native-safe-area-context/jest/mock').default +) + +const HOST = 'https://us.posthog.com' +const USER_UUID = 'user-uuid-1' + +function signIn(approved: boolean, membershipLevel: number | null = 8): void { + jest.mocked(fetchOrganization).mockResolvedValue({ + id: 'org-1', + name: 'Hedgebox', + teams: [{ id: 7, name: 'Hedgebox' }], + is_ai_data_processing_approved: approved, + membership_level: membershipLevel, + }) + useAuthStore.setState({ + status: 'signed_in', + signedIn: true, + host: HOST, + user: { id: 1, uuid: USER_UUID, distinctId: 'distinct-1', email: 'max@example.com', name: 'Max' }, + organizations: [{ id: 'org-1', name: 'Hedgebox', aiDataProcessingApproved: approved, membershipLevel }], + projects: [{ id: 7, name: 'Hedgebox', organizationId: 'org-1', organizationName: 'Hedgebox' }], + projectId: 7, + }) +} + +async function renderGate(onDecline: () => void = jest.fn()): Promise { + render( + + Composer + , + { wrapper: OverlayHost } + ) + // Lets the persisted demo and consent stores finish loading. + await act(async () => undefined) +} + +beforeEach(() => { + // The sheet unmounts on a timer after it closes. + jest.useFakeTimers() + useDemoMode.setState({ enabled: false }) + useAiConsentStore.getState().clear() +}) +afterEach(() => { + act(() => jest.runOnlyPendingTimers()) + jest.useRealTimers() +}) + +describe('ChatAccessGate', () => { + test.each<[string, { approved: boolean; consented: boolean; demo: boolean }, string, string[]]>([ + [ + 'an organization that has not approved AI shows the notice instead of the composer', + { approved: false, consented: true, demo: false }, + 'chat-ai-org-notice', + ['composer', 'chat-ai-consent'], + ], + [ + 'a person who already agreed gets the composer directly', + { approved: true, consented: true, demo: false }, + 'composer', + ['chat-ai-consent', 'chat-ai-org-notice'], + ], + [ + 'demo mode sends nothing, so it skips both gates', + { approved: false, consented: false, demo: true }, + 'composer', + ['chat-ai-consent', 'chat-ai-org-notice'], + ], + ])('%s', async (_name, { approved, consented, demo }, shown, hidden) => { + signIn(approved) + useDemoMode.setState({ enabled: demo }) + if (consented) { + useAiConsentStore.getState().accept(aiConsentKey(HOST, USER_UUID)) + } + + await renderGate() + + expect(screen.getByTestId(shown)).toBeTruthy() + for (const testID of hidden) { + expect(screen.queryByTestId(testID)).toBeNull() + } + }) + + test('the org notice opens the AI setting of the chat project on the web', async () => { + const open = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined) + signIn(false) + + await renderGate() + fireEvent.press(screen.getByTestId('chat-ai-org-settings')) + + expect(open).toHaveBeenCalledWith(`${HOST}/project/7/settings/organization-details#organization-ai-consent`) + expect(screen.getByTestId('chat-ai-org-notice')).toHaveTextContent(/PostHog AI is off for Hedgebox/) + }) + + test.each([1, null])( + 'members and unknown roles get admin guidance without a settings link (%s)', + async (membership) => { + signIn(false, membership) + await renderGate() + expect(screen.queryByTestId('chat-ai-org-settings')).toBeNull() + expect(screen.getByTestId('chat-ai-org-notice')).toHaveTextContent(/Ask an admin or owner/) + } + ) + + test('Continue stores consent and reveals the composer', async () => { + signIn(true) + + await renderGate() + expect(screen.getByTestId('chat-ai-consent')).toBeTruthy() + expect(screen.queryByTestId('composer')).toBeNull() + fireEvent.press(screen.getByTestId('chat-ai-consent-continue')) + + expect(screen.getByTestId('composer')).toBeTruthy() + expect(useAiConsentStore.getState().accepted[aiConsentKey(HOST, USER_UUID)]).toBe(true) + }) + + test('Not now closes the chat without storing consent', async () => { + const onDecline = jest.fn() + signIn(true) + + await renderGate(onDecline) + fireEvent.press(screen.getByTestId('chat-ai-consent-not-now')) + + expect(onDecline).toHaveBeenCalledTimes(1) + expect(screen.queryByTestId('composer')).toBeNull() + expect(useAiConsentStore.getState().accepted).toEqual({}) + }) +}) diff --git a/products/mobile/src/features/chat/ChatAccessGate.tsx b/products/mobile/src/features/chat/ChatAccessGate.tsx new file mode 100644 index 000000000000..d29d28a5083f --- /dev/null +++ b/products/mobile/src/features/chat/ChatAccessGate.tsx @@ -0,0 +1,95 @@ +import { type ReactElement, type ReactNode, useEffect, useState } from 'react' +import { ActivityIndicator, Linking, StyleSheet, View } from 'react-native' + +import { track } from '@/lib/analytics' +import { useColors } from '@/ui/theme' +import { AiConsentSheet } from './AiConsentSheet' +import { AiOrgNotice } from './AiOrgNotice' +import { PRIVACY_POLICY_URL } from './aiAccess' +import { useChatAccess } from './useChatAccess' + +interface ChatAccessGateProps { + /** "Not now" on the consent sheet. The chat closes, because it cannot send without consent. */ + onDecline: () => void + /** The composer and anything else that sends to the agent. */ + children: ReactNode +} + +/** Shows `children` only once the organization allows PostHog AI and the person agreed on this device. */ +export function ChatAccessGate({ onDecline, children }: ChatAccessGateProps): ReactElement { + const colors = useColors() + const { access, rechecking, accept, checkAgain } = useChatAccess() + const [declined, setDeclined] = useState(false) + const kind = access.kind + + useEffect(() => { + if (kind === 'needs_consent') { + track('mobile_ai_consent_shown') + } else if (kind === 'org_blocked' || kind === 'org_unknown') { + track('mobile_ai_org_notice_shown', { reason: kind === 'org_blocked' ? 'not_approved' : 'unknown' }) + } + }, [kind]) + + const accepted = (): void => { + accept() + track('mobile_ai_consent_accepted') + } + const decline = (): void => { + if (declined) { + return + } + setDeclined(true) + track('mobile_ai_consent_declined') + onDecline() + } + + if (access.kind === 'checking') { + return ( + + + + ) + } + if (access.kind === 'org_blocked' || access.kind === 'org_unknown') { + const settingsUrl = access.kind === 'org_blocked' ? access.settingsUrl : null + return ( + { + track('mobile_ai_org_settings_opened') + Linking.openURL(settingsUrl).catch(() => undefined) + } + : null + } + onCheckAgain={checkAgain} + checking={rechecking} + /> + ) + } + return ( + <> + {access.kind === 'ready' ? children : null} + {/* The sheet stays mounted after Continue, so it slides out while the composer appears. */} + { + Linking.openURL(PRIVACY_POLICY_URL).catch(() => undefined) + }} + /> + + ) +} + +const styles = StyleSheet.create({ + checking: { + height: 64, + alignItems: 'center', + justifyContent: 'center', + }, +}) diff --git a/products/mobile/src/features/chat/ChatEmptyState.tsx b/products/mobile/src/features/chat/ChatEmptyState.tsx new file mode 100644 index 000000000000..cbbcf92ad956 --- /dev/null +++ b/products/mobile/src/features/chat/ChatEmptyState.tsx @@ -0,0 +1,117 @@ +import type { ReactElement } from 'react' +import { Text, View } from 'react-native' + +import { track } from '@/lib/analytics' +import { PostHogLogomark } from '@/ui/icons/PostHogLogomark' +import { Pressable44 } from '@/ui/Pressable44' +import { createStyles, useColors } from '@/ui/theme' +import { prettyWrap, radii, type } from '@/ui/tokens' + +const REPORT_PROMPTS = ['What caused this?', 'Who is affected?', 'Draft a status update'] as const +const GLOBAL_PROMPTS = ['What needs me today?', 'Summarize this week', 'What did the agents ship?'] as const + +interface ChatEmptyStateProps { + reportCount: number + suggestedPrompts?: string[] + onPrompt: (prompt: string) => void +} + +function copyFor(reportCount: number): { title: string; sub: string } { + if (reportCount === 0) { + return { title: 'What can I help you with?', sub: 'Ask anything, or use @ to add a report as context.' } + } + if (reportCount === 1) { + return { + title: 'Ask about this report', + sub: 'The report is attached. Ask anything or tell the agent what to do.', + } + } + return { + title: 'Ask about these reports', + sub: `${reportCount} reports are attached. Ask anything or tell the agent what to do.`, + } +} + +export function ChatEmptyState({ reportCount, suggestedPrompts, onPrompt }: ChatEmptyStateProps): ReactElement { + const colors = useColors() + const styles = useStyles() + const fromReport = suggestedPrompts !== undefined && suggestedPrompts.length > 0 + const prompts: readonly string[] = fromReport ? suggestedPrompts : reportCount > 0 ? REPORT_PROMPTS : GLOBAL_PROMPTS + const copy = copyFor(reportCount) + // A report's own prompts are generated text, so the event names the list and the position only. + const pick = (prompt: string, index: number): void => { + track('mobile_chat_suggestion_picked', { + source: fromReport ? 'report' : 'default', + index, + report_count: reportCount, + }) + onPrompt(prompt) + } + return ( + + + {copy.title} + {copy.sub} + + {prompts.map((prompt, index) => ( + pick(prompt, index)} + pressedBackground={colors.background} + style={styles.chip} + > + + {prompt} + + + ))} + + + ) +} + +const useStyles = createStyles(({ colors }) => ({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 10, + paddingHorizontal: 28, + }, + title: { + ...type.sheetTitleLarge, + color: colors.ink, + marginTop: 6, + textAlign: 'center', + }, + sub: { + fontSize: 15, + lineHeight: 21, + color: colors.muted, + textAlign: 'center', + }, + chips: { + marginTop: 8, + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + gap: 8, + }, + chip: { + minHeight: 40, + maxWidth: '100%', + paddingHorizontal: 14, + paddingVertical: 9, + borderWidth: 1, + borderColor: colors.hairline, + borderRadius: radii.card, + backgroundColor: colors.surface, + justifyContent: 'center', + }, + chipText: { + fontSize: 14, + lineHeight: 19.6, + fontWeight: '500', + color: colors.ink, + }, +})) diff --git a/products/mobile/src/features/chat/ChatErrorBubble.tsx b/products/mobile/src/features/chat/ChatErrorBubble.tsx new file mode 100644 index 000000000000..26d94bd6459f --- /dev/null +++ b/products/mobile/src/features/chat/ChatErrorBubble.tsx @@ -0,0 +1,45 @@ +import type { ReactElement } from 'react' +import { Text, View } from 'react-native' + +import { WarningIcon } from '@/ui/icons/WarningIcon' +import { createStyles, useColors } from '@/ui/theme' +import { iconSizes, radii, type } from '@/ui/tokens' + +interface ChatErrorBubbleProps { + text: string +} + +export function ChatErrorBubble({ text }: ChatErrorBubbleProps): ReactElement { + const colors = useColors() + const styles = useStyles() + return ( + + + + {text} + + + ) +} + +const useStyles = createStyles(({ colors }) => ({ + card: { + alignSelf: 'stretch', + borderWidth: 1, + borderColor: colors.hairline, + borderRadius: radii.menuRow, + paddingVertical: 12, + paddingHorizontal: 14, + gap: 10, + }, + row: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 10, + }, + text: { + ...type.bodySmall, + color: colors.ink, + flex: 1, + }, +})) diff --git a/products/mobile/src/features/chat/ChatMarkdown.test.tsx b/products/mobile/src/features/chat/ChatMarkdown.test.tsx new file mode 100644 index 000000000000..18523508fff7 --- /dev/null +++ b/products/mobile/src/features/chat/ChatMarkdown.test.tsx @@ -0,0 +1,128 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, waitFor } from '@testing-library/react-native' +import { Linking, useColorScheme } from 'react-native' +import { getInsightSummary } from '@/api/insights' +import { darkColors } from '@/ui/theme' +import { ChatMarkdown } from './ChatMarkdown' +import { MermaidDiagram } from './MermaidDiagram' + +jest.mock('@/auth/useProjectScope', () => ({ useProjectScope: () => ({ host: 'https://example.com', projectId: 7 }) })) +jest.mock('@/api/insights', () => ({ getInsightSummary: jest.fn() })) +jest.mock('./MermaidDiagram', () => ({ MermaidDiagram: jest.fn(() => null) })) +const summary = getInsightSummary as jest.MockedFunction +let client: QueryClient +function view(text: string): ReturnType { + return render( + + + + ) +} +describe('reply object rendering', () => { + test('renders only closed Mermaid fences, including blockquotes', () => { + const diagram = MermaidDiagram as jest.Mock + view('```mermaid\nflowchart LR\nA-->B\n```') + expect(diagram).toHaveBeenCalledWith(expect.objectContaining({ code: 'flowchart LR\nA-->B\n' }), undefined) + diagram.mockClear() + const screen = view('```mermaid\nflowchart LR\nA-->B') + expect(diagram).not.toHaveBeenCalled() + expect(screen.getByText('flowchart LR\nA-->B')).toBeTruthy() + view('> ```mermaid\n> flowchart LR\n> A-->B\n> ```') + expect(diagram).toHaveBeenCalled() + }) + beforeEach(() => { + jest.clearAllMocks() + client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }) + }) + afterEach(() => { + client.clear() + jest.restoreAllMocks() + jest.mocked(useColorScheme).mockReturnValue('light') + }) + test.each([ + ['reply', darkColors.ink], + ['thought', darkColors.muted], + ] as const)('draws %s code in the dark palette', (tone, color) => { + jest.mocked(useColorScheme).mockReturnValue('dark') + const screen = render() + expect(screen.getByText('pnpm test')).toHaveStyle({ color }) + }) + test('opens an inline object in the current project', () => { + const open = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined) + const screen = view('See Count') + fireEvent.press(screen.getByText('Count')) + expect(open).toHaveBeenCalledWith('https://example.com/project/7/insights/abc') + }) + test('opens explicit links and leaves bare URLs as text', () => { + const open = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined) + const screen = view( + 'Read https://example.com/bare or [the guide](https://example.com/guide) or ' + ) + expect(screen.queryByText('https://example.com/bare')).toBeNull() + fireEvent.press(screen.getByText('the guide')) + fireEvent.press(screen.getByText('https://example.com/auto')) + expect(open.mock.calls).toEqual([['https://example.com/guide'], ['https://example.com/auto']]) + }) + test.each([ + ['ordinary prose', 'plain words '], + ['a repeated mailto: scheme', 'mailto:'], + ['an opening angle bracket with no close', ''], + ['an object tag with no closing tag', ''], + ['an object tag with no closing bracket', ' { + const text = unit.repeat(Math.ceil(12_000 / unit.length)) + const slice = String.prototype.slice + let copied = 0 + const spy = jest.spyOn(String.prototype, 'slice').mockImplementation(function ( + this: string, + start?: number, + end?: number + ): string { + const part = slice.call(this, start, end) + copied += part.length + return part + }) + try { + view(text) + } finally { + spy.mockRestore() + } + expect(copied).toBeLessThan(text.length * 50) + }) + test('shows loading, name and description without executing SQL', async () => { + let resolve: (value: { name: string; description: string }) => void = () => undefined + summary.mockReturnValue( + new Promise((done) => { + resolve = done + }) + ) + const screen = view('') + expect(screen.getByText('Loading insight…')).toBeTruthy() + resolve({ name: 'Count', description: 'Visits per day' }) + await waitFor(() => expect(screen.getByText('Visits per day')).toBeTruthy()) + expect(summary).toHaveBeenCalledWith('abc', expect.any(AbortSignal)) + }) + test('shows unavailable for a missing insight with a supplied title', async () => { + summary.mockResolvedValue(null) + const screen = view('') + await waitFor(() => expect(screen.getByText('Insight unavailable')).toBeTruthy()) + }) + test('keeps errors visible with a supplied title', async () => { + summary.mockRejectedValue(new Error('unavailable')) + const screen = view('') + await waitFor(() => expect(screen.getByText('Could not load insight')).toBeTruthy()) + expect(screen.getByText('Visits')).toBeTruthy() + }) + test('expands SQL without opening its editor', () => { + const open = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined) + const screen = view('SELECT 1\nUNION ALL\nSELECT 2\nORDER BY 1') + fireEvent.press(screen.getByText('Show all')) + expect(screen.getByText('Show less')).toBeTruthy() + expect(open).not.toHaveBeenCalled() + fireEvent.press(screen.getByText('SQL query')) + expect(open).toHaveBeenCalledWith( + 'https://example.com/project/7/sql?open_query=SELECT%201%0AUNION%20ALL%0ASELECT%202%0AORDER%20BY%201' + ) + }) +}) diff --git a/products/mobile/src/features/chat/ChatMarkdown.tsx b/products/mobile/src/features/chat/ChatMarkdown.tsx new file mode 100644 index 000000000000..0979f120ed28 --- /dev/null +++ b/products/mobile/src/features/chat/ChatMarkdown.tsx @@ -0,0 +1,249 @@ +import type { ReactElement } from 'react' +import { type ImageStyle, Text, type TextStyle, type ViewStyle } from 'react-native' +import Markdown, { type RenderRules } from 'react-native-markdown-display' +import { createMarkdownIt } from '@/lib/markdownIt' +import { isMermaidLang } from '@/lib/mermaid' +import { createStyles, type Theme } from '@/ui/theme' +import { fonts, radii, type } from '@/ui/tokens' +import { InsightCard } from './InsightCard' +import { MermaidDiagram } from './MermaidDiagram' +import { ObjectChip } from './ObjectChip' +import { type ObjectCardSpec, type ObjectRef, objectTagsPlugin } from './objectTags' +import { QueryCard } from './QueryCard' +import { openReplyUrl, resolveReplyLink } from './webLinks' + +const markdownIt = createMarkdownIt().use(objectTagsPlugin) + +markdownIt.core.ruler.after('block', 'closed_fences', (state) => { + const lines = state.src.split('\n') + for (const token of state.tokens) { + if (token.type === 'fence' && token.map) { + const last = lines[token.map[1] - 1]?.replace(/^(?:\s*>\s*)+/, '').trim() || '' + token.meta = { closed: new RegExp(`^${token.markup[0]}{${token.markup.length},}\\s*$`).test(last) } + } + } +}) + +const rules: RenderRules = { + // Remote images in a reply would load from any host; the thread drops them. + image: () => null, + object_ref: (node) => ( + + ), + object_card: (node) => { + const spec = (node as unknown as { sourceMeta: ObjectCardSpec }).sourceMeta + return spec.mode === 'insight' ? ( + + ) : ( + + ) + }, + fence: (node, _children, _parents, styles) => { + const fallback = ( + + {node.content} + + ) + const meta = node as unknown as { sourceMeta?: { closed?: boolean }; sourceInfo?: string } + return isMermaidLang(meta.sourceInfo) && meta.sourceMeta?.closed ? ( + + ) : ( + fallback + ) + }, +} + +function openLink(url: string): boolean { + const resolved = resolveReplyLink(url) + if (resolved) { + openReplyUrl(resolved, 'markdown', { appLink: resolved !== url }) + } + // false stops the library from opening the unresolved URL itself. + return false +} + +/** A snapshot can end right after a list marker, which would render as an empty bullet until the next one. */ +const TRAILING_LIST_MARKER = /\n[ \t]*(?:[-*+]|\d+[.)])[ \t]*$/ + +interface ChatMarkdownProps { + text: string + streaming?: boolean + tone?: 'reply' | 'thought' +} + +export function ChatMarkdown({ text, streaming = false, tone = 'reply' }: ChatMarkdownProps): ReactElement { + const replyStyles = useReplyStyles() + const thoughtStyles = useThoughtStyles() + return ( + + {streaming ? text.replace(TRAILING_LIST_MARKER, '') : text} + + ) +} + +type MarkdownStyles = Record + +function replyMarkdownStyles({ colors }: Theme) { + const code = { + fontFamily: fonts.mono, + color: colors.ink, + } as const + + return { + body: { + fontSize: 16, + lineHeight: 23.2, + color: colors.ink, + }, + paragraph: { + marginTop: 0, + marginBottom: 10, + }, + heading1: { ...type.sheetTitleLarge, marginTop: 4, marginBottom: 8 }, + heading2: { ...type.sheetTitle, marginTop: 4, marginBottom: 8 }, + heading3: { ...type.sectionHeader, marginTop: 4, marginBottom: 6 }, + heading4: { ...type.bodyStrong, marginTop: 4, marginBottom: 6 }, + heading5: { ...type.bodyStrong, marginTop: 4, marginBottom: 6 }, + heading6: { ...type.bodyStrong, marginTop: 4, marginBottom: 6 }, + strong: { + fontWeight: '600', + }, + link: { + color: colors.ink, + textDecorationLine: 'underline', + }, + bullet_list: { + marginBottom: 10, + }, + ordered_list: { + marginBottom: 10, + }, + list_item: { + marginBottom: 4, + }, + bullet_list_icon: { + color: colors.tertiary, + marginLeft: 2, + marginRight: 8, + }, + ordered_list_icon: { + color: colors.secondary, + marginLeft: 2, + marginRight: 8, + }, + code_inline: { + ...code, + fontSize: 14, + backgroundColor: colors.sunken, + borderWidth: 0, + borderRadius: 4, + // The library default pads 10 on every side, which makes the line box taller than the text. + padding: 0, + paddingHorizontal: 4, + }, + code_block: { + ...code, + ...type.mono, + backgroundColor: colors.background, + borderColor: colors.hairline, + borderWidth: 1, + borderRadius: radii.card, + padding: 12, + marginBottom: 10, + }, + fence: { + ...code, + ...type.mono, + backgroundColor: colors.background, + borderColor: colors.hairline, + borderWidth: 1, + borderRadius: radii.card, + padding: 12, + marginBottom: 10, + }, + blockquote: { + backgroundColor: 'transparent', + borderColor: colors.hairline, + borderLeftWidth: 3, + marginLeft: 0, + paddingHorizontal: 12, + paddingVertical: 0, + marginBottom: 10, + }, + hr: { + backgroundColor: colors.hairline, + height: 1, + marginVertical: 12, + }, + table: { + borderColor: colors.hairline, + borderWidth: 1, + borderRadius: 8, + marginBottom: 10, + }, + tr: { + borderBottomWidth: 1, + borderColor: colors.hairline, + }, + th: { + padding: 8, + fontWeight: '600', + }, + td: { + padding: 8, + }, + } satisfies MarkdownStyles +} + +function thoughtMarkdownStyles(theme: Theme) { + const { colors } = theme + const replyStyles = replyMarkdownStyles(theme) + const thoughtCode = { + fontFamily: fonts.mono, + color: colors.muted, + // Code inherits the italic body style, and italic monospace is hard to read. + fontStyle: 'normal', + } as const + + const thoughtHeading = { ...type.bodySmallStrong, color: colors.muted, marginTop: 4, marginBottom: 6 } as const + + return { + ...replyStyles, + body: { + ...type.bodySmall, + color: colors.muted, + fontStyle: 'italic', + }, + heading1: thoughtHeading, + heading2: thoughtHeading, + heading3: thoughtHeading, + heading4: thoughtHeading, + heading5: thoughtHeading, + heading6: thoughtHeading, + link: { + color: colors.muted, + textDecorationLine: 'underline', + }, + code_inline: { + ...replyStyles.code_inline, + ...thoughtCode, + fontSize: 13, + }, + code_block: { + ...replyStyles.code_block, + ...thoughtCode, + }, + fence: { + ...replyStyles.fence, + ...thoughtCode, + }, + } satisfies MarkdownStyles +} + +const useReplyStyles = createStyles(replyMarkdownStyles) +const useThoughtStyles = createStyles(thoughtMarkdownStyles) diff --git a/products/mobile/src/features/chat/ChatOptionsSheet.tsx b/products/mobile/src/features/chat/ChatOptionsSheet.tsx new file mode 100644 index 000000000000..349356117340 --- /dev/null +++ b/products/mobile/src/features/chat/ChatOptionsSheet.tsx @@ -0,0 +1,132 @@ +import { type ReactElement, useMemo } from 'react' +import { Gesture } from 'react-native-gesture-handler' + +import type { + ModelChoiceApi, + PermissionModeApi, + ReasoningEffortApi, + ResolvedAiRunDefaultsApi, + RuntimeAdapterApi, +} from '@/api/tasks' +import { track } from '@/lib/analytics' +import { BottomSheet } from '@/ui/BottomSheet' + +import { ModelPage } from '../handoff/ModelPage' +import { ModePage } from '../handoff/ModePage' +import { + defaultRuntimeAdapter, + type ModelCatalogueState, + modelAfterEffort, + modelAfterPick, +} from '../handoff/useRunOptions' +import { + type ChatRunOptions, + type ChatRunOptionsAction, + chatPermissionMode, + chatRuntimeAdapter, +} from './chatRunOptions' + +export type ChatOptionsPage = 'model' | 'mode' + +interface ChatOptionsSheetProps { + visible: boolean + page: ChatOptionsPage + catalogue: ModelCatalogueState + defaults: ResolvedAiRunDefaultsApi | undefined + options: ChatRunOptions + /** The open session's harness. A live run cannot switch harness, so the sheet offers only its models. */ + runAdapter: RuntimeAdapterApi | null + onChange: (action: ChatRunOptionsAction) => void + onClose: () => void +} + +// The option name only, as the hand-off tracks it. +function trackOptionChanged(option: 'model' | 'effort' | 'mode'): void { + track('mobile_chat_option_changed', { option }) +} + +/** The chat route is a native modal outside the tab layout's overlay host, so this sheet draws inline over the chat. */ +export function ChatOptionsSheet({ + visible, + page, + catalogue, + defaults, + options, + runAdapter, + onChange, + onClose, +}: ChatOptionsSheetProps): ReactElement { + const modelListGesture = useMemo(() => Gesture.Native(), []) + const offered = useMemo( + (): ModelCatalogueState => + runAdapter + ? { + ...catalogue, + catalogue: catalogue.catalogue.filter((option) => option.runtime_adapter === runAdapter), + } + : catalogue, + [catalogue, runAdapter] + ) + const models = offered.catalogue + + const pickModel = (option: ModelChoiceApi): void => { + onChange({ + type: 'model', + model: modelAfterPick(models, defaults, options.model, option), + runtimeAdapter: option.runtime_adapter, + }) + trackOptionChanged('model') + } + const pickEffort = (effort: ReasoningEffortApi): void => { + const model = modelAfterEffort(models, defaults, options.model, effort) + if (!model) { + return + } + onChange({ type: 'model', model, runtimeAdapter: model.runtimeAdapter }) + trackOptionChanged('effort') + } + const resetModel = (): void => { + if (options.model === 'default') { + return + } + onChange({ type: 'model', model: 'default', runtimeAdapter: runAdapter ?? defaultRuntimeAdapter(defaults) }) + trackOptionChanged('model') + } + const pickMode = (mode: PermissionModeApi): void => { + if (mode !== options.permissionMode) { + onChange({ type: 'mode', mode }) + trackOptionChanged('mode') + } + onClose() + } + + return ( + + {page === 'model' ? ( + + ) : ( + + )} + + ) +} diff --git a/products/mobile/src/features/chat/ChatProgressLine.tsx b/products/mobile/src/features/chat/ChatProgressLine.tsx new file mode 100644 index 000000000000..280d814ed43f --- /dev/null +++ b/products/mobile/src/features/chat/ChatProgressLine.tsx @@ -0,0 +1,35 @@ +import type { ReactElement } from 'react' +import { ActivityIndicator, Text, View } from 'react-native' + +import { createStyles, useColors } from '@/ui/theme' +import { type } from '@/ui/tokens' + +interface ChatProgressLineProps { + text: string +} + +export function ChatProgressLine({ text }: ChatProgressLineProps): ReactElement { + const colors = useColors() + const styles = useStyles() + return ( + + + + {text} + + + ) +} + +const useStyles = createStyles(({ colors }) => ({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + text: { + ...type.bodySmall, + color: colors.tertiary, + flexShrink: 1, + }, +})) diff --git a/products/mobile/src/features/chat/ChatSheet.test.tsx b/products/mobile/src/features/chat/ChatSheet.test.tsx new file mode 100644 index 000000000000..44d32d69e01c --- /dev/null +++ b/products/mobile/src/features/chat/ChatSheet.test.tsx @@ -0,0 +1,96 @@ +import { act, fireEvent, render, screen } from '@testing-library/react-native' +import { ChatSheet } from './ChatSheet' +import { useSession } from './session/useSession' +import { useChatDraft } from './useChatDraft' + +jest.mock('expo-notifications', () => ({})) +jest.mock('react-native-gesture-handler', () => ({ + ...jest.requireActual('react-native-gesture-handler'), + GestureHandlerRootView: require('react-native').View, +})) +jest.mock('react-native-worklets', () => jest.requireActual('react-native-worklets/src/mock')) +jest.mock('react-native-reanimated', () => ({ + ...jest.requireActual('react-native-reanimated/mock'), + useReducedMotion: () => true, +})) +jest.mock( + 'react-native-safe-area-context', + () => jest.requireActual('react-native-safe-area-context/jest/mock').default +) +jest.mock('react-native-keyboard-controller', () => jest.requireActual('react-native-keyboard-controller/jest')) +jest.mock('@/auth/useProjectScope', () => ({ useProjectScope: () => ({ host: 'https://example.com', projectId: 1 }) })) +jest.mock('@/demo/demoMode', () => ({ useDataMode: () => 'live' })) +jest.mock('../inbox/hooks', () => ({ useInboxItems: () => ({ data: [] }) })) +jest.mock('../handoff/useRunOptions', () => ({ + useRunDefaults: () => undefined, + useModelCatalogue: () => ({ catalogue: [], loading: false, unavailable: false }), +})) +jest.mock('./ChatAccessGate', () => ({ ChatAccessGate: ({ children }: { children: React.ReactNode }) => children })) +jest.mock('./ChatOptionsSheet', () => ({ ChatOptionsSheet: () => null })) +jest.mock('./session/SessionThread', () => ({ SessionThread: () => null })) +jest.mock('./session/useSession', () => ({ useSession: jest.fn() })) +jest.mock('./useChatDraft', () => ({ useChatDraft: jest.fn() })) + +beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() +}) +afterEach(() => { + act(() => jest.runOnlyPendingTimers()) + jest.useRealTimers() +}) + +describe('chat send preparation', () => { + test.each(['new', 'close', 'unmount'])('a prepared message does not send after %s', async (transition) => { + let finishStorage!: (settle: (sent: boolean) => Promise) => void + const beginSend = jest.fn( + () => + new Promise<(sent: boolean) => Promise>((resolve) => { + finishStorage = resolve + }) + ) + const send = jest.fn(async () => true) + const reset = jest.fn() + const settle = jest.fn(async () => undefined) + jest.mocked(useChatDraft).mockReturnValue({ + draft: { text: 'Continue the old chat', reportIds: [] }, + ready: true, + change: jest.fn(), + beginSend, + }) + jest.mocked(useSession).mockReturnValue({ + taskId: 'task-1', + runId: 'run-1', + title: 'Old chat', + runStatus: 'completed', + items: [], + invocations: new Map(), + phase: 'idle', + history: { status: 'idle', error: null }, + isSending: false, + isStopping: false, + canSteer: false, + start: send, + send, + answer: jest.fn(), + stop: jest.fn(), + reloadHistory: jest.fn(), + reset, + dismissNotice: jest.fn(), + }) + const view = render() + fireEvent.press(screen.getByTestId('chat-send')) + expect(beginSend).toHaveBeenCalled() + if (transition === 'unmount') { + view.unmount() + } else { + fireEvent.press(screen.getByTestId(transition === 'new' ? 'chat-new' : 'chat-close')) + } + await act(async () => { + finishStorage(settle) + }) + expect(send).not.toHaveBeenCalled() + expect(settle).toHaveBeenCalledWith(false) + if (transition === 'new') expect(reset).toHaveBeenCalledTimes(1) + }) +}) diff --git a/products/mobile/src/features/chat/ChatSheet.tsx b/products/mobile/src/features/chat/ChatSheet.tsx new file mode 100644 index 000000000000..198277bc7817 --- /dev/null +++ b/products/mobile/src/features/chat/ChatSheet.tsx @@ -0,0 +1,784 @@ +import { usePathname } from 'expo-router' +import { type ReactElement, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' +import { Keyboard, Text, type TextInput, useWindowDimensions, View } from 'react-native' +import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' +import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller' +import Animated, { useAnimatedStyle } from 'react-native-reanimated' + +import { useAuthStore } from '@/auth/authStore' +import { sessionGeneration } from '@/auth/tokens' +import { useProjectScope } from '@/auth/useProjectScope' +import { useDataMode } from '@/demo/demoMode' +import { setViewedNotificationTask } from '@/features/notifications/foregroundNotifications' +import { track } from '@/lib/analytics' +import { isReportId } from '@/lib/ids' +import { useNow } from '@/lib/useNow' +import { reportWebUrl } from '@/model/adapters/report' +import type { InboxItem, ReportView } from '@/model/types' +import { CloseIcon } from '@/ui/icons/CloseIcon' +import { ComposeIcon } from '@/ui/icons/ComposeIcon' +import { OverlayHost } from '@/ui/OverlayPortal' +import { Pressable44 } from '@/ui/Pressable44' +import { Scrim } from '@/ui/Scrim' +import { createStyles, useTheme } from '@/ui/theme' +import { iconSizes, motion, radii, type } from '@/ui/tokens' +import { usePresence } from '@/ui/usePresence' +import { useScreenInsets } from '@/ui/useScreenInsets' +import { useSheetDrag } from '@/ui/useSheetDrag' + +import { defaultModelSelection, isPermissionMode, modeLabel } from '../handoff/runOptions' +import { useModelCatalogue, useRunDefaults } from '../handoff/useRunOptions' +import { useInboxItems } from '../inbox/hooks' +import { AttachMenuSheet } from './attachments/AttachMenuSheet' +import { AttachmentChip } from './attachments/AttachmentChip' +import type { AttachmentSource, PendingAttachment } from './attachments/types' +import { releaseAttachments, useComposerAttachments } from './attachments/useComposerAttachments' +import { ChatAccessGate } from './ChatAccessGate' +import { ChatEmptyState } from './ChatEmptyState' +import { type ChatOptionsPage, ChatOptionsSheet } from './ChatOptionsSheet' +import { Composer } from './Composer' +import { ContextChip } from './ContextChip' +import { + type ChatRunOptions, + type ChatRunOptionsAction, + chatPermissionMode, + chatRunOptionsFromConfig, + chatRunOptionsReducer, + chipModelLabel, + DEFAULT_CHAT_RUN_OPTIONS, +} from './chatRunOptions' +import { type ChatSheetSource, useChatSheetEvents } from './chatSheetEvents' +import { inboxContextItem, reportContextFromItem, reportContextFromView } from './contextPrompt' +import type { ChatDraft } from './draftStore' +import { MentionPopover } from './MentionPopover' +import { + canDrain, + EMPTY_QUEUE, + joinQueue, + joinQueueAttachments, + type QueueState, + queueActions, + queueReducer, + type SendGate, + sendNowState, + shouldQueue, +} from './messageQueue' +import { ObjectContextChip } from './ObjectContextChip' +import type { ChatObjectContext } from './objectContext' +import { QueuedMessages } from './QueuedMessages' +import { SessionDecisionPanel } from './session/SessionDecisionPanel' +import { SessionThread } from './session/SessionThread' +import { type SessionOptions, useSession } from './session/useSession' +import type { ChatContext } from './types' +import { useChatDraft } from './useChatDraft' + +/** The Discuss shape and the context block name each attached report, so the list stays short. */ +const MAX_ATTACHED_REPORTS = 5 + +interface ChatSheetProps { + initialReportId?: string + /** An issue or a recording the chat opens about. It frames each message while its chip stays. */ + initialObject?: ChatObjectContext | null + initialTaskId?: string + initialRunId?: string + source: ChatSheetSource + onClosed: () => void +} + +function sessionTarget(taskId: string | undefined, runId: string | undefined): SessionOptions { + if (!taskId) { + return {} + } + return runId ? { taskId, runId } : { taskId } +} + +export function ChatSheet({ + initialReportId, + initialObject = null, + initialTaskId, + initialRunId, + source, + onClosed, +}: ChatSheetProps): ReactElement { + const { colors, shadows } = useTheme() + const styles = useStyles() + const insets = useScreenInsets() + const { height: windowHeight } = useWindowDimensions() + const now = useNow() + const inbox = useInboxItems() + const [target, setTarget] = useState(() => sessionTarget(initialTaskId, initialRunId)) + const session = useSession(target) + const scope = useProjectScope() + const demo = useDataMode() === 'demo' + const keyboard = useReanimatedKeyboardAnimation() + const preparingSend = useRef(false) + const [preparing, setPreparing] = useState(false) + const queueEpoch = useRef(0) + const [visible, setVisible] = useState(true) + const pathname = usePathname() + const userUuid = useAuthStore((state) => state.user?.uuid) + useEffect(() => { + setViewedNotificationTask( + visible && pathname === '/chat' && !demo && scope && userUuid && session.taskId + ? { + accountKey: `${scope.host.replace(/\/$/, '')}|${userUuid}`, + projectId: scope.projectId, + taskId: session.taskId, + } + : null + ) + return () => setViewedNotificationTask(null) + }, [visible, pathname, demo, scope, userUuid, session.taskId]) + const { progress } = usePresence(visible, motion.sheet) + const close = useCallback((): void => { + queueEpoch.current++ + setVisible(false) + }, []) + const accountScope = useAuthStore((state) => + JSON.stringify([state.host, state.projectId, state.user?.uuid, sessionGeneration()]) + ) + const openedScope = useRef(accountScope) + useEffect(() => { + if (accountScope !== openedScope.current) { + close() + } + }, [accountScope, close]) + const { drag, pan } = useSheetDrag(visible, close) + const [draftReportId, setDraftReportId] = useState(() => + initialReportId && isReportId(initialReportId, demo) ? initialReportId : undefined + ) + const [draftTaskId, setDraftTaskId] = useState(initialTaskId ?? null) + const [attachedObject, setAttachedObject] = useState(initialObject) + const sheetEvents = useChatSheetEvents( + { + ...source, + hasContext: draftReportId !== undefined || initialObject !== null, + resumed: initialTaskId !== undefined, + }, + session.runId + ) + useEffect(() => { + if (!session.isSending && !session.unsentText && session.taskId) { + setDraftTaskId(session.taskId) + } + }, [session.isSending, session.unsentText, session.taskId]) + const composerDraft = useChatDraft(draftTaskId, draftReportId, demo) + const { text, reportIds: attached } = composerDraft.draft + const setText = (value: string | ((current: string) => string)): void => { + composerDraft.change({ ...composerDraft.draft, text: typeof value === 'function' ? value(text) : value }) + } + const setAttached = (value: string[] | ((current: string[]) => string[])): void => { + composerDraft.change({ + ...composerDraft.draft, + reportIds: typeof value === 'function' ? value(attached) : value, + }) + } + const [queue, dispatchQueue] = useReducer(queueReducer, EMPTY_QUEUE) + const delivering = useRef(false) + const [isDelivering, setDelivering] = useState(false) + const latest = useRef<{ queue: QueueState; draft: ChatDraft; change: (draft: ChatDraft) => void }>({ + queue, + draft: composerDraft.draft, + change: composerDraft.change, + }) + useEffect(() => { + latest.current = { queue, draft: composerDraft.draft, change: composerDraft.change } + }) + const [reports, setReports] = useState>({}) + const [mentionOpen, setMentionOpen] = useState(false) + const mentionTrigger = useRef<'button' | 'typed'>('button') + const [runOptions, dispatchRunOptions] = useReducer(chatRunOptionsReducer, DEFAULT_CHAT_RUN_OPTIONS) + const pickedOptions = useRef(false) + const [optionsOpen, setOptionsOpen] = useState(false) + const [optionsPage, setOptionsPage] = useState('model') + const attachments = useComposerAttachments() + const [attachOpen, setAttachOpen] = useState(false) + // The files of the direct send in flight. They stay in the composer until it settles, and a queued message + // never takes them. + const [sendingFileIds, setSendingFileIds] = useState([]) + const input = useRef(null) + const historyStatus = session.history.status + const optionsDisabled = + session.phase !== 'idle' || historyStatus !== 'idle' || session.isSending || session.decision !== undefined + const runDefaults = useRunDefaults(true) + const modelCatalogue = useModelCatalogue( + true, + runOptions.model === 'default' ? defaultModelSelection(runDefaults)?.model : runOptions.model.model + ) + // A live run cannot switch harness, so an open chat stays on the harness its run uses. + const runAdapter = session.taskId ? (session.runConfig?.runtimeAdapter ?? null) : null + const permissionMode = chatPermissionMode(runOptions, runDefaults, runAdapter) + + const historyErrorKind = historyStatus === 'error' ? (session.history.error?.kind ?? 'unknown') : null + useEffect(() => { + if (historyErrorKind) { + track('mobile_session_load_failed', { code: historyErrorKind }) + } + }, [historyErrorKind]) + + useEffect(() => { + if (mentionOpen) { + track('mobile_chat_mention_opened', { trigger: mentionTrigger.current }) + } + }, [mentionOpen]) + + useEffect(() => { + if (optionsDisabled) { + setOptionsOpen(false) + } + }, [optionsDisabled]) + + useEffect(() => { + if (visible) { + return undefined + } + const timer = setTimeout(onClosed, motion.sheet) + return () => clearTimeout(timer) + }, [visible, onClosed]) + + const hasRunConfig = session.runConfig !== undefined + const seedAdapter = session.runConfig?.runtimeAdapter ?? null + const seedModel = session.runConfig?.model ?? null + const seedEffort = session.runConfig?.reasoningEffort ?? null + const seedMode = session.runConfig?.permissionMode ?? null + useEffect(() => { + if (!hasRunConfig || pickedOptions.current) { + return + } + dispatchRunOptions({ + type: 'seed', + options: chatRunOptionsFromConfig( + { + runtimeAdapter: seedAdapter, + model: seedModel, + reasoningEffort: seedEffort, + permissionMode: seedMode, + }, + target.taskId !== undefined + ), + }) + }, [hasRunConfig, seedAdapter, seedModel, seedEffort, seedMode, target.taskId]) + + // The agent can change mode on its own, such as out of Plan when a plan is approved. Its new mode replaces an + // earlier pick, as the web drops its mode override on `current_mode_update`. + useEffect(() => { + if (pickedOptions.current && seedMode && isPermissionMode(seedMode)) { + dispatchRunOptions({ type: 'mode', mode: seedMode }) + } + }, [seedMode]) + + const items = inbox.data ?? [] + const itemsById = useMemo(() => new Map(items.map((item) => [item.reportId, item])), [items]) + const mentionItems = useMemo(() => items.filter((item) => !attached.includes(item.reportId)), [items, attached]) + const canMention = attachedObject === null && attached.length < MAX_ATTACHED_REPORTS && mentionItems.length > 0 + const sheetStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: (1 - progress.value) * windowHeight + drag.value }], + })) + // The sheet ends at the bottom edge of the window, so padding it by the keyboard height keeps the composer above + // the keyboard. `height` is negative while the keyboard is open, and the keyboard covers the home indicator. + const bottomInset = insets.bottom + const keyboardStyle = useAnimatedStyle(() => ({ paddingBottom: Math.max(-keyboard.height.value, bottomInset) })) + // A message sent before the stored history loads would land in a thread the person cannot see yet. + const composerReady = composerDraft.ready && historyStatus === 'idle' + const ready = composerReady && !preparing && !session.isSending + const gate: SendGate = { + phase: session.phase, + sending: session.isSending || preparing || isDelivering, + decisionPending: session.decision !== undefined, + } + const queueing = shouldQueue(gate, queue) + // Queueing needs no network, so only a direct send waits for the previous one. + const canSend = text.trim().length > 0 && (queueing ? composerReady : ready) + const sendNow = sendNowState({ + ...gate, + // A direct send only starts while the run is idle. Its promise can outlast the start, such as a demo reply + // that resolves when the reply ends, and must not block steering the turn it started. + sending: session.isSending || isDelivering || (preparing && session.phase === 'idle'), + canSteer: session.canSteer, + stopping: session.isStopping, + }) + // Files stay in the composer until the send that uploads them settles. A demo send uploads nothing. + const uploading = !demo && (preparing || session.isSending) + const showEmptyState = session.taskId === null && session.items.length === 0 && historyStatus === 'idle' + const webUrl = (reportId: string): string | undefined => (scope ? reportWebUrl(scope, reportId) : undefined) + + const rememberReport = useCallback((report: ReportView): void => { + setReports((previous) => (previous[report.id] === report ? previous : { ...previous, [report.id]: report })) + }, []) + + // A message frames one kind of context. The object wins, so reports a restored draft holds stay hidden until the + // object chip goes. + const shownReports = attachedObject ? [] : attached + const onlyReport = shownReports.length === 1 ? reports[shownReports[0] as string] : undefined + // The web shows a report's own prompts only when the agent may act on it. + const suggestedPrompts = onlyReport?.canAct ? onlyReport.suggestedPrompts : undefined + + const buildContext = (): ChatContext => { + if (attachedObject) { + return { kind: 'object', item: attachedObject } + } + if (attached.length > 0) { + return { + kind: 'reports', + reports: attached.map((reportId) => { + const view = reports[reportId] + if (view) { + return reportContextFromView(view, webUrl(reportId)) + } + const item = itemsById.get(reportId) + return item + ? reportContextFromItem(item, webUrl(reportId)) + : { reportId, title: '', webUrl: webUrl(reportId), canAct: false } + }), + } + } + // The inbox list frames the first message of a new chat only. The agent has it in the thread after that. + if (session.taskId === null && items.length > 0) { + return { kind: 'inbox', items: items.map(inboxContextItem) } + } + return { kind: 'none' } + } + + const changeText = (value: string): void => { + setText(value) + mentionTrigger.current = 'typed' + setMentionOpen((open) => (value.endsWith('@') ? canMention : open && value.includes('@'))) + } + const toggleMention = (): void => { + mentionTrigger.current = 'button' + setMentionOpen((open) => !open && canMention) + } + const openOptions = (page: ChatOptionsPage): void => { + if (optionsDisabled) { + return + } + // The keyboard would cover the sheet. + Keyboard.dismiss() + setMentionOpen(false) + setOptionsPage(page) + setOptionsOpen(true) + track('mobile_chat_options_opened', { page }) + } + const closeOptions = (): void => { + setOptionsOpen(false) + input.current?.focus() + } + const openAttach = (): void => { + if (uploading) { + return + } + // The keyboard would cover the sheet. + Keyboard.dismiss() + setMentionOpen(false) + setAttachOpen(true) + track('mobile_chat_attach_menu_opened') + } + const closeAttach = (): void => { + setAttachOpen(false) + input.current?.focus() + } + const pickAttachment = (source: AttachmentSource): void => { + setAttachOpen(false) + void attachments.pick(source).finally(() => input.current?.focus()) + } + const notice = attachments.notice ?? session.notice + const dismissNotice = (): void => { + if (attachments.notice) { + attachments.dismissNotice() + } else { + session.dismissNotice() + } + } + const changeOptions = (action: ChatRunOptionsAction): void => { + pickedOptions.current = true + dispatchRunOptions(action) + } + const pickMention = (item: InboxItem): void => { + if (!attached.includes(item.reportId)) { + track('mobile_chat_report_attached', { + attached_count: Math.min(attached.length + 1, MAX_ATTACHED_REPORTS), + }) + } + composerDraft.change({ + text: text.replace(/@$/, ''), + reportIds: attached.includes(item.reportId) + ? attached + : [...attached, item.reportId].slice(0, MAX_ATTACHED_REPORTS), + }) + setMentionOpen(false) + input.current?.focus() + } + const detach = (reportId: string): void => { + track('mobile_chat_report_removed', { + attached_count: attached.filter((id) => id !== reportId).length, + initial: reportId === initialReportId, + }) + setAttached((previous) => previous.filter((id) => id !== reportId)) + } + // Queued messages go back into the composer draft, so closing the sheet or starting a new chat loses none. + // Returns the queued files; the draft cannot hold them. + const foldQueue = (): PendingAttachment[] => { + queueEpoch.current++ + const { queue: current, draft, change } = latest.current + if (current.rows.length === 0) { + return [] + } + const next = { ...draft, text: [joinQueue(current.rows), draft.text.trim()].filter(Boolean).join('\n\n') } + latest.current = { queue: EMPTY_QUEUE, draft: next, change } + dispatchQueue(queueActions.clear()) + change(next) + return joinQueueAttachments(current.rows) + } + // The draft keeps text only, so a closed sheet loses the queued files, as it loses unsent composer files. + // biome-ignore lint/correctness/useExhaustiveDependencies: `foldQueue` reads only refs. + useEffect(() => { + if (!visible) { + releaseAttachments(foldQueue()) + } + }, [visible]) + // An Android back pops the route without the sheet's own close. + // biome-ignore lint/correctness/useExhaustiveDependencies: `foldQueue` reads only refs. + useEffect(() => () => releaseAttachments(foldQueue()), []) + // The mode the chip shows, already mapped onto the harness the message runs on. + const sendOptions = (): ChatRunOptions => ({ ...runOptions, permissionMode }) + const deliverQueue = (steer: boolean): void => { + const rows = queue.rows + if (rows.length === 0 || delivering.current) { + return + } + const epoch = queueEpoch.current + delivering.current = true + setDelivering(true) + // Cleared before the send, so a message queued while it is in flight waits for the next delivery. + dispatchQueue(queueActions.clear()) + void session + .send(joinQueue(rows), buildContext(), sendOptions(), { + ...(steer ? { steer: true } : {}), + queuedCount: rows.length, + attachments: joinQueueAttachments(rows), + }) + .catch(() => false) + .then((sent) => { + if (sent) { + sheetEvents.countSent() + } else if (queueEpoch.current === epoch) { + // Held, because the failed send may have reached the agent, and a second automatic try could + // deliver it twice. + dispatchQueue(queueActions.restore(rows)) + } + }) + .finally(() => { + delivering.current = false + setDelivering(false) + }) + } + const drainable = composerReady && canDrain(gate, queue) + // biome-ignore lint/correctness/useExhaustiveDependencies: drains on the transition into a drainable state only. + useEffect(() => { + if (drainable) { + deliverQueue(false) + } + }, [drainable]) + const sendQueueNow = (): void => { + if (queue.rows.length === 0 || sendNow.pending || sendNow.disabledReason) { + return + } + track('mobile_session_queue_sent_now', { queued_count: queue.rows.length, steer: session.canSteer }) + deliverQueue(true) + } + const removeQueued = (id: string): void => { + releaseAttachments(queue.rows.find((row) => row.id === id)?.attachments ?? []) + dispatchQueue(queueActions.remove(id)) + track('mobile_session_queue_removed', { + queue_length: queue.rows.filter((row) => row.id !== id).length, + }) + } + const stop = (): void => { + // The stopped turn ends like any other, so without the hold the queue would go out right after the Stop. + dispatchQueue(queueActions.hold('cancelled')) + session.stop() + } + const send = (): void => { + const message = text.trim() + if (!message || !composerReady) { + return + } + if (queueing || preparingSend.current) { + setMentionOpen(false) + const queuedFiles = attachments.files.filter((file) => !sendingFileIds.includes(file.id)) + dispatchQueue(queueActions.enqueue(message, queuedFiles)) + attachments.clear({ only: queuedFiles.map((file) => file.id), release: false }) + track('mobile_session_queued', { queue_length: queue.rows.length + 1 }) + composerDraft.change({ text: '', reportIds: attached }) + return + } + if (!ready) { + return + } + setMentionOpen(false) + const options = sendOptions() + const context = buildContext() + const files = attachments.files + attachments.dismissNotice() + preparingSend.current = true + setPreparing(true) + setSendingFileIds(files.map((file) => file.id)) + if (demo) { + // The bubble already shows the files, and a demo send settles only after the canned reply plays. + attachments.clear({ only: files.map((file) => file.id), release: false }) + } + const epoch = queueEpoch.current + void composerDraft + .beginSend({ text: message, reportIds: attached }) + .then(async (settle) => { + if (queueEpoch.current !== epoch) { + await settle(false) + return + } + const sent = await session.send(message, context, options, { attachments: files }).catch(() => false) + if (sent) { + sheetEvents.countSent() + attachments.clear({ only: files.map((file) => file.id), release: false }) + } + await settle(sent) + }) + .finally(() => { + if (queueEpoch.current === epoch) { + preparingSend.current = false + setPreparing(false) + setSendingFileIds([]) + } + }) + } + const startNewChat = (): void => { + sheetEvents.countNewChat() + // The queued text folds into this chat's draft, so its files must not follow into the new chat. + releaseAttachments(foldQueue()) + preparingSend.current = false + setPreparing(false) + setSendingFileIds([]) + if (target.taskId) { + // The session reloads as an empty new chat when its task option goes. + setTarget({}) + } else { + session.reset() + } + setDraftTaskId(null) + setDraftReportId(undefined) + setAttachedObject(null) + setMentionOpen(false) + setOptionsOpen(false) + setAttachOpen(false) + attachments.clear() + pickedOptions.current = false + dispatchRunOptions({ type: 'reset' }) + input.current?.focus() + } + + const chips = + attachedObject !== null || shownReports.length > 0 || attachments.files.length > 0 ? ( + <> + {attachedObject ? ( + setAttachedObject(null)} /> + ) : null} + {shownReports.map((reportId) => ( + detach(reportId)} + /> + ))} + {attachments.files.map((file) => ( + attachments.remove(file.id)} + /> + ))} + + ) : null + + // The route is a native modal, so on Android the app's gesture root does not reach it and the sheet needs its own. + return ( + + + + + + + {/* The thread scrolls, so only the header takes the drag; a drag there never competes with a scroll. */} + + + + + + + + + {session.title ?? 'New chat'} + + {showEmptyState ? ( + + ) : ( + + + + )} + + + + {showEmptyState ? ( + + ) : ( + { + track('mobile_session_load_retried') + session.reloadHistory().catch(() => undefined) + }} + /> + )} + + + + void session.answer(answerInput)} + onDismissNotice={dismissNotice} + /> + {/* A pending decision has its own reply field, so it replaces the composer, as on the web. */} + {session.decision ? null : ( + <> + {mentionOpen ? ( + + ) : null} + openOptions('model')} + onOpenMode={() => openOptions('mode')} + onSend={send} + canSend={canSend} + isStreaming={session.phase !== 'idle'} + onStop={stop} + /> + + )} + + + + + + + + + + ) +} + +const useStyles = createStyles(({ colors }) => ({ + overlay: { + flex: 1, + justifyContent: 'flex-end', + }, + sheet: { + height: '94%', + backgroundColor: colors.surface, + borderTopLeftRadius: radii.floating, + borderTopRightRadius: radii.floating, + }, + keyboard: { + flex: 1, + }, + header: { + paddingTop: 10, + }, + grabber: { + width: 40, + height: 5, + borderRadius: 3, + backgroundColor: colors.border, + alignSelf: 'center', + }, + headerRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + paddingTop: 6, + paddingHorizontal: 16, + paddingBottom: 8, + }, + round: { + width: 44, + height: 44, + borderRadius: 22, + borderWidth: 1, + borderColor: colors.hairline, + backgroundColor: colors.surface, + alignItems: 'center', + justifyContent: 'center', + }, + title: { + ...type.sheetTitle, + color: colors.ink, + flexShrink: 1, + textAlign: 'center', + }, + headerSpacer: { + width: 44, + height: 44, + }, + composerArea: { + position: 'relative', + }, +})) diff --git a/products/mobile/src/features/chat/Composer.tsx b/products/mobile/src/features/chat/Composer.tsx new file mode 100644 index 000000000000..115fca827b54 --- /dev/null +++ b/products/mobile/src/features/chat/Composer.tsx @@ -0,0 +1,320 @@ +import { forwardRef, type ReactElement, type ReactNode } from 'react' +import { Text, TextInput, View } from 'react-native' + +import { ArrowUpIcon } from '@/ui/icons/ArrowUpIcon' +import { ChevronDownIcon } from '@/ui/icons/ChevronDownIcon' +import { PaperclipIcon } from '@/ui/icons/PaperclipIcon' +import { Pressable44 } from '@/ui/Pressable44' +import { createStyles, useColors, useTheme } from '@/ui/theme' +import { iconSizes, inputReset, radii, type } from '@/ui/tokens' + +import { MAX_ATTACHMENTS_PER_MESSAGE } from './attachments/attachmentRules' + +interface ComposerProps { + text: string + onChangeText: (text: string) => void + chips?: ReactNode + onAttach: () => void + attachOpen: boolean + attachDisabled: boolean + attachFull: boolean + mentionOpen: boolean + onToggleMention: () => void + modelLabel: string + modeLabel: string + openOption: 'model' | 'mode' | null + optionsDisabled: boolean + onOpenModel: () => void + onOpenMode: () => void + onSend: () => void + canSend: boolean + isStreaming: boolean + onStop: () => void +} + +interface OptionChipProps { + label: string + accessibilityLabel: string + open: boolean + disabled: boolean + onPress: () => void + /** False keeps the whole label, so a short label never truncates to one letter beside a long one. */ + shrinks: boolean + testID: string +} + +function OptionChip({ + label, + accessibilityLabel, + open, + disabled, + onPress, + shrinks, + testID, +}: OptionChipProps): ReactElement { + const colors = useColors() + const styles = useStyles() + return ( + + + {label} + + + + + + ) +} + +export const Composer = forwardRef(function Composer( + { + text, + onChangeText, + chips, + onAttach, + attachOpen, + attachDisabled, + attachFull, + mentionOpen, + onToggleMention, + modelLabel, + modeLabel, + openOption, + optionsDisabled, + onOpenModel, + onOpenMode, + onSend, + canSend, + isStreaming, + onStop, + }, + ref +): ReactElement { + const { colors, shadows, scheme } = useTheme() + const styles = useStyles() + const typed = text.trim().length > 0 + return ( + + {chips ? {chips} : null} + + + + + + + @ + + + + + {isStreaming ? ( + + + + ) : null} + {/* While the agent works, Send queues the message, so it shows beside Stop once there is text. */} + {!isStreaming || typed ? ( + + + + ) : null} + + + ) +}) + +const useStyles = createStyles(({ colors }) => ({ + card: { + marginHorizontal: 10, + marginBottom: 10, + borderWidth: 1, + borderColor: colors.hairline, + borderRadius: radii.softCard, + backgroundColor: colors.surface, + paddingTop: 12, + paddingHorizontal: 12, + paddingBottom: 10, + gap: 10, + }, + chips: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'flex-start', + gap: 6, + }, + input: { + ...inputReset, + minHeight: 40, + maxHeight: 120, + paddingHorizontal: 6, + paddingVertical: 0, + ...type.input, + color: colors.ink, + textAlignVertical: 'top', + }, + toolbar: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + tool: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + }, + // The default 6pt hit slop keeps the narrow button at a 44pt target, so it can give the option chips room. + attach: { + width: 32, + }, + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + flexShrink: 1, + minWidth: 0, + height: 32, + paddingLeft: 10, + paddingRight: 8, + borderRadius: radii.pill, + borderWidth: 1, + borderColor: colors.hairline, + }, + chevron: { + flexShrink: 0, + }, + chipFixed: { + flexShrink: 0, + }, + chipLabel: { + flexShrink: 1, + fontSize: 14, + lineHeight: 18, + fontWeight: '600', + color: colors.ink, + }, + toolActive: { + backgroundColor: colors.background, + }, + toolDisabled: { + opacity: 0.4, + }, + at: { + fontSize: 20, + lineHeight: 24, + fontWeight: '500', + color: colors.ink, + }, + spacer: { + flex: 1, + }, + send: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + }, + sendReady: { + backgroundColor: colors.ink, + }, + sendIdle: { + backgroundColor: colors.disabledFill, + }, + stopSecondary: { + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.background, + }, + stopSquare: { + width: 12, + height: 12, + borderRadius: 2, + backgroundColor: colors.onInk, + }, + stopSquareSecondary: { + backgroundColor: colors.ink, + }, +})) diff --git a/products/mobile/src/features/chat/ContextChip.tsx b/products/mobile/src/features/chat/ContextChip.tsx new file mode 100644 index 000000000000..0223c0e655f3 --- /dev/null +++ b/products/mobile/src/features/chat/ContextChip.tsx @@ -0,0 +1,80 @@ +import { type ReactElement, useEffect } from 'react' +import { Text, View } from 'react-native' + +import type { InboxItem, ReportView } from '@/model/types' +import { Dot } from '@/ui/Dot' +import { CloseIcon } from '@/ui/icons/CloseIcon' +import { Pressable44 } from '@/ui/Pressable44' +import { createStyles, useTheme } from '@/ui/theme' +import { iconSizes, radii } from '@/ui/tokens' + +import { useReport } from '../report/hooks' + +interface ContextChipProps { + reportId: string + item?: InboxItem + onReport: (report: ReportView) => void + onRemove: () => void +} + +/** An attached report in the composer. Each chip loads its own report, so any number can be attached. */ +export function ContextChip({ reportId, item, onReport, onRemove }: ContextChipProps): ReactElement { + const { colors, accentColor } = useTheme() + const styles = useStyles() + const report = useReport(reportId) + const data = report.data + + useEffect(() => { + if (data) { + onReport(data) + } + }, [data, onReport]) + + const title = data?.title ?? item?.title ?? 'Loading report…' + const accent = data?.accent ?? item?.accent ?? 'resolved' + return ( + + + + {title} + + + + + + ) +} + +const useStyles = createStyles(({ colors }) => ({ + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + height: 36, + paddingLeft: 10, + paddingRight: 6, + borderRadius: radii.pill, + backgroundColor: colors.sunken, + maxWidth: '100%', + }, + title: { + fontSize: 14, + lineHeight: 19.6, + fontWeight: '600', + color: colors.ink, + flexShrink: 1, + }, + remove: { + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + }, +})) diff --git a/products/mobile/src/features/chat/InsightCard.tsx b/products/mobile/src/features/chat/InsightCard.tsx new file mode 100644 index 000000000000..a204e82ff44e --- /dev/null +++ b/products/mobile/src/features/chat/InsightCard.tsx @@ -0,0 +1,67 @@ +import { useQuery } from '@tanstack/react-query' +import type { ReactElement } from 'react' +import { Pressable, Text } from 'react-native' +import { getInsightSummary } from '@/api/insights' +import { scopeKey } from '@/api/reports' +import { useProjectScope } from '@/auth/useProjectScope' +import { createStyles } from '@/ui/theme' +import { radii, type } from '@/ui/tokens' +import { objectMeta, objectWebUrl } from './objectTags' +import { openReplyUrl } from './webLinks' + +export function InsightCard({ shortId, title }: { shortId: string; title?: string }): ReactElement { + const styles = useStyles() + const scope = useProjectScope() + const insight = useQuery({ + queryKey: ['insight-summary', ...scopeKey(scope), shortId], + queryFn: ({ signal }) => getInsightSummary(shortId, signal), + enabled: !!scope, + staleTime: 5 * 60_000, + retry: false, + }) + const url = scope && objectWebUrl(scope.host, scope.projectId, 'insight', shortId) + const placeholder = insight.isLoading + ? 'Loading insight…' + : insight.isError + ? 'Could not load insight' + : insight.data === null + ? 'Insight unavailable' + : 'Open insight' + return ( + { + if (url) openReplyUrl(url, 'insight_card', { objectKind: 'insight' }) + }} + style={styles.card} + > + {objectMeta('insight')} + + {title || insight.data?.name || placeholder} + + {title && (insight.isLoading || insight.isError || insight.data === null) ? ( + {placeholder} + ) : null} + {insight.data?.description ? ( + + {insight.data.description} + + ) : null} + + ) +} +const useStyles = createStyles(({ colors }) => ({ + card: { + backgroundColor: colors.surface, + borderColor: colors.hairline, + borderWidth: 1, + borderRadius: radii.card, + padding: 12, + gap: 6, + marginBottom: 10, + }, + meta: { ...type.meta, color: colors.secondary }, + title: { ...type.bodyStrong, color: colors.ink }, + description: { ...type.bodySmall, color: colors.muted }, +})) diff --git a/products/mobile/src/features/chat/MentionPopover.tsx b/products/mobile/src/features/chat/MentionPopover.tsx new file mode 100644 index 000000000000..467f8abbb217 --- /dev/null +++ b/products/mobile/src/features/chat/MentionPopover.tsx @@ -0,0 +1,89 @@ +import type { ReactElement } from 'react' +import { ScrollView, Text, View } from 'react-native' + +import { formatRelativeAgo } from '@/lib/dates' +import type { InboxItem } from '@/model/types' +import { Dot } from '@/ui/Dot' +import { Pressable44 } from '@/ui/Pressable44' +import { createStyles, useTheme } from '@/ui/theme' +import { radii, type } from '@/ui/tokens' + +interface MentionPopoverProps { + items: InboxItem[] + now: Date + onPick: (item: InboxItem) => void +} + +export function MentionPopover({ items, now, onPick }: MentionPopoverProps): ReactElement { + const { colors, shadows, accentColor } = useTheme() + const styles = useStyles() + return ( + + + ADD A REPORT + {items.map((item) => ( + onPick(item)} + pressedBackground={colors.background} + style={styles.row} + testID={`mention-${item.reportId}`} + > + + + {item.title} + + {formatRelativeAgo(item.updatedAt, now)} + + ))} + + + ) +} + +const useStyles = createStyles(({ colors }) => ({ + popover: { + position: 'absolute', + left: 10, + right: 10, + bottom: '100%', + marginBottom: 8, + zIndex: 5, + // 260pt content box plus 6pt padding and a 1pt border on each side. + maxHeight: 274, + borderWidth: 1, + borderColor: colors.hairline, + borderRadius: radii.menuRow, + backgroundColor: colors.surface, + padding: 6, + }, + eyebrow: { + color: colors.tertiary, + paddingTop: 8, + paddingHorizontal: 12, + paddingBottom: 6, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + // 44pt content box plus 6pt padding on each side. + minHeight: 56, + paddingVertical: 6, + paddingHorizontal: 12, + borderRadius: radii.card, + }, + title: { + flex: 1, + minWidth: 0, + fontSize: 15, + lineHeight: 21, + fontWeight: '500', + color: colors.ink, + }, + date: { + fontSize: 12, + lineHeight: 16.8, + color: colors.tertiary, + }, +})) diff --git a/products/mobile/src/features/chat/MermaidDiagram.test.tsx b/products/mobile/src/features/chat/MermaidDiagram.test.tsx new file mode 100644 index 000000000000..e247a0852c92 --- /dev/null +++ b/products/mobile/src/features/chat/MermaidDiagram.test.tsx @@ -0,0 +1,59 @@ +import { act, fireEvent, render } from '@testing-library/react-native' +import { Platform, Text, View } from 'react-native' +import { MermaidDiagram } from './MermaidDiagram' + +jest.mock('react-native-webview', () => { + const { View } = require('react-native') + return { __esModule: true, default: (props: object) => } +}) +jest.mock('mermaid/dist/mermaid.min.js', () => 'window.mermaid = {};') + +it('keeps remote image diagrams as source without mounting a browser', () => { + const screen = render( + source fallback} + /> + ) + expect(screen.getByText('source fallback')).toBeTruthy() + expect(screen.queryByTestId('diagram-webview')).toBeNull() +}) + +it('sizes the diagram and refuses remote, file and script navigation', () => { + const screen = render(B'} fallback={source fallback} />) + const frame = screen.UNSAFE_getAllByType(View).find((view) => view.props.onLayout) + if (!frame) throw new Error('Missing diagram frame') + fireEvent(frame, 'layout', { nativeEvent: { layout: { width: 320 } } }) + const browser = screen.getByTestId('diagram-webview') + expect(browser.props.onShouldStartLoadWithRequest({ url: 'about:blank' })).toBe(true) + for (const url of ['https://example.com', 'file:///etc/passwd', 'about:srcdoc', 'javascript:alert(1)']) { + expect(browser.props.onShouldStartLoadWithRequest({ url })).toBe(false) + } + fireEvent(browser, 'message', { nativeEvent: { data: '{"type":"size","width":600,"height":180}' } }) + expect(screen.getByTestId('diagram-webview').props.style).toContainEqual({ width: 600, height: 180 }) + fireEvent(browser, 'message', { nativeEvent: { data: '{"type":"error"}' } }) + expect(screen.getByText('source fallback')).toBeTruthy() +}) + +it('falls back when the browser never finishes rendering', () => { + jest.useFakeTimers() + try { + const screen = render(D'} fallback={source fallback} />) + act(() => jest.advanceTimersByTime(15000)) + expect(screen.getByText('source fallback')).toBeTruthy() + } finally { + jest.useRealTimers() + } +}) + +it('shows source in web preview without invoking the native browser', () => { + const previous = Platform.OS + Object.defineProperty(Platform, 'OS', { value: 'web', configurable: true }) + try { + const screen = render(source fallback} />) + expect(screen.getByText('source fallback')).toBeTruthy() + expect(screen.queryByTestId('diagram-webview')).toBeNull() + } finally { + Object.defineProperty(Platform, 'OS', { value: previous, configurable: true }) + } +}) diff --git a/products/mobile/src/features/chat/MermaidDiagram.tsx b/products/mobile/src/features/chat/MermaidDiagram.tsx new file mode 100644 index 000000000000..943e62b9bf7d --- /dev/null +++ b/products/mobile/src/features/chat/MermaidDiagram.tsx @@ -0,0 +1,154 @@ +import { type ReactNode, useEffect, useMemo, useState } from 'react' +import { ActivityIndicator, Platform, ScrollView, View } from 'react-native' +import WebView from 'react-native-webview' +import { track } from '@/lib/analytics' +import { + type DiagramSize, + hasRemoteImageNode, + inlineScriptSource, + mermaidHtml, + parseMermaidMessage, + rememberSize, +} from '@/lib/mermaid' +import { createStyles, useColorSchemeName, useColors } from '@/ui/theme' + +const PADDING = 12 +const PENDING_HEIGHT = 120 +const SIZE_CACHE_LIMIT = 50 + +let bundle: string | null = null + +// Metro exports the prebuilt browser bundle as a string (see metro.transformer.js). +function mermaidBundle(): string { + bundle ??= inlineScriptSource(require('mermaid/dist/mermaid.min.js') as string) + return bundle +} + +// Remounted rows keep their height instead of jumping while the WebView loads. +const sizes = new Map() + +type DiagramFailure = 'timeout' | 'render_error' | 'load_error' | 'process_gone' | 'open_window' + +// A remounted row fails again for the same diagram, so each diagram reports its failure once. +const reportedFailures = new Set() + +function reportFailure(cacheKey: string, reason: DiagramFailure): void { + if (reportedFailures.has(cacheKey)) return + if (reportedFailures.size >= SIZE_CACHE_LIMIT) reportedFailures.clear() + reportedFailures.add(cacheKey) + track('mobile_chat_diagram_failed', { reason }) +} + +interface MermaidDiagramProps { + code: string + fallback: ReactNode +} + +// Keyed by theme and code, so a new diagram starts without the old failure or size. +export function MermaidDiagram(props: MermaidDiagramProps): ReactNode { + const dark = useColorSchemeName() === 'dark' + const key = `${dark ? 'dark' : 'light'}\n${props.code}` + if (Platform.OS === 'web' || hasRemoteImageNode(props.code)) return props.fallback + return +} + +interface DiagramViewProps extends MermaidDiagramProps { + cacheKey: string + dark: boolean +} + +function DiagramView({ code, fallback, cacheKey, dark }: DiagramViewProps): ReactNode { + const colors = useColors() + const styles = useStyles() + const html = useMemo(() => mermaidHtml(code, dark, mermaidBundle()), [code, dark]) + const [width, setWidth] = useState(0) + const [size, setSize] = useState(() => sizes.get(cacheKey) ?? null) + const [failed, setFailed] = useState(false) + + useEffect(() => { + if (size || failed) return + const timeout = setTimeout(() => { + reportFailure(cacheKey, 'timeout') + setFailed(true) + }, 15000) + return () => clearTimeout(timeout) + }, [size, failed, cacheKey]) + + if (failed) return fallback + + const fail = (reason: DiagramFailure) => { + reportFailure(cacheKey, reason) + setFailed(true) + } + const onMessage = (data: string) => { + const message = parseMermaidMessage(data) + if (message?.type === 'size') { + const next = { width: message.width, height: message.height } + rememberSize(sizes, cacheKey, next, SIZE_CACHE_LIMIT) + setSize(next) + } else if (message?.type === 'error') { + fail('render_error') + } + } + + return ( + setWidth(event.nativeEvent.layout.width - PADDING * 2)}> + + {width > 0 ? ( + request.url === 'about:blank'} + onMessage={(event) => onMessage(event.nativeEvent.data)} + onError={() => fail('load_error')} + onContentProcessDidTerminate={() => fail('process_gone')} + onRenderProcessGone={() => fail('process_gone')} + onOpenWindow={() => fail('open_window')} + allowFileAccess={false} + allowFileAccessFromFileURLs={false} + allowUniversalAccessFromFileURLs={false} + mixedContentMode="never" + javaScriptCanOpenWindowsAutomatically={false} + javaScriptEnabled + scrollEnabled={false} + bounces={false} + setSupportMultipleWindows={false} + showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={false} + style={[styles.webview, size ?? { width, height: PENDING_HEIGHT, opacity: 0 }]} + /> + ) : ( + + )} + + {size ? null : ( + + + + )} + + ) +} + +const useStyles = createStyles(({ colors }) => ({ + frame: { + backgroundColor: colors.background, + borderRadius: 12, + overflow: 'hidden', + }, + content: { + flexGrow: 1, + justifyContent: 'center', + padding: PADDING, + }, + webview: { backgroundColor: 'transparent' }, + spinner: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + alignItems: 'center', + justifyContent: 'center', + }, +})) diff --git a/products/mobile/src/features/chat/ObjectChip.tsx b/products/mobile/src/features/chat/ObjectChip.tsx new file mode 100644 index 000000000000..1a1aef2e37f1 --- /dev/null +++ b/products/mobile/src/features/chat/ObjectChip.tsx @@ -0,0 +1,27 @@ +import type { ReactElement } from 'react' +import { Text } from 'react-native' +import { useProjectScope } from '@/auth/useProjectScope' +import { useColors } from '@/ui/theme' +import { type ObjectRef, objectWebUrl } from './objectTags' +import { openReplyUrl } from './webLinks' + +export function ObjectChip({ target }: { target: ObjectRef }): ReactElement { + const colors = useColors() + const scope = useProjectScope() + const url = scope && objectWebUrl(scope.host, scope.projectId, target.kind, target.id) + return ( + { + openReplyUrl(url, 'object_chip', { objectKind: target.kind }) + } + : undefined + } + > + {target.label} + + ) +} diff --git a/products/mobile/src/features/chat/ObjectContextChip.tsx b/products/mobile/src/features/chat/ObjectContextChip.tsx new file mode 100644 index 000000000000..663e8a15fd1b --- /dev/null +++ b/products/mobile/src/features/chat/ObjectContextChip.tsx @@ -0,0 +1,67 @@ +import type { ReactElement } from 'react' +import { Text, View } from 'react-native' + +import { CloseIcon } from '@/ui/icons/CloseIcon' +import { ErrorTriangleIcon } from '@/ui/icons/ErrorTriangleIcon' +import { RecordingIcon } from '@/ui/icons/RecordingIcon' +import { Pressable44 } from '@/ui/Pressable44' +import { createStyles, useColors } from '@/ui/theme' +import { iconSizes, radii } from '@/ui/tokens' + +import { type ChatObjectContext, ISSUE_OBJECT_TYPE } from './objectContext' + +interface ObjectContextChipProps { + object: ChatObjectContext + onRemove: () => void +} + +export function ObjectContextChip({ object, onRemove }: ObjectContextChipProps): ReactElement { + const colors = useColors() + const styles = useStyles() + const Icon = object.type === ISSUE_OBJECT_TYPE ? ErrorTriangleIcon : RecordingIcon + return ( + + + + {object.label} + + + + + + ) +} + +const useStyles = createStyles(({ colors }) => ({ + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + height: 36, + paddingLeft: 10, + paddingRight: 6, + borderRadius: radii.pill, + backgroundColor: colors.sunken, + maxWidth: '100%', + }, + title: { + fontSize: 14, + lineHeight: 19.6, + fontWeight: '600', + color: colors.ink, + flexShrink: 1, + }, + remove: { + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + }, +})) diff --git a/products/mobile/src/features/chat/QueryCard.tsx b/products/mobile/src/features/chat/QueryCard.tsx new file mode 100644 index 000000000000..d4be30a10f2d --- /dev/null +++ b/products/mobile/src/features/chat/QueryCard.tsx @@ -0,0 +1,56 @@ +import { type ReactElement, useState } from 'react' +import { Pressable, Text, View } from 'react-native' +import { useProjectScope } from '@/auth/useProjectScope' +import { createStyles } from '@/ui/theme' +import { radii, type } from '@/ui/tokens' +import { objectMeta, objectWebUrl } from './objectTags' +import { openReplyUrl } from './webLinks' + +export function QueryCard({ query, title }: { query: string; title?: string }): ReactElement { + const styles = useStyles() + const scope = useProjectScope() + const url = scope && objectWebUrl(scope.host, scope.projectId, 'hogql', query) + const [expanded, setExpanded] = useState(false) + return ( + + { + if (url) openReplyUrl(url, 'query_card', { objectKind: 'hogql' }) + }} + > + {objectMeta('hogql')} + + {title || 'SQL query'} + + + + {query} + + + + {query.split('\n').length > 3 ? ( + setExpanded(!expanded)}> + {expanded ? 'Show less' : 'Show all'} + + ) : null} + + ) +} +const useStyles = createStyles(({ colors }) => ({ + card: { + backgroundColor: colors.surface, + borderColor: colors.hairline, + borderWidth: 1, + borderRadius: radii.card, + padding: 12, + gap: 6, + marginBottom: 10, + }, + meta: { ...type.meta, color: colors.secondary }, + title: { ...type.bodyStrong, color: colors.ink, marginVertical: 6 }, + queryBox: { backgroundColor: colors.sunken, padding: 8, borderRadius: 8 }, + query: { ...type.mono, color: colors.muted }, + toggle: { ...type.meta, color: colors.blue }, +})) diff --git a/products/mobile/src/features/chat/QueuedMessages.tsx b/products/mobile/src/features/chat/QueuedMessages.tsx new file mode 100644 index 000000000000..a9a14ded322e --- /dev/null +++ b/products/mobile/src/features/chat/QueuedMessages.tsx @@ -0,0 +1,170 @@ +import type { ReactElement } from 'react' +import { ActivityIndicator, ScrollView, Text, View } from 'react-native' + +import { CloseIcon } from '@/ui/icons/CloseIcon' +import { Pressable44 } from '@/ui/Pressable44' +import { createStyles, useColors } from '@/ui/theme' +import { iconSizes, radii, type } from '@/ui/tokens' + +import type { QueuedMessage } from './messageQueue' + +interface QueuedMessagesProps { + rows: QueuedMessage[] + held: boolean + onRemove: (id: string) => void + onSendNow: () => void + sendNowDisabledReason?: string + sendNowPending: boolean +} + +export function QueuedMessages({ + rows, + held, + onRemove, + onSendNow, + sendNowDisabledReason, + sendNowPending, +}: QueuedMessagesProps): ReactElement | null { + const colors = useColors() + const styles = useStyles() + if (rows.length === 0) { + return null + } + const disabled = sendNowPending || sendNowDisabledReason !== undefined + return ( + + + + {`${rows.length} queued${held ? ', not sent yet' : ''}`} + + + {sendNowPending ? : null} + Send now + + + {sendNowDisabledReason ? {sendNowDisabledReason} : null} + {/* Capped so a long queue cannot push the composer off the screen. */} + + {rows.map((row) => ( + + + {row.text} + + {row.attachments.length > 0 ? ( + + {row.attachments.length === 1 ? '1 file' : `${row.attachments.length} files`} + + ) : null} + onRemove(row.id)} + accessibilityLabel="Remove from queue" + pressedBackground={colors.pressed} + style={styles.remove} + hitSlop={10} + testID="chat-queue-remove" + > + + + + ))} + + + ) +} + +const useStyles = createStyles(({ colors }) => ({ + banner: { + marginHorizontal: 10, + marginBottom: 8, + gap: 6, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + paddingLeft: 6, + }, + label: { + ...type.meta, + fontWeight: '600', + color: colors.secondary, + flexShrink: 1, + }, + sendNow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + height: 32, + paddingHorizontal: 12, + borderRadius: radii.pill, + borderWidth: 1, + borderColor: colors.hairline, + backgroundColor: colors.surface, + }, + sendNowHeld: { + borderColor: colors.border, + }, + sendNowDisabled: { + opacity: 0.4, + }, + sendNowLabel: { + fontSize: 14, + lineHeight: 18, + fontWeight: '600', + color: colors.ink, + }, + reason: { + ...type.meta, + color: colors.tertiary, + paddingLeft: 6, + }, + rows: { + maxHeight: 152, + flexGrow: 0, + }, + rowsContent: { + gap: 6, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingVertical: 8, + paddingLeft: 12, + paddingRight: 8, + borderRadius: radii.tile, + borderWidth: 1, + borderColor: colors.hairline, + backgroundColor: colors.background, + }, + rowText: { + ...type.metaSmall, + color: colors.ink, + flex: 1, + }, + rowFiles: { + ...type.metaSmall, + color: colors.tertiary, + }, + remove: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + }, +})) diff --git a/products/mobile/src/features/chat/UserBubble.tsx b/products/mobile/src/features/chat/UserBubble.tsx new file mode 100644 index 000000000000..8c0502e4bcfe --- /dev/null +++ b/products/mobile/src/features/chat/UserBubble.tsx @@ -0,0 +1,112 @@ +import { memo, type ReactElement } from 'react' +import { Image, Text, View } from 'react-native' + +import { DocumentIcon } from '@/ui/icons/DocumentIcon' +import { createStyles, useColors } from '@/ui/theme' +import { iconSizes, radii, type } from '@/ui/tokens' + +import type { SessionAttachment } from './session/types' + +interface UserBubbleProps { + text: string + attachments?: readonly SessionAttachment[] +} + +const THUMBNAIL_SIZE = 120 + +/** + * A picked image draws from the file on this device. A file from history has no local copy, and the download + * endpoint redirects to S3, which the app's bearer token must not follow, so it shows by name only. + */ +export const UserBubble = memo(function UserBubble({ text, attachments = [] }: UserBubbleProps): ReactElement { + const colors = useColors() + const styles = useStyles() + const thumbnails = attachments.filter((attachment) => attachment.isImage && attachment.localUri) + const named = attachments.filter((attachment) => !(attachment.isImage && attachment.localUri)) + return ( + + {thumbnails.length > 0 ? ( + + {thumbnails.map((attachment, index) => ( + + ))} + + ) : null} + {named.length > 0 ? ( + + {named.map((attachment, index) => ( + + + + {attachment.name} + + + ))} + + ) : null} + {text ? ( + + {text} + + ) : null} + + ) +}) + +const useStyles = createStyles(({ colors }) => ({ + column: { + alignSelf: 'flex-end', + alignItems: 'flex-end', + maxWidth: '82%', + gap: 6, + }, + row: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'flex-end', + gap: 6, + }, + thumbnail: { + width: THUMBNAIL_SIZE, + height: THUMBNAIL_SIZE, + borderRadius: radii.menuRow, + backgroundColor: colors.sunken, + }, + file: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + maxWidth: '100%', + paddingVertical: 6, + paddingHorizontal: 10, + borderRadius: radii.pill, + borderWidth: 1, + borderColor: colors.hairline, + }, + fileName: { + ...type.meta, + color: colors.ink, + flexShrink: 1, + }, + bubble: { + backgroundColor: colors.sunken, + borderRadius: radii.menuRow, + paddingVertical: 10, + paddingHorizontal: 14, + }, + text: { + ...type.body, + color: colors.ink, + }, +})) diff --git a/products/mobile/src/features/chat/aiAccess.ts b/products/mobile/src/features/chat/aiAccess.ts new file mode 100644 index 000000000000..da57d91ebd7a --- /dev/null +++ b/products/mobile/src/features/chat/aiAccess.ts @@ -0,0 +1,40 @@ +import type { ProjectScope } from '@/api/reports' +import type { AuthOrganization, AuthProject } from '@/auth/account' + +/** `unknown` is a session persisted before the app stored the flag, or an organization that failed to load. */ +export type OrgAiStatus = 'approved' | 'not_approved' | 'unknown' + +export interface ProjectOrganization { + name: string | null + status: OrgAiStatus + canApprove: boolean +} + +// The chat runs in the selected project, so the approval that counts is the one of that project's organization, +// which can differ from the user's current organization on the web. +export function projectOrganization( + organizations: readonly AuthOrganization[], + projects: readonly AuthProject[], + projectId: number | null +): ProjectOrganization { + const project = projects.find((candidate) => candidate.id === projectId) + const organization = project + ? organizations.find((candidate) => candidate.id === project.organizationId) + : undefined + const approved = organization?.aiDataProcessingApproved + return { + name: organization?.name ?? project?.organizationName ?? null, + status: approved === undefined ? 'unknown' : approved ? 'approved' : 'not_approved', + canApprove: organization?.membershipLevel === 8 || organization?.membershipLevel === 15, + } +} + +/** + * The web settings section that holds the "AI service providers" switch. The project prefix makes the web open the + * organization of this project, not the one last used in the browser. + */ +export function orgAiSettingsUrl(scope: ProjectScope): string { + return `${scope.host}/project/${scope.projectId}/settings/organization-details#organization-ai-consent` +} + +export const PRIVACY_POLICY_URL = 'https://posthog.com/privacy' diff --git a/products/mobile/src/features/chat/aiConsentStore.test.ts b/products/mobile/src/features/chat/aiConsentStore.test.ts new file mode 100644 index 000000000000..e555ce0055cd --- /dev/null +++ b/products/mobile/src/features/chat/aiConsentStore.test.ts @@ -0,0 +1,33 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +import { useAuthStore } from '@/auth/authStore' + +import { aiConsentKey, useAiConsentStore } from './aiConsentStore' + +describe('AI consent store', () => { + test('the broader AI disclosure requires new consent after an older chat-only consent', async () => { + await AsyncStorage.setItem( + 'posthog-mobile-ai-consent', + JSON.stringify({ version: 0, state: { accepted: { 'https://app.example.com|person': true } } }) + ) + await useAiConsentStore.persist.rehydrate() + expect(useAiConsentStore.getState().accepted).toEqual({}) + }) + + test('consent covers only the person and host that gave it', () => { + useAiConsentStore.getState().accept(aiConsentKey('https://us.posthog.com', 'user-a')) + + const { accepted } = useAiConsentStore.getState() + expect(accepted[aiConsentKey('https://us.posthog.com', 'user-a')]).toBe(true) + expect(accepted[aiConsentKey('https://us.posthog.com', 'user-b')]).toBeUndefined() + expect(accepted[aiConsentKey('https://eu.posthog.com', 'user-a')]).toBeUndefined() + }) + + test('sign out drops every stored consent', async () => { + useAiConsentStore.getState().accept(aiConsentKey('https://us.posthog.com', 'user-a')) + + await useAuthStore.getState().signOut() + + expect(useAiConsentStore.getState().accepted).toEqual({}) + }) +}) diff --git a/products/mobile/src/features/chat/aiConsentStore.ts b/products/mobile/src/features/chat/aiConsentStore.ts new file mode 100644 index 000000000000..46495ba43fa5 --- /dev/null +++ b/products/mobile/src/features/chat/aiConsentStore.ts @@ -0,0 +1,54 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { useSyncExternalStore } from 'react' +import { create } from 'zustand' +import { createJSONStorage, persist } from 'zustand/middleware' + +import { addSessionEndTask } from '@/auth/authStore' +import { createPersistHydrationGate } from '@/lib/persistHydration' + +// Each person on this device agrees once that PostHog AI sends the data used by their AI actions to third-party model providers. +// The key carries the host, because the same person on another instance has a separate account. + +export function aiConsentKey(host: string, userUuid: string): string { + return `${host}|${userUuid}` +} + +interface AiConsentState { + /** Keyed by `aiConsentKey`. */ + accepted: Record + accept: (key: string) => void + clear: () => void +} + +const hydration = createPersistHydrationGate() + +export const useAiConsentStore = create()( + persist( + (set) => ({ + accepted: {}, + accept: (key) => + set((state) => (state.accepted[key] ? state : { accepted: { ...state.accepted, [key]: true } })), + clear: () => set({ accepted: {} }), + }), + { + name: 'posthog-mobile-ai-consent', + version: 1, + migrate: () => ({ accepted: {} }), + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ accepted: state.accepted }), + onRehydrateStorage: () => () => hydration.settle(), + } + ) +) + +/** True once the stored consent has loaded, so a person who already agreed never sees the sheet flash. */ +export function useAiConsentHydrated(): boolean { + return useSyncExternalStore(hydration.subscribe, hydration.isHydrated, hydration.isHydrated) +} + +export function waitForAiConsent(): Promise { + return hydration.wait() +} + +// Consent is per-user state like the rest, so sign out and session expiry drop it, and the next sign-in asks again. +addSessionEndTask(() => useAiConsentStore.getState().clear()) diff --git a/products/mobile/src/features/chat/attachments/AttachMenuSheet.tsx b/products/mobile/src/features/chat/attachments/AttachMenuSheet.tsx new file mode 100644 index 000000000000..6c43aa1c1c7a --- /dev/null +++ b/products/mobile/src/features/chat/attachments/AttachMenuSheet.tsx @@ -0,0 +1,72 @@ +import type { ReactElement } from 'react' +import { Text, View } from 'react-native' + +import { BottomSheet } from '@/ui/BottomSheet' +import { DocumentIcon } from '@/ui/icons/DocumentIcon' +import { ImageIcon } from '@/ui/icons/ImageIcon' +import { Pressable44 } from '@/ui/Pressable44' +import { createStyles, useColors } from '@/ui/theme' +import { iconSizes, radii, type } from '@/ui/tokens' + +import type { AttachmentSource } from './types' + +interface AttachMenuSheetProps { + visible: boolean + onPick: (source: AttachmentSource) => void + onClose: () => void +} + +/** The Attach button's two sources. The chat route is a native modal, so the sheet draws inline over the chat. */ +export function AttachMenuSheet({ visible, onPick, onClose }: AttachMenuSheetProps): ReactElement { + const colors = useColors() + const styles = useStyles() + return ( + + + onPick('photos')} + style={styles.row} + pressedBackground={colors.background} + testID="chat-attach-photos" + > + + Photo library + + onPick('files')} + style={styles.row} + pressedBackground={colors.background} + testID="chat-attach-files" + > + + Choose a file + + + + ) +} + +const useStyles = createStyles(({ colors }) => ({ + rows: { + marginTop: 12, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 14, + minHeight: 56, + paddingHorizontal: 4, + borderRadius: radii.card, + }, + label: { + ...type.label, + color: colors.ink, + flex: 1, + }, +})) diff --git a/products/mobile/src/features/chat/attachments/AttachmentChip.tsx b/products/mobile/src/features/chat/attachments/AttachmentChip.tsx new file mode 100644 index 000000000000..2e1c6a40b4a0 --- /dev/null +++ b/products/mobile/src/features/chat/attachments/AttachmentChip.tsx @@ -0,0 +1,86 @@ +import type { ReactElement } from 'react' +import { ActivityIndicator, Image, Text, View } from 'react-native' + +import { CloseIcon } from '@/ui/icons/CloseIcon' +import { DocumentIcon } from '@/ui/icons/DocumentIcon' +import { Pressable44 } from '@/ui/Pressable44' +import { createStyles, useColors } from '@/ui/theme' +import { iconSizes, radii, type } from '@/ui/tokens' + +import type { PendingAttachment } from './types' + +interface AttachmentChipProps { + attachment: PendingAttachment + uploading: boolean + onRemove: () => void +} + +export function AttachmentChip({ attachment, uploading, onRemove }: AttachmentChipProps): ReactElement { + const colors = useColors() + const styles = useStyles() + return ( + + {attachment.isImage ? ( + + ) : ( + + )} + + {attachment.name} + + {uploading ? ( + + + + ) : ( + + + + )} + + ) +} + +const useStyles = createStyles(({ colors }) => ({ + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + height: 36, + paddingLeft: 6, + paddingRight: 6, + borderRadius: radii.pill, + backgroundColor: colors.sunken, + maxWidth: '100%', + }, + thumbnail: { + width: 24, + height: 24, + borderRadius: radii.markSmall, + backgroundColor: colors.hairline, + }, + name: { + ...type.metaSmall, + fontWeight: '600', + color: colors.ink, + flexShrink: 1, + }, + trailing: { + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + }, +})) diff --git a/products/mobile/src/features/chat/attachments/attachmentRules.test.ts b/products/mobile/src/features/chat/attachments/attachmentRules.test.ts new file mode 100644 index 000000000000..230c3197b389 --- /dev/null +++ b/products/mobile/src/features/chat/attachments/attachmentRules.test.ts @@ -0,0 +1,77 @@ +import { + ATTACHMENT_MAX_SIZE_BYTES, + addPickedFiles, + contentTypeFor, + MAX_ATTACHMENTS_PER_MESSAGE, + PDF_ATTACHMENT_MAX_SIZE_BYTES, + pickNotice, +} from './attachmentRules' +import type { PendingAttachment, PickedFile } from './types' + +function picked(name: string, size: number | null = 100): PickedFile { + return { uri: `file:///picked/${name}`, name, size } +} + +function counter(): () => string { + let next = 0 + return () => { + next += 1 + return `id-${next}` + } +} + +describe('attachment rules', () => { + test.each([ + ['shot.PNG', 'image/png'], + ['notes.md', 'text/markdown'], + ['archive.tar.gz', 'application/octet-stream'], + ['Makefile', 'application/octet-stream'], + ])('%s is sent as %s', (name, contentType) => { + expect(contentTypeFor(name)).toBe(contentType) + }) + + test.each<[string, PickedFile, string | null]>([ + ['a PDF at its limit', picked('a.pdf', PDF_ATTACHMENT_MAX_SIZE_BYTES), null], + ['a PDF one byte over', picked('a.pdf', PDF_ATTACHMENT_MAX_SIZE_BYTES + 1), 'too_large'], + ['an image past the PDF limit', picked('a.png', PDF_ATTACHMENT_MAX_SIZE_BYTES + 1), null], + ['an image one byte over', picked('a.png', ATTACHMENT_MAX_SIZE_BYTES + 1), 'too_large'], + ['an empty file', picked('a.txt', 0), 'empty'], + ['a file with no size', picked('a.txt', null), 'unreadable'], + ])('%s', (_name, file, reason) => { + const pick = addPickedFiles([], [file], counter()) + expect(pick.rejected.map((rejection) => rejection.reason)).toEqual(reason ? [reason] : []) + expect(pick.added).toHaveLength(reason ? 0 : 1) + }) + + test('fills the free slots in pick order and says how many were left off', () => { + const current = Array.from({ length: MAX_ATTACHMENTS_PER_MESSAGE - 2 }, (_, index) => ({ + id: `old-${index}`, + })) as PendingAttachment[] + const pick = addPickedFiles( + current, + [picked('empty.txt', 0), picked('one.png'), picked('two.csv'), picked('three.txt')], + counter() + ) + + expect(pick.added).toEqual([ + { + id: 'id-1', + uri: 'file:///picked/one.png', + name: 'one.png', + size: 100, + contentType: 'image/png', + isImage: true, + }, + { + id: 'id-2', + uri: 'file:///picked/two.csv', + name: 'two.csv', + size: 100, + contentType: 'text/csv', + isImage: false, + }, + ]) + expect(pick.leftOff).toBe(1) + expect(pickNotice(pick)).toBe('empty.txt is empty. Only 10 files fit in one message, so 1 was left off.') + }) +}) diff --git a/products/mobile/src/features/chat/attachments/attachmentRules.ts b/products/mobile/src/features/chat/attachments/attachmentRules.ts new file mode 100644 index 000000000000..7951e77b6696 --- /dev/null +++ b/products/mobile/src/features/chat/attachments/attachmentRules.ts @@ -0,0 +1,141 @@ +import type { AttachmentRejectionReason, PendingAttachment, PickedFile } from './types' + +// The same limits and content types as `products/posthog_ai/frontend/utils/attachments.ts`. + +/** The artifact API's ceiling: `size` on a prepare request is capped at 31457280. */ +export const ATTACHMENT_MAX_SIZE_BYTES = 30 * 1024 * 1024 +/** The model rejects a larger PDF, which would fail the run after a successful upload. */ +export const PDF_ATTACHMENT_MAX_SIZE_BYTES = 10 * 1024 * 1024 +export const MAX_ATTACHMENTS_PER_MESSAGE = 10 + +const CONTENT_TYPE_BY_EXTENSION: Record = { + bmp: 'image/bmp', + c: 'text/plain', + cc: 'text/plain', + conf: 'text/plain', + cpp: 'text/plain', + css: 'text/css', + csv: 'text/csv', + gif: 'image/gif', + go: 'text/plain', + h: 'text/plain', + html: 'text/html', + ini: 'text/plain', + java: 'text/plain', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + js: 'text/javascript', + json: 'application/json', + jsx: 'text/javascript', + log: 'text/plain', + md: 'text/markdown', + pdf: 'application/pdf', + png: 'image/png', + py: 'text/x-python', + rb: 'text/plain', + rs: 'text/plain', + sh: 'text/x-shellscript', + sql: 'application/sql', + svg: 'image/svg+xml', + toml: 'application/toml', + ts: 'text/typescript', + tsx: 'text/typescript', + txt: 'text/plain', + webp: 'image/webp', + xml: 'application/xml', + yaml: 'application/yaml', + yml: 'application/yaml', + zip: 'application/zip', +} + +const DEFAULT_CONTENT_TYPE = 'application/octet-stream' + +export function fileExtension(name: string): string { + const parts = name.split('.') + return parts.length > 1 ? (parts.at(-1)?.toLowerCase() ?? '') : '' +} + +export function contentTypeFor(name: string): string { + return CONTENT_TYPE_BY_EXTENSION[fileExtension(name)] ?? DEFAULT_CONTENT_TYPE +} + +export function isImageName(name: string): boolean { + return contentTypeFor(name).startsWith('image/') +} + +function maxSizeFor(name: string): number { + return fileExtension(name) === 'pdf' ? PDF_ATTACHMENT_MAX_SIZE_BYTES : ATTACHMENT_MAX_SIZE_BYTES +} + +export function rejectionReason(file: PickedFile): AttachmentRejectionReason | null { + if (file.size === null) { + return 'unreadable' + } + if (file.size === 0) { + return 'empty' + } + return file.size > maxSizeFor(file.name) ? 'too_large' : null +} + +export interface AttachmentRejection { + name: string + reason: AttachmentRejectionReason +} + +export interface AttachmentPick { + added: PendingAttachment[] + rejected: AttachmentRejection[] + /** Files that passed the limits but did not fit under the per-message cap. */ + leftOff: number +} + +/** Applies the limits to a pick, then fills the free slots in pick order, as the web composer does. */ +export function addPickedFiles( + current: readonly PendingAttachment[], + picked: readonly PickedFile[], + makeId: () => string +): AttachmentPick { + const rejected: AttachmentRejection[] = [] + const acceptable: PendingAttachment[] = [] + for (const file of picked) { + const reason = rejectionReason(file) + if (reason) { + rejected.push({ name: file.name, reason }) + continue + } + const contentType = contentTypeFor(file.name) + acceptable.push({ + id: makeId(), + uri: file.uri, + name: file.name, + size: file.size ?? 0, + contentType, + isImage: contentType.startsWith('image/'), + ...(file.webFile ? { webFile: file.webFile } : {}), + }) + } + const slots = Math.max(0, MAX_ATTACHMENTS_PER_MESSAGE - current.length) + return { added: acceptable.slice(0, slots), rejected, leftOff: Math.max(0, acceptable.length - slots) } +} + +function rejectionText({ name, reason }: AttachmentRejection): string { + switch (reason) { + case 'empty': + return `${name} is empty.` + case 'unreadable': + return `Couldn't read ${name}. Try another file.` + case 'too_large': + return fileExtension(name) === 'pdf' + ? `${name} is over the 10 MB limit for PDFs.` + : `${name} is over the 30 MB limit.` + } +} + +export function pickNotice(pick: AttachmentPick): string | undefined { + const lines = pick.rejected.map(rejectionText) + if (pick.leftOff > 0) { + const verb = pick.leftOff === 1 ? 'was' : 'were' + lines.push(`Only ${MAX_ATTACHMENTS_PER_MESSAGE} files fit in one message, so ${pick.leftOff} ${verb} left off.`) + } + return lines.length > 0 ? lines.join(' ') : undefined +} diff --git a/products/mobile/src/features/chat/attachments/pickAttachments.ts b/products/mobile/src/features/chat/attachments/pickAttachments.ts new file mode 100644 index 000000000000..f2f0ad48e41a --- /dev/null +++ b/products/mobile/src/features/chat/attachments/pickAttachments.ts @@ -0,0 +1,74 @@ +import { getDocumentAsync } from 'expo-document-picker' +import { File } from 'expo-file-system' +import { launchImageLibraryAsync, UIImagePickerPreferredAssetRepresentationMode } from 'expo-image-picker' + +import type { PickedFile } from './types' + +function lastPathSegment(uri: string): string { + const segment = uri.split(/[?#]/)[0]?.split('/').filter(Boolean).at(-1) ?? '' + try { + return decodeURIComponent(segment) + } catch { + return segment + } +} + +/** Some platforms leave out the size, so the file on disk answers. Null when it cannot be read. */ +function sizeOf(uri: string, reported: number | undefined): number | null { + if (typeof reported === 'number') { + return reported + } + try { + return new File(uri).size + } catch { + return null + } +} + +/** + * iOS names a converted photo after its original, such as `IMG_0001.HEIC` for a JPEG, and the content type comes + * from the name. The extension of the file the picker wrote wins. A web pick has a `data:` or `blob:` URI with no + * file name in it, so only its own name counts. + */ +function photoName(fileName: string | null | undefined, uri: string): string { + const written = uri.startsWith('file:') ? lastPathSegment(uri) : '' + const writtenExtension = written.includes('.') ? written.split('.').at(-1) : undefined + const base = fileName?.trim() || written || 'photo' + if (!writtenExtension) { + return base + } + const stem = base.includes('.') ? base.slice(0, base.lastIndexOf('.')) : base + return `${stem}.${writtenExtension.toLowerCase()}` +} + +export async function pickPhotos(selectionLimit: number): Promise { + const result = await launchImageLibraryAsync({ + mediaTypes: ['images'], + allowsMultipleSelection: true, + selectionLimit, + // A HEIC photo arrives as a JPEG, which the agent and the web thread can read. + preferredAssetRepresentationMode: UIImagePickerPreferredAssetRepresentationMode.Compatible, + }) + if (result.canceled) { + return [] + } + return result.assets.map((asset) => ({ + uri: asset.uri, + name: photoName(asset.fileName, asset.uri), + size: sizeOf(asset.uri, asset.file?.size ?? asset.fileSize), + ...(asset.file ? { webFile: asset.file } : {}), + })) +} + +export async function pickFiles(): Promise { + const result = await getDocumentAsync({ type: '*/*', multiple: true, copyToCacheDirectory: true }) + if (result.canceled) { + return [] + } + return result.assets.map((asset) => ({ + uri: asset.uri, + name: asset.name || lastPathSegment(asset.uri) || 'file', + size: sizeOf(asset.uri, asset.file?.size ?? asset.size), + ...(asset.file ? { webFile: asset.file } : {}), + })) +} diff --git a/products/mobile/src/features/chat/attachments/types.ts b/products/mobile/src/features/chat/attachments/types.ts new file mode 100644 index 000000000000..fc8e62e646b6 --- /dev/null +++ b/products/mobile/src/features/chat/attachments/types.ts @@ -0,0 +1,25 @@ +/** A file in the composer, not yet uploaded. It lives in memory only and never goes to the draft store. */ +export interface PendingAttachment { + /** Local key for the chip row. Two files can share a name. */ + id: string + uri: string + name: string + size: number + /** Derived from the extension, as the web does, because the sandbox reads the file by this type. */ + contentType: string + isImage: boolean + /** Web only: the picked file itself, so the upload reads it without fetching its `blob:` URL. */ + webFile?: Blob +} + +export interface PickedFile { + uri: string + name: string + /** Null when the platform did not report a size. */ + size: number | null + webFile?: Blob +} + +export type AttachmentSource = 'photos' | 'files' + +export type AttachmentRejectionReason = 'empty' | 'too_large' | 'unreadable' diff --git a/products/mobile/src/features/chat/attachments/uploadAttachments.test.ts b/products/mobile/src/features/chat/attachments/uploadAttachments.test.ts new file mode 100644 index 000000000000..a158fde7fbc7 --- /dev/null +++ b/products/mobile/src/features/chat/attachments/uploadAttachments.test.ts @@ -0,0 +1,148 @@ +import { setAuthProvider } from '@/api/client' + +import type { PendingAttachment } from './types' +import { AttachmentUploadError, uploadAttachments } from './uploadAttachments' + +const S3_URL = 'https://uploads.example.com/bucket' + +jest.mock('expo-file-system', () => ({ + File: class { + readonly uri: string + + constructor(uri: string) { + this.uri = uri + } + + async bytes(): Promise { + return new TextEncoder().encode(`contents of ${this.uri}`) + } + }, +})) + +/** Node's `FormData` takes only a real `Blob` as a file part, so the test records the parts instead. */ +class RecordingFormData { + readonly parts: [string, unknown][] = [] + + append(key: string, value: unknown): void { + this.parts.push([key, value]) + } +} + +function attachment(name: string, contentType: string): PendingAttachment { + return { id: name, uri: `file:///picked/${name}`, name, size: 100, contentType, isImage: false } +} + +interface Call { + url: string + init: { method?: string; body?: unknown; headers?: Record; signal?: AbortSignal } +} + +function s3Response(status: number): unknown { + return { ok: status < 300, status, headers: { get: () => null }, text: async () => '' } +} + +describe('uploadAttachments', () => { + const originalFormData = globalThis.FormData + let calls: Call[] + let s3Reply: (index: number, signal: AbortSignal | undefined) => Promise + + beforeEach(() => { + calls = [] + s3Reply = async () => s3Response(204) + globalThis.FormData = RecordingFormData as unknown as typeof FormData + setAuthProvider({ + getAuth: () => ({ host: 'https://app.example.com', accessToken: 'token', projectId: 2, session: 1 }), + refreshAccessToken: async () => null, + }) + let s3Count = 0 + globalThis.fetch = jest.fn(async (url: string, init: Call['init'] = {}) => { + calls.push({ url, init }) + if (url === S3_URL) { + return s3Reply(s3Count++, init.signal) + } + const body = url.includes('prepare_upload') + ? { + artifacts: ['art-1', 'art-2'].map((id, index) => ({ + id, + name: `server-${index}`, + storage_path: `staged/${id}`, + presigned_post: { url: S3_URL, fields: { key: `staged/${id}`, policy: `policy-${id}` } }, + })), + } + : { artifacts: [] } + return { ok: true, status: 200, headers: { get: () => null }, text: async () => JSON.stringify(body) } + }) as unknown as typeof fetch + }) + + afterEach(() => { + globalThis.FormData = originalFormData + setAuthProvider(null) + }) + + test('prepares, posts each file to S3 without the PostHog token, then finalizes, and returns ids in order', async () => { + const ids = await uploadAttachments({ kind: 'staged', taskId: 'task-1' }, [ + attachment('notes.md', 'text/markdown'), + attachment('shot.png', 'image/png'), + ]) + + expect(ids).toEqual(['art-1', 'art-2']) + expect(calls.map((call) => call.url)).toEqual([ + 'https://app.example.com/api/projects/2/tasks/task-1/staged_artifacts/prepare_upload/', + S3_URL, + S3_URL, + 'https://app.example.com/api/projects/2/tasks/task-1/staged_artifacts/finalize_upload/', + ]) + const [first, second] = calls.slice(1, 3) + expect(first?.init.headers).toBeUndefined() + const parts = (first?.init.body as RecordingFormData).parts + expect(parts).toEqual([ + ['key', 'staged/art-1'], + ['policy', 'policy-art-1'], + ['file', expect.objectContaining({ name: 'notes.md', type: 'text/markdown', bytes: expect.any(Function) })], + ]) + const filePart = parts[2]?.[1] as { bytes: () => Promise } + expect(new TextDecoder().decode(await filePart.bytes())).toBe('contents of file:///picked/notes.md') + expect((second?.init.body as RecordingFormData).parts.at(-1)?.[0]).toBe('file') + expect(JSON.parse(calls[3]?.init.body as string)).toEqual({ + artifacts: [ + { + id: 'art-1', + name: 'server-0', + type: 'user_attachment', + source: 'posthog_ai', + storage_path: 'staged/art-1', + content_type: 'text/markdown', + }, + { + id: 'art-2', + name: 'server-1', + type: 'user_attachment', + source: 'posthog_ai', + storage_path: 'staged/art-2', + content_type: 'image/png', + }, + ], + }) + }) + + test('one failed S3 post stops the other posts, rejects the batch and finalizes nothing', async () => { + s3Reply = (index, signal) => + index === 1 + ? Promise.resolve(s3Response(403)) + : new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) + }) + + await expect( + uploadAttachments({ kind: 'run', taskId: 'task-1', runId: 'run-1' }, [ + attachment('a.txt', 'text/plain'), + attachment('b.txt', 'text/plain'), + ]) + ).rejects.toEqual(new AttachmentUploadError("Couldn't upload b.txt", 403)) + expect(calls[0]?.url).toBe( + 'https://app.example.com/api/projects/2/tasks/task-1/runs/run-1/artifacts/prepare_upload/' + ) + expect(calls[1]?.init.signal?.aborted).toBe(true) + expect(calls.some((call) => call.url.includes('finalize_upload'))).toBe(false) + }) +}) diff --git a/products/mobile/src/features/chat/attachments/uploadAttachments.ts b/products/mobile/src/features/chat/attachments/uploadAttachments.ts new file mode 100644 index 000000000000..1545b9a82e2f --- /dev/null +++ b/products/mobile/src/features/chat/attachments/uploadAttachments.ts @@ -0,0 +1,112 @@ +import { File } from 'expo-file-system' +import { Platform } from 'react-native' + +import { + type ArtifactUploadTarget, + finalizeArtifactUpload, + type PreparedArtifactApi, + prepareArtifactUpload, +} from '@/api/tasks' + +import type { PendingAttachment } from './types' + +// The three-step upload of `products/posthog_ai/frontend/utils/artifactUpload.ts`: prepare, a presigned S3 POST per +// file, then finalize. + +const ATTACHMENT_TYPE = 'user_attachment' +const ATTACHMENT_SOURCE = 'posthog_ai' + +/** The presigned S3 POST, or the prepare answer, did not take a file. `status` is the S3 status, when there was one. */ +export class AttachmentUploadError extends Error { + readonly status: number | undefined + + constructor(message: string, status?: number) { + super(message) + this.name = 'AttachmentUploadError' + this.status = status + } +} + +/** The global `fetch` is `expo/fetch`, which encodes only a `Blob`-like part and reads its file name and type from the + * part, not from `append`, so the part carries the attachment's name and type instead of the on-disk ones. */ +async function filePart(file: PendingAttachment): Promise { + if (Platform.OS === 'web') { + return file.webFile ?? (await fetch(file.uri)).blob() + } + const onDisk = new File(file.uri) + return { name: file.name, type: file.contentType, bytes: () => onDisk.bytes() } as unknown as Blob +} + +async function postToS3(prepared: PreparedArtifactApi, file: PendingAttachment, signal?: AbortSignal): Promise { + const form = new FormData() + // S3 needs the policy fields verbatim and before the file part. + for (const [key, value] of Object.entries(prepared.presigned_post.fields)) { + form.append(key, value) + } + form.append('file', await filePart(file), file.name) + // A plain fetch, so the PostHog bearer token never goes to S3. + const response = await fetch(prepared.presigned_post.url, { method: 'POST', body: form, signal }) + if (!response.ok) { + throw new AttachmentUploadError(`Couldn't upload ${file.name}`, response.status) + } +} + +/** + * Uploads every file and returns the artifact ids in the order of `files`. One failure rejects the whole batch, + * because a message that arrives with fewer files than the person attached is worse than one that does not send. + */ +export async function uploadAttachments( + target: ArtifactUploadTarget, + files: readonly PendingAttachment[], + signal?: AbortSignal +): Promise { + if (files.length === 0) { + return [] + } + const prepared = await prepareArtifactUpload( + target, + files.map((file) => ({ + name: file.name, + type: ATTACHMENT_TYPE, + source: ATTACHMENT_SOURCE, + size: file.size, + content_type: file.contentType, + })), + signal + ) + if (prepared.length !== files.length) { + throw new AttachmentUploadError("Couldn't prepare every attachment for upload") + } + // The first failed post fails the batch, so the other posts stop instead of uploading files nobody will finalize. + const posts = new AbortController() + const abortPosts = (): void => posts.abort() + signal?.addEventListener('abort', abortPosts, { once: true }) + if (signal?.aborted) { + posts.abort() + } + try { + await Promise.all( + prepared.map((artifact, index) => + postToS3(artifact, files[index] as PendingAttachment, posts.signal).catch((error: unknown) => { + posts.abort() + throw error + }) + ) + ) + } finally { + signal?.removeEventListener('abort', abortPosts) + } + await finalizeArtifactUpload( + target, + prepared.map((artifact, index) => ({ + id: artifact.id, + name: artifact.name, + type: ATTACHMENT_TYPE, + source: ATTACHMENT_SOURCE, + storage_path: artifact.storage_path, + content_type: (files[index] as PendingAttachment).contentType, + })), + signal + ) + return prepared.map((artifact) => artifact.id) +} diff --git a/products/mobile/src/features/chat/attachments/useComposerAttachments.ts b/products/mobile/src/features/chat/attachments/useComposerAttachments.ts new file mode 100644 index 000000000000..97afef607866 --- /dev/null +++ b/products/mobile/src/features/chat/attachments/useComposerAttachments.ts @@ -0,0 +1,120 @@ +import { randomUUID } from 'expo-crypto' +import { useCallback, useRef, useState } from 'react' +import { Platform } from 'react-native' + +import { track } from '@/lib/analytics' + +import { addPickedFiles, MAX_ATTACHMENTS_PER_MESSAGE, pickNotice } from './attachmentRules' +import { pickFiles, pickPhotos } from './pickAttachments' +import type { AttachmentSource, PendingAttachment, PickedFile } from './types' + +const PICKER_FAILED: Record = { + photos: "Couldn't open your photos. Try again.", + files: "Couldn't open your files. Try again.", +} + +/** A web pick holds its preview as a `blob:` URL, which stays in memory until it is revoked. */ +export function releaseAttachments(files: readonly PendingAttachment[]): void { + if (Platform.OS !== 'web' || typeof URL.revokeObjectURL !== 'function') { + return + } + for (const file of files) { + if (file.uri.startsWith('blob:')) { + URL.revokeObjectURL(file.uri) + } + } +} + +export interface ComposerAttachments { + files: readonly PendingAttachment[] + notice: string | undefined + isFull: boolean + pick: (source: AttachmentSource) => Promise + remove: (id: string) => void + /** + * Drops the files, or only those in `only`. `release: false` keeps web previews alive for a sent file, whose + * bubble still shows them. + */ + clear: (options?: { only?: readonly string[]; release?: boolean }) => void + dismissNotice: () => void +} + +/** The composer's picked files. They stay in memory, so a closed chat or an app restart drops them. */ +export function useComposerAttachments(): ComposerAttachments { + const [files, setFilesState] = useState([]) + const latest = useRef(files) + const [notice, setNotice] = useState(undefined) + + const setFiles = useCallback((next: readonly PendingAttachment[]): void => { + latest.current = next + setFilesState(next) + }, []) + + const pick = useCallback( + async (source: AttachmentSource): Promise => { + let picked: PickedFile[] + try { + const slots = Math.max(1, MAX_ATTACHMENTS_PER_MESSAGE - latest.current.length) + picked = source === 'photos' ? await pickPhotos(slots) : await pickFiles() + } catch { + setNotice(PICKER_FAILED[source]) + return + } + if (picked.length === 0) { + return + } + const result = addPickedFiles(latest.current, picked, randomUUID) + for (const rejection of result.rejected) { + track('mobile_chat_file_rejected', { reason: rejection.reason }) + } + if (result.leftOff > 0) { + track('mobile_chat_file_rejected', { reason: 'too_many' }) + } + if (result.added.length > 0) { + const next = [...latest.current, ...result.added] + setFiles(next) + track('mobile_chat_file_attached', { + source, + attached_count: next.length, + image_count: result.added.filter((file) => file.isImage).length, + }) + } + setNotice(pickNotice(result)) + }, + [setFiles] + ) + + const remove = useCallback( + (id: string): void => { + releaseAttachments(latest.current.filter((file) => file.id === id)) + const next = latest.current.filter((file) => file.id !== id) + setFiles(next) + track('mobile_chat_file_removed', { attached_count: next.length }) + }, + [setFiles] + ) + + const clear = useCallback( + ({ only, release = true }: { only?: readonly string[]; release?: boolean } = {}): void => { + const dropped = only ? latest.current.filter((file) => only.includes(file.id)) : latest.current + if (release) { + releaseAttachments(dropped) + } + setFiles(latest.current.filter((file) => !dropped.includes(file))) + setNotice(undefined) + }, + [setFiles] + ) + + const dismissNotice = useCallback((): void => setNotice(undefined), []) + + return { + files, + notice, + isFull: files.length >= MAX_ATTACHMENTS_PER_MESSAGE, + pick, + remove, + clear, + dismissNotice, + } +} diff --git a/products/mobile/src/features/chat/chatErrors.test.ts b/products/mobile/src/features/chat/chatErrors.test.ts new file mode 100644 index 000000000000..5a97a731f327 --- /dev/null +++ b/products/mobile/src/features/chat/chatErrors.test.ts @@ -0,0 +1,94 @@ +import { ApiError, NotSignedInError } from '@/api/client' + +import { toChatError } from './chatErrors' +import type { ChatError } from './types' + +function headers(values: Record): Headers { + return { get: (name: string) => values[name] ?? null } as unknown as Headers +} + +describe('chat error mapping', () => { + test.each<[string, unknown, Partial]>([ + [ + '402 out of credits', + new ApiError(402, { code: 'quota_limit_exceeded', detail: 'Your organization reached its limit.' }), + { + kind: 'credits', + message: 'Your organization is out of AI credits. An admin can raise the limit in Billing settings.', + }, + ], + [ + '429 with server copy', + new ApiError( + 429, + { code: 'throttled', detail: 'You hit the hourly limit.' }, + headers({ 'Retry-After': '30' }) + ), + { kind: 'throttled', message: 'You hit the hourly limit.', retryAfterSeconds: 30 }, + ], + [ + '429 without server copy', + new ApiError(429, undefined, headers({ 'Retry-After': '30' })), + { kind: 'throttled', message: "You've sent a lot of messages. Try again in 30 seconds." }, + ], + ['409 busy', new ApiError(409, { detail: 'Cannot resume streaming with a new message' }), { kind: 'busy' }], + [ + '503 at capacity', + new ApiError(503, undefined, headers({ 'Retry-After': '120' })), + { kind: 'unavailable', message: 'PostHog AI is busy right now. Try again in 2 minutes.' }, + ], + [ + '400 from run/ shows the server copy', + new ApiError(400, { error: 'Invalid resume_from_run_id' }), + { kind: 'invalid', message: 'Invalid resume_from_run_id' }, + ], + [ + '400 without server copy', + new ApiError(400, undefined), + { kind: 'invalid', message: "PostHog AI couldn't take this message. Start a new chat and try again." }, + ], + [ + '403 from the PostHog Desktop gate shows the server copy', + new ApiError(403, { + type: 'permission_denied', + code: 'code_access_required', + reason: 'startup_plan', + error: "PostHog Desktop isn't available for Startup or YC program organizations.", + }), + { kind: 'access', message: "PostHog Desktop isn't available for Startup or YC program organizations." }, + ], + [ + '503 when the gate cannot decide', + new ApiError(503, { code: 'desktop_access_unavailable', error: 'x' }), + { kind: 'unavailable', message: "We couldn't verify PostHog Desktop access. Try again." }, + ], + [ + '429 from a billing limit', + new ApiError(429, { type: 'billing_limit', code: 'compute_quota' }), + { kind: 'throttled', message: "You've reached your PostHog Desktop usage limit." }, + ], + [ + "429 from the report's task limit shows the server copy", + new ApiError(429, { + type: 'rate_limit', + code: 'signal_report_task_cap', + error: 'This report hit its limit.', + }), + { kind: 'throttled', message: 'This report hit its limit.' }, + ], + [ + "429 from the report's task limit without server copy", + new ApiError(429, { type: 'rate_limit', code: 'signal_report_task_cap' }), + { kind: 'throttled', message: 'This report has reached its chat limit. Try again later.' }, + ], + [ + '502 when the message was not queued', + new ApiError(502, { error: 'Failed to queue user message for task run' }), + { kind: 'unavailable', message: "Your message didn't reach the agent. Try again in a moment." }, + ], + ['a dropped connection', new TypeError('Network request failed'), { kind: 'network' }], + ['a signed-out session', new NotSignedInError(), { kind: 'auth' }], + ])('%s', (_label, error, expected) => { + expect(toChatError(error)).toMatchObject(expected) + }) +}) diff --git a/products/mobile/src/features/chat/chatErrors.ts b/products/mobile/src/features/chat/chatErrors.ts new file mode 100644 index 000000000000..77afef81efe9 --- /dev/null +++ b/products/mobile/src/features/chat/chatErrors.ts @@ -0,0 +1,121 @@ +import { ApiError, NotSignedInError } from '@/api/client' +import { isRecord } from '@/lib/records' + +import type { ChatError } from './types' + +const DESKTOP_ACCESS_REQUIRED = 'PostHog Desktop access is required to run tasks in the cloud.' +const DESKTOP_ACCESS_UNVERIFIED = "We couldn't verify PostHog Desktop access. Try again." +const DESKTOP_USAGE_LIMIT = "You've reached your PostHog Desktop usage limit." +const NOT_QUEUED = "Your message didn't reach the agent. Try again in a moment." +const REPORT_TASK_CAP = 'This report has reached its chat limit. Try again later.' + +function retryHint(seconds: number | undefined): string { + if (!seconds) { + return 'Try again in a moment.' + } + if (seconds < 90) { + return `Try again in ${seconds} seconds.` + } + return `Try again in ${Math.ceil(seconds / 60)} minutes.` +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError' +} + +export function isNetworkError(error: unknown): boolean { + if (error instanceof ApiError || error instanceof NotSignedInError || isAbortError(error)) { + return false + } + // fetch reports a lost connection as a TypeError on every platform the app runs on. + return error instanceof TypeError || (error instanceof Error && /network|connection|socket/i.test(error.message)) +} + +/** The tasks API's PostHog Desktop gate and limits. Their copy is under `error`, which `ApiError.detail` carries. */ +function taskRunError(error: ApiError): ChatError | undefined { + const type = isRecord(error.body) ? error.body.type : undefined + if (error.status === 403 && error.code === 'code_access_required') { + // The server copy names the reason, such as a Startup plan or prepaid credits. + return { kind: 'access', message: error.detail ?? DESKTOP_ACCESS_REQUIRED } + } + if (error.status === 503 && error.code === 'desktop_access_unavailable') { + return { kind: 'unavailable', message: DESKTOP_ACCESS_UNVERIFIED } + } + if (error.status === 429 && error.code === 'signal_report_task_cap') { + // A one-report chat counts toward the report's discussion limit, on create and on `run/`. + return { kind: 'throttled', message: error.detail ?? REPORT_TASK_CAP } + } + if (error.status === 429 && (error.code === 'usage_limit_exceeded' || type === 'billing_limit')) { + return { + kind: 'throttled', + message: error.detail ?? DESKTOP_USAGE_LIMIT, + retryAfterSeconds: error.retryAfterSeconds, + } + } + if (error.status === 502 && error.detail?.startsWith('Failed to queue')) { + return { kind: 'unavailable', message: NOT_QUEUED } + } + return undefined +} + +export function toChatError(error: unknown): ChatError { + if (error instanceof NotSignedInError) { + return { kind: 'auth', message: 'Sign in again to keep chatting.' } + } + if (error instanceof ApiError) { + const taskError = taskRunError(error) + if (taskError) { + return taskError + } + switch (error.status) { + case 401: + return { kind: 'auth', message: 'Sign in again to keep chatting.' } + case 402: + return { + kind: 'credits', + message: + 'Your organization is out of AI credits. An admin can raise the limit in Billing settings.', + } + case 403: + return { + kind: 'auth', + message: "Your sign-in can't use PostHog AI in this project. Sign out and sign in again.", + } + case 404: + return { kind: 'not_found', message: 'This chat no longer exists. Start a new chat.' } + case 409: + return { + kind: 'busy', + message: 'PostHog AI is still answering your last message. Try again when it finishes.', + } + case 429: + return { + kind: 'throttled', + // The throttle's `detail` is written for people, so it wins over generic copy. + message: error.detail ?? `You've sent a lot of messages. ${retryHint(error.retryAfterSeconds)}`, + retryAfterSeconds: error.retryAfterSeconds, + } + case 503: + return { + kind: 'unavailable', + message: `PostHog AI is busy right now. ${retryHint(error.retryAfterSeconds)}`, + retryAfterSeconds: error.retryAfterSeconds, + } + case 400: + return { + kind: 'invalid', + // Such as "Invalid resume_from_run_id" or a model the account cannot use. + message: error.detail ?? "PostHog AI couldn't take this message. Start a new chat and try again.", + } + default: + if (error.status >= 500) { + return { kind: 'unavailable', message: 'PostHog AI ran into a problem. Try again.' } + } + return { kind: 'unknown', message: 'Something went wrong. Try again.' } + } + } + if (isNetworkError(error)) { + return { kind: 'network', message: "Couldn't reach PostHog. Check your connection and try again." } + } + return { kind: 'unknown', message: 'Something went wrong. Try again.' } +} diff --git a/products/mobile/src/features/chat/chatNavigation.test.tsx b/products/mobile/src/features/chat/chatNavigation.test.tsx new file mode 100644 index 000000000000..8927d47333fd --- /dev/null +++ b/products/mobile/src/features/chat/chatNavigation.test.tsx @@ -0,0 +1,158 @@ +import * as Notifications from 'expo-notifications' +import { router } from 'expo-router' +import { act, fireEvent, renderRouter, screen } from 'expo-router/testing-library' +import type { ReactElement } from 'react' +import { AppState, View } from 'react-native' + +import { useAuthStore } from '@/auth/authStore' +import { setViewedNotificationTask } from '@/features/notifications/foregroundNotifications' +import { findTask } from '@/features/notifications/taskLookup' + +import RootLayout from '../../../app/_layout' +import ChatRoute from '../../../app/chat' + +jest.mock('react-native-worklets', () => jest.requireActual('react-native-worklets/src/mock')) +jest.mock('react-native-keyboard-controller', () => ({ KeyboardProvider: require('react-native').View })) +jest.mock('@/auth/BootSplash', () => ({ BootSplash: require('react-native').View })) +jest.mock('expo-splash-screen', () => ({ + preventAutoHideAsync: jest.fn(async () => undefined), + setOptions: jest.fn(), + hideAsync: jest.fn(async () => undefined), +})) +jest.mock('@/demo/demoMode', () => ({ + useDemoHydrated: () => true, + useDataMode: () => 'live', + useDemoMode: Object.assign((selector: (state: { enabled: boolean }) => unknown) => selector({ enabled: false }), { + getState: () => ({ enabled: false }), + }), +})) +jest.mock('@/features/chat/draftPersistence', () => ({})) +jest.mock('@/features/hedgehog/useHedgehogWidgetSync', () => ({ useHedgehogWidgetSync: jest.fn() })) +jest.mock('@/features/notifications/pushRegistration', () => ({ configureNotifications: jest.fn() })) +jest.mock('@/features/notifications/usePushRegistration', () => ({ usePushRegistration: jest.fn() })) +jest.mock('expo-notifications', () => ({ + getLastNotificationResponse: jest.fn(() => null), + clearLastNotificationResponse: jest.fn(), + addNotificationResponseReceivedListener: jest.fn(() => ({ remove: jest.fn() })), +})) +jest.mock('@/features/notifications/pushSupport', () => ({ notificationsSupported: () => true })) +jest.mock('@/features/notifications/taskLookup', () => ({ findTask: jest.fn() })) +jest.mock('@/lib/posthogClient', () => ({ initAnalytics: jest.fn() })) +jest.mock('@/features/chat/ChatSheet', () => { + const { useEffect, useState } = jest.requireActual('react') + const { Button, Text, TextInput, View } = jest.requireActual('react-native') + const { setViewedNotificationTask } = jest.requireActual< + typeof import('@/features/notifications/foregroundNotifications') + >('@/features/notifications/foregroundNotifications') + return { + ChatSheet: ({ initialTaskId, onClosed }: { initialTaskId?: string; onClosed: () => void }): ReactElement => { + const [taskId, setTaskId] = useState(initialTaskId) + const [draft, setDraft] = useState('') + useEffect(() => { + setViewedNotificationTask( + taskId ? { accountKey: 'https://app.example.com|chat-navigation-user', projectId: 2, taskId } : null + ) + return () => setViewedNotificationTask(null) + }, [taskId]) + return ( + + {taskId ?? 'new'} + +