diff --git a/.agents/skills/mobile-android-verification/SKILL.md b/.agents/skills/mobile-android-verification/SKILL.md new file mode 100644 index 00000000000..470fd9683f2 --- /dev/null +++ b/.agents/skills/mobile-android-verification/SKILL.md @@ -0,0 +1,100 @@ +--- +name: mobile-android-verification +description: Run and verify apps/mobile on a local Android emulator against this worktree's API and relay, with adb and Maestro for input. Use when asked to run, screenshot, verify, or reproduce a mobile change on Android. For iOS use mobile-sim-verification; with no local toolchain (a cloud sandbox) use mobile-eas-dev, which is iOS only. +--- + +# Mobile Android Verification + +The Android counterpart to `mobile-sim-verification`. Its Rules apply here unchanged: use the real +screen, drive state through real services, run the edge pass, never show "unknown" as a negative, +record flashes, attribute failures correctly. This file is the Android mechanics. + +`apps/mobile/scripts/android-dev.sh` does the setup. Read its header for the flags. + +## Setup + +```bash +apps/mobile/scripts/android-dev.sh --doctor # what is missing on this machine +apps/mobile/scripts/android-dev.sh # boot, build if needed, Metro, open the app +apps/mobile/scripts/android-dev.sh --user maya@local.test # also sign in as a dev account +``` + +- **Toolchain.** Android Studio's SDK (`~/Library/Android/sdk`) with an AVD, and JDK 17. Gradle for + RN 0.88 fails on the Java 8 that macOS often has on `PATH`; `brew install openjdk@17`. The script + finds both without `ANDROID_HOME` or `JAVA_HOME` set. For Google sign-in, the AVD needs a Google + Play system image and a Google account added on the device. +- **Services.** Start the API, relay and realtime as in `mobile-sim-verification` (each in a + Paradev terminal). The script reverses Metro, `API_PORT`, `RELAY_PORT`, `REALTIME_PORT` and + `WEB_PORT` with `adb reverse`, so `localhost` in the app reaches this machine. A port it does not + reverse fails in the app as a network error. +- **Build.** `android/` is generated by `expo prebuild` and gitignored. The first Gradle build takes + about 10 minutes; later ones reuse the daemon. The script builds only for the emulator's ABI. + JS/TS changes need no rebuild: `android-dev.sh --reload`. + +## One emulator, many worktrees + +Every worktree installs the same package, `sh.superset.mobile`, so the last install wins. + +- The script writes the worktree path and native fingerprint of the installed APK to + `/data/local/tmp/superset-mobile-owner` on the device. A mismatch rebuilds, so another worktree's + APK is never tested by mistake. Check it before you report: `adb shell cat + /data/local/tmp/superset-mobile-owner`. +- Agents in parallel workspaces take turns. Ask with `superset agents` before you install or + launch, and say when you hand it back. Do not boot a second emulator next to a Gradle build: the + machine runs out of memory. +- Each worktree's Metro gets its own port (saved in `.tmp/android-dev/metro.port`). Stop it with + `--stop` when you are done. + +## Traps + +- **Stale Metro cache.** The transform cache is shared across worktrees. A stale entry fails the + Android bundle with a 500: ENOENT on `react-native-worklets/.worklets/.js`. The script + starts Metro with `--clear`; if you start Metro yourself, do the same. +- **Dev auto sign-in.** `EXPO_PUBLIC_DEV_ONE_TIME_TOKEN` in `.env` signs the app in at launch even + after `pm clear`. Blank it and restart Metro to see the sign-in screen. +- **Notification permission.** Home asks for `POST_NOTIFICATIONS` once, when the first agent card + would show, and remembers the answer. The script grants it up front; to see the prompt, run + `adb shell pm revoke sh.superset.mobile android.permission.POST_NOTIFICATIONS` and `pm clear`. +- **iOS-only header items.** SF Symbol `Stack.Toolbar` buttons and `Stack.SearchBar` do not render + on Android, and a form sheet draws no native bar there (see the sheet convention in + `apps/mobile/AGENTS.md`). An empty header on Android is not always a regression. +- **The iOS fingerprint.** `app.config.ts`, `eas.json`, `apps/mobile/.gitignore` and + `packages/shared/src/app-identity.ts` feed the iOS runtime version even for Android-only edits. + Changing them rebuilds and resubmits iOS on merge. Check with + `MOBILE_EAS_BUILD=1 node_modules/.bin/fingerprint fingerprint:generate . --platform ios` in + `apps/mobile`, and keep such a change in its own commit. +- **The Mobile pane** in the desktop app shows the same emulator (local hub, scrcpy stream). It + releases an emulator it booted 60 s after its pane closes, which ends an `adb` session on it. If + you booted the emulator yourself, the pane only shows it. + +- **Low memory kills the dev app.** The dev build holds about 800 MB (Hermes runs the full dev + bundle). An AVD that booted from a 2 GB snapshot loses the app to the low-memory killer as soon as + a picker or Chrome opens (`lowmemorykiller: Kill 'sh.superset.mobile'` in logcat). Check + `adb shell cat /proc/meminfo`; if `MemTotal` is near 2 GB, restart that AVD with + `emulator -avd -port -memory 4096 -no-snapshot-load`. +- **The emulator mic.** `adb emu avd hostmicon` feeds the Mac microphone to the AVD, so the + speech recogniser hears the room. Speech played through the Mac speakers often reaches it as + `NO_SPEECH_DETECTED`, and Google's recogniser needs about 5 s after the mic tap before it listens. + +## Input and evidence + +```bash +adb exec-out screencap -p > shot.png # or: android-dev.sh --shot shot.png +adb shell screenrecord /sdcard/run.mp4 # stop with Ctrl-C, then adb pull +adb shell uiautomator dump /sdcard/ui.xml && adb pull /sdcard/ui.xml # element bounds for taps +adb shell input tap X Y; adb shell input text 'hello'; adb shell input keyevent 4 # 4 = back +adb shell am start -a android.intent.action.VIEW -d 'superset://settings' sh.superset.mobile +adb logcat -d -s ReactNativeJS:V # JS console +adb logcat -d -b crash # native crashes +adb shell cmd statusbar expand-notifications # the agent card lives here +adb shell cmd locale set-app-locales sh.superset.mobile --locales de-DE # per-app locale +adb shell settings put system font_scale 1.3 # large text; reset to 1.0 +``` + +Maestro runs on Android too: `maestro --device emulator-5554 test `, with `JAVA_HOME` on JDK 17. +`apps/mobile/.maestro/flows/dev-sign-in.yml` is the sign-in flow `--user` uses. Maestro 2 rejects a +flow where `optional: true` sits beside a command (`Invalid Command Format`); put it inside the +command's map. Check a flow with `maestro check-syntax ` before running it. + +Reset the locale and font scale, stop Metro (`--stop`) and the services, and remove test data when +done. Leave the emulator running if another agent is waiting for it. diff --git a/.env.example b/.env.example index 177cdc8cadb..fc602b4999b 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,9 @@ NEXT_PUBLIC_DOCS_URL= NEXT_PUBLIC_ROOT_DOMAIN= # Desktop name in menus, the Dock and dialogs; productName stays "Superset". DESKTOP_DISPLAY_NAME= +# Origins that serve the mobile app on the web, comma-separated, no wildcards. +# The API allows them for CORS and sign-in; unset = none. +MOBILE_WEB_ORIGINS= # ----------------------------------------------------------------------------- # Mobile (Expo) — read at Metro bundle time; EAS builds get these from eas.json @@ -40,6 +43,7 @@ DESKTOP_DISPLAY_NAME= EXPO_PUBLIC_API_URL= EXPO_PUBLIC_ROOT_DOMAIN= EXPO_PUBLIC_POSTHOG_KEY= +EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID= EXPO_PUBLIC_SENTRY_DSN_MOBILE= EXPO_PUBLIC_SENTRY_ENVIRONMENT= # App Review demo account, read by apps/mobile/store.config.js for `eas metadata:push` @@ -139,6 +143,9 @@ R2_ENDPOINT= # Where pages are served from, one subdomain per page under it (apps/usercontent): # https://frame.supersetusercontent.com USERCONTENT_URL= +# The agents web app (apps/mobile web export), which apps/web serves at /agents: +# https://superset-agents.vercel.app +AGENTS_URL= # App-referenced files with tickets (apps/usercontent, same Worker); must match # the Worker's own MEDIA_URL: https://media.supersetusercontent.com MEDIA_URL= diff --git a/.env.local.example b/.env.local.example index c60a9b9f33a..f1df1573d0e 100644 --- a/.env.local.example +++ b/.env.local.example @@ -42,6 +42,8 @@ NEXT_PUBLIC_MARKETING_URL=http://localhost:3002 NEXT_PUBLIC_DOCS_URL=http://localhost:3004 NEXT_PUBLIC_ROOT_DOMAIN= DESKTOP_DISPLAY_NAME= +# `expo start --web` in apps/mobile +MOBILE_WEB_ORIGINS=http://localhost:8081 # ----------------------------------------------------------------------------- # Mobile (Expo) — setup.local.sh overrides these with allocated ports. @@ -49,6 +51,7 @@ DESKTOP_DISPLAY_NAME= EXPO_PUBLIC_API_URL=http://localhost:3001 EXPO_PUBLIC_ROOT_DOMAIN= EXPO_PUBLIC_POSTHOG_KEY=phc_local_dev_disabled +EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID= # A syntactically valid but inert DSN: @sentry/react-native's useNativeInit # crashes the app on launch (SIGSEGV, before any JS runs) if this fails to # parse as a URL, which an empty or missing value does. Needs a well-formed @@ -129,6 +132,9 @@ R2_ENDPOINT=http://localhost:9000 USERCONTENT_URL=http://frame.usercontent.localhost:8787 MEDIA_URL=http://media.usercontent.localhost:8787 STATIC_URL=http://static.usercontent.localhost:8787 +# Setup writes AGENTS_PORT (base+19) and this URL; serve the agents web export +# there with apps/mobile/scripts/serve-web.ts (docs/agents-web.md). +AGENTS_URL=http://localhost:3019 USERCONTENT_TOKEN_SECRET=local-dev-usercontent-secret-change-me-32 AGENT_WORKER_SECRET=local-dev-agent-worker-secret-change-me-32 AGENT_TURN_SIGNING_SECRET=local-dev-agent-turn-signing-secret-change-me-32 diff --git a/.github/templates/preview-comment.md b/.github/templates/preview-comment.md index 6596a97453f..d9bd743e5e3 100644 --- a/.github/templates/preview-comment.md +++ b/.github/templates/preview-comment.md @@ -24,6 +24,11 @@ $WEB_LINK +Vercel Agents Web (Vercel) +$AGENTS_STATUS +$AGENTS_LINK + + Vercel Marketing (Vercel) $MARKETING_STATUS $MARKETING_LINK diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 699b2216c91..70d839442d1 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -13,6 +13,7 @@ env: PR_NUMBER: ${{ github.event.pull_request.number }} API_ALIAS: api-pr-${{ github.event.pull_request.number }}-superset.vercel.app WEB_ALIAS: web-pr-${{ github.event.pull_request.number }}-superset.vercel.app + AGENTS_ALIAS: agents-pr-${{ github.event.pull_request.number }}-superset.vercel.app MARKETING_ALIAS: marketing-pr-${{ github.event.pull_request.number }}-superset.vercel.app ADMIN_ALIAS: admin-pr-${{ github.event.pull_request.number }}-superset.vercel.app DOCS_ALIAS: docs-pr-${{ github.event.pull_request.number }}-superset.vercel.app @@ -172,6 +173,7 @@ jobs: GOOGLE_CONNECTIONS_CLIENT_SECRET: ${{ secrets.GOOGLE_CONNECTIONS_CLIENT_SECRET }} GH_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }} GH_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }} + MOBILE_WEB_ORIGINS: ${{ secrets.MOBILE_WEB_ORIGINS }} NEXT_PUBLIC_SENTRY_DSN_API: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN_API }} NEXT_PUBLIC_SENTRY_ENVIRONMENT: ${{ secrets.NEXT_PUBLIC_SENTRY_ENVIRONMENT }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} @@ -255,6 +257,7 @@ jobs: --env GOOGLE_CONNECTIONS_CLIENT_SECRET=$GOOGLE_CONNECTIONS_CLIENT_SECRET \ --env GH_CLIENT_ID=$GH_CLIENT_ID \ --env GH_CLIENT_SECRET=$GH_CLIENT_SECRET \ + --env MOBILE_WEB_ORIGINS=$MOBILE_WEB_ORIGINS \ --env NEXT_PUBLIC_SENTRY_DSN_API=$NEXT_PUBLIC_SENTRY_DSN_API \ --env NEXT_PUBLIC_SENTRY_ENVIRONMENT=$NEXT_PUBLIC_SENTRY_ENVIRONMENT \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ @@ -409,6 +412,7 @@ jobs: SERVER_ANTHROPIC_API_KEY: ${{ secrets.SERVER_ANTHROPIC_API_KEY }} RELAY_URL: ${{ secrets.RELAY_URL }} RELAY_BACKUP_URL: ${{ secrets.RELAY_BACKUP_URL }} + AGENTS_URL: https://${{ env.AGENTS_ALIAS }} run: | vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN @@ -418,6 +422,7 @@ jobs: --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ + --env AGENTS_URL=$AGENTS_URL \ --env NEXT_PUBLIC_MARKETING_URL=$NEXT_PUBLIC_MARKETING_URL \ --env NEXT_PUBLIC_ROOT_DOMAIN=$NEXT_PUBLIC_ROOT_DOMAIN \ --env NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \ @@ -465,6 +470,85 @@ jobs: name: web-status path: web-status.env + deploy-agents: + name: Deploy Agents Web + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 + environment: + name: preview + url: https://${{ env.WEB_ALIAS }}/agents + + steps: + - name: Check the agents project is configured + id: agents-config + env: + AGENTS_PROJECT_ID: ${{ secrets.VERCEL_AGENTS_PROJECT_ID }} + run: echo "configured=$([ -n "$AGENTS_PROJECT_ID" ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Checkout repository + if: steps.agents-config.outputs.configured == 'true' + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Bun + if: steps.agents-config.outputs.configured == 'true' + id: setup-bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Cache dependencies + if: steps.agents-config.outputs.configured == 'true' + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} + + - name: Install dependencies + if: steps.agents-config.outputs.configured == 'true' + run: bun install --frozen --ignore-scripts + + - name: Compile translations + if: steps.agents-config.outputs.configured == 'true' + run: bun run --filter=@superset/i18n build + + - name: Install Vercel CLI + if: steps.agents-config.outputs.configured == 'true' + run: bun add --global vercel@$VERCEL_CLI_VERSION + + - name: Build and Deploy Agents Web + if: steps.agents-config.outputs.configured == 'true' + working-directory: apps/mobile + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_AGENTS_PROJECT_ID }} + EXPO_PUBLIC_API_URL: https://${{ env.API_ALIAS }} + EXPO_PUBLIC_WEB_URL: https://${{ env.WEB_ALIAS }} + EXPO_PUBLIC_RELAY_URL: ${{ secrets.RELAY_URL }} + EXPO_PUBLIC_REALTIME_URL: ${{ secrets.REALTIME_URL }} + EXPO_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} + EXPO_PUBLIC_SENTRY_DSN_MOBILE: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN_MOBILE }} + EXPO_PUBLIC_SENTRY_ENVIRONMENT: ${{ secrets.NEXT_PUBLIC_SENTRY_ENVIRONMENT }} + run: | + bun scripts/build-web.ts + VERCEL_URL=$(vercel deploy --prebuilt --archive=tgz --token=$VERCEL_TOKEN) + vercel alias $VERCEL_URL ${{ env.AGENTS_ALIAS }} --scope=$VERCEL_ORG_ID --token=$VERCEL_TOKEN + + - name: Save agents success status + if: steps.agents-config.outputs.configured == 'true' + run: | + cat > agents-status.env << EOF + AGENTS_STATUS="✅" + AGENTS_LINK="Open Preview" + EOF + + - name: Upload agents status + if: steps.agents-config.outputs.configured == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: agents-status + path: agents-status.env + deploy-marketing: name: Deploy Marketing runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -708,7 +792,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 if: always() - needs: [create-database, deploy-database, deploy-api, deploy-web, deploy-marketing, deploy-docs] + needs: [create-database, deploy-database, deploy-api, deploy-web, deploy-agents, deploy-marketing, deploy-docs] permissions: contents: read pull-requests: write @@ -731,6 +815,8 @@ jobs: API_LINK="Failed to deploy" WEB_STATUS="❌" WEB_LINK="Failed to deploy" + AGENTS_STATUS="❌" + AGENTS_LINK="Failed to deploy" MARKETING_STATUS="❌" MARKETING_LINK="Failed to deploy" DOCS_STATUS="❌" @@ -751,6 +837,10 @@ jobs: source web-status.env fi + if [[ "${{ needs.deploy-agents.result }}" == "success" ]]; then + source agents-status.env + fi + if [[ "${{ needs.deploy-marketing.result }}" == "success" ]]; then source marketing-status.env fi @@ -759,7 +849,7 @@ jobs: source docs-status.env fi - export DATABASE_STATUS DATABASE_LINK API_STATUS API_LINK WEB_STATUS WEB_LINK MARKETING_STATUS MARKETING_LINK DOCS_STATUS DOCS_LINK + export DATABASE_STATUS DATABASE_LINK API_STATUS API_LINK WEB_STATUS WEB_LINK AGENTS_STATUS AGENTS_LINK MARKETING_STATUS MARKETING_LINK DOCS_STATUS DOCS_LINK envsubst < .github/templates/preview-comment.md > final-comment.md - name: Post final deployment comment diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index d51dd85ae7e..31007877e9a 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -136,6 +136,7 @@ jobs: GOOGLE_PUBSUB_TOPIC: ${{ secrets.GOOGLE_PUBSUB_TOPIC }} GOOGLE_PUBSUB_PUSH_TOKEN: ${{ secrets.GOOGLE_PUBSUB_PUSH_TOKEN }} SUPPORT_LOOKUP_TOKEN: ${{ secrets.SUPPORT_LOOKUP_TOKEN }} + MOBILE_WEB_ORIGINS: ${{ secrets.MOBILE_WEB_ORIGINS }} GH_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }} GH_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }} NEXT_PUBLIC_SENTRY_DSN_API: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN_API }} @@ -234,6 +235,7 @@ jobs: --env GOOGLE_PUBSUB_TOPIC=$GOOGLE_PUBSUB_TOPIC \ --env GOOGLE_PUBSUB_PUSH_TOKEN=$GOOGLE_PUBSUB_PUSH_TOKEN \ --env SUPPORT_LOOKUP_TOKEN=$SUPPORT_LOOKUP_TOKEN \ + --env MOBILE_WEB_ORIGINS=$MOBILE_WEB_ORIGINS \ --env GH_CLIENT_ID=$GH_CLIENT_ID \ --env GH_CLIENT_SECRET=$GH_CLIENT_SECRET \ --env NEXT_PUBLIC_SENTRY_DSN_API=$NEXT_PUBLIC_SENTRY_DSN_API \ @@ -368,6 +370,7 @@ jobs: SERVER_ANTHROPIC_API_KEY: ${{ secrets.SERVER_ANTHROPIC_API_KEY }} RELAY_URL: ${{ secrets.RELAY_URL }} RELAY_BACKUP_URL: ${{ secrets.RELAY_BACKUP_URL }} + AGENTS_URL: ${{ secrets.AGENTS_URL }} run: | vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN @@ -377,6 +380,7 @@ jobs: --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ + --env AGENTS_URL=$AGENTS_URL \ --env NEXT_PUBLIC_MARKETING_URL=$NEXT_PUBLIC_MARKETING_URL \ --env NEXT_PUBLIC_ROOT_DOMAIN=$NEXT_PUBLIC_ROOT_DOMAIN \ --env NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \ @@ -405,6 +409,68 @@ jobs: --env SECRETS_ENCRYPTION_KEY=$SECRETS_ENCRYPTION_KEY \ --env SERVER_ANTHROPIC_API_KEY=$SERVER_ANTHROPIC_API_KEY + deploy-agents: + name: Deploy Agents Web to Vercel + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 + environment: production + needs: deploy-api + + steps: + - name: Check the agents project is configured + id: agents-config + env: + AGENTS_PROJECT_ID: ${{ secrets.VERCEL_AGENTS_PROJECT_ID }} + run: echo "configured=$([ -n "$AGENTS_PROJECT_ID" ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Checkout repository + if: steps.agents-config.outputs.configured == 'true' + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Bun + if: steps.agents-config.outputs.configured == 'true' + id: setup-bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Cache dependencies + if: steps.agents-config.outputs.configured == 'true' + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} + + - name: Install dependencies + if: steps.agents-config.outputs.configured == 'true' + run: bun install --frozen --ignore-scripts + + - name: Compile translations + if: steps.agents-config.outputs.configured == 'true' + run: bun run --filter=@superset/i18n build + + - name: Install Vercel CLI + if: steps.agents-config.outputs.configured == 'true' + run: bun add --global vercel@$VERCEL_CLI_VERSION + + - name: Build and Deploy Agents Web + if: steps.agents-config.outputs.configured == 'true' + working-directory: apps/mobile + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_AGENTS_PROJECT_ID }} + EXPO_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} + EXPO_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} + EXPO_PUBLIC_RELAY_URL: ${{ secrets.RELAY_URL }} + EXPO_PUBLIC_REALTIME_URL: ${{ secrets.REALTIME_URL }} + EXPO_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} + EXPO_PUBLIC_SENTRY_DSN_MOBILE: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN_MOBILE }} + EXPO_PUBLIC_SENTRY_ENVIRONMENT: ${{ secrets.NEXT_PUBLIC_SENTRY_ENVIRONMENT }} + run: | + bun scripts/build-web.ts + vercel deploy --prod --prebuilt --archive=tgz --token=$VERCEL_TOKEN + deploy-marketing: name: Deploy Marketing to Vercel runs-on: blacksmith-4vcpu-ubuntu-2404 diff --git a/.gitignore b/.gitignore index 288808b7879..68d0d5d3433 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ dist-types # electron-builder output (apps/desktop/electron-builder.ts: directories.output); # scoped so it can't silently ignore unrelated dirs named "release". /apps/desktop/release/ +/apps/mobile/.vercel/ *.local # Editor directories and files diff --git a/.superset/lib/setup/steps.sh b/.superset/lib/setup/steps.sh index 8a26c33b16c..65c663d027f 100644 --- a/.superset/lib/setup/steps.sh +++ b/.superset/lib/setup/steps.sh @@ -352,7 +352,7 @@ step_write_env() { # Offsets: +0 web, +1 api, +2 marketing, +3 admin, +4 docs, # +5 desktop vite, +6 notifications, +7 streams, +8 streams internal, # +11 code inspector, +13 relay, +14 usercontent worker, - # +18 realtime worker + # +18 realtime worker, +19 agents web export # (+9, +10, +12 were Electric/Caddy/wrangler; retired, kept unassigned so # the surviving offsets stay stable across existing allocations) local BASE=$SUPERSET_PORT_BASE @@ -371,6 +371,7 @@ step_write_env() { local RELAY_PORT=$((BASE + 13)) local USERCONTENT_DEV_PORT=$((BASE + 14)) local SANDBOX_GATE_DEV_PORT=$((BASE + 15)) + local AGENTS_PORT=$((BASE + 19)) local REALTIME_PORT=$((BASE + 18)) echo "" @@ -389,6 +390,7 @@ step_write_env() { write_env_var "RELAY_PORT" "$RELAY_PORT" write_env_var "USERCONTENT_DEV_PORT" "$USERCONTENT_DEV_PORT" write_env_var "SANDBOX_GATE_DEV_PORT" "$SANDBOX_GATE_DEV_PORT" + write_env_var "AGENTS_PORT" "$AGENTS_PORT" write_env_var "REALTIME_PORT" "$REALTIME_PORT" echo "" echo "# Cross-app URLs (overrides from root .env)" @@ -411,6 +413,7 @@ step_write_env() { write_env_var "USERCONTENT_URL" "http://frame.usercontent.localhost:$USERCONTENT_DEV_PORT" write_env_var "MEDIA_URL" "http://media.usercontent.localhost:$USERCONTENT_DEV_PORT" write_env_var "SANDBOX_GATE_ORIGIN" "http://127.0.0.1:$SANDBOX_GATE_DEV_PORT" + write_env_var "AGENTS_URL" "http://localhost:$AGENTS_PORT" echo "" echo "# Streams URLs (overrides from root .env)" write_env_var "PORT" "$STREAMS_PORT" @@ -429,7 +432,7 @@ step_write_env() { assert_unique_ports "$WEB_PORT" "$API_PORT" "$MARKETING_PORT" "$ADMIN_PORT" \ "$DOCS_PORT" "$DESKTOP_VITE_PORT" "$DESKTOP_NOTIFICATIONS_PORT" "$STREAMS_PORT" \ "$STREAMS_INTERNAL_PORT" "$CODE_INSPECTOR_PORT" "$RELAY_PORT" "$USERCONTENT_DEV_PORT" \ - "$SANDBOX_GATE_DEV_PORT" "$REALTIME_PORT" || return 1 + "$SANDBOX_GATE_DEV_PORT" "$AGENTS_PORT" "$REALTIME_PORT" || return 1 cat > "$superset_dir/ports.json" < "$SUPERSET_SCRIPT_DIR/ports.json" < { + try { + parseOriginList(value); + return true; + } catch { + return false; + } + }, + { message: "Comma-separated origins, without wildcards" }, + ), }, client: { NEXT_PUBLIC_API_URL: z.string().url(), diff --git a/apps/api/src/proxy.ts b/apps/api/src/proxy.ts index 29ccc8f8089..191459cd79f 100644 --- a/apps/api/src/proxy.ts +++ b/apps/api/src/proxy.ts @@ -1,4 +1,5 @@ import { ORGANIZATION_HEADER } from "@superset/shared/constants"; +import { parseOriginList } from "@superset/shared/origin-list"; import { withOwnDomainTwins } from "@superset/shared/own-domains"; import { getTrustedVercelPreviewOrigins } from "@superset/shared/vercel-preview-origins"; import { type NextRequest, NextResponse } from "next/server"; @@ -22,6 +23,7 @@ function getAllowedOrigins(deploymentOrigin: string) { env.NEXT_PUBLIC_MARKETING_URL, env.NEXT_PUBLIC_DESKTOP_URL, ]), + ...parseOriginList(env.MOBILE_WEB_ORIGINS), ...getTrustedVercelPreviewOrigins(deploymentOrigin), ...desktopDevOrigins, ]; @@ -63,6 +65,7 @@ export const config = { "/api/trpc/(.*)", "/api/auth/((?!jwks$|oauth2/token$).*)", "/api/desktop/version", + "/api/mobile/version", "/api/proxy/linear-image", ], }; diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 78768d38635..1b41152aa7f 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -9,7 +9,7 @@ "predev": "bun run --filter=@superset/i18n build", "dev": "dotenv -e ../../.env -- sh -c 'exec next dev --port ${MARKETING_PORT:-3002}'", "indexnow": "bun run scripts/indexnow-submit.ts", - "lint:prose": "vale --config=vale.ini content \"src/app/[lang]/layout.tsx\" src/components/JsonLd/JsonLd.tsx src/lib/product-facts.ts src/lib/llms.ts \"src/app/[lang]/components/Header/constants.tsx\" \"src/app/[lang]/components/HeroSection/HeroSection.tsx\" \"src/app/[lang]/components/HowItWorksSection\" \"src/app/[lang]/components/FeaturesSection/constants.ts\" \"src/app/[lang]/components/FAQSection/constants.ts\" \"src/app/[lang]/components/CTASection/CTASection.tsx\" \"src/app/[lang]/components/SecuritySection/SecuritySection.tsx\" \"src/app/[lang]/pages\" \"src/app/[lang]/automations\" \"src/app/[lang]/browser\" \"src/app/[lang]/plugins\"", + "lint:prose": "vale --config=vale.ini content \"src/app/[lang]/layout.tsx\" src/components/JsonLd/JsonLd.tsx src/lib/product-facts.ts src/lib/llms.ts \"src/app/[lang]/components/Header/constants.tsx\" \"src/app/[lang]/components/HeroSection/HeroSection.tsx\" \"src/app/[lang]/components/HowItWorksSection\" \"src/app/[lang]/components/FeaturesSection/constants.ts\" \"src/app/[lang]/components/FAQSection/constants.ts\" \"src/app/[lang]/components/Footer/components/CTASection/CTASection.tsx\" \"src/app/[lang]/components/SecuritySection/SecuritySection.tsx\" \"src/app/[lang]/pages\" \"src/app/[lang]/automations\" \"src/app/[lang]/browser\" \"src/app/[lang]/plugins\"", "start": "next start", "test": "bun test", "typecheck": "tsc --noEmit" diff --git a/apps/mobile/.eas/workflows/deploy-android.yml b/apps/mobile/.eas/workflows/deploy-android.yml new file mode 100644 index 00000000000..d403f7cb3f4 --- /dev/null +++ b/apps/mobile/.eas/workflows/deploy-android.yml @@ -0,0 +1,42 @@ +name: Deploy Mobile (Android) + +# Manual until Play accepts API uploads (the first .aab goes up by hand) and +# Android takes OTA updates; then these jobs move into deploy.yml. See RELEASE.md. +on: + workflow_dispatch: {} + +defaults: + tools: + bun: 1.4.3 + +jobs: + fingerprint: + name: Fingerprint + type: fingerprint + environment: production + + get_production_build: + name: Find production build for this fingerprint + needs: [fingerprint] + type: get-build + params: + platform: android + profile: production + fingerprint_hash: ${{ needs.fingerprint.outputs.android_fingerprint_hash }} + wait_for_in_progress: true + + build_production: + name: Build production + needs: [get_production_build] + if: ${{ !needs.get_production_build.outputs.build_id }} + type: build + params: + platform: android + profile: production + + submit_to_play_internal: + name: Submit to Play internal track + needs: [build_production] + type: submit + params: + build_id: ${{ needs.build_production.outputs.build_id }} diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index 677f9db036c..08d14119852 100644 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -44,6 +44,8 @@ yarn-error.* # generated native folders /ios /android +# Gradle output of a local module's android/ library +modules/*/android/build/ # storybook (generated on start) .rnstorybook/storybook.requires.ts diff --git a/apps/mobile/.maestro/flows/dev-sign-in.yml b/apps/mobile/.maestro/flows/dev-sign-in.yml new file mode 100644 index 00000000000..6f16d4d26d6 --- /dev/null +++ b/apps/mobile/.maestro/flows/dev-sign-in.yml @@ -0,0 +1,28 @@ +# Signs in through the dev email prompt as ${EMAIL}, signing out first if needed. +# Run by scripts/android-dev.sh --user; works on the iOS simulator too. +appId: sh.superset.mobile +--- +- extendedWaitUntil: + visible: "Sign in with email \\(dev\\)" + timeout: 15000 + optional: true +- runFlow: + when: + notVisible: "Sign in with email \\(dev\\)" + commands: + - openLink: "superset://settings" + - scrollUntilVisible: + element: "Sign out" + - tapOn: "Sign out" + - tapOn: "Log out" + - extendedWaitUntil: + visible: "Sign in with email \\(dev\\)" + timeout: 15000 +- tapOn: "Sign in with email \\(dev\\)" +- eraseText: 80 +- inputText: ${EMAIL} +- tapOn: "Next" +- tapOn: "Sign in" +- extendedWaitUntil: + notVisible: "Sign in with email \\(dev\\)" + timeout: 20000 diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 63ed571ced4..f4dad498c16 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -63,20 +63,47 @@ export default function AuthenticatedLayout() { ## Conventions -- **`apps/mobile` is iOS-only.** No Android fallbacks or platform guards for iOS-only APIs, and - Android incompatibility isn't a blocker until Android is explicitly in scope. -- **Bottom sheets are expo-router `formSheet` routes** — `...glassHeaderOptions` gives the native - title and ✕, the body is RN + uniwind (never `@expo/ui` SwiftUI content, which can't be themed to - match ours), and the list stays the screen's only layout child or it cold-mounts at zero height. - Copy `PullRequestsSheet` and its route entry. +- **Native on every platform, React Native as the fallback.** iOS ships; Android and web build + from the same code. Every platform-specific API sits behind a typed contract, picked by Metro's + platform extensions, never by `Platform.OS` checks in screens. For each piece, use the first + of these that fits: + 1. An `@expo/ui` universal component (`@expo/ui`) or community drop-in (`@expo/ui/community/menu`, + `/bottom-sheet`, `/picker` ...): SwiftUI on iOS, Jetpack Compose on Android, DOM on web, one + import. Example: `ShareAccess/components/ValueMenu/ValueMenu.android.tsx` uses `MenuView`. + 2. Per-platform files behind one contract: + - `X.types.ts` is the contract: props, handle and function types, with no SF Symbol names or + other platform words in what callers pass. Screens import only the folder's `index.ts`. + - `X.ios.tsx`: an Expo module in `modules//ios`, `@expo/ui/swift-ui`, or an iOS-only + Expo package. It is the only file that may import those. + - `X.android.tsx`: `@expo/ui/jetpack-compose` or a module's `android/` code. For a module that + has both, add `"android"` to `expo-module.config.json` and name the binding `X.native.tsx`. + - `X.web.tsx`: where the browser has a better control than plain RN (a file input, CSS + `backdrop-filter`, the browser's WebRTC or session cookie). + 3. `X.tsx`, the React Native fallback. Web, `bun test` and any platform without its own file + resolve it, so it must work: never a no-op behind something the user taps. A capability that + does not exist there (Live Activities, native sign-in) may be a no-op. + Every implementation annotates its exports with the contract type, so `tsc` checks each file + against the same shape. Examples: `modules/composer/src` (SwiftUI on iOS, RN composer as `.tsx`), + `modules/attachments-sheet` (native sheet, Compose sheet, browser file chooser), + `modules/alert-prompt`, `sign-in/utils/signInWithGoogle` (`.android.ts` on + `modules/google-credential`, browser flow everywhere else). +- **Bottom sheets are expo-router `formSheet` routes** — `...sheetHeaderOptions` gives the native + title and ✕ on iOS and a JS drag handle and title on Android, where a form sheet draws no native + bar (so `Stack.SearchBar` and SF Symbol `Stack.Toolbar` buttons do not show there). The body is + RN + uniwind (never `@expo/ui` SwiftUI content, which can't be themed to match ours), and the + list stays the screen's only layout child or it cold-mounts at zero height. Copy + `PullRequestsSheet` and its route entry. - **Hermes ships a partial `Intl`.** `lib/intl-polyfills` lists what is missing and what is polyfilled. A `@superset/i18n/format` helper that reaches for an API outside that list throws, and no mobile screen has an error boundary, so the throw takes the screen down. Add the polyfill, its - per-locale data, and a case in the polyfill test. + per-locale data, and a case in the polyfill test. Android's Hermes also lacks `zh-CN`/`zh-TW` in + `NumberFormat`/`DateTimeFormat` and formats them as `en-US`; `scriptSubtagFallback.android.ts` + passes the script-qualified tag instead. - **Pending and failed are not answers.** A query with no data yet has not said "offline" or "empty". Keep loading while it is pending and say you could not check when it failed; Home once painted "is offline" on every cold start because presence defaulted to `false`. - **Verifying in the real app:** on a machine with Xcode, `.agents/skills/mobile-sim-verification/SKILL.md`. + On Android, `apps/mobile/scripts/android-dev.sh` and `.agents/skills/mobile-android-verification/SKILL.md`. On a cloud sandbox (no local Xcode/simulator), run `apps/mobile/scripts/eas-dev.sh`; see `.agents/skills/mobile-eas-dev/SKILL.md`. The script encodes what was found getting the app running on an EAS simulator from a sandbox (2026-10-09), so nobody has to rediscover it: diff --git a/apps/mobile/RELEASE.md b/apps/mobile/RELEASE.md index aea619242ef..8521b8eae2d 100644 --- a/apps/mobile/RELEASE.md +++ b/apps/mobile/RELEASE.md @@ -1,6 +1,9 @@ # Mobile App Release Process -iOS only. Builds and submissions go through EAS; the App Store listing and the +iOS ships; Android has not shipped yet. Its build and submit lanes are in +[Android](#android), and what it still needs is in +[`plans/20261029-android-launch-checklist.md`](./plans/20261029-android-launch-checklist.md). +Builds and submissions go through EAS; the App Store listing and the App Review notes live in [`store.config.js`](./store.config.js) and are pushed with `eas metadata:push`. This file is the runbook for shipping a build and for getting unstuck when App Review rejects or stalls it. @@ -154,6 +157,71 @@ carries `MINIMUM_MOBILE_VERSION`; a build below it shows a full-screen once the replacement build is live in the store, or that build is bricked. The check fails open: no answer from the server, no gate. +## Android + +Same EAS project, same `production` build and submit profiles. Android has not +shipped; the launch checklist tracks what is missing. + +### Versioning + +- `version` in `app.config.ts` is the Android `versionName` too. Bump it with + iOS; Play does not care about the number, only users see it. +- `versionCode` is the Android build number, and it works like iOS's: EAS keeps + it server-side (`appVersionSource: "remote"`) and the `production` profile + raises it on every build (`autoIncrement: true`). Never set it by hand or in + `app.config.ts`; EAS writes it into the build. Read it with + `eas build:version:get -p android --profile production`. +- Play rejects an upload whose `versionCode` is not higher than every code it + has seen on any track, and a code is spent even if the build is never + uploaded. A `--local` production build raises the remote counter too, so + gaps are normal. If a build was ever uploaded with a code EAS does not know + (a hand-built `.aab`), move EAS past it with `eas build:version:set -p android`. +- The fingerprint ignores versions (`fingerprint.config.js`), so a version bump + does not need a new binary on Android either. + +### Shipping a build + +```bash +cd apps/mobile + +# 1. Build an .aab signed with the upload key in EAS credentials. +eas build --platform android --profile production + +# 2. Upload to the Play internal track as a draft. Needs the Google service +# account key in EAS credentials. Google refuses API uploads until the +# first .aab has been uploaded by hand in Play Console. +eas submit --platform android --profile production --latest +``` + +`.eas/workflows/deploy-android.yml` runs the same two steps, skipping the build +when one already matches the fingerprint. It is manual (`workflow_dispatch`) +until the first hand upload is done and Android takes OTA updates; then its +jobs move into `deploy.yml`. Promote from internal to production in Play +Console. + +### Checking a release build without publishing + +```bash +cd apps/mobile +eas build -p android --profile production --local --output /tmp/superset.aab +bundletool build-apks --bundle=/tmp/superset.aab --output=/tmp/superset.apks \ + --mode=universal # signs with the debug key; fine for an emulator +unzip -p /tmp/superset.apks universal.apk > /tmp/superset.apk +adb install -r /tmp/superset.apk +``` + +`--local` needs JDK 17 and the Android SDK, and pulls the `production` EAS +environment and the upload keystore. It submits nothing, but it spends a +`versionCode`. The installed app talks to production. Check +`adb shell pm get-app-links sh.superset.mobile` for the App Links hosts. + +- R8 runs in full mode in release builds only. A keep rule it ignores shows up + at run time, not at build time: look for `NoSuchMethodException` or + `ClassNotFoundException` in `adb logcat` after you open each screen. Add + rules with `extraProguardRules` in the `expo-build-properties` entry. +- bundletool signs the APK with the debug key, so Google sign-in falls back to + the browser and the App Links do not verify against `assetlinks.json`. + ## When the build is rejected Work the list top to bottom; each step costs minutes and they compound. diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 61419417f0a..5e81fb4b2dd 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,9 +1,10 @@ import path from "node:path"; import { SUPPORTED_LOCALES } from "@superset/i18n/locales"; -import { IOS_APP } from "@superset/shared/app-identity"; +import { ANDROID_APP, IOS_APP } from "@superset/shared/app-identity"; import { BRAND } from "@superset/shared/brand"; import { config } from "dotenv"; import type { ConfigContext } from "expo/config"; +import { withAndroidLabel } from "./config-plugins/withAndroidLabel"; import { withIosAccentColor } from "./config-plugins/withIosAccentColor"; import { withSceneLifecycle } from "./config-plugins/withSceneLifecycle"; @@ -21,8 +22,23 @@ if (process.env.MOBILE_EAS_BUILD !== "1") { const webUrl = new URL( process.env.EXPO_PUBLIC_WEB_URL || "https://app.superset.sh", ); -const associatedDomains = - webUrl.protocol === "https:" ? [`applinks:${webUrl.hostname}`] : undefined; +const appLinksHost = webUrl.protocol === "https:" ? webUrl.hostname : undefined; +const associatedDomains = appLinksHost && [`applinks:${appLinksHost}`]; +// Same paths as apple-app-site-association in apps/web. +const appLinkIntentFilters = appLinksHost && [ + { + action: "VIEW", + autoVerify: true, + data: ["/page/", "/workspaces/"].map((pathPrefix) => ({ + scheme: "https", + host: appLinksHost, + pathPrefix, + })), + category: ["BROWSABLE", "DEFAULT"], + }, +]; + +const webBaseUrl = process.env.MOBILE_WEB_BASE_URL; const SIGNED_BUILD_PROFILES = ["preview", "production"]; const signedUpdates = process.env.MOBILE_SIGNED_UPDATES === "1"; @@ -90,15 +106,17 @@ export default ({ config }: ConfigContext) => ({ android: { adaptiveIcon: { foregroundImage: "./assets/adaptive-icon.png", - backgroundColor: "#ffffff", + backgroundColor: "#151110", }, - package: "sh.superset.mobile", + package: ANDROID_APP.PACKAGE, predictiveBackGestureEnabled: false, + ...(appLinkIntentFilters && { intentFilters: appLinkIntentFilters }), }, web: { favicon: "./assets/favicon.png", bundler: "metro", }, + ...(webBaseUrl && { experiments: { baseUrl: webBaseUrl } }), plugins: [ // Dark, not white: iOS 26 fills a prominent system control with the // accent, so the photo picker's confirm button became a lit white disc @@ -107,6 +125,7 @@ export default ({ config }: ConfigContext) => ({ [withIosAccentColor, { color: "#262626" }], // iOS 27 SDK: an app without the UIScene life cycle traps on launch. withSceneLifecycle, + [withAndroidLabel, { label: BRAND.NAME }], "@bacons/apple-targets", "expo-router", [ @@ -138,7 +157,12 @@ export default ({ config }: ConfigContext) => ({ ], [ "expo-localization", - { supportedLocales: { ios: [...SUPPORTED_LOCALES] } }, + { + supportedLocales: { + ios: [...SUPPORTED_LOCALES], + android: [...SUPPORTED_LOCALES], + }, + }, ], "expo-apple-authentication", [ @@ -172,6 +196,12 @@ export default ({ config }: ConfigContext) => ({ "expo-build-properties", { ios: { deploymentTarget: "26.0", buildReactNativeFromSource: true }, + // WorkManager keeps its input mergers without members, so R8 full + // mode strips the constructor it reflects on and every job fails. + android: { + extraProguardRules: + "-keep class * extends androidx.work.InputMerger { public (); }", + }, }, ], // SDK 57 no longer autolinks config plugins; every installed plugin has diff --git a/apps/mobile/app/(authenticated)/(home)/_layout.tsx b/apps/mobile/app/(authenticated)/(home)/_layout.tsx index 8f7478e0c03..1654c48f73b 100644 --- a/apps/mobile/app/(authenticated)/(home)/_layout.tsx +++ b/apps/mobile/app/(authenticated)/(home)/_layout.tsx @@ -1,11 +1,29 @@ import { useLingui } from "@lingui/react/macro"; import { Stack } from "expo-router"; +import { + ParentScreens, + screenLayout, + sheetScreenOptions, +} from "@/lib/navigation"; + +const NO_PARENTS: string[] = []; export default function HomeLayout() { const { t } = useLingui(); return ( ( + + {children} + + )} screenOptions={{ headerBackButtonDisplayMode: "minimal", headerShadowVisible: false, @@ -14,30 +32,27 @@ export default function HomeLayout() { ); diff --git a/apps/mobile/app/(authenticated)/_layout.tsx b/apps/mobile/app/(authenticated)/_layout.tsx index 21356f4fbe7..44035daba0b 100644 --- a/apps/mobile/app/(authenticated)/_layout.tsx +++ b/apps/mobile/app/(authenticated)/_layout.tsx @@ -4,11 +4,26 @@ import { View } from "react-native"; import { usePrimeRelayUrl } from "@/hooks/usePrimeRelayUrl"; import { useRealtimeNudges } from "@/hooks/useRealtimeNudges"; import { useSession } from "@/lib/auth/client"; -import { glassHeaderOptions, sheetDetents } from "@/lib/navigation"; +import { + glassHeaderOptions, + ParentScreens, + screenLayout, + sheetDetents, + sheetHeaderOptions, + sheetScreenOptions, +} from "@/lib/navigation"; import { VoiceLayer } from "@/screens/(authenticated)/voice/VoiceLayer"; export const unstable_settings = { anchor: "(home)" }; +const PARENT_SCREENS = [ + "workspace/[id]/index", + "workspace/[id]/pull-request/[pullRequestId]/index", + "pages/index", + "pages/[slug]/index", + "settings/index", +]; + const pageScreenOptions = { headerShown: true, headerBackButtonDisplayMode: "minimal" as const, @@ -40,7 +55,19 @@ export default function AuthenticatedLayout() { return ( - + ( + + {children} + + )} + > {/* Root headers are hidden — `title` here only names routes in back-button long-press menus (otherwise raw route names leak, e.g. "(home)"). */} @@ -59,23 +86,21 @@ export default function AuthenticatedLayout() { /> diff --git a/apps/mobile/app/(authenticated)/pages/[slug]/share/_layout.tsx b/apps/mobile/app/(authenticated)/pages/[slug]/share/_layout.tsx index 84285128115..2446d94936f 100644 --- a/apps/mobile/app/(authenticated)/pages/[slug]/share/_layout.tsx +++ b/apps/mobile/app/(authenticated)/pages/[slug]/share/_layout.tsx @@ -1,8 +1,13 @@ import { useLingui } from "@lingui/react/macro"; import { Stack } from "expo-router"; -import { glassHeaderOptions, sheetDetents } from "@/lib/navigation"; +import { + glassHeaderOptions, + sheetDetents, + sheetHeaderOptions, +} from "@/lib/navigation"; const accessSheetOptions = { + ...sheetHeaderOptions, presentation: "formSheet", sheetAllowedDetents: sheetDetents([0.45]), sheetGrabberVisible: true, diff --git a/apps/mobile/app/(authenticated)/workspace/[id]/share/_layout.tsx b/apps/mobile/app/(authenticated)/workspace/[id]/share/_layout.tsx index 56d5560edca..d867700fd41 100644 --- a/apps/mobile/app/(authenticated)/workspace/[id]/share/_layout.tsx +++ b/apps/mobile/app/(authenticated)/workspace/[id]/share/_layout.tsx @@ -1,8 +1,13 @@ import { useLingui } from "@lingui/react/macro"; import { Stack } from "expo-router"; -import { glassHeaderOptions, sheetDetents } from "@/lib/navigation"; +import { + glassHeaderOptions, + sheetDetents, + sheetHeaderOptions, +} from "@/lib/navigation"; const accessSheetOptions = { + ...sheetHeaderOptions, presentation: "formSheet", sheetAllowedDetents: sheetDetents([0.45]), sheetGrabberVisible: true, diff --git a/apps/mobile/components/GlassSurface/GlassSurface.ios.tsx b/apps/mobile/components/GlassSurface/GlassSurface.ios.tsx new file mode 100644 index 00000000000..1cae04ae47d --- /dev/null +++ b/apps/mobile/components/GlassSurface/GlassSurface.ios.tsx @@ -0,0 +1,26 @@ +import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; +import { View } from "react-native"; +import type { GlassSurfaceComponent } from "./GlassSurface.types"; + +export const GlassSurface: GlassSurfaceComponent = ({ + radius, + interactive, + dark, + fallbackClassName, + style, + children, +}) => + isLiquidGlassAvailable() ? ( + + {children} + + ) : ( + + {children} + + ); diff --git a/apps/mobile/components/GlassSurface/GlassSurface.tsx b/apps/mobile/components/GlassSurface/GlassSurface.tsx new file mode 100644 index 00000000000..d29bffe3091 --- /dev/null +++ b/apps/mobile/components/GlassSurface/GlassSurface.tsx @@ -0,0 +1,26 @@ +import { StyleSheet, View } from "react-native"; +import type { GlassSurfaceComponent } from "./GlassSurface.types"; + +const SURFACE = "hsl(0 0% 12%)"; +const OUTLINE = "rgba(255,255,255,0.08)"; + +export const GlassSurface: GlassSurfaceComponent = ({ + radius, + style, + children, +}) => ( + + {children} + +); diff --git a/apps/mobile/components/GlassSurface/GlassSurface.types.ts b/apps/mobile/components/GlassSurface/GlassSurface.types.ts new file mode 100644 index 00000000000..6dfb4eff41b --- /dev/null +++ b/apps/mobile/components/GlassSurface/GlassSurface.types.ts @@ -0,0 +1,17 @@ +import type { ComponentType, ReactNode } from "react"; +import type { StyleProp, ViewStyle } from "react-native"; + +export interface GlassSurfaceProps { + radius: number; + /** The surface is a button: it reacts to touch where the OS supports it. */ + interactive?: boolean; + /** Keeps the material dark whatever is behind it. */ + dark?: boolean; + /** The look on OS versions that draw no glass but share this platform's design. */ + fallbackClassName: string; + style?: StyleProp; + children: ReactNode; +} + +/** A floating surface over content: glass where the OS draws it. */ +export type GlassSurfaceComponent = ComponentType; diff --git a/apps/mobile/components/GlassSurface/GlassSurface.web.tsx b/apps/mobile/components/GlassSurface/GlassSurface.web.tsx new file mode 100644 index 00000000000..cd699001e70 --- /dev/null +++ b/apps/mobile/components/GlassSurface/GlassSurface.web.tsx @@ -0,0 +1,16 @@ +import { View } from "react-native"; +import type { GlassSurfaceComponent } from "./GlassSurface.types"; + +const GLASS = { + backgroundColor: "rgba(30,30,30,0.6)", + borderColor: "rgba(255,255,255,0.1)", + borderWidth: 1, + backdropFilter: "blur(20px) saturate(180%)", + boxShadow: "0 4px 16px rgba(0,0,0,0.3)", +}; + +export const GlassSurface: GlassSurfaceComponent = ({ + radius, + style, + children, +}) => {children}; diff --git a/apps/mobile/components/GlassSurface/index.ts b/apps/mobile/components/GlassSurface/index.ts new file mode 100644 index 00000000000..9f74155be1b --- /dev/null +++ b/apps/mobile/components/GlassSurface/index.ts @@ -0,0 +1,2 @@ +export { GlassSurface } from "./GlassSurface"; +export type { GlassSurfaceProps } from "./GlassSurface.types"; diff --git a/apps/mobile/components/RowLink/RowLink.tsx b/apps/mobile/components/RowLink/RowLink.tsx new file mode 100644 index 00000000000..922a3fa21f4 --- /dev/null +++ b/apps/mobile/components/RowLink/RowLink.tsx @@ -0,0 +1,3 @@ +import type { RowLinkComponent } from "./RowLink.types"; + +export const RowLink: RowLinkComponent = ({ children }) => children; diff --git a/apps/mobile/components/RowLink/RowLink.types.ts b/apps/mobile/components/RowLink/RowLink.types.ts new file mode 100644 index 00000000000..2f4d2d4d300 --- /dev/null +++ b/apps/mobile/components/RowLink/RowLink.types.ts @@ -0,0 +1,10 @@ +import type { ReactElement } from "react"; + +export interface RowLinkProps { + /** The app path the row opens, without route groups: `/workspace/`. */ + href: string; + /** The row; its own press handler still does the in-app navigation. */ + children: ReactElement; +} + +export type RowLinkComponent = (props: RowLinkProps) => ReactElement; diff --git a/apps/mobile/components/RowLink/RowLink.web.tsx b/apps/mobile/components/RowLink/RowLink.web.tsx new file mode 100644 index 00000000000..04df9696e54 --- /dev/null +++ b/apps/mobile/components/RowLink/RowLink.web.tsx @@ -0,0 +1,28 @@ +import type { MouseEvent } from "react"; +import { appUrl } from "@/lib/app-url"; +import type { RowLinkComponent } from "./RowLink.types"; + +const opensElsewhere = (event: MouseEvent) => + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey; + +/** + * A real link around the row, so ⌘-click, middle-click and Copy Link Address + * work. A plain click stays in the app: the anchor's own navigation is + * cancelled and the row's press handler runs. + */ +export const RowLink: RowLinkComponent = ({ href, children }) => ( + { + if (opensElsewhere(event)) event.stopPropagation(); + else event.preventDefault(); + }} + > + {children} + +); diff --git a/apps/mobile/components/RowLink/index.ts b/apps/mobile/components/RowLink/index.ts new file mode 100644 index 00000000000..6b9f6e88bf2 --- /dev/null +++ b/apps/mobile/components/RowLink/index.ts @@ -0,0 +1 @@ +export { RowLink } from "./RowLink"; diff --git a/apps/mobile/components/ai-elements/code-block-engine.native.ts b/apps/mobile/components/ai-elements/code-block-engine.native.ts new file mode 100644 index 00000000000..73785273cf9 --- /dev/null +++ b/apps/mobile/components/ai-elements/code-block-engine.native.ts @@ -0,0 +1,13 @@ +import { + createNativeEngine, + isNativeEngineAvailable, +} from "react-native-shiki-engine"; +import type { + CodeBlockEngineAvailable, + CreateCodeBlockEngine, +} from "./code-block-engine.types"; + +export const createCodeBlockEngine: CreateCodeBlockEngine = createNativeEngine; + +export const codeBlockEngineAvailable: CodeBlockEngineAvailable = + isNativeEngineAvailable; diff --git a/apps/mobile/components/ai-elements/code-block-engine.ts b/apps/mobile/components/ai-elements/code-block-engine.ts new file mode 100644 index 00000000000..7a80778d302 --- /dev/null +++ b/apps/mobile/components/ai-elements/code-block-engine.ts @@ -0,0 +1,10 @@ +import { createJavaScriptRegexEngine } from "@shikijs/engine-javascript"; +import type { + CodeBlockEngineAvailable, + CreateCodeBlockEngine, +} from "./code-block-engine.types"; + +export const createCodeBlockEngine: CreateCodeBlockEngine = () => + createJavaScriptRegexEngine(); + +export const codeBlockEngineAvailable: CodeBlockEngineAvailable = () => true; diff --git a/apps/mobile/components/ai-elements/code-block-engine.types.ts b/apps/mobile/components/ai-elements/code-block-engine.types.ts new file mode 100644 index 00000000000..3a37c3a5cfe --- /dev/null +++ b/apps/mobile/components/ai-elements/code-block-engine.types.ts @@ -0,0 +1,7 @@ +import type { RegexEngine } from "@shikijs/core"; + +/** The regex engine Shiki tokenizes with on this platform. */ +export type CreateCodeBlockEngine = () => RegexEngine; + +/** False where the engine cannot run, and code stays plain text. */ +export type CodeBlockEngineAvailable = () => boolean; diff --git a/apps/mobile/components/ai-elements/code-block.tsx b/apps/mobile/components/ai-elements/code-block.tsx index 12c7f4e7545..308f082f231 100644 Binary files a/apps/mobile/components/ai-elements/code-block.tsx and b/apps/mobile/components/ai-elements/code-block.tsx differ diff --git a/apps/mobile/components/ai-elements/conversation-list.tsx b/apps/mobile/components/ai-elements/conversation-list.tsx new file mode 100644 index 00000000000..9b362b1d4f4 --- /dev/null +++ b/apps/mobile/components/ai-elements/conversation-list.tsx @@ -0,0 +1,55 @@ +import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; +import { useCallback, useImperativeHandle, useRef } from "react"; +import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native"; +import type { ConversationListComponent } from "./conversation-list.types"; + +const AT_BOTTOM_THRESHOLD = 48; + +export const ConversationList: ConversationListComponent = ({ + ref, + onAtBottomChange, + onScroll, + ...listProps +}) => { + const listRef = useRef(null); + useImperativeHandle(ref, () => ({ + scrollToEnd: (options) => listRef.current?.scrollToEnd(options), + })); + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = + event.nativeEvent; + const distanceFromBottom = + contentSize.height - layoutMeasurement.height - contentOffset.y; + onAtBottomChange(distanceFromBottom < AT_BOTTOM_THRESHOLD); + onScroll?.(event); + }, + [onAtBottomChange, onScroll], + ); + + return ( + // Not alignItemsAtEnd: bottom-anchoring short threads means ANY item + // growth (expanding a tool card) shoves the whole thread upward. + // Top-aligned short threads grow downward instead, so the tapped + // trigger stays put; long threads are unaffected. + + ); +}; diff --git a/apps/mobile/components/ai-elements/conversation-list.types.ts b/apps/mobile/components/ai-elements/conversation-list.types.ts new file mode 100644 index 00000000000..241609ef3b2 --- /dev/null +++ b/apps/mobile/components/ai-elements/conversation-list.types.ts @@ -0,0 +1,23 @@ +import type { LegendListProps } from "@legendapp/list/react-native"; +import type { ReactElement, Ref } from "react"; + +export interface ConversationListHandle { + scrollToEnd: (options: { animated: boolean }) => void; +} + +export type ConversationListProps = Omit< + LegendListProps, + "children" | "data" | "renderItem" | "onScroll" | "ref" +> & { + data: readonly ItemT[]; + renderItem: LegendListProps["renderItem"]; + onScroll?: LegendListProps["onScroll"]; + /** Reports whether the list is pinned to its newest item. */ + onAtBottomChange: (atBottom: boolean) => void; + ref?: Ref; +}; + +/** The scrolling list of a conversation, newest item last. */ +export type ConversationListComponent = ( + props: ConversationListProps, +) => ReactElement; diff --git a/apps/mobile/components/ai-elements/conversation-list.web.tsx b/apps/mobile/components/ai-elements/conversation-list.web.tsx new file mode 100644 index 00000000000..378727310c0 --- /dev/null +++ b/apps/mobile/components/ai-elements/conversation-list.web.tsx @@ -0,0 +1,108 @@ +import { + type ComponentType, + createElement, + Fragment, + isValidElement, + type ReactElement, + useCallback, + useImperativeHandle, + useRef, +} from "react"; +import { + type NativeScrollEvent, + type NativeSyntheticEvent, + ScrollView, + type ScrollViewInstance, +} from "react-native"; +import type { ConversationListComponent } from "./conversation-list.types"; + +const AT_BOTTOM_THRESHOLD = 48; + +function slot(component: ComponentType | ReactElement | null | undefined) { + if (!component) return null; + return isValidElement(component) ? component : createElement(component); +} + +/** + * Every row stays in the DOM, so the browser anchors the scroll position and + * streamed text re-renders. Any scroll up releases the pin; reaching the + * bottom again restores it. + */ +export const ConversationList: ConversationListComponent = ({ + ref, + data, + renderItem, + keyExtractor, + ListHeaderComponent, + ListFooterComponent, + contentContainerStyle, + keyboardDismissMode, + onAtBottomChange, + onScroll, +}) => { + const scrollRef = useRef(null); + const pinned = useRef(true); + const lastOffset = useRef(0); + + const setPinned = useCallback( + (next: boolean) => { + if (pinned.current === next) return; + pinned.current = next; + onAtBottomChange(next); + }, + [onAtBottomChange], + ); + + useImperativeHandle(ref, () => ({ + scrollToEnd: (options) => { + setPinned(true); + scrollRef.current?.scrollToEnd(options); + }, + })); + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = + event.nativeEvent; + const distanceFromBottom = + contentSize.height - layoutMeasurement.height - contentOffset.y; + const scrolledUp = + contentOffset.y < lastOffset.current - 1 && distanceFromBottom > 1; + lastOffset.current = contentOffset.y; + setPinned(!scrolledUp && distanceFromBottom < AT_BOTTOM_THRESHOLD); + onScroll?.(event); + }, + [setPinned, onScroll], + ); + + const followContent = useCallback(() => { + if (pinned.current) scrollRef.current?.scrollToEnd({ animated: false }); + }, []); + + return ( + + {slot(ListHeaderComponent)} + {data.map((item, index) => ( + + {renderItem?.({ + item, + index, + data, + extraData: undefined, + type: undefined, + })} + + ))} + {slot(ListFooterComponent)} + + ); +}; diff --git a/apps/mobile/components/ai-elements/conversation.tsx b/apps/mobile/components/ai-elements/conversation.tsx index bce0771c58f..8fab2904f2b 100644 --- a/apps/mobile/components/ai-elements/conversation.tsx +++ b/apps/mobile/components/ai-elements/conversation.tsx @@ -1,8 +1,4 @@ -import { - LegendList, - type LegendListProps, - type LegendListRef, -} from "@legendapp/list/react-native"; +import type { LegendListProps } from "@legendapp/list/react-native"; import { msg } from "@lingui/core/macro"; import { i18n } from "@superset/i18n"; import { ArrowDownIcon } from "lucide-react-native"; @@ -14,7 +10,6 @@ import { useRef, useState, } from "react"; -import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native"; import { View } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { useResolveClassNames } from "uniwind"; @@ -22,8 +17,8 @@ import { Button, type ButtonProps } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { Text } from "@/components/ui/text"; import { cn } from "@/lib/utils"; - -const AT_BOTTOM_THRESHOLD = 48; +import { ConversationList } from "./conversation-list"; +import type { ConversationListHandle } from "./conversation-list.types"; interface ConversationContextType { isAtBottom: boolean; @@ -63,24 +58,12 @@ export const Conversation = ({ children, ...listProps }: ConversationProps) => { - const listRef = useRef(null); + const listRef = useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); const resolvedContentContainerStyle = useResolveClassNames( contentContainerClassName ?? "", ); - const handleScroll = useCallback( - (event: NativeSyntheticEvent) => { - const { contentOffset, contentSize, layoutMeasurement } = - event.nativeEvent; - const distanceFromBottom = - contentSize.height - layoutMeasurement.height - contentOffset.y; - setIsAtBottom(distanceFromBottom < AT_BOTTOM_THRESHOLD); - onScroll?.(event); - }, - [onScroll], - ); - const scrollToBottom = useCallback(() => { listRef.current?.scrollToEnd({ animated: true }); }, []); @@ -93,25 +76,8 @@ export const Conversation = ({ return ( - {/* Not alignItemsAtEnd: bottom-anchoring short threads means ANY item - growth (expanding a tool card) shoves the whole thread upward. - Top-aligned short threads grow downward instead, so the tapped - trigger stays put; long threads are unaffected. */} - ({ ? [resolvedContentContainerStyle, contentContainerStyle] : contentContainerStyle } - onScroll={handleScroll} + onAtBottomChange={setIsAtBottom} + onScroll={onScroll} ref={listRef} /> {children} diff --git a/apps/mobile/components/ai-elements/message-markdown.tsx b/apps/mobile/components/ai-elements/message-markdown.tsx new file mode 100644 index 00000000000..62d11e2059c --- /dev/null +++ b/apps/mobile/components/ai-elements/message-markdown.tsx @@ -0,0 +1,13 @@ +import { StreamdownText } from "react-native-streamdown"; +import type { MessageMarkdownComponent } from "./message-markdown.types"; + +export const MessageMarkdown: MessageMarkdownComponent = ({ + markdown, + markdownStyle, +}) => ( + +); diff --git a/apps/mobile/components/ai-elements/message-markdown.types.ts b/apps/mobile/components/ai-elements/message-markdown.types.ts new file mode 100644 index 00000000000..986c59b3752 --- /dev/null +++ b/apps/mobile/components/ai-elements/message-markdown.types.ts @@ -0,0 +1,10 @@ +import type { ComponentType } from "react"; +import type { MarkdownStyle } from "react-native-enriched-markdown"; + +export interface MessageMarkdownProps { + markdown: string; + markdownStyle: MarkdownStyle; +} + +/** Markdown that may still be streaming in. */ +export type MessageMarkdownComponent = ComponentType; diff --git a/apps/mobile/components/ai-elements/message-markdown.web.tsx b/apps/mobile/components/ai-elements/message-markdown.web.tsx new file mode 100644 index 00000000000..a98727a2fb1 --- /dev/null +++ b/apps/mobile/components/ai-elements/message-markdown.web.tsx @@ -0,0 +1,9 @@ +import { EnrichedMarkdownText } from "react-native-enriched-markdown"; +import type { MessageMarkdownComponent } from "./message-markdown.types"; + +export const MessageMarkdown: MessageMarkdownComponent = ({ + markdown, + markdownStyle, +}) => ( + +); diff --git a/apps/mobile/components/ai-elements/message.tsx b/apps/mobile/components/ai-elements/message.tsx index 6e53de5a1fe..4623ffc11f8 100644 --- a/apps/mobile/components/ai-elements/message.tsx +++ b/apps/mobile/components/ai-elements/message.tsx @@ -14,7 +14,6 @@ import { } from "react"; import { Platform, View } from "react-native"; import type { MarkdownStyle } from "react-native-enriched-markdown"; -import { StreamdownText } from "react-native-streamdown"; import { Button, type ButtonProps } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { Text, TextClassContext } from "@/components/ui/text"; @@ -25,6 +24,7 @@ import { } from "@/components/ui/tooltip"; import { THEME } from "@/lib/theme"; import { cn } from "@/lib/utils"; +import { MessageMarkdown } from "./message-markdown"; export type MessageRole = "system" | "user" | "assistant"; @@ -463,25 +463,16 @@ export const MESSAGE_MARKDOWN_STYLE: MarkdownStyle = { }, }; -export type MessageResponseProps = Omit< - React.ComponentProps, - "markdown" -> & { +export type MessageResponseProps = { children: string; + markdownStyle?: MarkdownStyle; }; export const MessageResponse = memo( - ({ - children, - markdownStyle, - flavor = "github", - ...props - }: MessageResponseProps) => ( - ( + ), (prevProps, nextProps) => diff --git a/apps/mobile/components/ui/input.tsx b/apps/mobile/components/ui/input.tsx index e7e5cfcbf18..386599bf12b 100644 --- a/apps/mobile/components/ui/input.tsx +++ b/apps/mobile/components/ui/input.tsx @@ -2,6 +2,9 @@ import { Platform, TextInput } from "react-native"; import { useTheme } from "@/hooks/useTheme"; import { cn } from "@/lib/utils"; +// Android fills the selection with this colour, so it must stay see-through. +const SELECTION_ANDROID = "rgba(250, 250, 250, 0.3)"; + function Input({ className, ...props @@ -30,7 +33,11 @@ function Input({ }), className, )} - selectionColor={theme.foreground} + selectionColor={Platform.select({ + android: SELECTION_ANDROID, + default: theme.foreground, + })} + cursorColor={theme.foreground} {...props} /> ); diff --git a/apps/mobile/config-plugins/withAndroidLabel.js b/apps/mobile/config-plugins/withAndroidLabel.js new file mode 100644 index 00000000000..98ff9e8b754 --- /dev/null +++ b/apps/mobile/config-plugins/withAndroidLabel.js @@ -0,0 +1,24 @@ +const { AndroidConfig, withStringsXml } = require("expo/config-plugins"); + +/** + * Android's counterpart to `CFBundleDisplayName`: the launcher reads + * `app_name`, which Expo fills from `name`. `name` stays as is because iOS + * derives its product and scheme names from it. + * + * @type {import("expo/config-plugins").ConfigPlugin<{ label: string }>} + */ +const withAndroidLabel = (config, { label }) => + withStringsXml(config, (config) => { + config.modResults = AndroidConfig.Strings.setStringItem( + [ + AndroidConfig.Resources.buildResourceItem({ + name: "app_name", + value: label, + }), + ], + config.modResults, + ); + return config; + }); + +module.exports = { withAndroidLabel }; diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 47d50906e17..0ad0ea78ec8 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -15,6 +15,9 @@ }, "ios": { "image": "macos-tahoe-26.6-xcode-27.1" + }, + "android": { + "buildType": "apk" } }, "development-simulator": { @@ -33,6 +36,9 @@ }, "ios": { "image": "macos-tahoe-26.6-xcode-27.1" + }, + "android": { + "buildType": "apk" } }, "e2e-test": { @@ -42,6 +48,9 @@ "simulator": true, "image": "macos-tahoe-26.6-xcode-27.1" }, + "android": { + "buildType": "apk" + }, "env": { "MOBILE_EAS_BUILD": "1", "EXPO_PUBLIC_E2E": "1" @@ -57,6 +66,9 @@ }, "ios": { "image": "macos-tahoe-26.6-xcode-27.1" + }, + "android": { + "buildType": "app-bundle" } } }, @@ -66,6 +78,10 @@ "ascAppId": "6788926383", "appleTeamId": "NV9657CS5A", "metadataPath": "./store.config.js" + }, + "android": { + "track": "internal", + "releaseStatus": "draft" } } } diff --git a/apps/mobile/fingerprint.config.js b/apps/mobile/fingerprint.config.js index 62e994c7694..2cccdf39fa4 100644 --- a/apps/mobile/fingerprint.config.js +++ b/apps/mobile/fingerprint.config.js @@ -2,14 +2,25 @@ // out of the fingerprint lets a build that differs only in its name take the // same updates as the builds already installed. const BRAND_SOURCE = "../../packages/shared/src/brand.ts"; +const ANDROID_LABEL_PLUGIN = "withAndroidLabel"; /** @param {string} expoConfigJson */ function withoutDisplayName(expoConfigJson) { const expoConfig = JSON.parse(expoConfigJson); - if (expoConfig.ios?.infoPlist?.CFBundleDisplayName === undefined) { + const hasIosName = + expoConfig.ios?.infoPlist?.CFBundleDisplayName !== undefined; + const androidLabel = expoConfig.plugins?.findIndex( + (plugin) => Array.isArray(plugin) && plugin[0] === ANDROID_LABEL_PLUGIN, + ); + if (!hasIosName && !(androidLabel >= 0)) { return expoConfigJson; } - delete expoConfig.ios.infoPlist.CFBundleDisplayName; + if (hasIosName) { + delete expoConfig.ios.infoPlist.CFBundleDisplayName; + } + if (androidLabel >= 0) { + expoConfig.plugins[androidLabel] = ANDROID_LABEL_PLUGIN; + } return JSON.stringify(expoConfig); } diff --git a/apps/mobile/fingerprint.config.test.ts b/apps/mobile/fingerprint.config.test.ts index 2829405eb41..2d37f55e53d 100644 --- a/apps/mobile/fingerprint.config.test.ts +++ b/apps/mobile/fingerprint.config.test.ts @@ -7,7 +7,7 @@ const hook = ( chunk: string, ) => fingerprintConfig.fileHookTransform(source, chunk, true, "utf8"); -describe("the runtime fingerprint ignores the iOS display name", () => { +describe("the runtime fingerprint ignores the home-screen name", () => { const withoutName = JSON.stringify({ ios: { infoPlist: { NSSupportsLiveActivities: true } }, name: "Superset", @@ -30,6 +30,19 @@ describe("the runtime fingerprint ignores the iOS display name", () => { expect(hook(expoConfigSource, withoutName)).toBe(withoutName); }); + test("the Android label hashes the same whatever it says", () => { + const labelled = (label: string) => + JSON.stringify({ + plugins: [["withAndroidLabel", { label }], "expo-router"], + }); + expect(hook(expoConfigSource, labelled("Other Name"))).toBe( + hook(expoConfigSource, labelled("Paradev")), + ); + expect(hook(expoConfigSource, labelled("Paradev"))).not.toContain( + "Paradev", + ); + }); + test("other sources are left as they are", () => { const chunk = '{"CFBundleDisplayName":"Other Name"}'; expect(hook({ type: "file", filePath: "eas.json" }, chunk)).toBe(chunk); diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 1328336dfe3..9df2eeab734 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -92,3 +92,22 @@ animation: accordion-up 0.2s ease-out; } } + +@layer base { + textarea { + resize: none; + } + textarea:focus-visible { + outline: 2px solid var(--color-ring); + outline-offset: 2px; + } + [data-input-modality="pointer"] textarea:focus { + outline: none; + } + ul { + list-style: disc; + } + ol { + list-style: decimal; + } +} diff --git a/apps/mobile/hooks/useComposeHostTheme/index.ts b/apps/mobile/hooks/useComposeHostTheme/index.ts new file mode 100644 index 00000000000..814c86432aa --- /dev/null +++ b/apps/mobile/hooks/useComposeHostTheme/index.ts @@ -0,0 +1 @@ +export { useComposeHostTheme } from "./useComposeHostTheme"; diff --git a/apps/mobile/hooks/useComposeHostTheme/useComposeHostTheme.ts b/apps/mobile/hooks/useComposeHostTheme/useComposeHostTheme.ts new file mode 100644 index 00000000000..416728fbee0 --- /dev/null +++ b/apps/mobile/hooks/useComposeHostTheme/useComposeHostTheme.ts @@ -0,0 +1,49 @@ +import type { HostProps } from "@expo/ui/jetpack-compose"; +import { useMemo } from "react"; +import { useUniwind } from "uniwind"; +import { THEME } from "@/lib/theme"; +import { hslToHex } from "./utils/hslToHex"; + +const RAISED_SURFACE = { + light: "hsl(0 0% 100%)", + dark: "hsl(0 0% 9%)", +} as const; + +/** + * `colorScheme` and `colorOverrides` for a Compose `Host`, so Material + * surfaces use the app's palette and not the wallpaper's. + */ +export function useComposeHostTheme(): Pick< + HostProps, + "colorScheme" | "colorOverrides" +> { + const { theme } = useUniwind(); + return useMemo(() => { + const colors = THEME[theme]; + const raised = hslToHex(RAISED_SURFACE[theme]); + return { + colorScheme: theme, + colorOverrides: { + primary: hslToHex(colors.primary), + onPrimary: hslToHex(colors.primaryForeground), + background: hslToHex(colors.background), + onBackground: hslToHex(colors.foreground), + surface: hslToHex(colors.background), + onSurface: hslToHex(colors.foreground), + surfaceVariant: hslToHex(colors.secondary), + onSurfaceVariant: hslToHex(colors.mutedForeground), + surfaceTint: hslToHex(colors.background), + surfaceContainerLowest: raised, + surfaceContainerLow: raised, + surfaceContainer: raised, + surfaceContainerHigh: raised, + surfaceContainerHighest: hslToHex(colors.secondary), + secondaryContainer: hslToHex(colors.secondary), + onSecondaryContainer: hslToHex(colors.secondaryForeground), + outline: hslToHex(colors.ring), + outlineVariant: hslToHex(colors.border), + error: hslToHex(colors.destructive), + }, + }; + }, [theme]); +} diff --git a/apps/mobile/hooks/useComposeHostTheme/utils/hslToHex/hslToHex.test.ts b/apps/mobile/hooks/useComposeHostTheme/utils/hslToHex/hslToHex.test.ts new file mode 100644 index 00000000000..fdc55f19836 --- /dev/null +++ b/apps/mobile/hooks/useComposeHostTheme/utils/hslToHex/hslToHex.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "bun:test"; +import { hslToHex } from "./hslToHex"; + +describe("hslToHex", () => { + test("converts theme colors", () => { + expect(hslToHex("hsl(0 0% 100%)")).toBe("#ffffff"); + expect(hslToHex("hsl(0 0% 3.9%)")).toBe("#0a0a0a"); + expect(hslToHex("hsl(0 84.2% 60.2%)")).toBe("#ef4444"); + }); + + test("throws on a color it cannot read", () => { + expect(() => hslToHex("#ffffff")).toThrow(); + }); +}); diff --git a/apps/mobile/hooks/useComposeHostTheme/utils/hslToHex/hslToHex.ts b/apps/mobile/hooks/useComposeHostTheme/utils/hslToHex/hslToHex.ts new file mode 100644 index 00000000000..837502d6319 --- /dev/null +++ b/apps/mobile/hooks/useComposeHostTheme/utils/hslToHex/hslToHex.ts @@ -0,0 +1,15 @@ +/** `hsl(H S% L%)` from `THEME` as the `#RRGGBB` a Compose host takes. */ +export function hslToHex(hsl: string): `#${string}` { + const match = hsl.match(/^hsl\(([\d.]+) ([\d.]+)% ([\d.]+)%\)$/); + if (!match) throw new Error(`Not an hsl() color: ${hsl}`); + const [h, s, l] = match.slice(1).map(Number) as [number, number, number]; + const a = (s / 100) * Math.min(l / 100, 1 - l / 100); + const channel = (n: number) => { + const k = (n + h / 30) % 12; + const value = l / 100 - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); + return Math.round(value * 255) + .toString(16) + .padStart(2, "0"); + }; + return `#${channel(0)}${channel(8)}${channel(4)}`; +} diff --git a/apps/mobile/hooks/useComposeHostTheme/utils/hslToHex/index.ts b/apps/mobile/hooks/useComposeHostTheme/utils/hslToHex/index.ts new file mode 100644 index 00000000000..e41785e7170 --- /dev/null +++ b/apps/mobile/hooks/useComposeHostTheme/utils/hslToHex/index.ts @@ -0,0 +1 @@ +export { hslToHex } from "./hslToHex"; diff --git a/apps/mobile/hooks/useReadableInset/useReadableInset.test.ts b/apps/mobile/hooks/useReadableInset/useReadableInset.test.ts index 84b36251807..fc386f41e2f 100644 --- a/apps/mobile/hooks/useReadableInset/useReadableInset.test.ts +++ b/apps/mobile/hooks/useReadableInset/useReadableInset.test.ts @@ -25,4 +25,19 @@ describe("READABLE_WIDTH", () => { expect(js).toBeString(); expect(swift).toBe(js); }); + + test("matches the React Native composer's column", () => { + const hook = readFileSync( + join(import.meta.dir, "useReadableInset.ts"), + "utf8", + ); + const constants = readFileSync( + join(import.meta.dir, "../../modules/composer/src/constants.ts"), + "utf8", + ); + const js = hook.match(/export const READABLE_WIDTH = (\d+);/)?.[1]; + const composer = constants.match(/\tmaxWidth: (\d+),/)?.[1]; + expect(js).toBeString(); + expect(composer).toBe(js); + }); }); diff --git a/apps/mobile/lib/action-sheet/actionSheetRequest.test.ts b/apps/mobile/lib/action-sheet/actionSheetRequest.test.ts new file mode 100644 index 00000000000..8b6aef941a2 --- /dev/null +++ b/apps/mobile/lib/action-sheet/actionSheetRequest.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, mock, test } from "bun:test"; +import { + requestActionSheet, + settleActionSheet, + subscribeActionSheet, +} from "./actionSheetRequest"; + +const options = { options: ["Edit", "Delete", "Cancel"], cancelButtonIndex: 2 }; + +describe("actionSheetRequest", () => { + test("without a mounted host nothing opens", () => { + expect(requestActionSheet({ options, onSelect: () => {} })).toBe(false); + }); + + test("a choice reports its index once, and a dismissal reports cancel", () => { + const unmount = subscribeActionSheet(() => {}); + const first = mock((_index: number) => {}); + expect(requestActionSheet({ options, onSelect: first })).toBe(true); + settleActionSheet(1); + settleActionSheet(0); + expect(first.mock.calls).toEqual([[1]]); + + const second = mock((_index: number) => {}); + requestActionSheet({ options, onSelect: second }); + settleActionSheet(); + expect(second.mock.calls).toEqual([[2]]); + unmount(); + }); + + test("a new sheet cancels the one still open", () => { + const unmount = subscribeActionSheet(() => {}); + const first = mock((_index: number) => {}); + requestActionSheet({ options, onSelect: first }); + requestActionSheet({ options, onSelect: () => {} }); + expect(first.mock.calls).toEqual([[2]]); + settleActionSheet(); + unmount(); + }); +}); diff --git a/apps/mobile/lib/action-sheet/actionSheetRequest.ts b/apps/mobile/lib/action-sheet/actionSheetRequest.ts new file mode 100644 index 00000000000..4dd8babca3a --- /dev/null +++ b/apps/mobile/lib/action-sheet/actionSheetRequest.ts @@ -0,0 +1,48 @@ +import { useSyncExternalStore } from "react"; +import type { ActionSheetOptions } from "./showActionSheet.types"; + +export interface ActionSheetRequest { + options: ActionSheetOptions; + onSelect: (index: number) => void; +} + +let pending: ActionSheetRequest | null = null; +const listeners = new Set<() => void>(); + +/** Calls `listener` when a sheet opens or closes. */ +export function subscribeActionSheet(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +const current = () => pending; + +function emit() { + for (const listener of listeners) listener(); +} + +/** The open action sheet, for the mounted `ActionSheetHost`. */ +export function useActionSheetRequest() { + return useSyncExternalStore(subscribeActionSheet, current, current); +} + +/** Opens a sheet; one already open is cancelled first. False when no host is mounted. */ +export function requestActionSheet(request: ActionSheetRequest) { + if (listeners.size === 0) return false; + settleActionSheet(); + pending = request; + emit(); + return true; +} + +/** Closes the open sheet and reports `index`, or its cancel index when none is given. */ +export function settleActionSheet(index?: number) { + const request = pending; + if (!request) return; + pending = null; + emit(); + const chosen = index ?? request.options.cancelButtonIndex; + if (chosen !== undefined) request.onSelect(chosen); +} diff --git a/apps/mobile/lib/action-sheet/components/ActionSheetHost/ActionSheetHost.android.tsx b/apps/mobile/lib/action-sheet/components/ActionSheetHost/ActionSheetHost.android.tsx new file mode 100644 index 00000000000..c08cd2cf0e9 --- /dev/null +++ b/apps/mobile/lib/action-sheet/components/ActionSheetHost/ActionSheetHost.android.tsx @@ -0,0 +1,73 @@ +import { + Column, + Host, + ListItem, + ModalBottomSheet, + type ModalBottomSheetRef, + Text, +} from "@expo/ui/jetpack-compose"; +import { clickable, padding } from "@expo/ui/jetpack-compose/modifiers"; +import { useRef } from "react"; +import { useWindowDimensions } from "react-native"; +import { useComposeHostTheme } from "@/hooks/useComposeHostTheme"; +import { useTheme } from "@/hooks/useTheme"; +import { + settleActionSheet, + useActionSheetRequest, +} from "../../actionSheetRequest"; +import type { ActionSheetHostComponent } from "./ActionSheetHost.types"; + +export const ActionSheetHost: ActionSheetHostComponent = () => { + const request = useActionSheetRequest(); + const sheetRef = useRef(null); + const hostTheme = useComposeHostTheme(); + const theme = useTheme(); + const { width } = useWindowDimensions(); + if (!request) return null; + + const { options, cancelButtonIndex, destructiveButtonIndex } = + request.options; + // The choice runs once the sheet is gone, so a screen it opens is not covered. + const choose = async (index: number) => { + await sheetRef.current?.hide().catch(() => {}); + settleActionSheet(index); + }; + + return ( + + settleActionSheet()} + skipPartiallyExpanded + > + + {options.map((label, index) => + index === cancelButtonIndex ? null : ( + void choose(index))]} + > + + + {label} + + + + ), + )} + + + + ); +}; diff --git a/apps/mobile/lib/action-sheet/components/ActionSheetHost/ActionSheetHost.tsx b/apps/mobile/lib/action-sheet/components/ActionSheetHost/ActionSheetHost.tsx new file mode 100644 index 00000000000..ed9d292a7b9 --- /dev/null +++ b/apps/mobile/lib/action-sheet/components/ActionSheetHost/ActionSheetHost.tsx @@ -0,0 +1,3 @@ +import type { ActionSheetHostComponent } from "./ActionSheetHost.types"; + +export const ActionSheetHost: ActionSheetHostComponent = () => null; diff --git a/apps/mobile/lib/action-sheet/components/ActionSheetHost/ActionSheetHost.types.ts b/apps/mobile/lib/action-sheet/components/ActionSheetHost/ActionSheetHost.types.ts new file mode 100644 index 00000000000..8523a239dfc --- /dev/null +++ b/apps/mobile/lib/action-sheet/components/ActionSheetHost/ActionSheetHost.types.ts @@ -0,0 +1,4 @@ +import type { ComponentType } from "react"; + +/** Mounted once at the app root; draws `showActionSheet` where the platform has no native sheet to call. */ +export type ActionSheetHostComponent = ComponentType; diff --git a/apps/mobile/lib/action-sheet/components/ActionSheetHost/index.ts b/apps/mobile/lib/action-sheet/components/ActionSheetHost/index.ts new file mode 100644 index 00000000000..de0a74b27a1 --- /dev/null +++ b/apps/mobile/lib/action-sheet/components/ActionSheetHost/index.ts @@ -0,0 +1 @@ +export { ActionSheetHost } from "./ActionSheetHost"; diff --git a/apps/mobile/lib/action-sheet/index.ts b/apps/mobile/lib/action-sheet/index.ts new file mode 100644 index 00000000000..5096201dfbd --- /dev/null +++ b/apps/mobile/lib/action-sheet/index.ts @@ -0,0 +1,2 @@ +export { ActionSheetHost } from "./components/ActionSheetHost"; +export { showActionSheet } from "./showActionSheet"; diff --git a/apps/mobile/lib/action-sheet/showActionSheet.android.ts b/apps/mobile/lib/action-sheet/showActionSheet.android.ts new file mode 100644 index 00000000000..54b5b20b500 --- /dev/null +++ b/apps/mobile/lib/action-sheet/showActionSheet.android.ts @@ -0,0 +1,9 @@ +import { requestActionSheet } from "./actionSheetRequest"; +import { showActionSheet as showAlertActionSheet } from "./showActionSheet"; +import type { ShowActionSheet } from "./showActionSheet.types"; + +/** A Material bottom sheet, drawn by `ActionSheetHost`. */ +export const showActionSheet: ShowActionSheet = (options, onSelect) => { + if (!requestActionSheet({ options, onSelect })) + showAlertActionSheet(options, onSelect); +}; diff --git a/apps/mobile/lib/action-sheet/showActionSheet.ios.ts b/apps/mobile/lib/action-sheet/showActionSheet.ios.ts new file mode 100644 index 00000000000..c534389c34d --- /dev/null +++ b/apps/mobile/lib/action-sheet/showActionSheet.ios.ts @@ -0,0 +1,5 @@ +import { ActionSheetIOS } from "react-native"; +import type { ShowActionSheet } from "./showActionSheet.types"; + +export const showActionSheet: ShowActionSheet = (options, onSelect) => + ActionSheetIOS.showActionSheetWithOptions(options, onSelect); diff --git a/apps/mobile/lib/action-sheet/showActionSheet.ts b/apps/mobile/lib/action-sheet/showActionSheet.ts new file mode 100644 index 00000000000..5859efde9e7 --- /dev/null +++ b/apps/mobile/lib/action-sheet/showActionSheet.ts @@ -0,0 +1,23 @@ +import { Alert } from "react-native"; +import type { ShowActionSheet } from "./showActionSheet.types"; + +/** No native action sheet here, so the choices become an alert's buttons. */ +export const showActionSheet: ShowActionSheet = ( + { options, cancelButtonIndex, destructiveButtonIndex }, + onSelect, +) => + Alert.alert( + "", + undefined, + options.map((text, index) => ({ + text, + style: + index === cancelButtonIndex + ? "cancel" + : index === destructiveButtonIndex + ? "destructive" + : "default", + onPress: () => onSelect(index), + })), + { cancelable: true }, + ); diff --git a/apps/mobile/lib/action-sheet/showActionSheet.types.ts b/apps/mobile/lib/action-sheet/showActionSheet.types.ts new file mode 100644 index 00000000000..629f86a7537 --- /dev/null +++ b/apps/mobile/lib/action-sheet/showActionSheet.types.ts @@ -0,0 +1,12 @@ +export interface ActionSheetOptions { + options: string[]; + cancelButtonIndex?: number; + destructiveButtonIndex?: number; + /** A view's React tag the iPad popover points at. */ + anchor?: number; +} + +export type ShowActionSheet = ( + options: ActionSheetOptions, + onSelect: (index: number) => void, +) => void; diff --git a/apps/mobile/lib/alert/alertQueue.ts b/apps/mobile/lib/alert/alertQueue.ts new file mode 100644 index 00000000000..5decc833ff0 --- /dev/null +++ b/apps/mobile/lib/alert/alertQueue.ts @@ -0,0 +1,42 @@ +import { useSyncExternalStore } from "react"; +import type { AlertButton, AlertOptions } from "react-native"; + +export interface PendingAlert { + title: string; + message?: string; + buttons: AlertButton[]; + options?: AlertOptions; +} + +const queue: PendingAlert[] = []; +const listeners = new Set<() => void>(); + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function emit() { + for (const listener of listeners) listener(); +} + +const current = () => queue[0]; + +/** The alert the mounted `AlertHost` shows, oldest first. */ +export function usePendingAlert() { + return useSyncExternalStore(subscribe, current, current); +} + +export function enqueueAlert(alert: PendingAlert) { + queue.push(alert); + emit(); +} + +/** Closes the shown alert, then runs the chosen button's handler. */ +export function settleAlert(button: AlertButton | undefined) { + queue.shift(); + emit(); + button?.onPress?.(button.text); +} diff --git a/apps/mobile/lib/alert/components/AlertHost/AlertHost.tsx b/apps/mobile/lib/alert/components/AlertHost/AlertHost.tsx new file mode 100644 index 00000000000..1e436d506f3 --- /dev/null +++ b/apps/mobile/lib/alert/components/AlertHost/AlertHost.tsx @@ -0,0 +1,99 @@ +import { useLingui } from "@lingui/react/macro"; +import type { AlertButton } from "react-native"; +import { Modal, Pressable, View } from "react-native"; +import { Text } from "@/components/ui/text"; +import { cn } from "@/lib/utils"; +import { settleAlert, usePendingAlert } from "../../alertQueue"; + +/** Draws `Alert.alert` with all its buttons where the OS has no alert. */ +export function AlertHost() { + const pending = usePendingAlert(); + const { t } = useLingui(); + if (!pending) return null; + + const buttons: AlertButton[] = pending.buttons.length + ? pending.buttons + : [{ text: t({ message: "OK" }) }]; + const cancel = buttons.find((button) => button.style === "cancel"); + const stacked = buttons.length > 2; + const dismiss = () => { + if (cancel) settleAlert(cancel); + else if (pending.options?.cancelable) { + settleAlert(undefined); + pending.options.onDismiss?.(); + } + }; + + return ( + + + true} + /> + + {pending.title || pending.message ? ( + + {pending.title ? ( + + {pending.title} + + ) : null} + {pending.message ? ( + + {pending.message} + + ) : null} + + ) : null} + + {buttons.map((button, index) => ( + settleAlert(button)} + className={cn( + "active:bg-accent hover:bg-accent min-h-12 flex-1 items-center justify-center px-4 py-3", + index > 0 && + (stacked + ? "border-border border-t" + : "border-border border-l"), + )} + > + + {button.text} + + + ))} + + + + + ); +} diff --git a/apps/mobile/lib/alert/components/AlertHost/index.ts b/apps/mobile/lib/alert/components/AlertHost/index.ts new file mode 100644 index 00000000000..f66e3e4f1bd --- /dev/null +++ b/apps/mobile/lib/alert/components/AlertHost/index.ts @@ -0,0 +1 @@ +export { AlertHost } from "./AlertHost"; diff --git a/apps/mobile/lib/alert/index.ts b/apps/mobile/lib/alert/index.ts new file mode 100644 index 00000000000..4b768b92af6 --- /dev/null +++ b/apps/mobile/lib/alert/index.ts @@ -0,0 +1,2 @@ +export { AlertHost } from "./components/AlertHost"; +export { installAlert } from "./installAlert"; diff --git a/apps/mobile/lib/alert/installAlert.ts b/apps/mobile/lib/alert/installAlert.ts new file mode 100644 index 00000000000..9fd323c0649 --- /dev/null +++ b/apps/mobile/lib/alert/installAlert.ts @@ -0,0 +1,3 @@ +import type { InstallAlert } from "./installAlert.types"; + +export const installAlert: InstallAlert = () => {}; diff --git a/apps/mobile/lib/alert/installAlert.types.ts b/apps/mobile/lib/alert/installAlert.types.ts new file mode 100644 index 00000000000..33a1ac08f3b --- /dev/null +++ b/apps/mobile/lib/alert/installAlert.types.ts @@ -0,0 +1,2 @@ +/** Makes `Alert.alert` reach the user where React Native has no native alert. */ +export type InstallAlert = () => void; diff --git a/apps/mobile/lib/alert/installAlert.web.ts b/apps/mobile/lib/alert/installAlert.web.ts new file mode 100644 index 00000000000..909aaede4e3 --- /dev/null +++ b/apps/mobile/lib/alert/installAlert.web.ts @@ -0,0 +1,15 @@ +import { Alert } from "react-native"; +import { enqueueAlert } from "./alertQueue"; +import type { InstallAlert } from "./installAlert.types"; + +// react-native-web's Alert.alert does nothing, so every error and every +// confirmation was silent in the browser. +export const installAlert: InstallAlert = () => { + Alert.alert = (title, message, buttons, options) => + enqueueAlert({ + title: title ?? "", + message: message ?? undefined, + buttons: buttons ?? [], + options, + }); +}; diff --git a/apps/mobile/lib/app-url/appUrl.ts b/apps/mobile/lib/app-url/appUrl.ts new file mode 100644 index 00000000000..59e74684dc8 --- /dev/null +++ b/apps/mobile/lib/app-url/appUrl.ts @@ -0,0 +1,4 @@ +/** An app path as the browser sees it, under the web export's base path. */ +export function appUrl(path: string): string { + return `${process.env.EXPO_BASE_URL ?? ""}${path}`; +} diff --git a/apps/mobile/lib/app-url/index.ts b/apps/mobile/lib/app-url/index.ts new file mode 100644 index 00000000000..c4d3efbb634 --- /dev/null +++ b/apps/mobile/lib/app-url/index.ts @@ -0,0 +1 @@ +export { appUrl } from "./appUrl"; diff --git a/apps/mobile/lib/attachments/attachmentFile/attachmentFile.ts b/apps/mobile/lib/attachments/attachmentFile/attachmentFile.ts new file mode 100644 index 00000000000..101282dc586 --- /dev/null +++ b/apps/mobile/lib/attachments/attachmentFile/attachmentFile.ts @@ -0,0 +1,16 @@ +import { File } from "expo-file-system"; +import type { + AttachmentSize, + OpenAttachmentFile, +} from "./attachmentFile.types"; + +export const attachmentSize: AttachmentSize = (uri) => new File(uri).size; + +export const openAttachmentFile: OpenAttachmentFile = async (uri) => { + const file = new File(uri); + return { + size: file.size, + upload: (url, options) => + file.upload(url, { httpMethod: "PUT", ...options }), + }; +}; diff --git a/apps/mobile/lib/attachments/attachmentFile/attachmentFile.types.ts b/apps/mobile/lib/attachments/attachmentFile/attachmentFile.types.ts new file mode 100644 index 00000000000..72f4b62bd5d --- /dev/null +++ b/apps/mobile/lib/attachments/attachmentFile/attachmentFile.types.ts @@ -0,0 +1,20 @@ +export interface AttachmentUploadOptions { + headers: Record; + signal: AbortSignal; + onProgress: (progress: { bytesSent: number; totalBytes: number }) => void; +} + +export interface AttachmentFile { + /** 0 when the file cannot be read. */ + size: number; + /** PUTs the bytes to a presigned URL. */ + upload: ( + url: string, + options: AttachmentUploadOptions, + ) => Promise<{ status: number }>; +} + +export type OpenAttachmentFile = (uri: string) => Promise; + +/** The size on disk, read synchronously; 0 when it cannot be measured. */ +export type AttachmentSize = (uri: string) => number; diff --git a/apps/mobile/lib/attachments/attachmentFile/attachmentFile.web.ts b/apps/mobile/lib/attachments/attachmentFile/attachmentFile.web.ts new file mode 100644 index 00000000000..8b66dc0bcc5 --- /dev/null +++ b/apps/mobile/lib/attachments/attachmentFile/attachmentFile.web.ts @@ -0,0 +1,35 @@ +import type { + AttachmentSize, + OpenAttachmentFile, +} from "./attachmentFile.types"; + +// A blob URL has no synchronous size; the upload measures it. +export const attachmentSize: AttachmentSize = () => 0; + +export const openAttachmentFile: OpenAttachmentFile = async (uri) => { + const blob = await fetch(uri) + .then((response) => response.blob()) + .catch(() => null); + return { + size: blob?.size ?? 0, + upload: (url, { headers, signal, onProgress }) => + new Promise((resolve, reject) => { + const request = new XMLHttpRequest(); + request.open("PUT", url); + for (const [name, value] of Object.entries(headers)) { + request.setRequestHeader(name, value); + } + request.upload.onprogress = (event) => + onProgress({ + bytesSent: event.loaded, + totalBytes: event.lengthComputable ? event.total : 0, + }); + request.onload = () => resolve({ status: request.status }); + request.onerror = () => reject(new TypeError("Network request failed")); + request.onabort = () => + reject(new DOMException("Aborted", "AbortError")); + signal.addEventListener("abort", () => request.abort()); + request.send(blob); + }), + }; +}; diff --git a/apps/mobile/lib/attachments/attachmentFile/index.ts b/apps/mobile/lib/attachments/attachmentFile/index.ts new file mode 100644 index 00000000000..6e107429f6a --- /dev/null +++ b/apps/mobile/lib/attachments/attachmentFile/index.ts @@ -0,0 +1,2 @@ +export { attachmentSize, openAttachmentFile } from "./attachmentFile"; +export type { AttachmentFile } from "./attachmentFile.types"; diff --git a/apps/mobile/lib/attachments/upload.ts b/apps/mobile/lib/attachments/upload.ts index 24b19bb82cf..d3d6a377a40 100644 --- a/apps/mobile/lib/attachments/upload.ts +++ b/apps/mobile/lib/attachments/upload.ts @@ -1,10 +1,10 @@ import { msg } from "@lingui/core/macro"; import { i18n } from "@superset/i18n"; import { MAX_ATTACHMENT_BYTES } from "@superset/shared/attachment-limits"; -import { File } from "expo-file-system"; import type { PromptInputAttachmentItem } from "@/components/ai-elements/prompt-input"; import { apiClient } from "@/lib/trpc/client"; import { useComposerDraftsStore } from "@/screens/(authenticated)/stores/composerDraftsStore"; +import { type AttachmentFile, openAttachmentFile } from "./attachmentFile"; import { waitForSettledUploads } from "./settle"; const FALLBACK_MEDIA_TYPE = "application/octet-stream"; @@ -35,7 +35,7 @@ function nameOf(attachment: PromptInputAttachmentItem, index: number): string { * It reaches here only when the picker reported no size and the file could * not be measured until now. */ -function assertSendable(file: File): void { +function assertSendable(file: AttachmentFile): void { // 0 is what `size` reports for a file it cannot read, not an empty one — // either way there is nothing to upload. if (file.size <= 0) { @@ -72,7 +72,7 @@ async function upload( store.beginUpload(draftKey, attachment.id); try { - const file = new File(attachment.uri); + const file = await openAttachmentFile(attachment.uri); const name = nameOf(attachment, index); assertSendable(file); @@ -85,7 +85,6 @@ async function upload( let reported = -1; const result = await file.upload(target.url, { - httpMethod: "PUT", headers: target.headers, signal: controller.signal, onProgress: ({ bytesSent, totalBytes }) => { diff --git a/apps/mobile/lib/auth/sessionTransport/index.ts b/apps/mobile/lib/auth/sessionTransport/index.ts new file mode 100644 index 00000000000..d137406cc52 --- /dev/null +++ b/apps/mobile/lib/auth/sessionTransport/index.ts @@ -0,0 +1 @@ +export { sessionFetch, sessionHeaders } from "./sessionTransport"; diff --git a/apps/mobile/lib/auth/sessionTransport/sessionTransport.ts b/apps/mobile/lib/auth/sessionTransport/sessionTransport.ts new file mode 100644 index 00000000000..d542b2d9dc9 --- /dev/null +++ b/apps/mobile/lib/auth/sessionTransport/sessionTransport.ts @@ -0,0 +1,11 @@ +import { authClient } from "../client"; +import type { SessionFetch, SessionHeaders } from "./sessionTransport.types"; + +export const sessionHeaders: SessionHeaders = () => { + const cookie = authClient.getCookie(); + const headers: Record = {}; + if (cookie) headers.Cookie = cookie; + return headers; +}; + +export const sessionFetch: SessionFetch = (input, init) => fetch(input, init); diff --git a/apps/mobile/lib/auth/sessionTransport/sessionTransport.types.ts b/apps/mobile/lib/auth/sessionTransport/sessionTransport.types.ts new file mode 100644 index 00000000000..09d11dd5093 --- /dev/null +++ b/apps/mobile/lib/auth/sessionTransport/sessionTransport.types.ts @@ -0,0 +1,3 @@ +export type SessionHeaders = () => Record; + +export type SessionFetch = typeof fetch; diff --git a/apps/mobile/lib/auth/sessionTransport/sessionTransport.web.ts b/apps/mobile/lib/auth/sessionTransport/sessionTransport.web.ts new file mode 100644 index 00000000000..fd3c26e5c2e --- /dev/null +++ b/apps/mobile/lib/auth/sessionTransport/sessionTransport.web.ts @@ -0,0 +1,7 @@ +import type { SessionFetch, SessionHeaders } from "./sessionTransport.types"; + +// The browser keeps the session cookie and sends it itself. +export const sessionHeaders: SessionHeaders = () => ({}); + +export const sessionFetch: SessionFetch = (input, init) => + fetch(input, { ...init, credentials: "include" }); diff --git a/apps/mobile/lib/env.ts b/apps/mobile/lib/env.ts index 09fb0d662d8..d86021ace34 100644 --- a/apps/mobile/lib/env.ts +++ b/apps/mobile/lib/env.ts @@ -14,6 +14,9 @@ const envSchema = z.object({ EXPO_PUBLIC_SENTRY_DSN_MOBILE: z.url().optional(), EXPO_PUBLIC_SENTRY_ENVIRONMENT: z.string().default("production"), EXPO_PUBLIC_E2E: z.string().optional(), + /** The API's Google web client (`GOOGLE_CLIENT_ID`). Android asks Credential + * Manager for an ID token addressed to it, and uses the browser without it. */ + EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID: z.string().optional(), /** Set by .superset/setup.sh or setup.cloud.sh (seed-local-mobile-token.ts, * seed-cloud-mobile-token.ts): a one-time token redeemable for a real * session as the real developer/workspace creator, via the dev-only @@ -32,6 +35,8 @@ const rawEnv: Record = { EXPO_PUBLIC_SENTRY_DSN_MOBILE: process.env.EXPO_PUBLIC_SENTRY_DSN_MOBILE, EXPO_PUBLIC_SENTRY_ENVIRONMENT: process.env.EXPO_PUBLIC_SENTRY_ENVIRONMENT, EXPO_PUBLIC_E2E: process.env.EXPO_PUBLIC_E2E, + EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID: + process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID, EXPO_PUBLIC_DEV_ONE_TIME_TOKEN: process.env.EXPO_PUBLIC_DEV_ONE_TIME_TOKEN, }; diff --git a/apps/mobile/lib/errors/index.ts b/apps/mobile/lib/errors/index.ts index 2ae84c35716..41ec910efae 100644 --- a/apps/mobile/lib/errors/index.ts +++ b/apps/mobile/lib/errors/index.ts @@ -5,5 +5,9 @@ export { transportFailureKind, watchNetworkState, } from "./errors"; -export { TransportError, transportFetch } from "./transport-fetch"; +export { + deadlineFetch, + TransportError, + transportFetch, +} from "./transport-fetch"; export { transportRetryLink } from "./transport-retry"; diff --git a/apps/mobile/lib/errors/transport-fetch.test.ts b/apps/mobile/lib/errors/transport-fetch.test.ts new file mode 100644 index 00000000000..8e0e06bb35d --- /dev/null +++ b/apps/mobile/lib/errors/transport-fetch.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { deadlineFetch, TransportError } from "./transport-fetch"; + +const realFetch = globalThis.fetch; + +/** A server that accepts the request and never answers. */ +function neverAnswers(): typeof fetch { + return ((_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ); + })) as typeof fetch; +} + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("deadlineFetch", () => { + test("fails as a transport error when no response arrives in time", async () => { + globalThis.fetch = neverAnswers(); + const outcome = await Promise.race([ + deadlineFetch(20)("http://host.test/trpc/x").catch( + (caught: unknown) => caught, + ), + new Promise((resolve) => setTimeout(() => resolve("pending"), 500)), + ]); + expect(outcome).toBeInstanceOf(TransportError); + }); + + test("returns the response that arrives in time", async () => { + globalThis.fetch = (async () => new Response("ok")) as typeof fetch; + const response = await deadlineFetch(1_000)("http://host.test/trpc/x"); + expect(await response.text()).toBe("ok"); + }); + + test("a caller's abort stays an abort, not a transport error", async () => { + globalThis.fetch = neverAnswers(); + const controller = new AbortController(); + const request = deadlineFetch(1_000)("http://host.test/trpc/x", { + signal: controller.signal, + }); + controller.abort(); + const error = await request.catch((caught: unknown) => caught); + expect(error).not.toBeInstanceOf(TransportError); + expect((error as Error).name).toBe("AbortError"); + }); +}); diff --git a/apps/mobile/lib/errors/transport-fetch.ts b/apps/mobile/lib/errors/transport-fetch.ts index 1c7bb2698a1..46cf902c613 100644 --- a/apps/mobile/lib/errors/transport-fetch.ts +++ b/apps/mobile/lib/errors/transport-fetch.ts @@ -28,3 +28,34 @@ export const transportFetch: typeof fetch = async (input, init) => { throw new TransportError(cause); } }; + +/** + * `fetch` that gives up when no response arrives within `ms`. React Native's + * Android client is built with no timeouts (OkHttpClientProvider sets them to + * 0), so a request the far side accepts and never answers stays pending for + * good there; iOS's URLSession gives up after 60 s. + */ +export function deadlineFetch(ms: number): typeof fetch { + return (input, init) => { + const callerSignal = init?.signal ?? undefined; + const controller = new AbortController(); + let timedOut = false; + const onCallerAbort = () => controller.abort(callerSignal?.reason); + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, ms); + if (callerSignal?.aborted) onCallerAbort(); + else callerSignal?.addEventListener("abort", onCallerAbort, { once: true }); + return fetch(input, { ...init, signal: controller.signal }) + .catch((cause: unknown) => { + throw timedOut + ? new TransportError(new Error(`no response in ${ms} ms`)) + : cause; + }) + .finally(() => { + clearTimeout(timer); + callerSignal?.removeEventListener("abort", onCallerAbort); + }); + }; +} diff --git a/apps/mobile/lib/host-service/client.ts b/apps/mobile/lib/host-service/client.ts index 3137107e612..ef81252daeb 100644 --- a/apps/mobile/lib/host-service/client.ts +++ b/apps/mobile/lib/host-service/client.ts @@ -6,7 +6,7 @@ import { createTRPCClient, httpLink, type TRPCClient } from "@trpc/client"; import type { inferRouterOutputs } from "@trpc/server"; import superjson from "superjson"; import { getJwt } from "../auth/client"; -import { transportRetryLink } from "../errors"; +import { deadlineFetch, transportRetryLink } from "../errors"; import { getRelayUrl } from "../host/client"; import { getSandboxAccess, sandboxToken } from "../sandbox-access"; @@ -26,6 +26,10 @@ export type BranchSearchResult = export type BranchSearchRow = BranchSearchResult["items"][number]; export type HostProjectRow = RouterOutputs["project"]["list"][number]; +// iOS's URLSession default. The relay sends a response in one piece, so on iOS +// this is already the cap on every host request. +const HOST_REQUEST_DEADLINE_MS = 60_000; + const clientCache = new Map(); /** @@ -52,6 +56,7 @@ export function getHostServiceClientByUrl(hostUrl: string): HostServiceClient { transportRetryLink(), httpLink({ url: `${hostUrl}/trpc`, + fetch: deadlineFetch(HOST_REQUEST_DEADLINE_MS), transformer: superjson, headers: () => { const headers: Record = {}; diff --git a/apps/mobile/lib/input-modality/index.ts b/apps/mobile/lib/input-modality/index.ts new file mode 100644 index 00000000000..5d2a024acdc --- /dev/null +++ b/apps/mobile/lib/input-modality/index.ts @@ -0,0 +1 @@ +export { installInputModality } from "./installInputModality"; diff --git a/apps/mobile/lib/input-modality/installInputModality.ts b/apps/mobile/lib/input-modality/installInputModality.ts new file mode 100644 index 00000000000..54fd8f30cef --- /dev/null +++ b/apps/mobile/lib/input-modality/installInputModality.ts @@ -0,0 +1,3 @@ +import type { InstallInputModality } from "./installInputModality.types"; + +export const installInputModality: InstallInputModality = () => {}; diff --git a/apps/mobile/lib/input-modality/installInputModality.types.ts b/apps/mobile/lib/input-modality/installInputModality.types.ts new file mode 100644 index 00000000000..2d5bdb5934b --- /dev/null +++ b/apps/mobile/lib/input-modality/installInputModality.types.ts @@ -0,0 +1,2 @@ +/** Marks the document with the last input type, so CSS can hide the focus ring for pointer focus only. */ +export type InstallInputModality = () => void; diff --git a/apps/mobile/lib/input-modality/installInputModality.web.ts b/apps/mobile/lib/input-modality/installInputModality.web.ts new file mode 100644 index 00000000000..6ebb572e931 --- /dev/null +++ b/apps/mobile/lib/input-modality/installInputModality.web.ts @@ -0,0 +1,13 @@ +import type { InstallInputModality } from "./installInputModality.types"; + +type Modality = "keyboard" | "pointer"; + +function setModality(modality: Modality) { + document.documentElement.dataset.inputModality = modality; +} + +export const installInputModality: InstallInputModality = () => { + setModality("pointer"); + document.addEventListener("keydown", () => setModality("keyboard"), true); + document.addEventListener("pointerdown", () => setModality("pointer"), true); +}; diff --git a/apps/mobile/lib/intl-polyfills/index.ts b/apps/mobile/lib/intl-polyfills/index.ts index cd224c393d1..1286240df22 100644 --- a/apps/mobile/lib/intl-polyfills/index.ts +++ b/apps/mobile/lib/intl-polyfills/index.ts @@ -1,2 +1,3 @@ // Side effects only — this module installs polyfills and exports nothing. import "./intl-polyfills"; +import "./scriptSubtagFallback"; diff --git a/apps/mobile/lib/intl-polyfills/scriptSubtagFallback.android.ts b/apps/mobile/lib/intl-polyfills/scriptSubtagFallback.android.ts new file mode 100644 index 00000000000..b9c581b6224 --- /dev/null +++ b/apps/mobile/lib/intl-polyfills/scriptSubtagFallback.android.ts @@ -0,0 +1,4 @@ +import { withScriptSubtagFallback } from "./withScriptSubtagFallback"; + +Intl.NumberFormat = withScriptSubtagFallback(Intl.NumberFormat); +Intl.DateTimeFormat = withScriptSubtagFallback(Intl.DateTimeFormat); diff --git a/apps/mobile/lib/intl-polyfills/scriptSubtagFallback.ts b/apps/mobile/lib/intl-polyfills/scriptSubtagFallback.ts new file mode 100644 index 00000000000..f24421b8b85 --- /dev/null +++ b/apps/mobile/lib/intl-polyfills/scriptSubtagFallback.ts @@ -0,0 +1,2 @@ +// Only Android needs this; see scriptSubtagFallback.android.ts. +export {}; diff --git a/apps/mobile/lib/intl-polyfills/withScriptSubtagFallback.test.ts b/apps/mobile/lib/intl-polyfills/withScriptSubtagFallback.test.ts new file mode 100644 index 00000000000..1232ee5df79 --- /dev/null +++ b/apps/mobile/lib/intl-polyfills/withScriptSubtagFallback.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { withScriptSubtagFallback } from "./withScriptSubtagFallback"; + +// Android Hermes's support list, as measured on API 33: script-qualified +// Chinese tags only. +const ANDROID_SUPPORTED = new Set(["en", "pt-BR", "zh-Hans-CN", "zh-Hant-TW"]); + +class AndroidLikeFormat { + readonly locale: string; + constructor(locales?: string | readonly string[]) { + const tags = typeof locales === "string" ? [locales] : (locales ?? []); + this.locale = tags.find((tag) => ANDROID_SUPPORTED.has(tag)) ?? "en-US"; + } + static supportedLocalesOf(locales: string | readonly string[]): string[] { + const tags = typeof locales === "string" ? [locales] : locales; + return tags.filter((tag) => ANDROID_SUPPORTED.has(tag)); + } +} + +describe("withScriptSubtagFallback", () => { + const Format = withScriptSubtagFallback(AndroidLikeFormat); + + test("formats region-only Chinese tags in Chinese, not en-US", () => { + expect(new Format("zh-CN").locale).toBe("zh-Hans-CN"); + expect(new Format(["zh-TW"]).locale).toBe("zh-Hant-TW"); + }); + + test("passes supported tags through unchanged", () => { + expect(new Format("pt-BR").locale).toBe("pt-BR"); + expect(new Format().locale).toBe("en-US"); + }); + + test("reports the requested tags as supported", () => { + expect(Format.supportedLocalesOf(["zh-CN", "zh-TW", "xx"])).toEqual([ + "zh-CN", + "zh-TW", + ]); + }); +}); diff --git a/apps/mobile/lib/intl-polyfills/withScriptSubtagFallback.ts b/apps/mobile/lib/intl-polyfills/withScriptSubtagFallback.ts new file mode 100644 index 00000000000..a03b21d0a54 --- /dev/null +++ b/apps/mobile/lib/intl-polyfills/withScriptSubtagFallback.ts @@ -0,0 +1,42 @@ +type Locales = string | readonly string[]; + +interface IntlFormatConstructor { + new (locales?: Locales, options?: object): object; + supportedLocalesOf(locales: Locales, options?: object): string[]; + prototype: object; +} + +/** + * Hermes on Android lists `zh-Hans-CN` but not `zh-CN`, and its lookup does + * not add the script subtag, so `zh-CN` and `zh-TW` silently format as + * `en-US`. This passes the native constructor the maximized tag (`zh-CN` -> + * `zh-Hans-CN`) for any tag it does not support as written. + */ +export function withScriptSubtagFallback( + Native: T, +): T { + const resolve = (locales: Locales | undefined) => { + if (locales === undefined) return undefined; + return (typeof locales === "string" ? [locales] : [...locales]).map( + (tag) => + Native.supportedLocalesOf([tag]).length > 0 + ? tag + : new Intl.Locale(tag).maximize().toString(), + ); + }; + + function Fallback(locales?: Locales, options?: object) { + return new Native(resolve(locales), options); + } + Fallback.prototype = Native.prototype; + Fallback.supportedLocalesOf = (locales: Locales, options?: object) => { + const requested = typeof locales === "string" ? [locales] : [...locales]; + const resolved = resolve(requested) ?? []; + return requested.filter( + (_, index) => + Native.supportedLocalesOf([resolved[index] as string], options).length > + 0, + ); + }; + return Fallback as unknown as T; +} diff --git a/apps/mobile/lib/layout/index.ts b/apps/mobile/lib/layout/index.ts new file mode 100644 index 00000000000..ffbaec44628 --- /dev/null +++ b/apps/mobile/lib/layout/index.ts @@ -0,0 +1,2 @@ +export { listSideInset } from "./listSideInset"; +export { useColumnInset } from "./useColumnInset"; diff --git a/apps/mobile/lib/layout/listSideInset.ts b/apps/mobile/lib/layout/listSideInset.ts new file mode 100644 index 00000000000..777e174843a --- /dev/null +++ b/apps/mobile/lib/layout/listSideInset.ts @@ -0,0 +1,6 @@ +import type { ListSideInsetFor } from "./listSideInset.types"; + +export const listSideInset: ListSideInsetFor = (inset) => ({ + content: { paddingHorizontal: inset }, + row: undefined, +}); diff --git a/apps/mobile/lib/layout/listSideInset.types.ts b/apps/mobile/lib/layout/listSideInset.types.ts new file mode 100644 index 00000000000..572da39a6c9 --- /dev/null +++ b/apps/mobile/lib/layout/listSideInset.types.ts @@ -0,0 +1,9 @@ +import type { ViewStyle } from "react-native"; + +/** Where a LegendList's side padding goes: on its content, or on each row. */ +export interface ListSideInset { + content: ViewStyle | undefined; + row: ViewStyle | undefined; +} + +export type ListSideInsetFor = (inset: number) => ListSideInset; diff --git a/apps/mobile/lib/layout/listSideInset.web.ts b/apps/mobile/lib/layout/listSideInset.web.ts new file mode 100644 index 00000000000..0ce4842afce --- /dev/null +++ b/apps/mobile/lib/layout/listSideInset.web.ts @@ -0,0 +1,8 @@ +import type { ListSideInsetFor } from "./listSideInset.types"; + +// LegendList's web rows are absolutely positioned at left: 0 and right: 0, so +// they ignore the content container's side padding. +export const listSideInset: ListSideInsetFor = (inset) => ({ + content: undefined, + row: { paddingHorizontal: inset }, +}); diff --git a/apps/mobile/lib/layout/useColumnInset.ts b/apps/mobile/lib/layout/useColumnInset.ts new file mode 100644 index 00000000000..bacc8e49159 --- /dev/null +++ b/apps/mobile/lib/layout/useColumnInset.ts @@ -0,0 +1,3 @@ +import type { UseColumnInset } from "./useColumnInset.types"; + +export const useColumnInset: UseColumnInset = (base) => base; diff --git a/apps/mobile/lib/layout/useColumnInset.types.ts b/apps/mobile/lib/layout/useColumnInset.types.ts new file mode 100644 index 00000000000..81bc9145954 --- /dev/null +++ b/apps/mobile/lib/layout/useColumnInset.types.ts @@ -0,0 +1,6 @@ +/** + * Side padding that keeps a full-width screen's chrome (chat, composer + * strip) in the readable column where the screen has one, and `base` + * where it does not. + */ +export type UseColumnInset = (base: number) => number; diff --git a/apps/mobile/lib/layout/useColumnInset.web.ts b/apps/mobile/lib/layout/useColumnInset.web.ts new file mode 100644 index 00000000000..cfc9345872a --- /dev/null +++ b/apps/mobile/lib/layout/useColumnInset.web.ts @@ -0,0 +1,4 @@ +import { useReadableInset } from "@/hooks/useReadableInset"; +import type { UseColumnInset } from "./useColumnInset.types"; + +export const useColumnInset: UseColumnInset = useReadableInset; diff --git a/apps/mobile/lib/menu-icons/camera.xml b/apps/mobile/lib/menu-icons/camera.xml new file mode 100644 index 00000000000..b4345193429 --- /dev/null +++ b/apps/mobile/lib/menu-icons/camera.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/menu-icons/close.xml b/apps/mobile/lib/menu-icons/close.xml new file mode 100644 index 00000000000..eb405b68960 --- /dev/null +++ b/apps/mobile/lib/menu-icons/close.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/menu-icons/copy.xml b/apps/mobile/lib/menu-icons/copy.xml new file mode 100644 index 00000000000..8aec65eb723 --- /dev/null +++ b/apps/mobile/lib/menu-icons/copy.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/menu-icons/delete.xml b/apps/mobile/lib/menu-icons/delete.xml new file mode 100644 index 00000000000..e88d65197dd --- /dev/null +++ b/apps/mobile/lib/menu-icons/delete.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/menu-icons/edit.xml b/apps/mobile/lib/menu-icons/edit.xml new file mode 100644 index 00000000000..a2d46c3e7f0 --- /dev/null +++ b/apps/mobile/lib/menu-icons/edit.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/menu-icons/fileComment.xml b/apps/mobile/lib/menu-icons/fileComment.xml new file mode 100644 index 00000000000..f3a60994ff9 --- /dev/null +++ b/apps/mobile/lib/menu-icons/fileComment.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/menu-icons/files.xml b/apps/mobile/lib/menu-icons/files.xml new file mode 100644 index 00000000000..377675027da --- /dev/null +++ b/apps/mobile/lib/menu-icons/files.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/menu-icons/index.ts b/apps/mobile/lib/menu-icons/index.ts new file mode 100644 index 00000000000..744147fd994 --- /dev/null +++ b/apps/mobile/lib/menu-icons/index.ts @@ -0,0 +1,13 @@ +// Material Symbols from scripts/generate-toolbar-icons.ts, for Compose menu +// items and sheet rows: a Compose `Icon` takes an image source, not an SF Symbol. +export const MENU_ICON = { + camera: require("./camera.xml"), + close: require("./close.xml"), + copy: require("./copy.xml"), + delete: require("./delete.xml"), + edit: require("./edit.xml"), + files: require("./files.xml"), + fileComment: require("./fileComment.xml"), + photos: require("./photos.xml"), + viewFile: require("./viewFile.xml"), +} as const; diff --git a/apps/mobile/lib/menu-icons/photos.xml b/apps/mobile/lib/menu-icons/photos.xml new file mode 100644 index 00000000000..e7900087128 --- /dev/null +++ b/apps/mobile/lib/menu-icons/photos.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/menu-icons/viewFile.xml b/apps/mobile/lib/menu-icons/viewFile.xml new file mode 100644 index 00000000000..dc71b9e4648 --- /dev/null +++ b/apps/mobile/lib/menu-icons/viewFile.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation.ts b/apps/mobile/lib/navigation.ts deleted file mode 100644 index 7b24570e65a..00000000000 --- a/apps/mobile/lib/navigation.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { isLiquidGlassAvailable } from "expo-glass-effect"; -import type { NativeStackNavigationOptions } from "expo-router"; -import { Platform } from "react-native"; - -export const titledScreenOptions: NativeStackNavigationOptions = { - headerShadowVisible: false, -}; - -/** A sheet's native header: transparent, glass where the OS draws it, content scrolling underneath. */ -export const glassHeaderOptions = { - headerShown: true, - headerTransparent: true, - headerLargeTitle: false, - headerBackButtonDisplayMode: "minimal", - headerShadowVisible: false, - ...(isLiquidGlassAvailable() - ? {} - : { headerBlurEffect: "systemUltraThinMaterial" as const }), - headerStyle: { backgroundColor: "transparent" }, -} as const; - -// iPad shows a form sheet as a fixed-size centered card, so a partial detent -// only shrinks the card and clips what is inside it. -export const sheetDetents = (phoneDetents: number[]) => - Platform.OS === "ios" && Platform.isPad ? [1.0] : phoneDetents; diff --git a/apps/mobile/lib/navigation/components/HeaderToolbar/HeaderToolbar.tsx b/apps/mobile/lib/navigation/components/HeaderToolbar/HeaderToolbar.tsx new file mode 100644 index 00000000000..1136838ad34 --- /dev/null +++ b/apps/mobile/lib/navigation/components/HeaderToolbar/HeaderToolbar.tsx @@ -0,0 +1,100 @@ +import { Stack } from "expo-router"; +import { Children, isValidElement, type ReactNode } from "react"; +import { menuIcon } from "../../menuIcon"; +import { toolbarIcon } from "../../toolbarIcon"; +import type { + HeaderButtonProps, + HeaderMenuActionProps, + HeaderMenuProps, + HeaderMenuSectionProps, + HeaderSubmenuProps, + HeaderToolbarComponent, + HeaderToolbarProps, + HeaderViewProps, +} from "./HeaderToolbar.types"; + +// Stack.Toolbar finds its items by element type, so these only mark what to +// build; `toolbarItems` turns them into the Stack.Toolbar elements. +const Button = (_: HeaderButtonProps) => null; +const Menu = (_: HeaderMenuProps) => null; +const Submenu = (_: HeaderSubmenuProps) => null; +const MenuSection = (_: HeaderMenuSectionProps) => null; +const MenuAction = (_: HeaderMenuActionProps) => null; +const Spacer = () => null; +const View = (_: HeaderViewProps) => null; + +function toolbarItems(children: ReactNode): ReactNode { + return Children.map(children, (child) => { + if (!isValidElement(child)) return child; + switch (child.type) { + case Button: { + const { icon, ...props } = child.props as HeaderButtonProps; + return ( + + ); + } + case Menu: { + const { icon, children, ...props } = child.props as HeaderMenuProps; + return ( + + {toolbarItems(children)} + + ); + } + case Submenu: { + const { icon, title, children } = child.props as HeaderSubmenuProps; + return ( + + {toolbarItems(children)} + + ); + } + case MenuSection: { + const { children } = child.props as HeaderMenuSectionProps; + return ( + + {toolbarItems(children)} + + ); + } + case MenuAction: { + const { icon, ...props } = child.props as HeaderMenuActionProps; + return ( + + ); + } + case Spacer: + return ; + case View: + return ; + default: + return child; + } + }); +} + +function HeaderToolbarRoot({ placement, children }: HeaderToolbarProps) { + return ( + + {toolbarItems(children)} + + ); +} + +export const HeaderToolbar: HeaderToolbarComponent = Object.assign( + HeaderToolbarRoot, + { Button, Menu, Submenu, MenuSection, MenuAction, Spacer, View }, +); diff --git a/apps/mobile/lib/navigation/components/HeaderToolbar/HeaderToolbar.types.ts b/apps/mobile/lib/navigation/components/HeaderToolbar/HeaderToolbar.types.ts new file mode 100644 index 00000000000..9bce5544f0f --- /dev/null +++ b/apps/mobile/lib/navigation/components/HeaderToolbar/HeaderToolbar.types.ts @@ -0,0 +1,62 @@ +import type { ComponentType, ReactElement, ReactNode } from "react"; +import type { MenuIconName } from "../../menuIcon.types"; +import type { ToolbarIconName } from "../../toolbarIcon.types"; + +export interface HeaderToolbarProps { + placement: "left" | "right" | "bottom"; + children?: ReactNode; +} + +export interface HeaderButtonProps { + icon?: ToolbarIconName; + accessibilityLabel?: string; + onPress?: () => void; + hidden?: boolean; + disabled?: boolean; + variant?: "plain" | "prominent"; + tintColor?: string; + separateBackground?: boolean; +} + +export interface HeaderMenuProps { + icon?: ToolbarIconName; + accessibilityLabel?: string; + separateBackground?: boolean; + children?: ReactNode; +} + +/** A titled menu inside a menu. */ +export interface HeaderSubmenuProps { + title: string; + icon?: MenuIconName; + children?: ReactNode; +} + +/** Rows shown in place, set apart from the rows around them. */ +export interface HeaderMenuSectionProps { + children?: ReactNode; +} + +export interface HeaderMenuActionProps { + icon?: MenuIconName; + isOn?: boolean; + destructive?: boolean; + onPress: () => void; + children: string; +} + +export interface HeaderViewProps { + hidesSharedBackground?: boolean; + children?: ReactElement; +} + +/** Header and bottom-bar items of the current screen. */ +export type HeaderToolbarComponent = ComponentType & { + Button: ComponentType; + Menu: ComponentType; + Submenu: ComponentType; + MenuSection: ComponentType; + MenuAction: ComponentType; + Spacer: ComponentType; + View: ComponentType; +}; diff --git a/apps/mobile/lib/navigation/components/HeaderToolbar/HeaderToolbar.web.tsx b/apps/mobile/lib/navigation/components/HeaderToolbar/HeaderToolbar.web.tsx new file mode 100644 index 00000000000..622af49dbb0 --- /dev/null +++ b/apps/mobile/lib/navigation/components/HeaderToolbar/HeaderToolbar.web.tsx @@ -0,0 +1,170 @@ +import { Stack } from "expo-router"; +import { Check, type LucideIcon } from "lucide-react-native"; +import { Pressable, View as RNView } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Icon } from "@/components/ui/icon"; +import { Text } from "@/components/ui/text"; +import { cn } from "@/lib/utils"; +import { MENU_ICONS, TOOLBAR_ICONS } from "./constants"; +import type { + HeaderButtonProps, + HeaderMenuActionProps, + HeaderMenuProps, + HeaderMenuSectionProps, + HeaderSubmenuProps, + HeaderToolbarComponent, + HeaderToolbarProps, + HeaderViewProps, +} from "./HeaderToolbar.types"; + +const ITEM_CLASS = + "size-10 items-center justify-center rounded-full hover:bg-accent active:bg-accent"; + +function ItemIcon({ as, color }: { as: LucideIcon; color?: string }) { + return color ? ( + + ) : ( + + ); +} + +function Button({ + icon, + accessibilityLabel, + onPress, + hidden, + disabled, + variant, + tintColor, +}: HeaderButtonProps) { + if (hidden) return null; + const prominent = variant === "prominent"; + return ( + + {icon ? ( + + ) : null} + + ); +} + +function Menu({ icon, accessibilityLabel, children }: HeaderMenuProps) { + return ( + + + + {icon ? : null} + + + + {children} + + + ); +} + +function Submenu({ title, icon, children }: HeaderSubmenuProps) { + return ( + + + + {icon ? : null} + {title} + + + {children} + + ); +} + +function MenuSection({ children }: HeaderMenuSectionProps) { + return ( + <> + + {children} + + ); +} + +function MenuAction({ + icon, + isOn, + destructive, + onPress, + children, +}: HeaderMenuActionProps) { + return ( + + {icon ? : null} + {children} + {isOn ? : null} + + ); +} + +function Spacer() { + return ; +} + +function View({ children }: HeaderViewProps) { + return <>{children}; +} + +function BottomBar({ children }: { children?: React.ReactNode }) { + const insets = useSafeAreaInsets(); + return ( + + {children} + + ); +} + +function HeaderToolbarRoot({ placement, children }: HeaderToolbarProps) { + if (placement === "bottom") return {children}; + return ( + + {children} + + ); +} + +export const HeaderToolbar: HeaderToolbarComponent = Object.assign( + HeaderToolbarRoot, + { Button, Menu, Submenu, MenuSection, MenuAction, Spacer, View }, +); diff --git a/apps/mobile/lib/navigation/components/HeaderToolbar/constants.ts b/apps/mobile/lib/navigation/components/HeaderToolbar/constants.ts new file mode 100644 index 00000000000..3b0f673dd01 --- /dev/null +++ b/apps/mobile/lib/navigation/components/HeaderToolbar/constants.ts @@ -0,0 +1,84 @@ +import { + Archive, + ArrowUpRight, + AudioLines, + BellDot, + Check, + CircleStop, + Copy, + Ellipsis, + ExternalLink, + Files, + Funnel, + FunnelX, + Globe, + Info, + Layers, + Link, + ListTree, + Lock, + type LucideIcon, + Maximize2, + MessageSquareText, + MessagesSquare, + Mic, + MicOff, + Pencil, + Pin, + PinOff, + Plus, + ScanSearch, + Search, + Send, + Share, + SlidersHorizontal, + Trash2, + Wrench, + X, +} from "lucide-react-native"; +import type { MenuIconName } from "../../menuIcon.types"; +import type { ToolbarIconName } from "../../toolbarIcon.types"; + +export const TOOLBAR_ICONS: Record = { + add: Plus, + close: X, + comments: MessagesSquare, + commentMode: MessageSquareText, + commentModeOn: ScanSearch, + confirm: Check, + expand: Maximize2, + filter: Funnel, + filterActive: FunnelX, + fix: Wrench, + jumpToFile: ListTree, + link: Link, + mic: Mic, + micOff: MicOff, + more: Ellipsis, + overflow: Ellipsis, + search: Search, + send: Send, + sessions: Layers, + share: Share, + stop: CircleStop, + submit: Check, + voice: AudioLines, + voiceSettings: SlidersHorizontal, +}; + +export const MENU_ICONS: Record = { + archive: Archive, + collection: Files, + copy: Copy, + delete: Trash2, + info: Info, + markUnread: BellDot, + openExternal: ExternalLink, + openLink: ArrowUpRight, + pin: Pin, + private: Lock, + rename: Pencil, + share: Share, + team: Globe, + unpin: PinOff, +}; diff --git a/apps/mobile/lib/navigation/components/HeaderToolbar/index.ts b/apps/mobile/lib/navigation/components/HeaderToolbar/index.ts new file mode 100644 index 00000000000..cef42c408e4 --- /dev/null +++ b/apps/mobile/lib/navigation/components/HeaderToolbar/index.ts @@ -0,0 +1,2 @@ +export { HeaderToolbar } from "./HeaderToolbar"; +export type { HeaderToolbarComponent } from "./HeaderToolbar.types"; diff --git a/apps/mobile/lib/navigation/components/ParentScreens/ParentScreens.tsx b/apps/mobile/lib/navigation/components/ParentScreens/ParentScreens.tsx new file mode 100644 index 00000000000..c5477250a78 --- /dev/null +++ b/apps/mobile/lib/navigation/components/ParentScreens/ParentScreens.tsx @@ -0,0 +1,68 @@ +import { + type ReactNode, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { type PartialRoute, withParentRoutes } from "../../parentRoutes"; + +const RESET_TIMEOUT_MS = 1000; + +interface ParentScreensProps { + state: { key: string; index: number; routes: PartialRoute[] }; + navigation: { + dispatch: (action: { + type: "RESET"; + payload: object; + target: string; + }) => void; + }; + parents: readonly string[]; + root?: string; + children: ReactNode; +} + +/** + * A stack's `layout`: when the stack opens on a deep link, the screens above + * the anchor get their parent screens below them, so back goes up a level, + * and a stack that opened on another route gets its `root` screen first. + * Only the state the stack opens with changes. + */ +export function ParentScreens({ + state, + navigation, + parents, + root, + children, +}: ParentScreensProps) { + const [opened] = useState(() => { + const routes = withParentRoutes(state.routes, parents, root); + if (routes.length === state.routes.length) return null; + const focused = state.routes[state.index] as PartialRoute; + return { + state, + payload: { ...state, routes, index: routes.indexOf(focused) }, + }; + }); + const reset = useRef(false); + useLayoutEffect(() => { + if (!opened || reset.current) return; + reset.current = true; + navigation.dispatch({ + type: "RESET", + payload: opened.payload, + target: opened.state.key, + }); + }, [navigation, opened]); + const [gaveUp, setGaveUp] = useState(false); + useEffect(() => { + if (!opened) return; + const timer = setTimeout(() => setGaveUp(true), RESET_TIMEOUT_MS); + return () => clearTimeout(timer); + }, [opened]); + // A native stack cannot put a screen below a sheet it already shows, so it + // first mounts on the state with the parents in it. + if (opened && state === opened.state && !gaveUp) return null; + return <>{children}; +} diff --git a/apps/mobile/lib/navigation/components/ParentScreens/index.ts b/apps/mobile/lib/navigation/components/ParentScreens/index.ts new file mode 100644 index 00000000000..9aa3c8702a2 --- /dev/null +++ b/apps/mobile/lib/navigation/components/ParentScreens/index.ts @@ -0,0 +1 @@ +export { ParentScreens } from "./ParentScreens"; diff --git a/apps/mobile/lib/navigation/components/SheetHeader/SheetHeader.tsx b/apps/mobile/lib/navigation/components/SheetHeader/SheetHeader.tsx new file mode 100644 index 00000000000..c64c17b153c --- /dev/null +++ b/apps/mobile/lib/navigation/components/SheetHeader/SheetHeader.tsx @@ -0,0 +1,43 @@ +import type { NativeStackHeaderProps } from "expo-router"; +import { View } from "react-native"; +import { Text } from "@/components/ui/text"; +import { THEME } from "@/lib/theme"; +import { SheetHeaderSlot } from "../SheetHeaderSlot"; + +const SIDE_WIDTH = 48; + +/** Drag handle, title and bar items, drawn in JS where the sheet has no native bar. */ +export function SheetHeader({ options }: NativeStackHeaderProps) { + // Never the route name: it is a file path, and a sheet that sets its title + // once its data loads would show that path until then. + const title = options.title ?? ""; + const tintColor = THEME.dark.foreground; + const headerTitle = + typeof options.headerTitle === "function" ? ( + options.headerTitle({ children: title, tintColor }) + ) : ( + + {options.headerTitle ?? title} + + ); + + return ( + + + + + + {options.headerLeft?.({ tintColor, canGoBack: false })} + + {headerTitle} + + {options.headerRight?.({ tintColor, canGoBack: false })} + + + + + ); +} diff --git a/apps/mobile/lib/navigation/components/SheetHeader/index.ts b/apps/mobile/lib/navigation/components/SheetHeader/index.ts new file mode 100644 index 00000000000..8bbb9f9645e --- /dev/null +++ b/apps/mobile/lib/navigation/components/SheetHeader/index.ts @@ -0,0 +1 @@ +export { SheetHeader } from "./SheetHeader"; diff --git a/apps/mobile/lib/navigation/components/SheetHeaderSlot/SheetHeaderSlot.tsx b/apps/mobile/lib/navigation/components/SheetHeaderSlot/SheetHeaderSlot.tsx new file mode 100644 index 00000000000..f80d216adbb --- /dev/null +++ b/apps/mobile/lib/navigation/components/SheetHeaderSlot/SheetHeaderSlot.tsx @@ -0,0 +1,4 @@ +import type { SheetHeaderSlotComponent } from "./SheetHeaderSlot.types"; + +export const SheetHeaderSlot: SheetHeaderSlotComponent = ({ children }) => + children; diff --git a/apps/mobile/lib/navigation/components/SheetHeaderSlot/SheetHeaderSlot.types.ts b/apps/mobile/lib/navigation/components/SheetHeaderSlot/SheetHeaderSlot.types.ts new file mode 100644 index 00000000000..71dddcb7606 --- /dev/null +++ b/apps/mobile/lib/navigation/components/SheetHeaderSlot/SheetHeaderSlot.types.ts @@ -0,0 +1,8 @@ +import type { NativeStackNavigationOptions } from "expo-router"; +import type { ReactNode } from "react"; + +/** Wraps a sheet's JS header, which the stack renders, so it shows where the platform draws that sheet. */ +export type SheetHeaderSlotComponent = (props: { + options: NativeStackNavigationOptions; + children: ReactNode; +}) => ReactNode; diff --git a/apps/mobile/lib/navigation/components/SheetHeaderSlot/SheetHeaderSlot.web.tsx b/apps/mobile/lib/navigation/components/SheetHeaderSlot/SheetHeaderSlot.web.tsx new file mode 100644 index 00000000000..dcd6502a713 --- /dev/null +++ b/apps/mobile/lib/navigation/components/SheetHeaderSlot/SheetHeaderSlot.web.tsx @@ -0,0 +1,20 @@ +import { Portal } from "@rn-primitives/portal"; +import { useRoute } from "expo-router"; +import { isWebSheet } from "../../utils/isWebSheet"; +import { sheetHeaderHostName } from "./hostName"; +import type { SheetHeaderSlotComponent } from "./SheetHeaderSlot.types"; + +/** The stack renders a sheet's header above the page; it moves into the sheet's dialog. */ +export const SheetHeaderSlot: SheetHeaderSlotComponent = ({ + options, + children, +}) => { + const route = useRoute(); + if (!isWebSheet(options)) return children; + const hostName = sheetHeaderHostName(route.key); + return ( + + {children} + + ); +}; diff --git a/apps/mobile/lib/navigation/components/SheetHeaderSlot/hostName.ts b/apps/mobile/lib/navigation/components/SheetHeaderSlot/hostName.ts new file mode 100644 index 00000000000..c370c59f632 --- /dev/null +++ b/apps/mobile/lib/navigation/components/SheetHeaderSlot/hostName.ts @@ -0,0 +1,3 @@ +/** The portal host inside a web sheet's dialog where that route's header is drawn. */ +export const sheetHeaderHostName = (routeKey: string) => + `sheet-header:${routeKey}`; diff --git a/apps/mobile/lib/navigation/components/SheetHeaderSlot/index.ts b/apps/mobile/lib/navigation/components/SheetHeaderSlot/index.ts new file mode 100644 index 00000000000..0368861155b --- /dev/null +++ b/apps/mobile/lib/navigation/components/SheetHeaderSlot/index.ts @@ -0,0 +1 @@ +export { SheetHeaderSlot } from "./SheetHeaderSlot"; diff --git a/apps/mobile/lib/navigation/components/WebScreen/WebScreen.tsx b/apps/mobile/lib/navigation/components/WebScreen/WebScreen.tsx new file mode 100644 index 00000000000..8ec7f6080c5 --- /dev/null +++ b/apps/mobile/lib/navigation/components/WebScreen/WebScreen.tsx @@ -0,0 +1,74 @@ +import { BRAND } from "@superset/shared/brand"; +import { useIsFocused, useNavigationContainerRef } from "expo-router"; +import { useEffect } from "react"; +import type { ScreenLayoutProps } from "../../screenLayout.types"; +import { isWebSheet } from "../../utils/isWebSheet"; +import { WebSheet } from "./components/WebSheet"; + +const DISMISSIBLE_PRESENTATIONS = new Set([ + "modal", + "transparentModal", + "containedModal", + "containedTransparentModal", + "fullScreenModal", + "formSheet", + "pageSheet", +]); + +const OPEN_OVERLAY = + '[aria-modal="true"]:not([data-web-sheet]), [role="menu"], [role="listbox"]'; + +/** Names the browser tab after the screen, lets Escape close a sheet, and draws sheet routes as dialogs. */ +export function WebScreen({ + navigation, + route, + options, + children, +}: ScreenLayoutProps) { + const focused = useIsFocused(); + const container = useNavigationContainerRef(); + const dismissible = + options.presentation !== undefined && + DISMISSIBLE_PRESENTATIONS.has(options.presentation); + + const ownTitle = options.title; + useEffect(() => { + if (!focused) return; + const sync = () => { + const current = container.getCurrentOptions() as + | { title?: string } + | undefined; + const title = current?.title ?? ownTitle; + document.title = title ? `${title} · ${BRAND.NAME}` : BRAND.NAME; + }; + sync(); + return container.addListener("state", sync); + }, [focused, container, ownTitle]); + + useEffect(() => { + if (!focused || !dismissible) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || event.isComposing) return; + if (event.defaultPrevented || document.querySelector(OPEN_OVERLAY)) + return; + event.preventDefault(); + navigation.goBack(); + }; + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [focused, dismissible, navigation]); + + if (!isWebSheet(options)) return children; + + return ( + navigation.goBack()} + > + {children} + + ); +} diff --git a/apps/mobile/lib/navigation/components/WebScreen/components/WebSheet/WebSheet.tsx b/apps/mobile/lib/navigation/components/WebScreen/components/WebSheet/WebSheet.tsx new file mode 100644 index 00000000000..33af5141f24 --- /dev/null +++ b/apps/mobile/lib/navigation/components/WebScreen/components/WebSheet/WebSheet.tsx @@ -0,0 +1,143 @@ +import { useLingui } from "@lingui/react/macro"; +import { PortalHost } from "@rn-primitives/portal"; +import { type ReactNode, useEffect, useLayoutEffect, useRef } from "react"; +import { + Pressable, + useWindowDimensions, + View, + type ViewInstance, +} from "react-native"; +import { cn } from "@/lib/utils"; +import { sheetHeaderHostName } from "../../../SheetHeaderSlot/hostName"; +import { sheetFrame } from "../../utils/sheetFrame"; +import { BACKDROP_FRAMES, REDUCED_MOTION, SHEET_MOTION } from "./constants"; +import { playExit } from "./utils/playExit"; + +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +interface WebSheetProps { + routeKey: string; + title?: string; + detents: readonly number[]; + focused: boolean; + onClose: () => void; + children: ReactNode; +} + +/** A sheet route drawn over the screen it opened from, with a backdrop that closes it. */ +export function WebSheet({ + routeKey, + title, + detents, + focused, + onClose, + children, +}: WebSheetProps) { + const { t } = useLingui(); + const frame = sheetFrame(useWindowDimensions(), detents); + const sheetRef = useRef(null); + const backdropRef = useRef(null); + const cardRef = useRef(null); + const kindRef = useRef(frame.kind); + kindRef.current = frame.kind; + + useLayoutEffect(() => { + const sheet = sheetRef.current as unknown as HTMLElement | null; + const backdrop = backdropRef.current as unknown as HTMLElement | null; + const card = cardRef.current as unknown as HTMLElement | null; + if (!sheet || !backdrop || !card) return; + backdrop.dataset.webSheetBackdrop = ""; + sheet.style.overflow = "clip"; + const reducedMotion = () => window.matchMedia(REDUCED_MOTION).matches; + if (!reducedMotion()) { + const motion = SHEET_MOTION[kindRef.current]; + backdrop.animate(BACKDROP_FRAMES, motion.open); + card.animate(motion.frames, motion.open); + } + return () => { + if (!reducedMotion()) playExit(sheet, SHEET_MOTION[kindRef.current]); + }; + }, []); + + useEffect(() => { + const card = cardRef.current as unknown as HTMLElement | null; + if (!card) return; + card.dataset.webSheet = ""; + const opener = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + if (!card.contains(document.activeElement)) + card.focus({ preventScroll: true }); + return () => { + if (opener?.isConnected) opener.focus(); + }; + }, []); + + useEffect(() => { + if (!focused) return; + const card = cardRef.current as unknown as HTMLElement | null; + if (!card) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Tab") return; + const items = [...card.querySelectorAll(FOCUSABLE)].filter( + (item) => item.offsetParent !== null, + ); + const first = items[0]; + const last = items[items.length - 1]; + if (!first || !last) { + event.preventDefault(); + card.focus(); + return; + } + const inside = card.contains(document.activeElement); + if (event.shiftKey && (!inside || document.activeElement === first)) { + event.preventDefault(); + last.focus(); + } else if ( + !event.shiftKey && + (!inside || document.activeElement === last) + ) { + event.preventDefault(); + first.focus(); + } + }; + document.addEventListener("keydown", onKeyDown, true); + return () => document.removeEventListener("keydown", onKeyDown, true); + }, [focused]); + + const dialog = frame.kind === "dialog"; + return ( + + + + + {children} + + + ); +} diff --git a/apps/mobile/lib/navigation/components/WebScreen/components/WebSheet/constants.ts b/apps/mobile/lib/navigation/components/WebScreen/components/WebSheet/constants.ts new file mode 100644 index 00000000000..d4692c0507f --- /dev/null +++ b/apps/mobile/lib/navigation/components/WebScreen/components/WebSheet/constants.ts @@ -0,0 +1,37 @@ +import type { SheetFrame } from "../../utils/sheetFrame"; + +const OPEN_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; +const CLOSE_EASING = "cubic-bezier(0.4, 0, 1, 1)"; + +export const BACKDROP_FRAMES: Keyframe[] = [{ opacity: 0 }, { opacity: 1 }]; + +export interface SheetMotion { + frames: Keyframe[]; + open: KeyframeAnimationOptions; + close: KeyframeAnimationOptions; +} + +export const SHEET_MOTION: Record = { + dialog: { + frames: [ + { opacity: 0, transform: "scale(0.96)" }, + { opacity: 1, transform: "scale(1)" }, + ], + open: { duration: 200, easing: OPEN_EASING }, + close: { duration: 140, easing: CLOSE_EASING }, + }, + bottom: { + frames: [{ transform: "translateY(100%)" }, { transform: "translateY(0)" }], + open: { duration: 280, easing: OPEN_EASING }, + close: { duration: 200, easing: CLOSE_EASING }, + }, +}; + +export const CLOSE_ACTIONS = new Set([ + "GO_BACK", + "POP", + "POP_TO", + "POP_TO_TOP", +]); + +export const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"; diff --git a/apps/mobile/lib/navigation/components/WebScreen/components/WebSheet/index.ts b/apps/mobile/lib/navigation/components/WebScreen/components/WebSheet/index.ts new file mode 100644 index 00000000000..234463bc13e --- /dev/null +++ b/apps/mobile/lib/navigation/components/WebScreen/components/WebSheet/index.ts @@ -0,0 +1 @@ +export { WebSheet } from "./WebSheet"; diff --git a/apps/mobile/lib/navigation/components/WebScreen/components/WebSheet/utils/playExit/index.ts b/apps/mobile/lib/navigation/components/WebScreen/components/WebSheet/utils/playExit/index.ts new file mode 100644 index 00000000000..1077751e122 --- /dev/null +++ b/apps/mobile/lib/navigation/components/WebScreen/components/WebSheet/utils/playExit/index.ts @@ -0,0 +1 @@ +export { playExit } from "./playExit"; diff --git a/apps/mobile/lib/navigation/components/WebScreen/components/WebSheet/utils/playExit/playExit.ts b/apps/mobile/lib/navigation/components/WebScreen/components/WebSheet/utils/playExit/playExit.ts new file mode 100644 index 00000000000..c1aa6201416 --- /dev/null +++ b/apps/mobile/lib/navigation/components/WebScreen/components/WebSheet/utils/playExit/playExit.ts @@ -0,0 +1,43 @@ +import { BACKDROP_FRAMES, type SheetMotion } from "../../constants"; + +/** Plays a closed sheet's exit on an inert copy, because its route has already left the page. */ +export function playExit(sheet: HTMLElement, motion: SheetMotion) { + const rect = sheet.getBoundingClientRect(); + const ghost = sheet.cloneNode(true) as HTMLElement; + const fields = sheet.querySelectorAll("input, textarea"); + ghost.querySelectorAll("input, textarea").forEach((field, index) => { + (field as HTMLInputElement).value = + (fields[index] as HTMLInputElement | undefined)?.value ?? ""; + }); + const card = ghost.querySelector("[data-web-sheet]"); + card?.removeAttribute("role"); + card?.removeAttribute("aria-modal"); + card?.removeAttribute("data-web-sheet"); + ghost.inert = true; + ghost.setAttribute("aria-hidden", "true"); + Object.assign(ghost.style, { + position: "fixed", + left: `${rect.left}px`, + top: `${rect.top}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + pointerEvents: "none", + zIndex: "2147483000", + }); + document.body.append(ghost); + + const timing = { ...motion.close, fill: "forwards" } as const; + const backdrop = ghost.querySelector( + "[data-web-sheet-backdrop]", + ); + const finished = Promise.all([ + backdrop?.animate(BACKDROP_FRAMES.toReversed(), timing).finished, + card?.animate(motion.frames.toReversed(), timing).finished, + ]); + const timeout = new Promise((resolve) => + setTimeout(resolve, Number(motion.close.duration) + 100), + ); + Promise.race([finished, timeout]) + .catch(() => undefined) + .then(() => ghost.remove()); +} diff --git a/apps/mobile/lib/navigation/components/WebScreen/index.ts b/apps/mobile/lib/navigation/components/WebScreen/index.ts new file mode 100644 index 00000000000..d4e678a60dc --- /dev/null +++ b/apps/mobile/lib/navigation/components/WebScreen/index.ts @@ -0,0 +1 @@ +export { WebScreen } from "./WebScreen"; diff --git a/apps/mobile/lib/navigation/components/WebScreen/utils/sheetFrame/index.ts b/apps/mobile/lib/navigation/components/WebScreen/utils/sheetFrame/index.ts new file mode 100644 index 00000000000..3eea11e2448 --- /dev/null +++ b/apps/mobile/lib/navigation/components/WebScreen/utils/sheetFrame/index.ts @@ -0,0 +1,2 @@ +export type { SheetFrame } from "./sheetFrame"; +export { sheetFrame } from "./sheetFrame"; diff --git a/apps/mobile/lib/navigation/components/WebScreen/utils/sheetFrame/sheetFrame.test.ts b/apps/mobile/lib/navigation/components/WebScreen/utils/sheetFrame/sheetFrame.test.ts new file mode 100644 index 00000000000..bd1b22d3143 --- /dev/null +++ b/apps/mobile/lib/navigation/components/WebScreen/utils/sheetFrame/sheetFrame.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { sheetFrame } from "./sheetFrame"; + +describe("sheetFrame", () => { + test("a wide window gets a centred dialog sized by the detent", () => { + expect(sheetFrame({ width: 1440, height: 900 }, [0.5])).toEqual({ + kind: "dialog", + width: 560, + height: 450, + }); + }); + + test("a full-height dialog stops short of the window", () => { + expect(sheetFrame({ width: 1440, height: 900 }, [0.6, 1.0])).toEqual({ + kind: "dialog", + width: 560, + height: 720, + }); + }); + + test("a narrow window gets a bottom sheet across its width", () => { + expect(sheetFrame({ width: 390, height: 844 }, [0.5])).toEqual({ + kind: "bottom", + width: 390, + height: 422, + }); + expect(sheetFrame({ width: 390, height: 844 }, [1.0]).height).toBe(796); + }); +}); diff --git a/apps/mobile/lib/navigation/components/WebScreen/utils/sheetFrame/sheetFrame.ts b/apps/mobile/lib/navigation/components/WebScreen/utils/sheetFrame/sheetFrame.ts new file mode 100644 index 00000000000..ff75760880b --- /dev/null +++ b/apps/mobile/lib/navigation/components/WebScreen/utils/sheetFrame/sheetFrame.ts @@ -0,0 +1,35 @@ +const DIALOG_MIN_WIDTH = 640; +const DIALOG_MAX_WIDTH = 560; +const DIALOG_MAX_HEIGHT = 720; +const DIALOG_MIN_HEIGHT = 320; + +export interface SheetFrame { + kind: "dialog" | "bottom"; + width: number; + height: number; +} + +/** Where a sheet sits on the web: a centred dialog on a wide window, a bottom sheet on a narrow one. */ +export function sheetFrame( + window: { width: number; height: number }, + detents: readonly number[], +): SheetFrame { + const detent = Math.min(1, Math.max(...detents, 0.25)); + const wanted = Math.round(window.height * detent); + if (window.width >= DIALOG_MIN_WIDTH) { + const maxHeight = Math.min(DIALOG_MAX_HEIGHT, window.height - 64); + return { + kind: "dialog", + width: Math.min(DIALOG_MAX_WIDTH, window.width - 32), + height: Math.max( + Math.min(wanted, maxHeight), + Math.min(DIALOG_MIN_HEIGHT, maxHeight), + ), + }; + } + return { + kind: "bottom", + width: window.width, + height: Math.min(wanted, window.height - 48), + }; +} diff --git a/apps/mobile/lib/navigation/contentScrollsUnderHeader.ios.ts b/apps/mobile/lib/navigation/contentScrollsUnderHeader.ios.ts new file mode 100644 index 00000000000..2a4befebb60 --- /dev/null +++ b/apps/mobile/lib/navigation/contentScrollsUnderHeader.ios.ts @@ -0,0 +1,3 @@ +import type { ContentScrollsUnderHeader } from "./contentScrollsUnderHeader.types"; + +export const contentScrollsUnderHeader: ContentScrollsUnderHeader = true; diff --git a/apps/mobile/lib/navigation/contentScrollsUnderHeader.ts b/apps/mobile/lib/navigation/contentScrollsUnderHeader.ts new file mode 100644 index 00000000000..24d785d5f7f --- /dev/null +++ b/apps/mobile/lib/navigation/contentScrollsUnderHeader.ts @@ -0,0 +1,5 @@ +import type { ContentScrollsUnderHeader } from "./contentScrollsUnderHeader.types"; + +// contentInsetAdjustmentBehavior is iOS-only: under a transparent header the +// first rows would sit behind the bar and lose their taps. +export const contentScrollsUnderHeader: ContentScrollsUnderHeader = false; diff --git a/apps/mobile/lib/navigation/contentScrollsUnderHeader.types.ts b/apps/mobile/lib/navigation/contentScrollsUnderHeader.types.ts new file mode 100644 index 00000000000..7c52053ed3c --- /dev/null +++ b/apps/mobile/lib/navigation/contentScrollsUnderHeader.types.ts @@ -0,0 +1,2 @@ +/** Whether scroll views inset themselves under a transparent native header. */ +export type ContentScrollsUnderHeader = boolean; diff --git a/apps/mobile/lib/navigation/glassHeaderOptions.ios.ts b/apps/mobile/lib/navigation/glassHeaderOptions.ios.ts new file mode 100644 index 00000000000..f19b71dcca8 --- /dev/null +++ b/apps/mobile/lib/navigation/glassHeaderOptions.ios.ts @@ -0,0 +1,15 @@ +import { isLiquidGlassAvailable } from "expo-glass-effect"; +import type { GlassHeaderOptions } from "./glassHeaderOptions.types"; + +/** Transparent, glass where the OS draws it, content scrolling underneath. */ +export const glassHeaderOptions: GlassHeaderOptions = { + headerShown: true, + headerTransparent: true, + headerLargeTitle: false, + headerBackButtonDisplayMode: "minimal", + headerShadowVisible: false, + ...(isLiquidGlassAvailable() + ? {} + : { headerBlurEffect: "systemUltraThinMaterial" as const }), + headerStyle: { backgroundColor: "transparent" }, +}; diff --git a/apps/mobile/lib/navigation/glassHeaderOptions.ts b/apps/mobile/lib/navigation/glassHeaderOptions.ts new file mode 100644 index 00000000000..22f83b719ef --- /dev/null +++ b/apps/mobile/lib/navigation/glassHeaderOptions.ts @@ -0,0 +1,12 @@ +import { THEME } from "@/lib/theme"; +import type { GlassHeaderOptions } from "./glassHeaderOptions.types"; + +/** Opaque: nothing blurs content under the bar, so content starts below it. */ +export const glassHeaderOptions: GlassHeaderOptions = { + headerShown: true, + headerTransparent: false, + headerLargeTitle: false, + headerBackButtonDisplayMode: "minimal", + headerShadowVisible: false, + headerStyle: { backgroundColor: THEME.dark.background }, +}; diff --git a/apps/mobile/lib/navigation/glassHeaderOptions.types.ts b/apps/mobile/lib/navigation/glassHeaderOptions.types.ts new file mode 100644 index 00000000000..23abb0ec29e --- /dev/null +++ b/apps/mobile/lib/navigation/glassHeaderOptions.types.ts @@ -0,0 +1,4 @@ +import type { NativeStackNavigationOptions } from "expo-router"; + +/** A sheet's or overlay screen's native header. */ +export type GlassHeaderOptions = NativeStackNavigationOptions; diff --git a/apps/mobile/lib/navigation/index.ts b/apps/mobile/lib/navigation/index.ts new file mode 100644 index 00000000000..728f206d391 --- /dev/null +++ b/apps/mobile/lib/navigation/index.ts @@ -0,0 +1,17 @@ +export { HeaderToolbar } from "./components/HeaderToolbar"; +export { ParentScreens } from "./components/ParentScreens"; +export { SheetHeaderSlot } from "./components/SheetHeaderSlot"; +export { contentScrollsUnderHeader } from "./contentScrollsUnderHeader"; +export type { ContentScrollsUnderHeader } from "./contentScrollsUnderHeader.types"; +export { glassHeaderOptions } from "./glassHeaderOptions"; +export type { GlassHeaderOptions } from "./glassHeaderOptions.types"; +export type { MenuIconName } from "./menuIcon.types"; +export { sheetDetents, titledScreenOptions } from "./navigation"; +export { screenLayout } from "./screenLayout"; +export type { ScreenLayout } from "./screenLayout.types"; +export { sheetHeaderOptions } from "./sheetHeaderOptions"; +export type { SheetHeaderOptions } from "./sheetHeaderOptions.types"; +export { sheetScreenOptions } from "./sheetScreenOptions"; +export type { SheetScreenOptions } from "./sheetScreenOptions.types"; +export { toolbarIcon } from "./toolbarIcon"; +export type { ToolbarIconName } from "./toolbarIcon.types"; diff --git a/apps/mobile/lib/navigation/menuIcon.ios.ts b/apps/mobile/lib/navigation/menuIcon.ios.ts new file mode 100644 index 00000000000..bad85671051 --- /dev/null +++ b/apps/mobile/lib/navigation/menuIcon.ios.ts @@ -0,0 +1,20 @@ +import type { MenuIcon, MenuIconName, MenuIconSource } from "./menuIcon.types"; + +const SF_SYMBOL: Record = { + archive: "archivebox", + collection: "square.stack", + copy: "doc.on.doc", + delete: "trash", + info: "info.circle", + markUnread: "bell.badge", + openExternal: "arrow.up.right.square", + openLink: "arrow.up.right", + pin: "pin", + private: "lock", + rename: "pencil", + share: "square.and.arrow.up", + team: "globe", + unpin: "pin.slash", +}; + +export const menuIcon: MenuIcon = (name) => SF_SYMBOL[name]; diff --git a/apps/mobile/lib/navigation/menuIcon.ts b/apps/mobile/lib/navigation/menuIcon.ts new file mode 100644 index 00000000000..fb751518b82 --- /dev/null +++ b/apps/mobile/lib/navigation/menuIcon.ts @@ -0,0 +1,3 @@ +import type { MenuIcon } from "./menuIcon.types"; + +export const menuIcon: MenuIcon = () => undefined; diff --git a/apps/mobile/lib/navigation/menuIcon.types.ts b/apps/mobile/lib/navigation/menuIcon.types.ts new file mode 100644 index 00000000000..1371b2f4c7f --- /dev/null +++ b/apps/mobile/lib/navigation/menuIcon.types.ts @@ -0,0 +1,25 @@ +import type { Stack } from "expo-router"; +import type { ComponentProps } from "react"; + +export type MenuIconName = + | "archive" + | "collection" + | "copy" + | "delete" + | "info" + | "markUnread" + | "openExternal" + | "openLink" + | "pin" + | "private" + | "rename" + | "share" + | "team" + | "unpin"; + +export type MenuIconSource = NonNullable< + ComponentProps["icon"] +>; + +/** The leading icon of a `Stack.Toolbar` menu row, if this platform draws one. */ +export type MenuIcon = (name: MenuIconName) => MenuIconSource | undefined; diff --git a/apps/mobile/lib/navigation/navigation.ts b/apps/mobile/lib/navigation/navigation.ts new file mode 100644 index 00000000000..8757e2c2141 --- /dev/null +++ b/apps/mobile/lib/navigation/navigation.ts @@ -0,0 +1,11 @@ +import type { NativeStackNavigationOptions } from "expo-router"; +import { Platform } from "react-native"; + +export const titledScreenOptions: NativeStackNavigationOptions = { + headerShadowVisible: false, +}; + +// iPad shows a form sheet as a fixed-size centered card, so a partial detent +// only shrinks the card and clips what is inside it. +export const sheetDetents = (phoneDetents: number[]) => + Platform.OS === "ios" && Platform.isPad ? [1.0] : phoneDetents; diff --git a/apps/mobile/lib/navigation/parentRoutes/index.ts b/apps/mobile/lib/navigation/parentRoutes/index.ts new file mode 100644 index 00000000000..0eecc63e7a7 --- /dev/null +++ b/apps/mobile/lib/navigation/parentRoutes/index.ts @@ -0,0 +1,2 @@ +export type { PartialRoute } from "./parentRoutes"; +export { withParentRoutes } from "./parentRoutes"; diff --git a/apps/mobile/lib/navigation/parentRoutes/parentRoutes.test.ts b/apps/mobile/lib/navigation/parentRoutes/parentRoutes.test.ts new file mode 100644 index 00000000000..30a6fb4712a --- /dev/null +++ b/apps/mobile/lib/navigation/parentRoutes/parentRoutes.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test"; +import { withParentRoutes } from "./parentRoutes"; + +const PARENTS = [ + "workspace/[id]/index", + "workspace/[id]/pull-request/[pullRequestId]/index", + "pages/index", +]; +const names = (routes: { name: string }[]) => routes.map((route) => route.name); + +describe("withParentRoutes", () => { + test("a deep link gets its parent screen below it", () => { + const routes = withParentRoutes( + [ + { name: "(home)" }, + { + name: "workspace/[id]/files-changed", + params: { id: "w1", path: "a" }, + }, + ], + PARENTS, + ); + expect(names(routes)).toEqual([ + "(home)", + "workspace/[id]/index", + "workspace/[id]/files-changed", + ]); + expect(routes[1]?.params).toEqual({ id: "w1" }); + }); + + test("every missing ancestor is added, outermost first", () => { + expect( + names( + withParentRoutes( + [ + { name: "(home)" }, + { + name: "workspace/[id]/pull-request/[pullRequestId]/checks", + params: { id: "w1", pullRequestId: "7" }, + }, + ], + PARENTS, + ), + ), + ).toEqual([ + "(home)", + "workspace/[id]/index", + "workspace/[id]/pull-request/[pullRequestId]/index", + "workspace/[id]/pull-request/[pullRequestId]/checks", + ]); + }); + + test("a route already above its parent is left alone", () => { + const routes = [ + { name: "(home)" }, + { name: "workspace/[id]/index", params: { id: "w1" } }, + { name: "workspace/[id]/files-changed", params: { id: "w1" } }, + ]; + expect(withParentRoutes(routes, PARENTS)).toEqual(routes); + }); + + test("a parent whose params are missing is not added", () => { + expect( + names( + withParentRoutes( + [{ name: "(home)" }, { name: "workspace/[id]/files-changed" }], + PARENTS, + ), + ), + ).toEqual(["(home)", "workspace/[id]/files-changed"]); + }); + + test("nothing goes below the first route", () => { + expect( + names( + withParentRoutes( + [{ name: "pages/[slug]/index", params: { slug: "s" } }], + PARENTS, + ), + ), + ).toEqual(["pages/[slug]/index"]); + }); + + test("a stack that opens on a sheet gets its root screen below it", () => { + const routes = withParentRoutes( + [{ name: "search", key: "search-1" }], + [], + "index", + ); + expect(routes).toEqual([ + { name: "index" }, + { name: "search", key: "search-1" }, + ]); + }); + + test("a stack that opens on its root screen is left alone", () => { + const routes = [{ name: "index" }, { name: "filter" }]; + expect(withParentRoutes(routes, [], "index")).toEqual(routes); + }); +}); diff --git a/apps/mobile/lib/navigation/parentRoutes/parentRoutes.ts b/apps/mobile/lib/navigation/parentRoutes/parentRoutes.ts new file mode 100644 index 00000000000..c85b94873b9 --- /dev/null +++ b/apps/mobile/lib/navigation/parentRoutes/parentRoutes.ts @@ -0,0 +1,49 @@ +export interface PartialRoute { + name: string; + params?: object; + key?: string; +} + +const baseOf = (parent: string) => parent.replace(/\/?index$/, ""); + +function parentOf(name: string, parents: readonly string[]) { + let best: string | undefined; + for (const parent of parents) { + if (parent === name || !name.startsWith(`${baseOf(parent)}/`)) continue; + if (!best || baseOf(parent).length > baseOf(best).length) best = parent; + } + return best; +} + +function paramsFor(parent: string, params: object | undefined) { + const names = [...parent.matchAll(/\[([^\]]+)\]/g)].map((match) => match[1]); + const source = (params ?? {}) as Record; + if (names.some((key) => source[key] === undefined)) return null; + return Object.fromEntries(names.map((key) => [key, source[key]])); +} + +/** + * Puts each route's missing parent screens below it, so back from a deep link goes up instead of to the first screen. + * A stack that opens on another route gets its `root` screen first. + */ +export function withParentRoutes( + routes: readonly Route[], + parents: readonly string[], + root?: string, +): (Route | PartialRoute)[] { + const out: (Route | PartialRoute)[] = + root && routes[0] && routes[0].name !== root ? [{ name: root }] : []; + for (const route of routes) { + const missing: PartialRoute[] = []; + let parent = parentOf(route.name, parents); + while (parent && out.at(-1)?.name !== parent) { + const params = paramsFor(parent, route.params); + if (!params) break; + missing.unshift({ name: parent, params }); + parent = parentOf(parent, parents); + } + if (out.length > 0) out.push(...missing); + out.push(route); + } + return out; +} diff --git a/apps/mobile/lib/navigation/screenLayout.tsx b/apps/mobile/lib/navigation/screenLayout.tsx new file mode 100644 index 00000000000..276a73f52ea --- /dev/null +++ b/apps/mobile/lib/navigation/screenLayout.tsx @@ -0,0 +1,3 @@ +import type { ScreenLayout } from "./screenLayout.types"; + +export const screenLayout: ScreenLayout = undefined; diff --git a/apps/mobile/lib/navigation/screenLayout.types.ts b/apps/mobile/lib/navigation/screenLayout.types.ts new file mode 100644 index 00000000000..cea8a391ef7 --- /dev/null +++ b/apps/mobile/lib/navigation/screenLayout.types.ts @@ -0,0 +1,14 @@ +import type { NativeStackNavigationOptions } from "expo-router"; +import type { ReactElement } from "react"; + +export interface ScreenLayoutProps { + navigation: { goBack: () => void }; + route: { key: string; name: string; params?: object }; + options: NativeStackNavigationOptions; + children: ReactElement; +} + +/** A stack's `screenLayout`: wraps each screen where the platform needs it. */ +export type ScreenLayout = + | ((props: ScreenLayoutProps) => ReactElement) + | undefined; diff --git a/apps/mobile/lib/navigation/screenLayout.web.tsx b/apps/mobile/lib/navigation/screenLayout.web.tsx new file mode 100644 index 00000000000..fb5158b3bba --- /dev/null +++ b/apps/mobile/lib/navigation/screenLayout.web.tsx @@ -0,0 +1,4 @@ +import { WebScreen } from "./components/WebScreen"; +import type { ScreenLayout } from "./screenLayout.types"; + +export const screenLayout: ScreenLayout = (props) => ; diff --git a/apps/mobile/lib/navigation/sheetHeaderOptions.ios.ts b/apps/mobile/lib/navigation/sheetHeaderOptions.ios.ts new file mode 100644 index 00000000000..3485d937949 --- /dev/null +++ b/apps/mobile/lib/navigation/sheetHeaderOptions.ios.ts @@ -0,0 +1,4 @@ +import { glassHeaderOptions } from "./glassHeaderOptions"; +import type { SheetHeaderOptions } from "./sheetHeaderOptions.types"; + +export const sheetHeaderOptions: SheetHeaderOptions = glassHeaderOptions; diff --git a/apps/mobile/lib/navigation/sheetHeaderOptions.tsx b/apps/mobile/lib/navigation/sheetHeaderOptions.tsx new file mode 100644 index 00000000000..65fded56a0e --- /dev/null +++ b/apps/mobile/lib/navigation/sheetHeaderOptions.tsx @@ -0,0 +1,9 @@ +import { SheetHeader } from "./components/SheetHeader"; +import type { SheetHeaderOptions } from "./sheetHeaderOptions.types"; + +/** A form sheet draws no native bar here, so the header is drawn in JS. */ +export const sheetHeaderOptions: SheetHeaderOptions = { + headerShown: true, + sheetCornerRadius: 28, + header: (props) => , +}; diff --git a/apps/mobile/lib/navigation/sheetHeaderOptions.types.ts b/apps/mobile/lib/navigation/sheetHeaderOptions.types.ts new file mode 100644 index 00000000000..a256bff2236 --- /dev/null +++ b/apps/mobile/lib/navigation/sheetHeaderOptions.types.ts @@ -0,0 +1,4 @@ +import type { NativeStackNavigationOptions } from "expo-router"; + +/** A `formSheet` route's header: its title, items and dismiss affordance. */ +export type SheetHeaderOptions = NativeStackNavigationOptions; diff --git a/apps/mobile/lib/navigation/sheetScreenOptions.ts b/apps/mobile/lib/navigation/sheetScreenOptions.ts new file mode 100644 index 00000000000..44a2127edd5 --- /dev/null +++ b/apps/mobile/lib/navigation/sheetScreenOptions.ts @@ -0,0 +1,6 @@ +import type { SheetScreenOptions } from "./sheetScreenOptions.types"; + +export const sheetScreenOptions: SheetScreenOptions = (options) => ({ + presentation: "formSheet", + ...options, +}); diff --git a/apps/mobile/lib/navigation/sheetScreenOptions.types.ts b/apps/mobile/lib/navigation/sheetScreenOptions.types.ts new file mode 100644 index 00000000000..b55b0695fab --- /dev/null +++ b/apps/mobile/lib/navigation/sheetScreenOptions.types.ts @@ -0,0 +1,6 @@ +import type { NativeStackNavigationOptions } from "expo-router"; + +/** Presents a route as a sheet over the screen it opened from. */ +export type SheetScreenOptions = ( + options: NativeStackNavigationOptions, +) => NativeStackNavigationOptions; diff --git a/apps/mobile/lib/navigation/sheetScreenOptions.web.ts b/apps/mobile/lib/navigation/sheetScreenOptions.web.ts new file mode 100644 index 00000000000..6cb7366ce5a --- /dev/null +++ b/apps/mobile/lib/navigation/sheetScreenOptions.web.ts @@ -0,0 +1,10 @@ +import type { SheetScreenOptions } from "./sheetScreenOptions.types"; + +/** The web stack shows the screen below only under a transparent modal; `WebScreen` draws the sheet and its header. */ +export const sheetScreenOptions: SheetScreenOptions = (options) => ({ + ...options, + presentation: "transparentModal", + headerShown: false, + contentStyle: { backgroundColor: "transparent" }, + sheetAllowedDetents: options.sheetAllowedDetents ?? [1.0], +}); diff --git a/apps/mobile/lib/navigation/toolbar-icons/add.xml b/apps/mobile/lib/navigation/toolbar-icons/add.xml new file mode 100644 index 00000000000..87e258de698 --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/add.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/close.xml b/apps/mobile/lib/navigation/toolbar-icons/close.xml new file mode 100644 index 00000000000..eb405b68960 --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/close.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/commentMode.xml b/apps/mobile/lib/navigation/toolbar-icons/commentMode.xml new file mode 100644 index 00000000000..7a4adb0e998 --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/commentMode.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/commentModeOn.xml b/apps/mobile/lib/navigation/toolbar-icons/commentModeOn.xml new file mode 100644 index 00000000000..ae05fd953a8 --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/commentModeOn.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/comments.xml b/apps/mobile/lib/navigation/toolbar-icons/comments.xml new file mode 100644 index 00000000000..e336a8c3c37 --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/comments.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/confirm.xml b/apps/mobile/lib/navigation/toolbar-icons/confirm.xml new file mode 100644 index 00000000000..d280b672e35 --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/confirm.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/expand.xml b/apps/mobile/lib/navigation/toolbar-icons/expand.xml new file mode 100644 index 00000000000..14334e2de08 --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/expand.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/filter.xml b/apps/mobile/lib/navigation/toolbar-icons/filter.xml new file mode 100644 index 00000000000..1ee45e39ba5 --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/filter.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/filterActive.xml b/apps/mobile/lib/navigation/toolbar-icons/filterActive.xml new file mode 100644 index 00000000000..f7f89f29398 --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/filterActive.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/fix.xml b/apps/mobile/lib/navigation/toolbar-icons/fix.xml new file mode 100644 index 00000000000..877bd95c087 --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/fix.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/jumpToFile.xml b/apps/mobile/lib/navigation/toolbar-icons/jumpToFile.xml new file mode 100644 index 00000000000..13982119d5b --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/jumpToFile.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/link.xml b/apps/mobile/lib/navigation/toolbar-icons/link.xml new file mode 100644 index 00000000000..f819f40f2b6 --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/link.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/mic.xml b/apps/mobile/lib/navigation/toolbar-icons/mic.xml new file mode 100644 index 00000000000..11b423cbe5b --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/mic.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/micOff.xml b/apps/mobile/lib/navigation/toolbar-icons/micOff.xml new file mode 100644 index 00000000000..f501cd7ad3b --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/micOff.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/more.xml b/apps/mobile/lib/navigation/toolbar-icons/more.xml new file mode 100644 index 00000000000..3ec7b5b901c --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/more.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/overflow.xml b/apps/mobile/lib/navigation/toolbar-icons/overflow.xml new file mode 100644 index 00000000000..3ec7b5b901c --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/overflow.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/search.xml b/apps/mobile/lib/navigation/toolbar-icons/search.xml new file mode 100644 index 00000000000..5ec121aa1dd --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/search.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/send.xml b/apps/mobile/lib/navigation/toolbar-icons/send.xml new file mode 100644 index 00000000000..91003a1135e --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/send.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/sessions.xml b/apps/mobile/lib/navigation/toolbar-icons/sessions.xml new file mode 100644 index 00000000000..4ee9a55d87d --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/sessions.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/share.xml b/apps/mobile/lib/navigation/toolbar-icons/share.xml new file mode 100644 index 00000000000..928ab74b92c --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/share.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/stop.xml b/apps/mobile/lib/navigation/toolbar-icons/stop.xml new file mode 100644 index 00000000000..3dc4be8bfc4 --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/stop.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/submit.xml b/apps/mobile/lib/navigation/toolbar-icons/submit.xml new file mode 100644 index 00000000000..d280b672e35 --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/submit.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/voice.xml b/apps/mobile/lib/navigation/toolbar-icons/voice.xml new file mode 100644 index 00000000000..cb7e9d6a5dc --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/voice.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbar-icons/voiceSettings.xml b/apps/mobile/lib/navigation/toolbar-icons/voiceSettings.xml new file mode 100644 index 00000000000..7addb40c2f9 --- /dev/null +++ b/apps/mobile/lib/navigation/toolbar-icons/voiceSettings.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/mobile/lib/navigation/toolbarIcon.ios.ts b/apps/mobile/lib/navigation/toolbarIcon.ios.ts new file mode 100644 index 00000000000..733b25df5be --- /dev/null +++ b/apps/mobile/lib/navigation/toolbarIcon.ios.ts @@ -0,0 +1,34 @@ +import type { + ToolbarIcon, + ToolbarIconName, + ToolbarIconSource, +} from "./toolbarIcon.types"; + +const SF_SYMBOL: Record = { + add: "plus", + close: "xmark", + comments: "bubble.left.and.bubble.right", + commentMode: "viewfinder", + commentModeOn: "viewfinder.circle.fill", + confirm: "checkmark", + expand: "arrow.up.left.and.arrow.down.right", + filter: "line.3.horizontal.decrease", + filterActive: "line.3.horizontal.decrease.circle.fill", + fix: "wrench.and.screwdriver", + jumpToFile: "text.line.first.and.arrowtriangle.forward", + link: "link", + mic: "mic.fill", + micOff: "mic.slash.fill", + more: "ellipsis", + overflow: "ellipsis", + search: "magnifyingglass", + send: "paperplane", + sessions: "rectangle.stack", + share: "square.and.arrow.up", + stop: "stop.fill", + submit: "checkmark", + voice: "waveform", + voiceSettings: "slider.horizontal.3", +}; + +export const toolbarIcon: ToolbarIcon = (name) => SF_SYMBOL[name]; diff --git a/apps/mobile/lib/navigation/toolbarIcon.ts b/apps/mobile/lib/navigation/toolbarIcon.ts new file mode 100644 index 00000000000..2ec15b7ad89 --- /dev/null +++ b/apps/mobile/lib/navigation/toolbarIcon.ts @@ -0,0 +1,36 @@ +import type { + ToolbarIcon, + ToolbarIconName, + ToolbarIconSource, +} from "./toolbarIcon.types"; + +// Material Symbols from scripts/generate-toolbar-icons.ts: a toolbar item here +// takes an image source, and an item without one draws nothing. +const DRAWABLE: Record = { + add: require("./toolbar-icons/add.xml"), + close: require("./toolbar-icons/close.xml"), + comments: require("./toolbar-icons/comments.xml"), + commentMode: require("./toolbar-icons/commentMode.xml"), + commentModeOn: require("./toolbar-icons/commentModeOn.xml"), + confirm: require("./toolbar-icons/confirm.xml"), + expand: require("./toolbar-icons/expand.xml"), + filter: require("./toolbar-icons/filter.xml"), + filterActive: require("./toolbar-icons/filterActive.xml"), + fix: require("./toolbar-icons/fix.xml"), + jumpToFile: require("./toolbar-icons/jumpToFile.xml"), + link: require("./toolbar-icons/link.xml"), + mic: require("./toolbar-icons/mic.xml"), + micOff: require("./toolbar-icons/micOff.xml"), + more: require("./toolbar-icons/more.xml"), + overflow: require("./toolbar-icons/overflow.xml"), + search: require("./toolbar-icons/search.xml"), + send: require("./toolbar-icons/send.xml"), + sessions: require("./toolbar-icons/sessions.xml"), + share: require("./toolbar-icons/share.xml"), + stop: require("./toolbar-icons/stop.xml"), + submit: require("./toolbar-icons/submit.xml"), + voice: require("./toolbar-icons/voice.xml"), + voiceSettings: require("./toolbar-icons/voiceSettings.xml"), +}; + +export const toolbarIcon: ToolbarIcon = (name) => DRAWABLE[name]; diff --git a/apps/mobile/lib/navigation/toolbarIcon.types.ts b/apps/mobile/lib/navigation/toolbarIcon.types.ts new file mode 100644 index 00000000000..b566011fe89 --- /dev/null +++ b/apps/mobile/lib/navigation/toolbarIcon.types.ts @@ -0,0 +1,37 @@ +import type { Stack } from "expo-router"; +import type { ComponentProps } from "react"; + +export type ToolbarIconName = + | "add" + | "close" + | "comments" + | "commentMode" + | "commentModeOn" + | "confirm" + | "expand" + | "filter" + | "filterActive" + | "fix" + | "jumpToFile" + | "link" + | "mic" + | "micOff" + | "more" + | "overflow" + | "search" + | "send" + | "sessions" + | "share" + | "stop" + | "submit" + | "voice" + | "voiceSettings"; + +export type ToolbarIconSource = NonNullable< + ComponentProps["icon"] +>; + +/** The `icon` a `Stack.Toolbar` button or menu shows, if this platform has one. */ +export type ToolbarIcon = ( + name: ToolbarIconName, +) => ToolbarIconSource | undefined; diff --git a/apps/mobile/lib/navigation/utils/isWebSheet/index.ts b/apps/mobile/lib/navigation/utils/isWebSheet/index.ts new file mode 100644 index 00000000000..17f8fab57e5 --- /dev/null +++ b/apps/mobile/lib/navigation/utils/isWebSheet/index.ts @@ -0,0 +1 @@ +export { isWebSheet } from "./isWebSheet"; diff --git a/apps/mobile/lib/navigation/utils/isWebSheet/isWebSheet.ts b/apps/mobile/lib/navigation/utils/isWebSheet/isWebSheet.ts new file mode 100644 index 00000000000..6ec22c2ed19 --- /dev/null +++ b/apps/mobile/lib/navigation/utils/isWebSheet/isWebSheet.ts @@ -0,0 +1,10 @@ +import type { NativeStackNavigationOptions } from "expo-router"; + +/** A route `sheetScreenOptions` presents as a sheet on the web: a transparent modal with detents. */ +export const isWebSheet = ( + options: NativeStackNavigationOptions, +): options is NativeStackNavigationOptions & { + sheetAllowedDetents: number[]; +} => + options.presentation === "transparentModal" && + Array.isArray(options.sheetAllowedDetents); diff --git a/apps/mobile/lib/posthog/client.ts b/apps/mobile/lib/posthog/client.ts index c2ce32ba06d..d07a19b81da 100644 --- a/apps/mobile/lib/posthog/client.ts +++ b/apps/mobile/lib/posthog/client.ts @@ -1,9 +1,10 @@ import { PostHog } from "posthog-react-native"; import { env } from "../env"; +import { posthogHost } from "./utils/posthogHost"; export const posthogConfig = { apiKey: env.EXPO_PUBLIC_POSTHOG_KEY, - host: env.EXPO_PUBLIC_POSTHOG_HOST, + host: posthogHost, options: { enableSessionReplay: true, sessionReplayConfig: { diff --git a/apps/mobile/lib/posthog/utils/posthogHost/index.ts b/apps/mobile/lib/posthog/utils/posthogHost/index.ts new file mode 100644 index 00000000000..4b79e48c573 --- /dev/null +++ b/apps/mobile/lib/posthog/utils/posthogHost/index.ts @@ -0,0 +1 @@ +export { posthogHost } from "./posthogHost"; diff --git a/apps/mobile/lib/posthog/utils/posthogHost/posthogHost.ts b/apps/mobile/lib/posthog/utils/posthogHost/posthogHost.ts new file mode 100644 index 00000000000..129deb2fae5 --- /dev/null +++ b/apps/mobile/lib/posthog/utils/posthogHost/posthogHost.ts @@ -0,0 +1,3 @@ +import { env } from "../../../env"; + +export const posthogHost: string = env.EXPO_PUBLIC_POSTHOG_HOST; diff --git a/apps/mobile/lib/posthog/utils/posthogHost/posthogHost.web.ts b/apps/mobile/lib/posthog/utils/posthogHost/posthogHost.web.ts new file mode 100644 index 00000000000..88641c4e287 --- /dev/null +++ b/apps/mobile/lib/posthog/utils/posthogHost/posthogHost.web.ts @@ -0,0 +1,6 @@ +import { env } from "../../../env"; + +// Under a base path the app is served by apps/web, whose /ingest proxies PostHog. +export const posthogHost: string = process.env.EXPO_BASE_URL + ? `${window.location.origin}/ingest` + : env.EXPO_PUBLIC_POSTHOG_HOST; diff --git a/apps/mobile/lib/trpc/client.ts b/apps/mobile/lib/trpc/client.ts index 0e4bf005614..c4916d5520d 100644 --- a/apps/mobile/lib/trpc/client.ts +++ b/apps/mobile/lib/trpc/client.ts @@ -2,7 +2,8 @@ import type { AppRouter } from "@superset/trpc"; import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; import * as Application from "expo-application"; import superjson from "superjson"; -import { authClient, getJwt } from "../auth/client"; +import { getJwt } from "../auth/client"; +import { sessionFetch, sessionHeaders } from "../auth/sessionTransport"; import { env } from "../env"; import { transportRetryLink } from "../errors"; @@ -13,12 +14,12 @@ export const apiClient = createTRPCProxyClient({ transportRetryLink(), httpBatchLink({ url: `${env.EXPO_PUBLIC_API_URL}/api/trpc`, + fetch: sessionFetch, headers() { - const cookies = authClient.getCookie(); const jwt = getJwt(); return { "x-superset-client": clientVersionHeader, - ...(cookies ? { Cookie: cookies } : {}), + ...sessionHeaders(), ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), }; }, diff --git a/apps/mobile/lib/voice/transport/WebRtcTransport.ts b/apps/mobile/lib/voice/transport/WebRtcTransport.ts index 0102c5955f5..f01d392579c 100644 --- a/apps/mobile/lib/voice/transport/WebRtcTransport.ts +++ b/apps/mobile/lib/voice/transport/WebRtcTransport.ts @@ -1,11 +1,4 @@ import { VoiceAudio } from "@superset/voice"; -import { - type MediaStream, - type MediaStreamTrack, - mediaDevices, - RTCPeerConnection, - RTCSessionDescription, -} from "react-native-webrtc"; import { parseServerEvent, type RealtimeClientEvent, @@ -16,6 +9,13 @@ import type { RealtimeTransport, TransportState, } from "./RealtimeTransport"; +import { + type MediaStream, + type MediaStreamTrack, + mediaDevices, + RTCPeerConnection, + RTCSessionDescription, +} from "./webrtc"; const CALLS_URL = "https://api.openai.com/v1/realtime/calls"; const DATA_CHANNEL = "oai-events"; diff --git a/apps/mobile/lib/voice/transport/webrtc/index.ts b/apps/mobile/lib/voice/transport/webrtc/index.ts new file mode 100644 index 00000000000..3de72edda01 --- /dev/null +++ b/apps/mobile/lib/voice/transport/webrtc/index.ts @@ -0,0 +1 @@ +export * from "./webrtc"; diff --git a/apps/mobile/lib/voice/transport/webrtc/webrtc.ts b/apps/mobile/lib/voice/transport/webrtc/webrtc.ts new file mode 100644 index 00000000000..c41c780243c --- /dev/null +++ b/apps/mobile/lib/voice/transport/webrtc/webrtc.ts @@ -0,0 +1,7 @@ +export { + type MediaStream, + type MediaStreamTrack, + mediaDevices, + RTCPeerConnection, + RTCSessionDescription, +} from "react-native-webrtc"; diff --git a/apps/mobile/lib/voice/transport/webrtc/webrtc.web.ts b/apps/mobile/lib/voice/transport/webrtc/webrtc.web.ts new file mode 100644 index 00000000000..7f0dec8dc99 --- /dev/null +++ b/apps/mobile/lib/voice/transport/webrtc/webrtc.web.ts @@ -0,0 +1,25 @@ +// react-native-webrtc plays remote audio itself; a browser needs an element. +class PlayingPeerConnection extends RTCPeerConnection { + private readonly audio = new Audio(); + + constructor(configuration?: RTCConfiguration) { + super(configuration); + this.audio.autoplay = true; + this.addEventListener("track", (event) => { + this.audio.srcObject = event.streams[0] ?? new MediaStream([event.track]); + }); + } + + override close(): void { + this.audio.srcObject = null; + super.close(); + } +} + +export type MediaStream = globalThis.MediaStream; +export type MediaStreamTrack = globalThis.MediaStreamTrack; + +export const mediaDevices = navigator.mediaDevices; + +export { PlayingPeerConnection as RTCPeerConnection }; +export const RTCSessionDescription = globalThis.RTCSessionDescription; diff --git a/apps/mobile/lib/web-links.ts b/apps/mobile/lib/web-links.ts index 4f75fb1d62a..676ce0617dd 100644 --- a/apps/mobile/lib/web-links.ts +++ b/apps/mobile/lib/web-links.ts @@ -18,3 +18,8 @@ export function billingSettingsUrl(organizationId?: string | null): string { if (organizationId) url.searchParams.set("organization", organizationId); return url.toString(); } + +/** The host the links above open, for labels that name where a button goes. */ +export function webHost(): string { + return new URL(env.EXPO_PUBLIC_WEB_URL).host; +} diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 28f63a5cc40..5a1451251bc 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -36,6 +36,10 @@ config.resolver.assetExts = [...config.resolver.assetExts, "riv"]; config.resolver.extraNodeModules = { "@superset/alert-prompt": path.resolve(projectRoot, "modules/alert-prompt"), "@superset/composer": path.resolve(projectRoot, "modules/composer"), + "@superset/google-credential": path.resolve( + projectRoot, + "modules/google-credential", + ), "@superset/symbol-button": path.resolve(projectRoot, "modules/symbol-button"), "@superset/attachments-sheet": path.resolve( projectRoot, diff --git a/apps/mobile/modules/alert-prompt/src/AlertPrompt.android.tsx b/apps/mobile/modules/alert-prompt/src/AlertPrompt.android.tsx new file mode 100644 index 00000000000..59295700894 --- /dev/null +++ b/apps/mobile/modules/alert-prompt/src/AlertPrompt.android.tsx @@ -0,0 +1,91 @@ +import { + AlertDialog, + Host, + Text, + TextButton, + TextField, + type TextFieldRef, + useNativeState, +} from "@expo/ui/jetpack-compose"; +import { useLingui } from "@lingui/react/macro"; +import { useRef } from "react"; +import { useComposeHostTheme } from "@/hooks/useComposeHostTheme"; +import type { + AlertPromptHostComponent, + Prompt, + PromptOptions, +} from "./AlertPrompt.types"; +import { enqueuePrompt, settlePrompt, usePendingPrompt } from "./promptQueue"; + +export const prompt: Prompt = enqueuePrompt; + +export const AlertPromptHost: AlertPromptHostComponent = () => { + const pending = usePendingPrompt(); + const hostTheme = useComposeHostTheme(); + if (!pending) return null; + return ( + + + + ); +}; + +function PromptDialog({ options }: { options: PromptOptions }) { + const { t } = useLingui(); + const value = useNativeState(options.defaultValue ?? ""); + const text = useRef(options.defaultValue ?? ""); + const fieldRef = useRef(null); + const confirm = () => settlePrompt(text.current); + const cancel = () => settlePrompt(null); + + return ( + + + {options.title} + + + { + text.current = value; + }} + onFocusChanged={(focused) => { + if (focused && options.selectText) { + void fieldRef.current?.setSelection(0, text.current.length); + } + }} + > + {options.message ? ( + + {options.message} + + ) : null} + {options.placeholder ? ( + + {options.placeholder} + + ) : null} + + + + + {options.cancelText ?? t({ message: "Cancel" })} + + + + + {options.confirmText ?? t({ message: "OK" })} + + + + ); +} diff --git a/apps/mobile/modules/alert-prompt/src/AlertPrompt.ios.ts b/apps/mobile/modules/alert-prompt/src/AlertPrompt.ios.ts new file mode 100644 index 00000000000..7cffa23e271 --- /dev/null +++ b/apps/mobile/modules/alert-prompt/src/AlertPrompt.ios.ts @@ -0,0 +1,8 @@ +import { requireNativeModule } from "expo"; +import type { AlertPromptHostComponent, Prompt } from "./AlertPrompt.types"; + +const AlertPromptModule = requireNativeModule("AlertPrompt"); + +export const prompt: Prompt = (options) => AlertPromptModule.prompt(options); + +export const AlertPromptHost: AlertPromptHostComponent = () => null; diff --git a/apps/mobile/modules/alert-prompt/src/AlertPrompt.tsx b/apps/mobile/modules/alert-prompt/src/AlertPrompt.tsx new file mode 100644 index 00000000000..2c4568a3fe0 --- /dev/null +++ b/apps/mobile/modules/alert-prompt/src/AlertPrompt.tsx @@ -0,0 +1,87 @@ +import { useLingui } from "@lingui/react/macro"; +import { useEffect, useState } from "react"; +import { KeyboardAvoidingView, Modal, Pressable, View } from "react-native"; +import { Input } from "@/components/ui/input"; +import { Text } from "@/components/ui/text"; +import type { AlertPromptHostComponent, Prompt } from "./AlertPrompt.types"; +import { enqueuePrompt, settlePrompt, usePendingPrompt } from "./promptQueue"; + +export const prompt: Prompt = enqueuePrompt; + +export const AlertPromptHost: AlertPromptHostComponent = () => { + const pending = usePendingPrompt(); + const { t } = useLingui(); + const [value, setValue] = useState(""); + + useEffect(() => { + setValue(pending?.options.defaultValue ?? ""); + }, [pending]); + + if (!pending) return null; + const { options } = pending; + const cancel = () => settlePrompt(null); + const confirm = () => settlePrompt(value); + + return ( + + + + + + + {options.title} + + {options.message ? ( + + {options.message} + + ) : null} + + + + + + {options.cancelText ?? t({ message: "Cancel" })} + + + + + {options.confirmText ?? t({ message: "OK" })} + + + + + + + ); +}; diff --git a/apps/mobile/modules/alert-prompt/src/AlertPrompt.types.ts b/apps/mobile/modules/alert-prompt/src/AlertPrompt.types.ts new file mode 100644 index 00000000000..487b1730fa5 --- /dev/null +++ b/apps/mobile/modules/alert-prompt/src/AlertPrompt.types.ts @@ -0,0 +1,17 @@ +import type { ComponentType } from "react"; + +export type PromptOptions = { + title: string; + message?: string; + defaultValue?: string; + placeholder?: string; + confirmText?: string; + cancelText?: string; + selectText?: boolean; +}; + +/** Resolves the entered text, or null when cancelled. */ +export type Prompt = (options: PromptOptions) => Promise; + +/** Mounted once at the app root; draws prompts where the OS has no native one. */ +export type AlertPromptHostComponent = ComponentType; diff --git a/apps/mobile/modules/alert-prompt/src/index.ts b/apps/mobile/modules/alert-prompt/src/index.ts index b651fd1509a..d9b28867023 100644 --- a/apps/mobile/modules/alert-prompt/src/index.ts +++ b/apps/mobile/modules/alert-prompt/src/index.ts @@ -1,17 +1,6 @@ -import { requireNativeModule } from "expo"; - -export type PromptOptions = { - title: string; - message?: string; - defaultValue?: string; - placeholder?: string; - confirmText?: string; - cancelText?: string; - selectText?: boolean; -}; - -const AlertPromptModule = requireNativeModule("AlertPrompt"); - -export function prompt(options: PromptOptions): Promise { - return AlertPromptModule.prompt(options); -} +export { AlertPromptHost, prompt } from "./AlertPrompt"; +export type { + AlertPromptHostComponent, + Prompt, + PromptOptions, +} from "./AlertPrompt.types"; diff --git a/apps/mobile/modules/alert-prompt/src/promptQueue.ts b/apps/mobile/modules/alert-prompt/src/promptQueue.ts new file mode 100644 index 00000000000..2883115a45a --- /dev/null +++ b/apps/mobile/modules/alert-prompt/src/promptQueue.ts @@ -0,0 +1,42 @@ +import { useSyncExternalStore } from "react"; +import type { Prompt, PromptOptions } from "./AlertPrompt.types"; + +interface PendingPrompt { + id: number; + options: PromptOptions; + resolve: (value: string | null) => void; +} + +const queue: PendingPrompt[] = []; +let nextId = 0; +const listeners = new Set<() => void>(); + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function emit() { + for (const listener of listeners) listener(); +} + +const current = () => queue[0]; + +/** The prompt the mounted `AlertPromptHost` shows, oldest first. */ +export function usePendingPrompt() { + return useSyncExternalStore(subscribe, current, current); +} + +export function settlePrompt(value: string | null) { + const pending = queue.shift(); + pending?.resolve(value); + emit(); +} + +export const enqueuePrompt: Prompt = (options) => + new Promise((resolve) => { + queue.push({ id: nextId++, options, resolve }); + emit(); + }); diff --git a/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ios.ts b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ios.ts new file mode 100644 index 00000000000..2b09692b535 --- /dev/null +++ b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ios.ts @@ -0,0 +1,68 @@ +import { requireOptionalNativeModule } from "expo"; +import { processColor } from "react-native"; +import type { + AttachmentsSheetAction, + AttachmentsSheetAsset, + PresentAttachmentsSheet, +} from "./AttachmentsSheet.types"; + +type AttachmentsSheetEvents = { + onAddAssets: (event: { assets: AttachmentsSheetAsset[] }) => void; + onAction: (event: { action: AttachmentsSheetAction }) => void; + onDismiss: () => void; +}; + +// expo-modules-core 56.0.23 exports a broken `NativeModule` type alias (the +// constructor type, generic dropped), so the emitter surface is typed by hand. +type AttachmentsSheetNativeModule = { + present(options: Record): Promise; + addListener( + eventName: Name, + listener: AttachmentsSheetEvents[Name], + ): { remove(): void }; +}; + +const AttachmentsSheet = + requireOptionalNativeModule("AttachmentsSheet"); + +export const presentAttachmentsSheet: PresentAttachmentsSheet = async ( + theme, + handlers, +) => { + if (!AttachmentsSheet) { + console.warn( + "AttachmentsSheet native module unavailable — rebuild the dev client", + ); + return false; + } + const subscriptions = [ + AttachmentsSheet.addListener("onAddAssets", ({ assets }) => { + settle(); + handlers.onAddAssets(assets); + }), + AttachmentsSheet.addListener("onAction", ({ action }) => { + settle(); + handlers.onAction(action); + }), + AttachmentsSheet.addListener("onDismiss", () => { + settle(); + handlers.onDismiss?.(); + }), + ]; + const settle = () => { + for (const subscription of subscriptions) subscription.remove(); + }; + + const { colorScheme, ...colors } = theme; + const presented = await AttachmentsSheet.present({ + colorScheme, + ...Object.fromEntries( + Object.entries(colors).map(([token, value]) => [ + token, + (processColor(value) as number | null) ?? 0, + ]), + ), + }); + if (!presented) settle(); + return presented; +}; diff --git a/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ts b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ts new file mode 100644 index 00000000000..04e11dbbd3b --- /dev/null +++ b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ts @@ -0,0 +1,43 @@ +import { useSyncExternalStore } from "react"; +import type { + AttachmentsSheetHandlers, + PresentAttachmentsSheet, +} from "./AttachmentsSheet.types"; + +let pending: AttachmentsSheetHandlers | null = null; +const listeners = new Set<() => void>(); + +function emit() { + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +const current = () => pending; + +/** The handlers of the open sheet, for the mounted `AttachmentsSheetHost`. */ +export function useAttachmentsSheetRequest() { + return useSyncExternalStore(subscribe, current, current); +} + +/** Closes the open sheet; the host then calls exactly one of its handlers. */ +export function settleAttachmentsSheet() { + pending = null; + emit(); +} + +export const presentAttachmentsSheet: PresentAttachmentsSheet = async ( + _theme, + handlers, +) => { + if (listeners.size === 0) return false; + pending?.onDismiss?.(); + pending = handlers; + emit(); + return true; +}; diff --git a/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.types.ts b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.types.ts new file mode 100644 index 00000000000..f1b60fe1b6c --- /dev/null +++ b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.types.ts @@ -0,0 +1,42 @@ +import type { ComponentType } from "react"; + +export type AttachmentsSheetAction = "photos" | "camera" | "files"; + +export interface AttachmentsSheetAsset { + uri: string; + name: string; + mediaType: string; + size: number; +} + +export interface AttachmentsSheetTheme { + colorScheme: "light" | "dark"; + background: string; + foreground: string; + mutedForeground: string; + border: string; + secondary: string; + secondaryForeground: string; + primary: string; + primaryForeground: string; +} + +export interface AttachmentsSheetHandlers { + onAddAssets: (assets: AttachmentsSheetAsset[]) => void; + onAction: (action: AttachmentsSheetAction) => void; + /** Dismissed with neither assets nor a row action. */ + onDismiss?: () => void; +} + +/** + * Presents the attachments sheet. Every presentation ends in exactly one + * handler call — assets added, a row action, or a plain dismissal. Resolves + * false when nothing was presented. + */ +export type PresentAttachmentsSheet = ( + theme: AttachmentsSheetTheme, + handlers: AttachmentsSheetHandlers, +) => Promise; + +/** Mounted once at the app root; draws the sheet where no native module does. */ +export type AttachmentsSheetHostComponent = ComponentType; diff --git a/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.web.ts b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.web.ts new file mode 100644 index 00000000000..d9de5ca0ef5 --- /dev/null +++ b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.web.ts @@ -0,0 +1,21 @@ +import type { + AttachmentsSheetHandlers, + PresentAttachmentsSheet, +} from "./AttachmentsSheet.types"; + +// The browser's file chooser is the sheet: on a phone it already offers the +// photo library, the camera and files. +export const presentAttachmentsSheet: PresentAttachmentsSheet = async ( + _theme, + { onAction }, +) => { + onAction("files"); + return true; +}; + +/** No sheet is ever drawn on web, so the root host has nothing to show. */ +export function useAttachmentsSheetRequest(): AttachmentsSheetHandlers | null { + return null; +} + +export function settleAttachmentsSheet() {} diff --git a/apps/mobile/modules/attachments-sheet/src/AttachmentsSheetHost.android.tsx b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheetHost.android.tsx new file mode 100644 index 00000000000..17a7ea68051 --- /dev/null +++ b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheetHost.android.tsx @@ -0,0 +1,81 @@ +import { + Column, + Host, + Icon, + ListItem, + ModalBottomSheet, + type ModalBottomSheetRef, + Text, +} from "@expo/ui/jetpack-compose"; +import { clickable, padding } from "@expo/ui/jetpack-compose/modifiers"; +import { useLingui } from "@lingui/react/macro"; +import { useRef } from "react"; +import { useWindowDimensions } from "react-native"; +import { useComposeHostTheme } from "@/hooks/useComposeHostTheme"; +import { MENU_ICON } from "@/lib/menu-icons"; +import { + settleAttachmentsSheet, + useAttachmentsSheetRequest, +} from "./AttachmentsSheet"; +import type { + AttachmentsSheetAction, + AttachmentsSheetHostComponent, +} from "./AttachmentsSheet.types"; + +export const AttachmentsSheetHost: AttachmentsSheetHostComponent = () => { + const handlers = useAttachmentsSheetRequest(); + const sheetRef = useRef(null); + const hostTheme = useComposeHostTheme(); + const { width } = useWindowDimensions(); + const { t } = useLingui(); + if (!handlers) return null; + + const rows: { action: AttachmentsSheetAction; label: string }[] = [ + { action: "photos", label: t({ message: "Photos" }) }, + { action: "camera", label: t({ message: "Camera" }) }, + { action: "files", label: t({ message: "Files" }) }, + ]; + + const dismiss = () => { + settleAttachmentsSheet(); + handlers.onDismiss?.(); + }; + // The action runs once the sheet is gone, so a picker never opens under it. + // Settling unmounts the sheet, so it hides first. + const choose = async (action: AttachmentsSheetAction) => { + await sheetRef.current?.hide().catch(() => {}); + settleAttachmentsSheet(); + handlers.onAction(action); + }; + + return ( + + + + {rows.map((row) => ( + void choose(row.action))]} + > + + + + + {row.label} + + + ))} + + + + ); +}; diff --git a/apps/mobile/modules/attachments-sheet/src/AttachmentsSheetHost.ios.ts b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheetHost.ios.ts new file mode 100644 index 00000000000..db42ec5403f --- /dev/null +++ b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheetHost.ios.ts @@ -0,0 +1,3 @@ +import type { AttachmentsSheetHostComponent } from "./AttachmentsSheet.types"; + +export const AttachmentsSheetHost: AttachmentsSheetHostComponent = () => null; diff --git a/apps/mobile/modules/attachments-sheet/src/AttachmentsSheetHost.tsx b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheetHost.tsx new file mode 100644 index 00000000000..dfc36f5e254 --- /dev/null +++ b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheetHost.tsx @@ -0,0 +1,79 @@ +import { useLingui } from "@lingui/react/macro"; +import { + Camera, + FolderOpen, + Images, + type LucideIcon, +} from "lucide-react-native"; +import { Modal, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Icon } from "@/components/ui/icon"; +import { Text } from "@/components/ui/text"; +import { + settleAttachmentsSheet, + useAttachmentsSheetRequest, +} from "./AttachmentsSheet"; +import type { + AttachmentsSheetAction, + AttachmentsSheetHostComponent, +} from "./AttachmentsSheet.types"; + +export const AttachmentsSheetHost: AttachmentsSheetHostComponent = () => { + const handlers = useAttachmentsSheetRequest(); + const insets = useSafeAreaInsets(); + const { t } = useLingui(); + if (!handlers) return null; + + const rows: { + action: AttachmentsSheetAction; + label: string; + icon: LucideIcon; + }[] = [ + { action: "photos", label: t({ message: "Photos" }), icon: Images }, + { action: "camera", label: t({ message: "Camera" }), icon: Camera }, + { action: "files", label: t({ message: "Files" }), icon: FolderOpen }, + ]; + + const dismiss = () => { + settleAttachmentsSheet(); + handlers.onDismiss?.(); + }; + + return ( + + + + + {rows.map((row) => ( + { + settleAttachmentsSheet(); + handlers.onAction(row.action); + }} + > + + {row.label} + + ))} + + + + ); +}; diff --git a/apps/mobile/modules/attachments-sheet/src/index.ts b/apps/mobile/modules/attachments-sheet/src/index.ts index 4c0e1031860..7e4598ffb7c 100644 --- a/apps/mobile/modules/attachments-sheet/src/index.ts +++ b/apps/mobile/modules/attachments-sheet/src/index.ts @@ -1,96 +1,10 @@ -import { requireOptionalNativeModule } from "expo"; -import { processColor } from "react-native"; - -export type AttachmentsSheetAction = "photos" | "camera" | "files"; - -export interface AttachmentsSheetAsset { - uri: string; - name: string; - mediaType: string; - size: number; -} - -export interface AttachmentsSheetTheme { - colorScheme: "light" | "dark"; - background: string; - foreground: string; - mutedForeground: string; - border: string; - secondary: string; - secondaryForeground: string; - primary: string; - primaryForeground: string; -} - -export interface AttachmentsSheetHandlers { - onAddAssets: (assets: AttachmentsSheetAsset[]) => void; - onAction: (action: AttachmentsSheetAction) => void; - /** Dismissed with neither assets nor a row action. */ - onDismiss?: () => void; -} - -type AttachmentsSheetEvents = { - onAddAssets: (event: { assets: AttachmentsSheetAsset[] }) => void; - onAction: (event: { action: AttachmentsSheetAction }) => void; - onDismiss: () => void; -}; - -// expo-modules-core 56.0.23 exports a broken `NativeModule` type alias (the -// constructor type, generic dropped), so the emitter surface is typed by hand. -type AttachmentsSheetNativeModule = { - present(options: Record): Promise; - addListener( - eventName: Name, - listener: AttachmentsSheetEvents[Name], - ): { remove(): void }; -}; - -const AttachmentsSheet = - requireOptionalNativeModule("AttachmentsSheet"); - -/** - * Presents the native attachments sheet. Every presentation ends in exactly - * one native event — assets added, a row action, or a plain dismissal — after - * which the listeners tear themselves down. - */ -export async function presentAttachmentsSheet( - theme: AttachmentsSheetTheme, - handlers: AttachmentsSheetHandlers, -): Promise { - if (!AttachmentsSheet) { - console.warn( - "AttachmentsSheet native module unavailable — rebuild the dev client", - ); - return false; - } - const subscriptions = [ - AttachmentsSheet.addListener("onAddAssets", ({ assets }) => { - settle(); - handlers.onAddAssets(assets); - }), - AttachmentsSheet.addListener("onAction", ({ action }) => { - settle(); - handlers.onAction(action); - }), - AttachmentsSheet.addListener("onDismiss", () => { - settle(); - handlers.onDismiss?.(); - }), - ]; - const settle = () => { - for (const subscription of subscriptions) subscription.remove(); - }; - - const { colorScheme, ...colors } = theme; - const presented = await AttachmentsSheet.present({ - colorScheme, - ...Object.fromEntries( - Object.entries(colors).map(([token, value]) => [ - token, - (processColor(value) as number | null) ?? 0, - ]), - ), - }); - if (!presented) settle(); - return presented; -} +export { presentAttachmentsSheet } from "./AttachmentsSheet"; +export type { + AttachmentsSheetAction, + AttachmentsSheetAsset, + AttachmentsSheetHandlers, + AttachmentsSheetHostComponent, + AttachmentsSheetTheme, + PresentAttachmentsSheet, +} from "./AttachmentsSheet.types"; +export { AttachmentsSheetHost } from "./AttachmentsSheetHost"; diff --git a/apps/mobile/modules/composer-android/.gitignore b/apps/mobile/modules/composer-android/.gitignore new file mode 100644 index 00000000000..0e1b04e90ed --- /dev/null +++ b/apps/mobile/modules/composer-android/.gitignore @@ -0,0 +1 @@ +android/build/ diff --git a/apps/mobile/modules/composer-android/android/build.gradle b/apps/mobile/modules/composer-android/android/build.gradle new file mode 100644 index 00000000000..f40640ed094 --- /dev/null +++ b/apps/mobile/modules/composer-android/android/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'sh.superset' +version = '0.0.1' + +expoModule { + v2 true +} + +android { + namespace "sh.superset.composerandroid" +} diff --git a/apps/mobile/modules/composer-android/android/src/main/AndroidManifest.xml b/apps/mobile/modules/composer-android/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..5e243398644 --- /dev/null +++ b/apps/mobile/modules/composer-android/android/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/mobile/modules/composer-android/android/src/main/java/sh/superset/composerandroid/ClipboardAttachments.kt b/apps/mobile/modules/composer-android/android/src/main/java/sh/superset/composerandroid/ClipboardAttachments.kt new file mode 100644 index 00000000000..04e4baf643d --- /dev/null +++ b/apps/mobile/modules/composer-android/android/src/main/java/sh/superset/composerandroid/ClipboardAttachments.kt @@ -0,0 +1,60 @@ +package sh.superset.composerandroid + +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import java.io.File +import java.util.UUID + +/** + * Images and files on the clipboard, copied into the cache so the tray gets + * file URIs the way the pickers give them. Reading only the description keeps + * Android's "pasted from clipboard" toast for the paste itself. + */ +object ClipboardAttachments { + fun available(clipboard: ClipboardManager): Boolean { + val description = clipboard.primaryClipDescription ?: return false + return (0 until description.mimeTypeCount).any { index -> + val mime = description.getMimeType(index) + !mime.startsWith("text/") && mime != ClipDescription.MIMETYPE_TEXT_INTENT + } + } + + fun read(context: Context, clipboard: ClipboardManager): List> { + val clip = clipboard.primaryClip ?: return emptyList() + val resolver = context.contentResolver + val dir = File(context.cacheDir, "composer-paste/${UUID.randomUUID()}").apply { mkdirs() } + return (0 until clip.itemCount).mapNotNull { index -> + val uri = clip.getItemAt(index).uri ?: return@mapNotNull null + val mime = resolver.getType(uri) ?: return@mapNotNull null + if (mime.startsWith("text/")) return@mapNotNull null + val name = displayName(context, uri) ?: fallbackName(index, mime) + val target = File(dir, name.replace('/', '_')) + val copied = runCatching { + resolver.openInputStream(uri)?.use { input -> + target.outputStream().use { output -> input.copyTo(output) } + } + }.getOrNull() + if (copied == null) return@mapNotNull null + mapOf( + "uri" to Uri.fromFile(target).toString(), + "name" to target.name, + "kind" to if (mime.startsWith("image/")) "image" else "file", + ) + } + } + + private fun displayName(context: Context, uri: Uri): String? = + runCatching { + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + }.getOrNull()?.takeIf { it.isNotBlank() } + + private fun fallbackName(index: Int, mime: String): String { + val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "bin" + return "pasted-${index + 1}.$extension" + } +} diff --git a/apps/mobile/modules/composer-android/android/src/main/java/sh/superset/composerandroid/ComposerAndroidModule.kt b/apps/mobile/modules/composer-android/android/src/main/java/sh/superset/composerandroid/ComposerAndroidModule.kt new file mode 100644 index 00000000000..4af844b94e9 --- /dev/null +++ b/apps/mobile/modules/composer-android/android/src/main/java/sh/superset/composerandroid/ComposerAndroidModule.kt @@ -0,0 +1,90 @@ +package sh.superset.composerandroid + +import android.Manifest +import android.content.ClipboardManager +import android.content.Context +import android.speech.SpeechRecognizer +import expo.modules.interfaces.permissions.Permissions +import expo.modules.kotlin.Promise +import expo.modules.kotlin.exception.Exceptions +import expo.modules.kotlin.functions.Queues +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class ComposerAndroidModule : Module() { + private val context: Context + get() = appContext.reactContext ?: throw Exceptions.ReactContextLost() + + private var dictation: Dictation? = null + + private val clipboard: ClipboardManager + get() = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + private val clipListener = ClipboardManager.OnPrimaryClipChangedListener { + sendEvent("onClipboardChange", mapOf("hasAttachments" to ClipboardAttachments.available(clipboard))) + } + + override fun definition() = ModuleDefinition { + Name("ComposerAndroid") + + Events( + "onDictationPartial", + "onDictationFinal", + "onDictationLevel", + "onDictationError", + "onDictationEnd", + "onClipboardChange", + ) + + Function("isDictationAvailable") { + SpeechRecognizer.isRecognitionAvailable(context) + } + + AsyncFunction("requestMicrophonePermission") { promise: Promise -> + Permissions.askForPermissionsWithPermissionsManager( + appContext.permissions, + promise, + Manifest.permission.RECORD_AUDIO, + ) + } + + AsyncFunction("startDictation") { locale: String? -> + dictation?.cancel() + dictation = Dictation(context, locale, ::sendEvent).also { it.start() } + }.runOnQueue(Queues.MAIN) + + AsyncFunction("stopDictation") { + dictation?.stop() + }.runOnQueue(Queues.MAIN) + + AsyncFunction("cancelDictation") { + dictation?.cancel() + dictation = null + }.runOnQueue(Queues.MAIN) + + Function("clipboardHasAttachments") { + ClipboardAttachments.available(clipboard) + } + + AsyncFunction("readClipboardAttachments") { + ClipboardAttachments.read(context, clipboard) + } + + AsyncFunction("documentThumbnail") { uri: String, sidePx: Int -> + DocumentThumbnail.render(context, uri, sidePx) + } + + OnStartObserving { + clipboard.addPrimaryClipChangedListener(clipListener) + } + + OnStopObserving { + clipboard.removePrimaryClipChangedListener(clipListener) + } + + OnDestroy { + dictation?.cancel() + dictation = null + } + } +} diff --git a/apps/mobile/modules/composer-android/android/src/main/java/sh/superset/composerandroid/Dictation.kt b/apps/mobile/modules/composer-android/android/src/main/java/sh/superset/composerandroid/Dictation.kt new file mode 100644 index 00000000000..606155ecb7b --- /dev/null +++ b/apps/mobile/modules/composer-android/android/src/main/java/sh/superset/composerandroid/Dictation.kt @@ -0,0 +1,119 @@ +package sh.superset.composerandroid + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.SystemClock +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer + +/** + * One dictation, from the mic tap to stop. The platform recogniser ends a + * session after a pause, so this starts it again until stop: the composer + * keeps listening the way iOS does. + */ +class Dictation( + private val context: Context, + private val locale: String?, + private val emit: (String, Map) -> Unit, +) : RecognitionListener { + private var recognizer: SpeechRecognizer? = null + private var listening = false + private var finished = false + private var lastLevelAt = 0L + + fun start() { + listening = true + recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { + it.setRecognitionListener(this) + } + listen() + } + + fun stop() { + if (!listening) return + listening = false + recognizer?.stopListening() + } + + fun cancel() { + listening = false + recognizer?.cancel() + finish() + } + + private fun listen() { + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + locale?.let { putExtra(RecognizerIntent.EXTRA_LANGUAGE, it) } + } + recognizer?.startListening(intent) + } + + private fun finish() { + if (finished) return + finished = true + recognizer?.destroy() + recognizer = null + emit("onDictationEnd", emptyMap()) + } + + private fun firstResult(results: Bundle?): String = + results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull().orEmpty() + + override fun onPartialResults(partialResults: Bundle?) { + val text = firstResult(partialResults) + if (text.isNotEmpty()) emit("onDictationPartial", mapOf("text" to text)) + } + + override fun onResults(results: Bundle?) { + val text = firstResult(results) + if (text.isNotEmpty()) emit("onDictationFinal", mapOf("text" to text)) + if (listening) listen() else finish() + } + + override fun onError(error: Int) { + val quiet = error == SpeechRecognizer.ERROR_NO_MATCH || + error == SpeechRecognizer.ERROR_SPEECH_TIMEOUT + if (quiet && listening) { + listen() + return + } + if (!quiet && error != SpeechRecognizer.ERROR_CLIENT) { + emit("onDictationError", mapOf("code" to errorCode(error))) + } + listening = false + finish() + } + + override fun onRmsChanged(rmsdB: Float) { + val now = SystemClock.elapsedRealtime() + if (now - lastLevelAt < LEVEL_INTERVAL_MS) return + lastLevelAt = now + emit("onDictationLevel", mapOf("level" to ((rmsdB + 2f) / 12f).coerceIn(0f, 1f))) + } + + override fun onReadyForSpeech(params: Bundle?) {} + override fun onBeginningOfSpeech() {} + override fun onBufferReceived(buffer: ByteArray?) {} + override fun onEndOfSpeech() {} + override fun onEvent(eventType: Int, params: Bundle?) {} + + private fun errorCode(error: Int): String = when (error) { + SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "permission" + SpeechRecognizer.ERROR_NETWORK, + SpeechRecognizer.ERROR_NETWORK_TIMEOUT, + SpeechRecognizer.ERROR_SERVER, + SpeechRecognizer.ERROR_SERVER_DISCONNECTED -> "network" + SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "busy" + SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED, + SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE -> "language" + else -> "failed" + } + + private companion object { + const val LEVEL_INTERVAL_MS = 100L + } +} diff --git a/apps/mobile/modules/composer-android/android/src/main/java/sh/superset/composerandroid/DocumentThumbnail.kt b/apps/mobile/modules/composer-android/android/src/main/java/sh/superset/composerandroid/DocumentThumbnail.kt new file mode 100644 index 00000000000..e32a14176af --- /dev/null +++ b/apps/mobile/modules/composer-android/android/src/main/java/sh/superset/composerandroid/DocumentThumbnail.kt @@ -0,0 +1,57 @@ +package sh.superset.composerandroid + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.pdf.PdfRenderer +import android.net.Uri +import android.os.Build +import android.util.Size +import java.io.File +import kotlin.math.max +import kotlin.math.roundToInt + +/** The first page of a PDF, or the provider's own preview for other documents. */ +object DocumentThumbnail { + fun render(context: Context, uriString: String, sidePx: Int): String? { + val uri = Uri.parse(uriString) + val bitmap = (if (isPdf(context, uri)) pdfPage(context, uri, sidePx) else null) + ?: providerThumbnail(context, uri, sidePx) + ?: return null + val dir = File(context.cacheDir, "composer-thumbs").apply { mkdirs() } + val file = File(dir, "${(uriString + sidePx).hashCode().toUInt()}.png") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + bitmap.recycle() + return Uri.fromFile(file).toString() + } + + private fun isPdf(context: Context, uri: Uri): Boolean = + uri.path?.endsWith(".pdf", ignoreCase = true) == true || + runCatching { context.contentResolver.getType(uri) }.getOrNull() == "application/pdf" + + private fun pdfPage(context: Context, uri: Uri, sidePx: Int): Bitmap? = runCatching { + context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> + PdfRenderer(descriptor).use { renderer -> + if (renderer.pageCount == 0) return@use null + renderer.openPage(0).use { page -> + val scale = sidePx.toFloat() / max(page.width, page.height) + val bitmap = Bitmap.createBitmap( + max(1, (page.width * scale).roundToInt()), + max(1, (page.height * scale).roundToInt()), + Bitmap.Config.ARGB_8888, + ) + bitmap.eraseColor(Color.WHITE) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + bitmap + } + } + } + }.getOrNull() + + private fun providerThumbnail(context: Context, uri: Uri, sidePx: Int): Bitmap? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || uri.scheme != "content") return null + return runCatching { + context.contentResolver.loadThumbnail(uri, Size(sidePx, sidePx), null) + }.getOrNull() + } +} diff --git a/apps/mobile/modules/composer-android/expo-module.config.json b/apps/mobile/modules/composer-android/expo-module.config.json new file mode 100644 index 00000000000..61526b99fad --- /dev/null +++ b/apps/mobile/modules/composer-android/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["sh.superset.composerandroid.ComposerAndroidModule"] + } +} diff --git a/apps/mobile/modules/composer-android/package.json b/apps/mobile/modules/composer-android/package.json new file mode 100644 index 00000000000..96db2559856 --- /dev/null +++ b/apps/mobile/modules/composer-android/package.json @@ -0,0 +1,9 @@ +{ + "name": "@superset/composer-android", + "version": "0.0.1", + "private": true, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } +} diff --git a/apps/mobile/modules/composer/src/Composer.ios.tsx b/apps/mobile/modules/composer/src/Composer.ios.tsx new file mode 100644 index 00000000000..945bba021a7 --- /dev/null +++ b/apps/mobile/modules/composer/src/Composer.ios.tsx @@ -0,0 +1,202 @@ +import { requireNativeModule, requireNativeView } from "expo"; +import { forwardRef, type Ref, useImperativeHandle, useRef } from "react"; +import type { + ComposerAttachment, + ComposerBackdrop, + ComposerComponent, + ComposerHandle, + ComposerMenuOption, + ComposerModeOption, + ComposerPastedItem, + ComposerProps, + ComposerQuickKey, + ComposerQuickKeysAction, + ComposerSessionTab, + ComposerSessionTabLabels, + ComposerSlashCommand, + FileThumbnail, + PresentImage, +} from "./Composer.types"; + +/** The imperative surface the native view exposes through its ref. */ +interface NativeComposerRef { + clear: () => void; + appendDraft: (text: string) => void; + focus: () => void; + blur: () => void; +} + +interface NativeComposerViewProps { + ref?: Ref; + placeholder?: string; + initialDraft?: string; + backdrop?: ComposerBackdrop; + attachments?: ComposerAttachment[]; + selectedModel?: ComposerMenuOption; + launchOptions?: ComposerMenuOption[]; + modeOptions?: ComposerModeOption[]; + selectedModeId?: string | null; + canStop?: boolean; + headerChips?: ComposerMenuOption[]; + quickKeys?: ComposerQuickKey[]; + sessionTabs?: ComposerSessionTab[]; + sessionTabLabels?: ComposerSessionTabLabels; + /** Null, never undefined — see the pass-through below. */ + quickKeysAction?: ComposerQuickKeysAction | null; + slashCommands?: ComposerSlashCommand[]; + showAttachments?: boolean; + autocapitalization?: "sentences" | "never"; + compactEditor?: boolean; + isSending?: boolean; + onSubmit?: (event: { nativeEvent: { text: string } }) => void; + onAttachmentsPress?: () => void; + onDictationError?: (event: { nativeEvent: { message: string } }) => void; + onModelPress?: () => void; + onLaunchOptionPress?: (event: { nativeEvent: { id: string } }) => void; + onModeSelect?: (event: { nativeEvent: { id: string } }) => void; + onStop?: () => void; + onChipPress?: (event: { nativeEvent: { id: string } }) => void; + onQuickKeyPress?: (event: { nativeEvent: { id: string } }) => void; + onSessionTabPress?: (event: { nativeEvent: { id: string } }) => void; + onSessionTabClose?: (event: { nativeEvent: { id: string } }) => void; + onSessionTabRename?: (event: { nativeEvent: { id: string } }) => void; + onSessionTabCopyId?: (event: { nativeEvent: { id: string } }) => void; + onNewSessionPress?: () => void; + onAllSessionsPress?: () => void; + onQuickKeysActionPress?: () => void; + onHeightChange?: (event: { nativeEvent: { height: number } }) => void; + onPaste?: (event: { nativeEvent: { items: ComposerPastedItem[] } }) => void; + onDraftChange?: (event: { nativeEvent: { text: string } }) => void; + onRemoveAttachment?: (event: { nativeEvent: { id: string } }) => void; + onAttachmentPress?: (event: { nativeEvent: { id: string } }) => void; + onExpandedChange?: (event: { nativeEvent: { expanded: boolean } }) => void; +} + +const NativeComposerView = + requireNativeView("Composer"); + +const NativeComposerModule = requireNativeModule<{ + presentImage: (uri: string) => Promise; + fileThumbnail: (uri: string, side: number) => Promise; +}>("Composer"); + +export const presentImage: PresentImage = (uri) => + NativeComposerModule.presentImage(uri); + +export const fileThumbnail: FileThumbnail = (uri, side) => + NativeComposerModule.fileThumbnail(uri, side); + +export const Composer: ComposerComponent = forwardRef< + ComposerHandle, + ComposerProps +>(function Composer( + { + placeholder = "", + initialDraft = "", + backdrop = "dim", + attachments, + selectedModel, + launchOptions, + modeOptions, + selectedModeId, + canStop = false, + headerChips, + quickKeys, + sessionTabs, + sessionTabLabels, + quickKeysAction, + slashCommands, + showAttachments = true, + autocapitalization = "sentences", + compactEditor = false, + isSending = false, + onSubmit, + onAttachmentsPress, + onDictationError, + onModelPress, + onLaunchOptionPress, + onModeSelect, + onStop, + onChipPress, + onQuickKeyPress, + onSessionTabPress, + onSessionTabClose, + onSessionTabRename, + onSessionTabCopyId, + onNewSessionPress, + onAllSessionsPress, + onQuickKeysActionPress, + onHeightChange, + onPaste, + onDraftChange, + onRemoveAttachment, + onAttachmentPress, + onExpandedChange, + }, + ref, +) { + const nativeRef = useRef(null); + + useImperativeHandle(ref, () => ({ + clear: () => nativeRef.current?.clear(), + appendDraft: (text: string) => nativeRef.current?.appendDraft(text), + focus: () => nativeRef.current?.focus(), + blur: () => nativeRef.current?.blur(), + })); + + return ( + onSubmit?.(event.nativeEvent.text)} + onAttachmentsPress={onAttachmentsPress} + onDictationError={(event) => + onDictationError?.(event.nativeEvent.message) + } + onModelPress={onModelPress} + onLaunchOptionPress={(event) => + onLaunchOptionPress?.(event.nativeEvent.id) + } + onModeSelect={(event) => onModeSelect?.(event.nativeEvent.id)} + onStop={() => onStop?.()} + onChipPress={(event) => onChipPress?.(event.nativeEvent.id)} + onQuickKeyPress={(event) => onQuickKeyPress?.(event.nativeEvent.id)} + onSessionTabPress={(event) => onSessionTabPress?.(event.nativeEvent.id)} + onSessionTabClose={(event) => onSessionTabClose?.(event.nativeEvent.id)} + onSessionTabRename={(event) => onSessionTabRename?.(event.nativeEvent.id)} + onSessionTabCopyId={(event) => onSessionTabCopyId?.(event.nativeEvent.id)} + onNewSessionPress={onNewSessionPress} + onAllSessionsPress={onAllSessionsPress} + onQuickKeysActionPress={onQuickKeysActionPress} + onHeightChange={(event) => onHeightChange?.(event.nativeEvent.height)} + onPaste={(event) => onPaste?.(event.nativeEvent.items)} + onDraftChange={(event) => onDraftChange?.(event.nativeEvent.text)} + onRemoveAttachment={(event) => onRemoveAttachment?.(event.nativeEvent.id)} + onAttachmentPress={(event) => onAttachmentPress?.(event.nativeEvent.id)} + onExpandedChange={(event) => + onExpandedChange?.(event.nativeEvent.expanded) + } + /> + ); +}); diff --git a/apps/mobile/modules/composer/src/Composer.tsx b/apps/mobile/modules/composer/src/Composer.tsx new file mode 100644 index 00000000000..c49bac58f0a --- /dev/null +++ b/apps/mobile/modules/composer/src/Composer.tsx @@ -0,0 +1,652 @@ +import { useLingui } from "@lingui/react/macro"; +import { Image } from "expo-image"; +import { + ArrowUp, + ChevronDown, + ClipboardPaste, + Mic, + Plus, + Square, +} from "lucide-react-native"; +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; +import { + ActivityIndicator, + Animated, + Keyboard, + Pressable, + StyleSheet, + Text, + TextInput, + type TextInputInstance, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { + ComposerAttachment, + ComposerComponent, + ComposerHandle, + ComposerMenuOption, + ComposerProps, + PresentImage, +} from "./Composer.types"; +import { ComposerAttachmentTray } from "./components/ComposerAttachmentTray"; +import { ComposerDictationPill } from "./components/ComposerDictationPill"; +import { + ComposerImageViewer, + showImage, +} from "./components/ComposerImageViewer"; +import { ComposerModeMenu } from "./components/ComposerModeMenu"; +import { ComposerQuickKeys } from "./components/ComposerQuickKeys"; +import { ComposerSessionTabs } from "./components/ComposerSessionTabs"; +import { ComposerSlashSuggestions } from "./components/ComposerSlashSuggestions"; +import { COMPOSER_COLORS, COMPOSER_METRICS } from "./constants"; +import { useAttachmentPaste } from "./hooks/useAttachmentPaste"; +import { useDictation } from "./hooks/useDictation"; +import { useSwipeToDismiss } from "./hooks/useSwipeToDismiss"; +import { clusterColumn } from "./utils/clusterColumn"; +import { composerKeyHandler } from "./utils/composerKeyHandler"; +import { joinDraft } from "./utils/joinDraft"; +import { + committedSlashDraft, + slashSuggestions, +} from "./utils/slashSuggestions"; + +export { fileThumbnail } from "./utils/fileThumbnail"; + +export const presentImage: PresentImage = async (uri) => { + showImage(uri); +}; + +export const Composer: ComposerComponent = forwardRef< + ComposerHandle, + ComposerProps +>(function Composer( + { + placeholder = "", + initialDraft = "", + backdrop = "dim", + attachments = [], + selectedModel, + launchOptions, + modeOptions, + selectedModeId, + canStop = false, + headerChips, + quickKeys, + quickKeysAction, + slashCommands, + sessionTabs, + sessionTabLabels, + showAttachments = true, + autocapitalization = "sentences", + compactEditor = false, + isSending = false, + onSubmit, + onAttachmentsPress, + onDictationError, + onModelPress, + onLaunchOptionPress, + onModeSelect, + onStop, + onChipPress, + onQuickKeyPress, + onSessionTabPress, + onSessionTabClose, + onSessionTabRename, + onSessionTabCopyId, + onNewSessionPress, + onAllSessionsPress, + onQuickKeysActionPress, + onHeightChange, + onPaste, + onDraftChange, + onRemoveAttachment, + onAttachmentPress, + onExpandedChange, + }, + ref, +) { + const { t } = useLingui(); + const inputRef = useRef(null); + const [draft, setDraftState] = useState(initialDraft); + const [expanded, setExpanded] = useState(false); + // Android keeps focus when Back lowers the keyboard; iOS collapses there. + const keyboardHeight = useKeyboardHeight(() => inputRef.current?.blur()); + const insets = useSafeAreaInsets(); + + const setDraft = (text: string) => { + setDraftState(text); + onDraftChange?.(text); + }; + + const draftRef = useRef(draft); + draftRef.current = draft; + + const dictation = useDictation({ + getDraft: () => draftRef.current, + setDraft, + onError: onDictationError, + }); + const isDictating = dictation.state !== "idle"; + const typeDraft = (text: string) => { + dictation.rebase(text); + setDraft(text); + }; + const attachmentPaste = useAttachmentPaste({ + input: inputRef, + active: expanded && showAttachments, + onPaste, + }); + const swipe = useSwipeToDismiss(() => inputRef.current?.blur()); + + useImperativeHandle(ref, () => ({ + clear: () => setDraft(""), + appendDraft: (text) => setDraft(joinDraft(draftRef.current, text)), + focus: () => inputRef.current?.focus(), + blur: () => inputRef.current?.blur(), + })); + + const onExpandedChangeRef = useRef(onExpandedChange); + onExpandedChangeRef.current = onExpandedChange; + const reportedExpanded = useRef(false); + useEffect(() => { + if (reportedExpanded.current === expanded) return; + reportedExpanded.current = expanded; + onExpandedChangeRef.current?.(expanded); + }, [expanded]); + + const hasDraft = draft.trim().length > 0; + const hasContent = hasDraft || attachments.length > 0; + const isUploading = attachments.some( + (item) => item.progress !== undefined && !item.failed, + ); + const isBusy = isSending || isUploading; + const submit = () => { + if (!hasContent || isSending || isUploading) return; + onSubmit?.(draft); + }; + const onKeyPress = composerKeyHandler({ + onSubmit: submit, + onDismiss: () => inputRef.current?.blur(), + }); + + const openAttachment = (attachment: ComposerAttachment) => { + if (attachment.kind === "image") showImage(attachment.uri); + else onAttachmentPress?.(attachment.id); + }; + + const suggestions = + expanded && slashCommands ? slashSuggestions(draft, slashCommands) : null; + + const editorLines = compactEditor + ? COMPOSER_METRICS.compactEditorLines + : COMPOSER_METRICS.editorLines; + // React Native reports the keyboard without the navigation bar under it. + const lift = keyboardHeight + insets.bottom; + + return ( + + {expanded && backdrop === "dim" ? ( + inputRef.current?.blur()} + style={[StyleSheet.absoluteFill, styles.scrim]} + /> + ) : null} + + {suggestions ? ( + + setDraft(committedSlashDraft(command))} + /> + + ) : null} + + onHeightChange?.(event.nativeEvent.layout.height) + } + style={[styles.stack, clusterColumn]} + > + {sessionTabs && sessionTabs.length > 0 ? ( + onSessionTabPress?.(id)} + onClose={(id) => onSessionTabClose?.(id)} + onRename={(id) => onSessionTabRename?.(id)} + onCopyId={(id) => onSessionTabCopyId?.(id)} + onNewSession={() => onNewSessionPress?.()} + onAllSessions={() => onAllSessionsPress?.()} + /> + ) : null} + {(quickKeys && quickKeys.length > 0) || quickKeysAction ? ( + onQuickKeyPress?.(id)} + onAction={() => onQuickKeysActionPress?.()} + /> + ) : null} + + {expanded ? ( + inputRef.current?.blur()} + {...swipe.panHandlers} + style={styles.grabberArea} + > + + + ) : null} + {expanded && headerChips && headerChips.length > 0 ? ( + + {headerChips.map((chip) => ( + onChipPress?.(chip.id)} + /> + ))} + + ) : null} + {expanded && attachments.length > 0 ? ( + onRemoveAttachment?.(id)} + /> + ) : null} + + setExpanded(true)} + onBlur={() => setExpanded(false)} + onKeyPress={onKeyPress} + placeholder={placeholder} + placeholderTextColor={COMPOSER_COLORS.muted} + autoCapitalize={ + autocapitalization === "never" ? "none" : "sentences" + } + autoCorrect={autocapitalization !== "never"} + multiline + textAlignVertical="top" + style={[ + styles.input, + { + minHeight: COMPOSER_METRICS.lineHeight * editorLines, + maxHeight: + COMPOSER_METRICS.lineHeight * + COMPOSER_METRICS.maxEditorLines, + }, + ]} + /> + + + {showAttachments ? ( + + + + ) : null} + {expanded && attachmentPaste.canPaste ? ( + + + + ) : null} + {modeOptions && modeOptions.length > 0 ? ( + + ) : null} + {expanded ? ( + + {selectedModel ? ( + + ) : null} + {launchOptions?.map((option) => ( + onLaunchOptionPress?.(option.id)} + /> + ))} + + ) : ( + inputRef.current?.focus()} + style={styles.collapsedText} + > + + + {hasDraft ? draft.trim() : placeholder} + + + )} + {dictation.available && !isBusy ? ( + dictation.state === "recording" ? ( + + ) : dictation.state === "starting" ? ( + + + + ) : ( + void dictation.start()} + > + + + ) + ) : null} + {isDictating ? null : isSending ? ( + + + + ) : hasContent ? ( + + + + ) : canStop ? ( + + + + ) : null} + + + + + + + ); +}); + +function useKeyboardHeight(onHide: () => void) { + const onHideRef = useRef(onHide); + onHideRef.current = onHide; + const [height, setHeight] = useState(0); + useEffect(() => { + const show = Keyboard.addListener("keyboardDidShow", (event) => + setHeight(event.endCoordinates.height), + ); + const hide = Keyboard.addListener("keyboardDidHide", () => { + setHeight(0); + onHideRef.current(); + }); + return () => { + show.remove(); + hide.remove(); + }; + }, []); + return height; +} + +function RoundButton({ + children, + label, + filled = false, + disabled = false, + onPress, +}: { + children: React.ReactNode; + label: string; + filled?: boolean; + disabled?: boolean; + onPress?: () => void; +}) { + return ( + [ + styles.round, + filled && styles.roundFilled, + (pressed || disabled) && styles.pressed, + ]} + > + {children} + + ); +} + +function MenuButton({ + option, + plain = false, + onPress, +}: { + option: ComposerMenuOption; + /** No fill: a header chip, which sits on the card itself. */ + plain?: boolean; + onPress?: () => void; +}) { + return ( + [ + styles.menuButton, + plain && styles.menuButtonPlain, + pressed && styles.pressed, + ]} + > + {option.iconUri ? ( + + ) : option.avatar ? ( + + + {option.label.charAt(0).toUpperCase()} + + + ) : null} + + {option.label} + + + + ); +} + +function CollapsedThumbs({ + attachments, +}: { + attachments: ComposerAttachment[]; +}) { + const first = attachments[0]; + if (!first) return null; + return ( + + {first.kind === "image" ? ( + + ) : ( + + )} + {attachments.length > 1 ? ( + +{attachments.length - 1} + ) : null} + + ); +} + +const styles = StyleSheet.create({ + scrim: { backgroundColor: COMPOSER_COLORS.scrim }, + anchor: { + flex: 1, + justifyContent: "flex-end", + paddingTop: COMPOSER_METRICS.gap, + }, + suggestions: { + flexShrink: 1, + paddingHorizontal: COMPOSER_METRICS.edgeInset, + }, + stack: { + paddingHorizontal: COMPOSER_METRICS.edgeInset, + paddingTop: COMPOSER_METRICS.gap, + paddingBottom: COMPOSER_METRICS.gap, + gap: COMPOSER_METRICS.gap, + }, + chips: { + flexDirection: "row", + flexWrap: "wrap", + paddingHorizontal: COMPOSER_METRICS.edgeInset - 2, + paddingTop: 10, + }, + card: { + backgroundColor: COMPOSER_COLORS.card, + borderColor: COMPOSER_COLORS.border, + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 26, + overflow: "hidden", + }, + grabberArea: { alignItems: "center", paddingTop: 8, paddingBottom: 2 }, + grabber: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: COMPOSER_COLORS.border, + }, + editor: { paddingHorizontal: COMPOSER_METRICS.edgeInset + 4, paddingTop: 12 }, + collapsedField: { + position: "absolute", + top: 0, + left: 0, + right: 0, + opacity: 0, + }, + input: { + color: COMPOSER_COLORS.foreground, + fontSize: 16, + lineHeight: COMPOSER_METRICS.lineHeight, + padding: 0, + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: COMPOSER_METRICS.gap, + padding: 6, + }, + pickers: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: 4, + overflow: "hidden", + }, + collapsedText: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: COMPOSER_METRICS.gap, + minHeight: COMPOSER_METRICS.controlSize, + paddingLeft: 4, + }, + collapsedLabel: { flex: 1, color: COMPOSER_COLORS.foreground, fontSize: 16 }, + round: { + width: COMPOSER_METRICS.controlSize, + height: COMPOSER_METRICS.controlSize, + borderRadius: COMPOSER_METRICS.controlSize / 2, + backgroundColor: COMPOSER_COLORS.control, + alignItems: "center", + justifyContent: "center", + }, + roundFilled: { backgroundColor: COMPOSER_COLORS.foreground }, + pressed: { opacity: 0.5 }, + menuButton: { + flexDirection: "row", + alignItems: "center", + gap: 6, + maxWidth: 200, + paddingHorizontal: 10, + height: 32, + borderRadius: 16, + backgroundColor: COMPOSER_COLORS.control, + }, + menuButtonPlain: { backgroundColor: "transparent", paddingHorizontal: 6 }, + menuIcon: { width: 16, height: 16, borderRadius: 4 }, + avatar: { + backgroundColor: COMPOSER_COLORS.border, + alignItems: "center", + justifyContent: "center", + }, + avatarLetter: { + color: COMPOSER_COLORS.foreground, + fontSize: 10, + fontWeight: "600", + }, + menuLabel: { flexShrink: 1, color: COMPOSER_COLORS.foreground, fontSize: 14 }, + menuLabelMuted: { color: COMPOSER_COLORS.muted }, + miniThumbs: { flexDirection: "row", alignItems: "center", gap: 4 }, + miniThumb: { + width: COMPOSER_METRICS.miniThumbSize, + height: COMPOSER_METRICS.miniThumbSize, + borderRadius: 6, + }, + miniFile: { backgroundColor: COMPOSER_COLORS.control }, + more: { color: COMPOSER_COLORS.muted, fontSize: 13 }, +}); diff --git a/apps/mobile/modules/composer/src/Composer.types.ts b/apps/mobile/modules/composer/src/Composer.types.ts new file mode 100644 index 00000000000..d8b290e9991 --- /dev/null +++ b/apps/mobile/modules/composer/src/Composer.types.ts @@ -0,0 +1,403 @@ +import type { ForwardRefExoticComponent, RefAttributes } from "react"; + +/** + * How the composer treats the screen behind it while expanded. + * + * `dim` matches the mocks: the composer owns the screen and an outside tap + * dismisses it. `passthrough` leaves the content behind live so it can be + * scrolled while the keyboard is up — what a chat transcript wants. In that + * mode the caller owns dismissal, since nothing intercepts the outside tap. + */ +export type ComposerBackdrop = "dim" | "passthrough"; + +/** + * One entry in a composer picker. + * + * `iconUri` may be a remote URL or a local file URI. What it must not be is a + * Metro asset reference — SwiftUI cannot read those. Resolve bundled art with + * `expo-asset` first; see `useAgentIconUri`. + */ +export interface ComposerMenuOption { + id: string; + label: string; + iconUri?: string; + /** + * Lead with a project avatar. Separate from `iconUri` because most projects + * have no logo and the app draws their initial instead of leaving a gap — + * the same thing `ProjectAvatar` does everywhere else. + */ + avatar?: boolean; + /** + * Render a step back, as a qualifier rather than as the subject. The branch + * belongs to the project name beside it and should not compete with it. + */ + muted?: boolean; +} + +/** + * One permission mode in the menu beside `+`. `symbol` is an SF Symbol name. + */ +export interface ComposerModeOption { + id: string; + label: string; + symbol: string; +} + +/** + * One item in the composer's tray. The tray stays in React Native — it is + * shared with the attachments sheet — so the composer renders a description of + * it and reports removals and taps back out. + */ +export interface ComposerAttachment { + id: string; + uri: string; + kind: "image" | "file"; + /** + * Shown on the file card. Documents are unidentifiable without it — they all + * draw the same glyph. Ignored for images, which show themselves. + */ + name?: string; + /** + * 0–1 while the file is still on its way to cloud storage, drawn as a ring + * over the thumbnail. Omitted once it has landed, or when nothing is + * uploading — a tray of settled attachments draws no rings. + */ + progress?: number; + /** + * The upload failed. Marks the thumbnail instead of the ring; the + * attachment stays in the tray because sending the message retries it. + */ + failed?: boolean; +} + +/** + * One key in the strip above the composer — the terminal's esc/tab/arrows. + * + * Deliberately carries no behaviour: what a key writes into the PTY stays with + * the terminal that owns it. The composer draws the mark and reports the id. + */ +export interface ComposerQuickKey { + id: string; + /** Monospaced label. Ignored when `symbol` is set. */ + label?: string; + /** SF Symbol name, e.g. `arrow.up`. */ + symbol?: string; + /** + * A hairline between groups rather than a key. Which keys belong together + * is the terminal's knowledge, so it arrives as data — but a divider still + * needs its own unique `id`, because the strip identifies entries by it. + */ + divider?: boolean; +} + +/** + * One session in the strip above the quick keys — the workspace's tab bar. + * + * Data only, like the quick keys: the composer draws a pill and reports which + * one was touched. Everything about what a session *is* stays with the caller. + */ +export interface ComposerSessionTab { + id: string; + label: string; + /** + * The agent's brand mark. Same rule as `ComposerMenuOption.iconUri`: a + * remote URL or a local file URI, never a Metro asset reference. Resolve + * bundled art with `expo-asset` first — see `useAgentIconUris`. Omit for a + * plain shell, which draws the session's initial instead. + */ + iconUri?: string; + selected?: boolean; + /** Desktop's StatusIndicator states; omit for a session with nothing to say. */ + attention?: "permission" | "working" | "failed" | "review"; +} + +/** + * The one static control beside the quick keys. + * + * Data only, like the keys: the composer draws a chip and says it was pressed. + * What it opens is the caller's to know — the workspace terminal points it at + * that workspace's pull requests, and nothing here names one. + * + * It sits ahead of the bar rather than inside it, so it holds still while the + * keys scroll. It takes its room only once it exists: the keys slide over the + * moment it arrives, animated by the composer. + */ +export interface ComposerQuickKeysAction { + /** SF Symbol name, e.g. `arrow.triangle.pull`. */ + symbol: string; + /** + * A mark to draw instead of `symbol`. Same rule as + * `ComposerSessionTab.iconUri`: a local file URI, never a Metro asset + * reference. Drawn as a template, so it takes `tint` like the symbol does, + * and `symbol` shows until it resolves. + */ + iconUri?: string; + /** + * Which accent the glyph takes. Omit for the same foreground the keys use. + * The name crosses the bridge and the composer owns the colour, the way + * `ComposerSessionTab.attention` does. + */ + tint?: "open" | "draft" | "queued" | "merged" | "closed"; + /** Accessibility label. Translated here; the composer has no catalog. */ + label: string; +} + +/** + * Every user-facing string the tab strip draws. + * + * They cross the bridge because the composer cannot translate — Lingui's macros + * and catalogs live here — and a hardcoded English menu item would be the one + * untranslated string on a translated screen. + */ +export interface ComposerSessionTabLabels { + /** Context menu: opens the prompt for the session's name. */ + rename: string; + /** Context menu: copies the session's id to the pasteboard. */ + copyId: string; + /** Context menu, destructive, and the close disc's accessibility label. */ + close: string; + newSession: string; + allSessions: string; + scrollToStart: string; +} + +/** + * One slash command or skill the active agent can run — the suggestion panel + * above the composer. Data only, like the quick keys: selection replaces the + * draft natively and reports back through `onDraftChange`. + */ +export interface ComposerSlashCommand { + id: string; + /** Full display name, namespace included (`agent-sdk-dev:new-sdk-app`). */ + name: string; + descriptionText?: string; + /** The sigil that opens and commits this entry: `/`, or `$` for Codex skills. */ + trigger: "/" | "$"; + /** Non-empty when the command takes arguments; a fully typed command with + * arguments keeps the panel closed so they can be typed in peace. */ + argumentHint?: string; + /** Harness-shipped commands sort after user-defined ones, like desktop. */ + isBuiltin?: boolean; + /** Alternate names; matched after the canonical name, like desktop. */ + aliases?: string[]; +} + +/** + * A file or image pasted into the field, already written to disk by the native + * side — the tray takes URIs, the same shape the pickers produce. + */ +export interface ComposerPastedItem { + uri: string; + name: string; + kind: "image" | "file"; +} + +export interface ComposerHandle { + /** Empties the draft. */ + clear: () => void; + /** + * Appends to the draft, for dictation. The composer owns the base text and + * does the join, so callers never have to read it back. + */ + appendDraft: (text: string) => void; + /** + * Re-opens the composer after something else took first responder — an + * attachments sheet, a picker — bringing the keyboard and draft back. + */ + focus: () => void; + blur: () => void; +} + +/** + * The strip and its strings arrive together or not at all. + * + * Split out of the props rather than left as two optional fields: the composer + * cannot translate, so tabs without labels render a context menu and + * accessibility labels that are empty strings — a caller can reach that state + * without any type error, and nothing about it looks wrong until VoiceOver + * reaches it. + */ +type ComposerSessionTabsProps = + | { + sessionTabs: ComposerSessionTab[]; + sessionTabLabels: ComposerSessionTabLabels; + } + | { sessionTabs?: undefined; sessionTabLabels?: undefined }; + +export type ComposerProps = ComposerBaseProps & ComposerSessionTabsProps; + +interface ComposerBaseProps { + placeholder?: string; + /** + * Whatever this surface had typed when it was last open, put back as the + * composer is set up. Read once by the caller and never changed after: this + * is a starting value, not a binding, and the composer owns its text from + * here on. There is deliberately no `value` prop — see `onDraftChange`. + */ + initialDraft?: string; + backdrop?: ComposerBackdrop; + attachments?: ComposerAttachment[]; + /** + * The selected agent, shown as brand mark + name. Omit to hide the picker — + * what the terminal surface wants. The list itself stays in React Native: + * the real pickers are `formSheet` routes with searchable lists. + */ + selectedModel?: ComposerMenuOption; + /** + * The selected agent's launch settings, drawn after it as their own + * chevron buttons — model, effort. Each reports its id on press; the + * lists stay in React Native like the agent's. Omit for agents with none. + */ + launchOptions?: ComposerMenuOption[]; + /** The agent's permission modes, as a native menu beside `+`. */ + modeOptions?: ComposerModeOption[]; + selectedModeId?: string; + /** The agent is working: with an empty draft, send becomes stop. */ + canStop?: boolean; + /** Frame 4's header row. Empty on the session surface (frame 13). */ + headerChips?: ComposerMenuOption[]; + /** + * Keys above the card — the terminal's esc/tab/arrows. Rendered natively + * rather than by the caller: as a React Native sibling the gap to the card + * had to guess a height it could not measure, and drifted every time the + * card grew. + */ + quickKeys?: ComposerQuickKey[]; + /** + * The one static control beside the quick keys. Omitted on every surface + * with nothing to link to — which is all of them but the workspace + * terminal. Independent of `quickKeys`: an action with the keys away still + * draws the row it belongs to. + */ + quickKeysAction?: ComposerQuickKeysAction; + /** + * What the active agent can run behind `/` (or `$`). Empty or omitted + * hides the suggestion panel — a plain shell, an agent without command + * discovery, or a host too old to answer all look the same here. + */ + slashCommands?: ComposerSlashCommand[]; + /** + * Offer the `+` button. A plain shell would try to *execute* an attachment + * path, so only agent sessions get it. + */ + showAttachments?: boolean; + /** `never` for the terminal — a shell command is not a sentence. */ + autocapitalization?: "sentences" | "never"; + /** + * Shrinks the expanded editor's floor by two lines, so the terminal screen + * can leave more of the transcript on screen when the composer is at its + * smallest. Omit for the generous floor the reference frames show. + */ + compactEditor?: boolean; + /** + * A submit is in flight. Send becomes a grey spinner and the mic steps + * aside. The caller owns this because only it knows when delivery finished. + */ + isSending?: boolean; + /** + * Never clears the composer — the caller clears through the ref once its own + * delivery succeeded, so a failed send keeps the draft. + */ + onSubmit?: (text: string) => void; + onAttachmentsPress?: () => void; + /** + * Dictation runs natively — the composer owns the recogniser, the permission + * prompt and the append — so there is no press to handle here. This only + * surfaces a failure so the caller can show its own alert. + */ + onDictationError?: (message: string) => void; + onModelPress?: () => void; + onLaunchOptionPress?: (id: string) => void; + onModeSelect?: (id: string) => void; + onStop?: () => void; + onChipPress?: (id: string) => void; + onQuickKeyPress?: (id: string) => void; + /** A tab was tapped — attach that session. */ + onSessionTabPress?: (id: string) => void; + /** + * Close was chosen, from the selected tab's disc or the press-and-hold + * menu. Nothing has been killed yet: the composer has no idea what closing + * costs, so the caller confirms. + */ + onSessionTabClose?: (id: string) => void; + /** + * Rename was chosen from the press-and-hold menu. The composer neither + * asks for the new name nor knows what a session's name is — a menu cannot + * take text, and the name is the host's — so the caller prompts and saves. + */ + onSessionTabRename?: (id: string) => void; + /** + * Copy id was chosen from the press-and-hold menu. The caller owns the + * pasteboard write and whatever it shows afterwards, so the confirmation + * matches every other copy on the screen. + */ + onSessionTabCopyId?: (id: string) => void; + onNewSessionPress?: () => void; + onAllSessionsPress?: () => void; + /** + * The control beside the quick keys was pressed. Only ever fires when + * `quickKeysAction` is set, so the caller that provided the chip is the one + * that hears about it. + */ + onQuickKeysActionPress?: () => void; + /** + * How much room the composer occupies above the bottom safe area — the + * session tabs, the card, the quick keys and the gaps between them. + * + * The composer draws in an overlay and takes no layout space, so a caller + * with content underneath cannot measure it. Excludes the keyboard, which + * the caller already tracks and gets a duration and curve for. + */ + onHeightChange?: (height: number) => void; + /** + * Files and images pasted into the field. A plain text field only ever takes + * strings, so the composer owns its text view to offer Paste for these and + * writes them out; adding them to the tray is the caller's job, because the + * tray is the caller's. + */ + onPaste?: (items: ComposerPastedItem[]) => void; + /** + * Every keystroke, so a caller can keep a shadow copy of the draft and put + * it back later. Outward only, like `onHeightChange`: the composer owns its + * text while it is live and takes nothing back mid-edit, which is why there + * is no `value` prop. Restore through the ref at mount instead. + */ + onDraftChange?: (text: string) => void; + onRemoveAttachment?: (id: string) => void; + /** + * Fires only for non-image attachments. Images open in the composer's own + * full-screen viewer — it already holds the URI, so routing the tap out and + * back would buy nothing — but only the app knows what to do with a document. + */ + onAttachmentPress?: (id: string) => void; + /** + * Fires whenever the composer opens or closes. Callers need it to restore + * the composer only when it was actually open — re-focusing unconditionally + * after a sheet pops the keyboard back up over a collapsed composer. + */ + onExpandedChange?: (expanded: boolean) => void; +} + +/** + * The composer. Renders nothing in the React Native layout — it mounts a + * full-screen overlay over the screen's own view controller and draws there, so + * callers reserve room for the collapsed pill with a content inset rather than + * with layout. + * + * Every surface-specific difference arrives as data, never as children: the + * moment a `ReactNode` crosses this boundary the seam artifacts that made + * `GlassComposer` unmaintainable come back with it. See + * `plans/20260821-native-composer.md`. + */ +export type ComposerComponent = ForwardRefExoticComponent< + ComposerProps & RefAttributes +>; + +/** Opens an image full screen in the composer's own viewer. */ +export type PresentImage = (uri: string) => Promise; + +/** A PNG preview of a local file, as the composer's file card draws it. */ +export type FileThumbnail = ( + uri: string, + side: number, +) => Promise; diff --git a/apps/mobile/modules/composer/src/components/ComposerAttachmentTray/ComposerAttachmentTray.tsx b/apps/mobile/modules/composer/src/components/ComposerAttachmentTray/ComposerAttachmentTray.tsx new file mode 100644 index 00000000000..2498da06ec3 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerAttachmentTray/ComposerAttachmentTray.tsx @@ -0,0 +1,121 @@ +import { Image } from "expo-image"; +import { CircleAlert, X } from "lucide-react-native"; +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import type { ComposerAttachment } from "../../Composer.types"; +import { COMPOSER_COLORS, COMPOSER_METRICS } from "../../constants"; +import { FileCardContent } from "./components/FileCardContent"; + +interface ComposerAttachmentTrayProps { + attachments: ComposerAttachment[]; + onPress: (attachment: ComposerAttachment) => void; + onRemove: (id: string) => void; +} + +export function ComposerAttachmentTray({ + attachments, + onPress, + onRemove, +}: ComposerAttachmentTrayProps) { + return ( + + {attachments.map((attachment) => { + const uploading = + attachment.progress !== undefined && !attachment.failed; + return ( + + onPress(attachment)} + style={[ + styles.thumb, + attachment.kind === "file" && styles.fileCard, + ]} + > + {attachment.kind === "image" ? ( + + ) : ( + + )} + {uploading ? ( + + + {Math.round((attachment.progress ?? 0) * 100)}% + + + ) : null} + {attachment.failed ? ( + + + + ) : null} + + onRemove(attachment.id)} + style={styles.remove} + > + + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + content: { + gap: COMPOSER_METRICS.gap, + paddingHorizontal: COMPOSER_METRICS.edgeInset, + paddingTop: 10, + }, + item: { paddingTop: 4, paddingRight: 4 }, + thumb: { + width: COMPOSER_METRICS.thumbSize, + height: COMPOSER_METRICS.thumbSize, + borderRadius: 10, + overflow: "hidden", + backgroundColor: COMPOSER_COLORS.control, + alignItems: "center", + justifyContent: "center", + }, + fileCard: { + width: COMPOSER_METRICS.thumbSize * 2, + alignItems: "flex-start", + justifyContent: "space-between", + padding: 8, + }, + dimmed: { opacity: 0.4 }, + badgeCenter: { + ...StyleSheet.absoluteFill, + alignItems: "center", + justifyContent: "center", + }, + progress: { + color: COMPOSER_COLORS.foreground, + fontSize: 12, + fontWeight: "600", + }, + remove: { + position: "absolute", + top: 0, + right: 0, + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: "#525252", + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/apps/mobile/modules/composer/src/components/ComposerAttachmentTray/components/FileCardContent/FileCardContent.tsx b/apps/mobile/modules/composer/src/components/ComposerAttachmentTray/components/FileCardContent/FileCardContent.tsx new file mode 100644 index 00000000000..ec8a8cea8fe --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerAttachmentTray/components/FileCardContent/FileCardContent.tsx @@ -0,0 +1,63 @@ +import { Image } from "expo-image"; +import { FileText } from "lucide-react-native"; +import { useEffect, useState } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { COMPOSER_COLORS, COMPOSER_METRICS } from "../../../../constants"; +import { fileThumbnail } from "../../../../utils/fileThumbnail"; + +const PREVIEW_HEIGHT = COMPOSER_METRICS.thumbSize - 16; + +/** The first page when the platform can draw one, the glyph otherwise. */ +export function FileCardContent({ uri, name }: { uri: string; name?: string }) { + const preview = useFileThumbnail(uri); + if (!preview) { + return ( + <> + + + {name} + + + ); + } + return ( + + + + {name} + + + ); +} + +function useFileThumbnail(uri: string) { + const [preview, setPreview] = useState(null); + useEffect(() => { + let live = true; + setPreview(null); + if (uri) { + void fileThumbnail(uri, PREVIEW_HEIGHT).then( + (result) => live && setPreview(result), + ); + } + return () => { + live = false; + }; + }, [uri]); + return preview; +} + +const styles = StyleSheet.create({ + row: { flex: 1, flexDirection: "row", alignItems: "center", gap: 6 }, + preview: { + width: PREVIEW_HEIGHT * 0.75, + height: PREVIEW_HEIGHT, + borderRadius: 4, + }, + name: { color: COMPOSER_COLORS.muted, fontSize: 11 }, + nameBeside: { flex: 1 }, +}); diff --git a/apps/mobile/modules/composer/src/components/ComposerAttachmentTray/components/FileCardContent/index.ts b/apps/mobile/modules/composer/src/components/ComposerAttachmentTray/components/FileCardContent/index.ts new file mode 100644 index 00000000000..8226cb0260b --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerAttachmentTray/components/FileCardContent/index.ts @@ -0,0 +1 @@ +export { FileCardContent } from "./FileCardContent"; diff --git a/apps/mobile/modules/composer/src/components/ComposerAttachmentTray/index.ts b/apps/mobile/modules/composer/src/components/ComposerAttachmentTray/index.ts new file mode 100644 index 00000000000..55e62577dcb --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerAttachmentTray/index.ts @@ -0,0 +1 @@ +export { ComposerAttachmentTray } from "./ComposerAttachmentTray"; diff --git a/apps/mobile/modules/composer/src/components/ComposerDictationPill/ComposerDictationPill.tsx b/apps/mobile/modules/composer/src/components/ComposerDictationPill/ComposerDictationPill.tsx new file mode 100644 index 00000000000..8dbb89fadc3 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerDictationPill/ComposerDictationPill.tsx @@ -0,0 +1,96 @@ +import { Square } from "lucide-react-native"; +import { useEffect, useState } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { COMPOSER_COLORS, COMPOSER_METRICS } from "../../constants"; + +const BAR_WEIGHTS = [0.5, 0.8, 1, 0.7, 0.45]; + +interface ComposerDictationPillProps { + level: number; + stopLabel: string; + onStop: () => void; +} + +/** Recording: the input level, the time so far, and stop. */ +export function ComposerDictationPill({ + level, + stopLabel, + onStop, +}: ComposerDictationPillProps) { + const seconds = useElapsedSeconds(); + return ( + [styles.pill, pressed && styles.pressed]} + > + + {BAR_WEIGHTS.map((weight, index) => ( + + ))} + + + {Math.floor(seconds / 60)}:{String(seconds % 60).padStart(2, "0")} + + + + + + ); +} + +function useElapsedSeconds() { + const [seconds, setSeconds] = useState(0); + useEffect(() => { + const startedAt = Date.now(); + const timer = setInterval( + () => setSeconds(Math.floor((Date.now() - startedAt) / 1000)), + 500, + ); + return () => clearInterval(timer); + }, []); + return seconds; +} + +const styles = StyleSheet.create({ + pill: { + flexDirection: "row", + alignItems: "center", + gap: 8, + height: COMPOSER_METRICS.controlSize, + paddingLeft: 12, + paddingRight: 4, + borderRadius: COMPOSER_METRICS.controlSize / 2, + backgroundColor: COMPOSER_COLORS.control, + }, + pressed: { opacity: 0.5 }, + bars: { flexDirection: "row", alignItems: "center", gap: 2, height: 18 }, + bar: { + width: 3, + borderRadius: 1.5, + backgroundColor: COMPOSER_COLORS.destructive, + }, + time: { + color: COMPOSER_COLORS.foreground, + fontSize: 13, + fontVariant: ["tabular-nums"], + }, + stop: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: COMPOSER_COLORS.foreground, + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/apps/mobile/modules/composer/src/components/ComposerDictationPill/index.ts b/apps/mobile/modules/composer/src/components/ComposerDictationPill/index.ts new file mode 100644 index 00000000000..358305d6e07 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerDictationPill/index.ts @@ -0,0 +1 @@ +export { ComposerDictationPill } from "./ComposerDictationPill"; diff --git a/apps/mobile/modules/composer/src/components/ComposerImageViewer/ComposerImageViewer.tsx b/apps/mobile/modules/composer/src/components/ComposerImageViewer/ComposerImageViewer.tsx new file mode 100644 index 00000000000..7b831afd822 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerImageViewer/ComposerImageViewer.tsx @@ -0,0 +1,76 @@ +import { Image } from "expo-image"; +import { X } from "lucide-react-native"; +import { useEffect, useState } from "react"; +import { Modal, Pressable, StyleSheet, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +type Show = (uri: string) => void; + +/** Mounted viewers, newest last: only the newest one opens. */ +const viewers: Show[] = []; + +/** Opens `uri` in the newest mounted viewer; false when none is mounted. */ +export function showImage(uri: string): boolean { + const show = viewers.at(-1); + show?.(uri); + return show !== undefined; +} + +/** + * The full-screen image viewer behind `presentImage`. A function cannot draw, + * so each mounted composer hosts one. + */ +export function ComposerImageViewer() { + const [uri, setUri] = useState(null); + const insets = useSafeAreaInsets(); + + useEffect(() => { + const show: Show = (next) => setUri(next); + viewers.push(show); + return () => { + viewers.splice(viewers.indexOf(show), 1); + }; + }, []); + + return ( + setUri(null)} + statusBarTranslucent + navigationBarTranslucent + > + + {uri ? ( + + ) : null} + setUri(null)} + style={[styles.close, { top: insets.top + 12 }]} + > + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: "#000" }, + close: { + position: "absolute", + left: 16, + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: "rgba(38, 38, 38, 0.8)", + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/apps/mobile/modules/composer/src/components/ComposerImageViewer/index.ts b/apps/mobile/modules/composer/src/components/ComposerImageViewer/index.ts new file mode 100644 index 00000000000..93920c1454e --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerImageViewer/index.ts @@ -0,0 +1 @@ +export { ComposerImageViewer, showImage } from "./ComposerImageViewer"; diff --git a/apps/mobile/modules/composer/src/components/ComposerMenuSheet/ComposerMenuSheet.tsx b/apps/mobile/modules/composer/src/components/ComposerMenuSheet/ComposerMenuSheet.tsx new file mode 100644 index 00000000000..5d035c05999 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerMenuSheet/ComposerMenuSheet.tsx @@ -0,0 +1,93 @@ +import { Check, type LucideIcon } from "lucide-react-native"; +import { Modal, Pressable, StyleSheet, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { COMPOSER_COLORS, COMPOSER_METRICS } from "../../constants"; + +export interface ComposerMenuSheetItem { + id: string; + label: string; + icon: LucideIcon; + checked?: boolean; + destructive?: boolean; +} + +interface ComposerMenuSheetProps { + visible: boolean; + items: ComposerMenuSheetItem[]; + onSelect: (id: string) => void; + onClose: () => void; +} + +/** A bottom menu, the Android stand-in for a SwiftUI `Menu` or context menu. */ +export function ComposerMenuSheet({ + visible, + items, + onSelect, + onClose, +}: ComposerMenuSheetProps) { + const insets = useSafeAreaInsets(); + return ( + + + + {items.map((item) => { + const color = item.destructive + ? COMPOSER_COLORS.destructive + : COMPOSER_COLORS.foreground; + const Icon = item.icon; + return ( + { + onClose(); + onSelect(item.id); + }} + style={({ pressed }) => [ + styles.row, + pressed && styles.rowPressed, + ]} + > + + {item.label} + {item.checked ? : null} + + ); + })} + + + + ); +} + +const styles = StyleSheet.create({ + scrim: { + flex: 1, + justifyContent: "flex-end", + backgroundColor: COMPOSER_COLORS.scrim, + paddingHorizontal: COMPOSER_METRICS.edgeInset, + }, + sheet: { + backgroundColor: COMPOSER_COLORS.card, + borderColor: COMPOSER_COLORS.border, + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 16, + paddingVertical: 6, + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingHorizontal: 16, + paddingVertical: 14, + }, + rowPressed: { backgroundColor: COMPOSER_COLORS.control }, + label: { flex: 1, fontSize: 16 }, +}); diff --git a/apps/mobile/modules/composer/src/components/ComposerMenuSheet/index.ts b/apps/mobile/modules/composer/src/components/ComposerMenuSheet/index.ts new file mode 100644 index 00000000000..08be4455215 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerMenuSheet/index.ts @@ -0,0 +1,4 @@ +export { + ComposerMenuSheet, + type ComposerMenuSheetItem, +} from "./ComposerMenuSheet"; diff --git a/apps/mobile/modules/composer/src/components/ComposerModeMenu/ComposerModeMenu.android.tsx b/apps/mobile/modules/composer/src/components/ComposerModeMenu/ComposerModeMenu.android.tsx new file mode 100644 index 00000000000..b64819904bb --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerModeMenu/ComposerModeMenu.android.tsx @@ -0,0 +1,48 @@ +import MenuView from "@expo/ui/community/menu"; +import { StyleSheet, View } from "react-native"; +import { useUniwind } from "uniwind"; +import { COMPOSER_COLORS, COMPOSER_METRICS } from "../../constants"; +import { symbolIcon } from "../../utils/symbolIcon"; +import type { ComposerModeMenuProps } from "./ComposerModeMenu.types"; + +export function ComposerModeMenu({ + options, + selectedId, + onSelect, +}: ComposerModeMenuProps) { + const { theme } = useUniwind(); + const selected = options.find((option) => option.id === selectedId); + const SelectedIcon = symbolIcon(selected?.symbol ?? options[0]?.symbol ?? ""); + + return ( + ({ + id: option.id, + title: option.label, + state: option.id === selectedId ? "on" : "off", + }))} + onPressAction={({ nativeEvent }) => onSelect?.(nativeEvent.event)} + > + + + + + ); +} + +const styles = StyleSheet.create({ + trigger: { + width: COMPOSER_METRICS.controlSize, + height: COMPOSER_METRICS.controlSize, + borderRadius: COMPOSER_METRICS.controlSize / 2, + backgroundColor: COMPOSER_COLORS.control, + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/apps/mobile/modules/composer/src/components/ComposerModeMenu/ComposerModeMenu.tsx b/apps/mobile/modules/composer/src/components/ComposerModeMenu/ComposerModeMenu.tsx new file mode 100644 index 00000000000..ba1a2bdf09a --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerModeMenu/ComposerModeMenu.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import { Pressable, StyleSheet } from "react-native"; +import { COMPOSER_COLORS, COMPOSER_METRICS } from "../../constants"; +import { symbolIcon } from "../../utils/symbolIcon"; +import { ComposerMenuSheet } from "../ComposerMenuSheet"; +import type { ComposerModeMenuProps } from "./ComposerModeMenu.types"; + +export function ComposerModeMenu({ + options, + selectedId, + onSelect, +}: ComposerModeMenuProps) { + const [open, setOpen] = useState(false); + const selected = options.find((option) => option.id === selectedId); + const SelectedIcon = symbolIcon(selected?.symbol ?? options[0]?.symbol ?? ""); + + return ( + <> + setOpen(true)} + style={styles.trigger} + > + + + ({ + id: option.id, + label: option.label, + icon: symbolIcon(option.symbol), + checked: option.id === selectedId, + }))} + onSelect={(id) => onSelect?.(id)} + onClose={() => setOpen(false)} + /> + + ); +} + +const styles = StyleSheet.create({ + trigger: { + width: COMPOSER_METRICS.controlSize, + height: COMPOSER_METRICS.controlSize, + borderRadius: COMPOSER_METRICS.controlSize / 2, + backgroundColor: COMPOSER_COLORS.control, + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/apps/mobile/modules/composer/src/components/ComposerModeMenu/ComposerModeMenu.types.ts b/apps/mobile/modules/composer/src/components/ComposerModeMenu/ComposerModeMenu.types.ts new file mode 100644 index 00000000000..b0aa7b1d1e3 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerModeMenu/ComposerModeMenu.types.ts @@ -0,0 +1,7 @@ +import type { ComposerModeOption } from "../../Composer.types"; + +export interface ComposerModeMenuProps { + options: ComposerModeOption[]; + selectedId?: string | null; + onSelect?: (id: string) => void; +} diff --git a/apps/mobile/modules/composer/src/components/ComposerModeMenu/index.ts b/apps/mobile/modules/composer/src/components/ComposerModeMenu/index.ts new file mode 100644 index 00000000000..9fb1d6d9573 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerModeMenu/index.ts @@ -0,0 +1 @@ +export { ComposerModeMenu } from "./ComposerModeMenu"; diff --git a/apps/mobile/modules/composer/src/components/ComposerQuickKeys/ComposerQuickKeys.tsx b/apps/mobile/modules/composer/src/components/ComposerQuickKeys/ComposerQuickKeys.tsx new file mode 100644 index 00000000000..d1d8b10b0eb --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerQuickKeys/ComposerQuickKeys.tsx @@ -0,0 +1,76 @@ +import { ScrollView, StyleSheet, View } from "react-native"; +import type { + ComposerQuickKey, + ComposerQuickKeysAction, +} from "../../Composer.types"; +import { COMPOSER_COLORS, COMPOSER_METRICS } from "../../constants"; +import { QuickKey } from "./components/QuickKey"; +import { QuickKeysActionChip } from "./components/QuickKeysActionChip"; +import { QUICK_KEYS_BAR_HEIGHT } from "./constants"; + +interface ComposerQuickKeysProps { + keys: ComposerQuickKey[]; + action?: ComposerQuickKeysAction; + onPress: (id: string) => void; + onAction: () => void; +} + +/** + * The terminal's keys on one surface, with the keys scrolling inside it. Keys + * share out any room the row has beyond their natural width. + */ +export function ComposerQuickKeys({ + keys, + action, + onPress, + onAction, +}: ComposerQuickKeysProps) { + return ( + + {action ? ( + + ) : null} + {keys.length > 0 ? ( + + + {keys.map((key) => + key.divider ? ( + + ) : ( + + ), + )} + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + row: { flexDirection: "row", gap: COMPOSER_METRICS.gap }, + surface: { + flex: 1, + height: QUICK_KEYS_BAR_HEIGHT, + borderRadius: COMPOSER_METRICS.stripRadius, + backgroundColor: COMPOSER_COLORS.strip, + overflow: "hidden", + }, + keys: { + flexGrow: 1, + alignItems: "center", + gap: 1, + padding: COMPOSER_METRICS.stripInset, + }, + divider: { + width: 1, + height: COMPOSER_METRICS.dividerHeight, + marginHorizontal: 4, + backgroundColor: COMPOSER_COLORS.divider, + }, +}); diff --git a/apps/mobile/modules/composer/src/components/ComposerQuickKeys/components/QuickKey/QuickKey.tsx b/apps/mobile/modules/composer/src/components/ComposerQuickKeys/components/QuickKey/QuickKey.tsx new file mode 100644 index 00000000000..394a0a35a30 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerQuickKeys/components/QuickKey/QuickKey.tsx @@ -0,0 +1,49 @@ +import { Pressable, StyleSheet, Text } from "react-native"; +import type { ComposerQuickKey } from "../../../../Composer.types"; +import { COMPOSER_COLORS, COMPOSER_METRICS } from "../../../../constants"; +import { symbolIcon } from "../../../../utils/symbolIcon"; + +interface QuickKeyProps { + quickKey: ComposerQuickKey; + onPress: (id: string) => void; +} + +export function QuickKey({ quickKey, onPress }: QuickKeyProps) { + const Icon = quickKey.symbol ? symbolIcon(quickKey.symbol) : null; + return ( + onPress(quickKey.id)} + className="web:hover:opacity-80" + style={({ pressed }) => [styles.key, pressed && styles.pressed]} + > + {Icon ? ( + + ) : ( + {quickKey.label} + )} + + ); +} + +const styles = StyleSheet.create({ + key: { + flexGrow: 1, + minWidth: COMPOSER_METRICS.keyMinWidth, + height: COMPOSER_METRICS.keyHeight, + paddingHorizontal: 7, + borderRadius: COMPOSER_METRICS.keyRadius, + alignItems: "center", + justifyContent: "center", + }, + pressed: { backgroundColor: COMPOSER_COLORS.keyPressed }, + label: { + color: COMPOSER_COLORS.foreground, + fontFamily: "monospace", + fontSize: COMPOSER_METRICS.keyGlyph + 1, + }, +}); diff --git a/apps/mobile/modules/composer/src/components/ComposerQuickKeys/components/QuickKey/index.ts b/apps/mobile/modules/composer/src/components/ComposerQuickKeys/components/QuickKey/index.ts new file mode 100644 index 00000000000..ca5fe9bc8c0 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerQuickKeys/components/QuickKey/index.ts @@ -0,0 +1 @@ +export { QuickKey } from "./QuickKey"; diff --git a/apps/mobile/modules/composer/src/components/ComposerQuickKeys/components/QuickKeysActionChip/QuickKeysActionChip.tsx b/apps/mobile/modules/composer/src/components/ComposerQuickKeys/components/QuickKeysActionChip/QuickKeysActionChip.tsx new file mode 100644 index 00000000000..b0a8cc60a4a --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerQuickKeys/components/QuickKeysActionChip/QuickKeysActionChip.tsx @@ -0,0 +1,60 @@ +import { Image } from "expo-image"; +import { useState } from "react"; +import { Pressable, StyleSheet } from "react-native"; +import type { ComposerQuickKeysAction } from "../../../../Composer.types"; +import { + COMPOSER_COLORS, + COMPOSER_METRICS, + QUICK_KEYS_ACTION_TINTS, +} from "../../../../constants"; +import { symbolIcon } from "../../../../utils/symbolIcon"; +import { QUICK_KEYS_BAR_HEIGHT } from "../../constants"; + +interface QuickKeysActionChipProps { + action: ComposerQuickKeysAction; + onPress: () => void; +} + +export function QuickKeysActionChip({ + action, + onPress, +}: QuickKeysActionChipProps) { + const [iconFailed, setIconFailed] = useState(false); + const color = action.tint + ? QUICK_KEYS_ACTION_TINTS[action.tint] + : COMPOSER_COLORS.foreground; + const Icon = symbolIcon(action.symbol); + return ( + [styles.chip, pressed && styles.pressed]} + > + {action.iconUri && !iconFailed ? ( + setIconFailed(true)} + style={styles.mark} + /> + ) : ( + + )} + + ); +} + +const styles = StyleSheet.create({ + chip: { + width: QUICK_KEYS_BAR_HEIGHT, + height: QUICK_KEYS_BAR_HEIGHT, + borderRadius: COMPOSER_METRICS.stripRadius, + backgroundColor: COMPOSER_COLORS.strip, + alignItems: "center", + justifyContent: "center", + }, + pressed: { backgroundColor: COMPOSER_COLORS.stripPressed }, + mark: { width: 16, height: 16 }, +}); diff --git a/apps/mobile/modules/composer/src/components/ComposerQuickKeys/components/QuickKeysActionChip/index.ts b/apps/mobile/modules/composer/src/components/ComposerQuickKeys/components/QuickKeysActionChip/index.ts new file mode 100644 index 00000000000..2d9dc94c6bc --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerQuickKeys/components/QuickKeysActionChip/index.ts @@ -0,0 +1 @@ +export { QuickKeysActionChip } from "./QuickKeysActionChip"; diff --git a/apps/mobile/modules/composer/src/components/ComposerQuickKeys/constants.ts b/apps/mobile/modules/composer/src/components/ComposerQuickKeys/constants.ts new file mode 100644 index 00000000000..45a451570b3 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerQuickKeys/constants.ts @@ -0,0 +1,4 @@ +import { COMPOSER_METRICS } from "../../constants"; + +export const QUICK_KEYS_BAR_HEIGHT = + COMPOSER_METRICS.keyHeight + COMPOSER_METRICS.stripInset * 2; diff --git a/apps/mobile/modules/composer/src/components/ComposerQuickKeys/index.ts b/apps/mobile/modules/composer/src/components/ComposerQuickKeys/index.ts new file mode 100644 index 00000000000..3372f64e667 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerQuickKeys/index.ts @@ -0,0 +1 @@ +export { ComposerQuickKeys } from "./ComposerQuickKeys"; diff --git a/apps/mobile/modules/composer/src/components/ComposerSessionTabs/ComposerSessionTabs.tsx b/apps/mobile/modules/composer/src/components/ComposerSessionTabs/ComposerSessionTabs.tsx new file mode 100644 index 00000000000..441e2ef1032 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerSessionTabs/ComposerSessionTabs.tsx @@ -0,0 +1,150 @@ +import { + ChevronLeft, + Copy, + LayoutGrid, + Pencil, + Plus, + X, +} from "lucide-react-native"; +import { useRef, useState } from "react"; +import { + Animated, + type ScrollViewInstance, + StyleSheet, + View, +} from "react-native"; +import type { + ComposerSessionTab, + ComposerSessionTabLabels, +} from "../../Composer.types"; +import { COMPOSER_METRICS } from "../../constants"; +import { ComposerMenuSheet } from "../ComposerMenuSheet"; +import { SessionTabPill } from "./components/SessionTabPill"; +import { StripControl } from "./components/StripControl"; + +interface ComposerSessionTabsProps { + tabs: ComposerSessionTab[]; + labels: ComposerSessionTabLabels; + onSelect: (id: string) => void; + onClose: (id: string) => void; + onRename: (id: string) => void; + onCopyId: (id: string) => void; + onNewSession: () => void; + onAllSessions: () => void; +} + +/** + * The workspace's sessions as a tab strip. Press and hold a tab for its menu; + * the chevron that scrolls back to the start fades in as the strip scrolls. + */ +export function ComposerSessionTabs({ + tabs, + labels, + onSelect, + onClose, + onRename, + onCopyId, + onNewSession, + onAllSessions, +}: ComposerSessionTabsProps) { + const scrollRef = useRef(null); + const scrollX = useRef(new Animated.Value(0)).current; + const [scrolled, setScrolled] = useState(false); + const [menuTabId, setMenuTabId] = useState(null); + + return ( + + + + setScrolled( + event.nativeEvent.contentOffset.x > + COMPOSER_METRICS.tabChevronReveal / 2, + ), + }, + )} + contentContainerStyle={styles.tabs} + > + {tabs.map((tab) => ( + onSelect(tab.id)} + onClose={() => onClose(tab.id)} + onLongPress={() => setMenuTabId(tab.id)} + /> + ))} + + + scrollRef.current?.scrollTo({ x: 0 })} + /> + + + + + { + if (menuTabId === null) return; + if (action === "rename") onRename(menuTabId); + else if (action === "copyId") onCopyId(menuTabId); + else onClose(menuTabId); + }} + onClose={() => setMenuTabId(null)} + /> + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: "row", + alignItems: "center", + gap: COMPOSER_METRICS.gap, + }, + strip: { flex: 1 }, + tabs: { gap: COMPOSER_METRICS.gap }, + chevron: { position: "absolute", left: 0, top: 0, bottom: 0 }, +}); diff --git a/apps/mobile/modules/composer/src/components/ComposerSessionTabs/components/SessionAttentionDot/SessionAttentionDot.tsx b/apps/mobile/modules/composer/src/components/ComposerSessionTabs/components/SessionAttentionDot/SessionAttentionDot.tsx new file mode 100644 index 00000000000..8f376b860eb --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerSessionTabs/components/SessionAttentionDot/SessionAttentionDot.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState } from "react"; +import { AccessibilityInfo, Animated, StyleSheet, View } from "react-native"; +import type { ComposerSessionTab } from "../../../../Composer.types"; +import { + COMPOSER_METRICS, + SESSION_ATTENTION_COLORS, +} from "../../../../constants"; + +interface SessionAttentionDotProps { + attention: NonNullable; +} + +/** Static for `review`, which is finished; the live states ping, as on desktop. */ +export function SessionAttentionDot({ attention }: SessionAttentionDotProps) { + const color = SESSION_ATTENTION_COLORS[attention]; + const ping = useRef(new Animated.Value(0)).current; + const [reduceMotion, setReduceMotion] = useState(false); + const pings = attention !== "review" && !reduceMotion; + + useEffect(() => { + void AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion); + const subscription = AccessibilityInfo.addEventListener( + "reduceMotionChanged", + setReduceMotion, + ); + return () => subscription.remove(); + }, []); + + useEffect(() => { + if (!pings) return; + const loop = Animated.loop( + Animated.timing(ping, { + toValue: 1, + duration: 1500, + useNativeDriver: true, + }), + ); + loop.start(); + return () => { + loop.stop(); + ping.setValue(0); + }; + }, [pings, ping]); + + return ( + + {pings ? ( + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + dot: { + width: COMPOSER_METRICS.tabDotSize, + height: COMPOSER_METRICS.tabDotSize, + borderRadius: COMPOSER_METRICS.tabDotSize / 2, + }, + ring: { + ...StyleSheet.absoluteFill, + borderRadius: COMPOSER_METRICS.tabDotSize / 2, + borderWidth: 1, + }, +}); diff --git a/apps/mobile/modules/composer/src/components/ComposerSessionTabs/components/SessionAttentionDot/index.ts b/apps/mobile/modules/composer/src/components/ComposerSessionTabs/components/SessionAttentionDot/index.ts new file mode 100644 index 00000000000..5fbeed0892e --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerSessionTabs/components/SessionAttentionDot/index.ts @@ -0,0 +1 @@ +export { SessionAttentionDot } from "./SessionAttentionDot"; diff --git a/apps/mobile/modules/composer/src/components/ComposerSessionTabs/components/SessionTabPill/SessionTabPill.tsx b/apps/mobile/modules/composer/src/components/ComposerSessionTabs/components/SessionTabPill/SessionTabPill.tsx new file mode 100644 index 00000000000..178ad6d74b5 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerSessionTabs/components/SessionTabPill/SessionTabPill.tsx @@ -0,0 +1,116 @@ +import { Image } from "expo-image"; +import { X } from "lucide-react-native"; +import { useState } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import type { ComposerSessionTab } from "../../../../Composer.types"; +import { COMPOSER_COLORS, COMPOSER_METRICS } from "../../../../constants"; +import { SessionAttentionDot } from "../SessionAttentionDot"; + +interface SessionTabPillProps { + tab: ComposerSessionTab; + closeLabel: string; + onSelect: () => void; + onClose: () => void; + onLongPress: () => void; +} + +const CLOSE_SLOT = COMPOSER_METRICS.tabCloseSize + 5; + +export function SessionTabPill({ + tab, + closeLabel, + onSelect, + onClose, + onLongPress, +}: SessionTabPillProps) { + const [iconFailed, setIconFailed] = useState(false); + const color = tab.selected + ? COMPOSER_COLORS.foreground + : COMPOSER_COLORS.muted; + return ( + + [ + styles.pill, + tab.selected && styles.selected, + tab.selected && { paddingRight: 10 + CLOSE_SLOT }, + pressed && styles.pressed, + ]} + > + {tab.iconUri && !iconFailed ? ( + setIconFailed(true)} + style={styles.mark} + /> + ) : ( + + {(tab.label.charAt(0) || "?").toUpperCase()} + + )} + + {tab.label} + + {tab.attention ? ( + + ) : null} + + {tab.selected ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + pill: { + flexDirection: "row", + alignItems: "center", + gap: 6, + height: COMPOSER_METRICS.tabControlSize, + paddingHorizontal: 10, + borderRadius: COMPOSER_METRICS.tabRadius, + backgroundColor: COMPOSER_COLORS.tab, + }, + selected: { backgroundColor: COMPOSER_COLORS.tabSelected }, + pressed: { opacity: 0.6 }, + mark: { + width: COMPOSER_METRICS.tabMarkSize, + height: COMPOSER_METRICS.tabMarkSize, + }, + initial: { + width: COMPOSER_METRICS.tabMarkSize, + textAlign: "center", + fontSize: 8, + fontWeight: "700", + }, + label: { + maxWidth: COMPOSER_METRICS.tabMaxLabelWidth, + fontSize: COMPOSER_METRICS.tabFontSize, + fontWeight: "500", + }, + close: { + position: "absolute", + right: 5, + top: (COMPOSER_METRICS.tabControlSize - COMPOSER_METRICS.tabCloseSize) / 2, + width: COMPOSER_METRICS.tabCloseSize, + height: COMPOSER_METRICS.tabCloseSize, + borderRadius: COMPOSER_METRICS.tabCloseSize / 2, + backgroundColor: COMPOSER_COLORS.tabClose, + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/apps/mobile/modules/composer/src/components/ComposerSessionTabs/components/SessionTabPill/index.ts b/apps/mobile/modules/composer/src/components/ComposerSessionTabs/components/SessionTabPill/index.ts new file mode 100644 index 00000000000..8c58e78ec7e --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerSessionTabs/components/SessionTabPill/index.ts @@ -0,0 +1 @@ +export { SessionTabPill } from "./SessionTabPill"; diff --git a/apps/mobile/modules/composer/src/components/ComposerSessionTabs/components/StripControl/StripControl.tsx b/apps/mobile/modules/composer/src/components/ComposerSessionTabs/components/StripControl/StripControl.tsx new file mode 100644 index 00000000000..d8bd303d088 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerSessionTabs/components/StripControl/StripControl.tsx @@ -0,0 +1,47 @@ +import type { LucideIcon } from "lucide-react-native"; +import { Pressable, StyleSheet } from "react-native"; +import { COMPOSER_COLORS, COMPOSER_METRICS } from "../../../../constants"; + +interface StripControlProps { + icon: LucideIcon; + label: string; + onPress: () => void; + /** For the chevron that tabs scroll beneath: a see-through fill shows them. */ + opaque?: boolean; +} + +export function StripControl({ + icon: Icon, + label, + onPress, + opaque = false, +}: StripControlProps) { + return ( + [ + styles.control, + opaque && styles.opaque, + pressed && styles.pressed, + ]} + > + + + ); +} + +const styles = StyleSheet.create({ + control: { + width: COMPOSER_METRICS.tabControlSize, + height: COMPOSER_METRICS.tabControlSize, + borderRadius: COMPOSER_METRICS.tabRadius, + backgroundColor: COMPOSER_COLORS.strip, + alignItems: "center", + justifyContent: "center", + }, + opaque: { backgroundColor: COMPOSER_COLORS.stripOpaque }, + pressed: { backgroundColor: COMPOSER_COLORS.stripPressed }, +}); diff --git a/apps/mobile/modules/composer/src/components/ComposerSessionTabs/components/StripControl/index.ts b/apps/mobile/modules/composer/src/components/ComposerSessionTabs/components/StripControl/index.ts new file mode 100644 index 00000000000..c1809db3fa6 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerSessionTabs/components/StripControl/index.ts @@ -0,0 +1 @@ +export { StripControl } from "./StripControl"; diff --git a/apps/mobile/modules/composer/src/components/ComposerSessionTabs/index.ts b/apps/mobile/modules/composer/src/components/ComposerSessionTabs/index.ts new file mode 100644 index 00000000000..5f02468927e --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerSessionTabs/index.ts @@ -0,0 +1 @@ +export { ComposerSessionTabs } from "./ComposerSessionTabs"; diff --git a/apps/mobile/modules/composer/src/components/ComposerSlashSuggestions/ComposerSlashSuggestions.tsx b/apps/mobile/modules/composer/src/components/ComposerSlashSuggestions/ComposerSlashSuggestions.tsx new file mode 100644 index 00000000000..61c82558ecc --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerSlashSuggestions/ComposerSlashSuggestions.tsx @@ -0,0 +1,68 @@ +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import type { ComposerSlashCommand } from "../../Composer.types"; +import { COMPOSER_COLORS, COMPOSER_METRICS } from "../../constants"; + +interface ComposerSlashSuggestionsProps { + commands: ComposerSlashCommand[]; + onSelect: (command: ComposerSlashCommand) => void; +} + +/** + * The commands that match a bare `/token`. Hugs a short list and shrinks to + * the room above the composer for a long one, which then scrolls. + */ +export function ComposerSlashSuggestions({ + commands, + onSelect, +}: ComposerSlashSuggestionsProps) { + return ( + + + {commands.map((command) => ( + onSelect(command)} + style={({ pressed }) => [styles.row, pressed && styles.pressed]} + > + + {command.trigger} + {command.name} + + {command.descriptionText ? ( + + {command.descriptionText} + + ) : null} + + ))} + + + ); +} + +const styles = StyleSheet.create({ + panel: { + flexShrink: 1, + minHeight: COMPOSER_METRICS.slashRowHeight, + backgroundColor: COMPOSER_COLORS.card, + borderColor: COMPOSER_COLORS.border, + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 20, + overflow: "hidden", + }, + scroll: { flexGrow: 0 }, + content: { paddingVertical: 6 }, + row: { gap: 2, paddingHorizontal: 16, paddingVertical: 10 }, + pressed: { backgroundColor: COMPOSER_COLORS.control }, + name: { + color: COMPOSER_COLORS.foreground, + fontFamily: "monospace", + fontSize: 14, + }, + description: { color: COMPOSER_COLORS.muted, fontSize: 12 }, +}); diff --git a/apps/mobile/modules/composer/src/components/ComposerSlashSuggestions/index.ts b/apps/mobile/modules/composer/src/components/ComposerSlashSuggestions/index.ts new file mode 100644 index 00000000000..c8dee80a610 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerSlashSuggestions/index.ts @@ -0,0 +1 @@ +export { ComposerSlashSuggestions } from "./ComposerSlashSuggestions"; diff --git a/apps/mobile/modules/composer/src/constants.ts b/apps/mobile/modules/composer/src/constants.ts new file mode 100644 index 00000000000..5ea413cc902 --- /dev/null +++ b/apps/mobile/modules/composer/src/constants.ts @@ -0,0 +1,66 @@ +/** The app is dark-only (`userInterfaceStyle: "dark"`); these follow `global.css`. */ +export const COMPOSER_COLORS = { + card: "#171717", + border: "#2a2a2a", + control: "#262626", + foreground: "#fafafa", + muted: "#a3a3a3", + scrim: "rgba(0, 0, 0, 0.45)", + destructive: "#ef4444", + strip: "rgba(255, 255, 255, 0.12)", + stripPressed: "rgba(255, 255, 255, 0.2)", + keyPressed: "rgba(255, 255, 255, 0.16)", + divider: "rgba(255, 255, 255, 0.16)", + tab: "rgba(255, 255, 255, 0.06)", + tabSelected: "rgba(255, 255, 255, 0.14)", + tabClose: "rgba(255, 255, 255, 0.38)", + /** The strip fill over black, for the chevron that tabs scroll beneath. */ + stripOpaque: "#1f1f1f", +} as const; + +/** Kept in step with `ComposerQuickKeysActionTint` on iOS. */ +export const QUICK_KEYS_ACTION_TINTS = { + open: "#00bd7d", + draft: "#a3a3a3", + queued: "#fd9900", + merged: "#ad45ff", + closed: "#e04f4f", +} as const; + +/** Kept in step with `ComposerSessionAttention` on iOS. */ +export const SESSION_ATTENTION_COLORS = { + permission: "#ebb308", + working: "#f59e0a", + failed: "#f04545", + review: "#21c55e", +} as const; + +export const COMPOSER_METRICS = { + controlSize: 36, + thumbSize: 64, + miniThumbSize: 24, + lineHeight: 22, + editorLines: 4, + compactEditorLines: 2, + maxEditorLines: 10, + edgeInset: 12, + gap: 8, + stripRadius: 11, + stripInset: 4, + keyHeight: 28, + keyRadius: 7, + keyMinWidth: 22, + keyGlyph: 13, + dividerHeight: 15, + tabRadius: 8, + tabFontSize: 12, + tabMarkSize: 13, + tabMaxLabelWidth: 128, + tabDotSize: 6, + tabCloseSize: 15, + tabControlSize: 28, + tabChevronReveal: 32, + slashRowHeight: 52, + /** `ComposerMetrics.maxWidth` on iOS. */ + maxWidth: 720, +} as const; diff --git a/apps/mobile/modules/composer/src/hooks/useAttachmentPaste/index.ts b/apps/mobile/modules/composer/src/hooks/useAttachmentPaste/index.ts new file mode 100644 index 00000000000..e3153a5dd0c --- /dev/null +++ b/apps/mobile/modules/composer/src/hooks/useAttachmentPaste/index.ts @@ -0,0 +1 @@ +export { useAttachmentPaste } from "./useAttachmentPaste"; diff --git a/apps/mobile/modules/composer/src/hooks/useAttachmentPaste/useAttachmentPaste.android.ts b/apps/mobile/modules/composer/src/hooks/useAttachmentPaste/useAttachmentPaste.android.ts new file mode 100644 index 00000000000..6b0639b471d --- /dev/null +++ b/apps/mobile/modules/composer/src/hooks/useAttachmentPaste/useAttachmentPaste.android.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef, useState } from "react"; +import { ComposerAndroid } from "../../nativeComposerAndroid"; +import type { UseAttachmentPaste } from "./useAttachmentPaste.types"; + +/** A text field takes only text, so images and files get their own Paste control. */ +export const useAttachmentPaste: UseAttachmentPaste = ({ active, onPaste }) => { + const [hasAttachments, setHasAttachments] = useState(false); + const onPasteRef = useRef(onPaste); + onPasteRef.current = onPaste; + + useEffect(() => { + if (!active) return; + setHasAttachments(ComposerAndroid.clipboardHasAttachments()); + const subscription = ComposerAndroid.addListener( + "onClipboardChange", + (event) => setHasAttachments(event.hasAttachments), + ); + return () => subscription.remove(); + }, [active]); + + return { + canPaste: hasAttachments && onPaste !== undefined, + paste: () => { + void ComposerAndroid.readClipboardAttachments() + .then((items) => { + if (items.length > 0) onPasteRef.current?.(items); + }) + .catch(() => {}); + }, + }; +}; diff --git a/apps/mobile/modules/composer/src/hooks/useAttachmentPaste/useAttachmentPaste.ts b/apps/mobile/modules/composer/src/hooks/useAttachmentPaste/useAttachmentPaste.ts new file mode 100644 index 00000000000..ceb1a9992ca --- /dev/null +++ b/apps/mobile/modules/composer/src/hooks/useAttachmentPaste/useAttachmentPaste.ts @@ -0,0 +1,6 @@ +import type { UseAttachmentPaste } from "./useAttachmentPaste.types"; + +export const useAttachmentPaste: UseAttachmentPaste = () => ({ + canPaste: false, + paste: () => {}, +}); diff --git a/apps/mobile/modules/composer/src/hooks/useAttachmentPaste/useAttachmentPaste.types.ts b/apps/mobile/modules/composer/src/hooks/useAttachmentPaste/useAttachmentPaste.types.ts new file mode 100644 index 00000000000..d83cbb18d3f --- /dev/null +++ b/apps/mobile/modules/composer/src/hooks/useAttachmentPaste/useAttachmentPaste.types.ts @@ -0,0 +1,20 @@ +import type { RefObject } from "react"; +import type { TextInputInstance } from "react-native"; +import type { ComposerPastedItem } from "../../Composer.types"; + +export interface AttachmentPasteOptions { + input: RefObject; + /** The composer is expanded; the clipboard is read only then. */ + active: boolean; + onPaste?: (items: ComposerPastedItem[]) => void; +} + +export interface AttachmentPaste { + /** Images or files are on the clipboard and a Paste control should show. */ + canPaste: boolean; + paste: () => void; +} + +export type UseAttachmentPaste = ( + options: AttachmentPasteOptions, +) => AttachmentPaste; diff --git a/apps/mobile/modules/composer/src/hooks/useAttachmentPaste/useAttachmentPaste.web.ts b/apps/mobile/modules/composer/src/hooks/useAttachmentPaste/useAttachmentPaste.web.ts new file mode 100644 index 00000000000..1db210c5d94 --- /dev/null +++ b/apps/mobile/modules/composer/src/hooks/useAttachmentPaste/useAttachmentPaste.web.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef } from "react"; +import type { UseAttachmentPaste } from "./useAttachmentPaste.types"; + +/** The browser hands pasted files to the field itself; text pastes as usual. */ +export const useAttachmentPaste: UseAttachmentPaste = ({ input, onPaste }) => { + const onPasteRef = useRef(onPaste); + onPasteRef.current = onPaste; + + useEffect(() => { + const node = input.current as unknown as HTMLElement | null; + if (!node?.addEventListener) return; + const handle = (event: ClipboardEvent) => { + const files = Array.from(event.clipboardData?.files ?? []); + if (files.length === 0 || !onPasteRef.current) return; + event.preventDefault(); + onPasteRef.current( + files.map((file, index) => ({ + uri: URL.createObjectURL(file), + name: + file.name || + `pasted-${index + 1}.${file.type.split("/")[1] ?? "bin"}`, + kind: file.type.startsWith("image/") ? "image" : "file", + })), + ); + }; + node.addEventListener("paste", handle); + return () => node.removeEventListener("paste", handle); + }, [input]); + + return { canPaste: false, paste: () => {} }; +}; diff --git a/apps/mobile/modules/composer/src/hooks/useDictation/index.ts b/apps/mobile/modules/composer/src/hooks/useDictation/index.ts new file mode 100644 index 00000000000..303c4ce6bd8 --- /dev/null +++ b/apps/mobile/modules/composer/src/hooks/useDictation/index.ts @@ -0,0 +1 @@ +export { type DictationState, useDictation } from "./useDictation"; diff --git a/apps/mobile/modules/composer/src/hooks/useDictation/useDictation.ts b/apps/mobile/modules/composer/src/hooks/useDictation/useDictation.ts new file mode 100644 index 00000000000..384e55e2da9 --- /dev/null +++ b/apps/mobile/modules/composer/src/hooks/useDictation/useDictation.ts @@ -0,0 +1,122 @@ +import { useLingui } from "@lingui/react/macro"; +import { useEffect, useRef, useState } from "react"; +import { + type DictationErrorCode, + type DictationSession, + dictation, +} from "../../utils/dictation"; +import { joinDraft } from "../../utils/joinDraft"; + +export type DictationState = "idle" | "starting" | "recording"; + +interface UseDictationOptions { + getDraft: () => string; + setDraft: (text: string) => void; + onError?: (message: string) => void; +} + +/** + * Dictation into the draft. Words in progress show at the end of the draft + * and are replaced as they firm up; typing during dictation becomes the new + * base, so it is never overwritten. + */ +export function useDictation({ + getDraft, + setDraft, + onError, +}: UseDictationOptions) { + const { t } = useLingui(); + const [state, setState] = useState("idle"); + const [level, setLevel] = useState(0); + const session = useRef(null); + const text = useRef({ base: "", heard: "", partial: "" }); + const callbacks = useRef({ setDraft, onError }); + callbacks.current = { setDraft, onError }; + + useEffect(() => () => session.current?.cancel(), []); + + const errorMessage = (code: DictationErrorCode, heardSomething: boolean) => { + if (code === "permission") { + return t({ message: "Allow microphone access in Settings to dictate." }); + } + if (heardSomething) { + return t({ + message: "Dictation stopped. What was heard so far is in your message.", + }); + } + if (code === "network") { + return t({ message: "Dictation needs a network connection." }); + } + if (code === "language") { + return t({ message: "Dictation is not available in this language." }); + } + return t({ message: "Could not start dictation" }); + }; + + const show = () => { + const { base, heard, partial } = text.current; + callbacks.current.setDraft(joinDraft(joinDraft(base, heard), partial)); + }; + + const start = async () => { + if (state !== "idle") return; + text.current = { base: getDraft(), heard: "", partial: "" }; + setState("starting"); + const started = await dictation.start({ + onPartial: (partial) => { + text.current.partial = partial; + setState("recording"); + show(); + }, + onFinal: (final) => { + text.current.heard = joinDraft(text.current.heard, final); + text.current.partial = ""; + show(); + }, + onLevel: (next) => { + setState("recording"); + setLevel(next); + }, + onError: (code) => { + const { heard, partial } = text.current; + callbacks.current.onError?.(errorMessage(code, !!(heard || partial))); + }, + onEnd: () => { + if (text.current.partial) { + text.current.heard = joinDraft( + text.current.heard, + text.current.partial, + ); + text.current.partial = ""; + show(); + } + session.current = null; + setLevel(0); + setState("idle"); + }, + }); + if (!started) { + setState("idle"); + return; + } + session.current = started; + setState((current) => (current === "starting" ? "recording" : current)); + }; + + const stop = () => session.current?.stop(); + + /** The user typed while dictating: what they typed is the new base. */ + const rebase = (draft: string) => { + if (!session.current) return; + text.current = { base: draft, heard: "", partial: "" }; + }; + + return { + available: dictation.available, + state, + level, + start, + stop, + rebase, + }; +} diff --git a/apps/mobile/modules/composer/src/hooks/useSwipeToDismiss/index.ts b/apps/mobile/modules/composer/src/hooks/useSwipeToDismiss/index.ts new file mode 100644 index 00000000000..f3c853ddf3d --- /dev/null +++ b/apps/mobile/modules/composer/src/hooks/useSwipeToDismiss/index.ts @@ -0,0 +1 @@ +export { useSwipeToDismiss } from "./useSwipeToDismiss"; diff --git a/apps/mobile/modules/composer/src/hooks/useSwipeToDismiss/useSwipeToDismiss.ts b/apps/mobile/modules/composer/src/hooks/useSwipeToDismiss/useSwipeToDismiss.ts new file mode 100644 index 00000000000..b6b4df9a81c --- /dev/null +++ b/apps/mobile/modules/composer/src/hooks/useSwipeToDismiss/useSwipeToDismiss.ts @@ -0,0 +1,36 @@ +import { useMemo, useRef } from "react"; +import { Animated, PanResponder } from "react-native"; + +const DISMISS_DISTANCE = 64; +const DISMISS_VELOCITY = 0.8; + +/** Drag down on the grabber to collapse; a short drag springs back. */ +export function useSwipeToDismiss(onDismiss: () => void) { + const offset = useRef(new Animated.Value(0)).current; + const onDismissRef = useRef(onDismiss); + onDismissRef.current = onDismiss; + + const panHandlers = useMemo(() => { + const settle = () => + Animated.spring(offset, { + toValue: 0, + useNativeDriver: true, + bounciness: 0, + }).start(); + return PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: (_, gesture) => gesture.dy > 4, + onPanResponderMove: (_, gesture) => + offset.setValue(Math.max(0, gesture.dy)), + onPanResponderRelease: (_, gesture) => { + if (gesture.dy > DISMISS_DISTANCE || gesture.vy > DISMISS_VELOCITY) { + onDismissRef.current(); + } + settle(); + }, + onPanResponderTerminate: settle, + }).panHandlers; + }, [offset]); + + return { offset, panHandlers }; +} diff --git a/apps/mobile/modules/composer/src/index.tsx b/apps/mobile/modules/composer/src/index.tsx index 29c513e0b62..6fc8babcb90 100644 --- a/apps/mobile/modules/composer/src/index.tsx +++ b/apps/mobile/modules/composer/src/index.tsx @@ -1,586 +1,18 @@ -import { requireNativeModule, requireNativeView } from "expo"; -import { forwardRef, type Ref, useImperativeHandle, useRef } from "react"; - -/** The imperative surface the native view exposes through its ref. */ -interface NativeComposerRef { - clear: () => void; - appendDraft: (text: string) => void; - focus: () => void; - blur: () => void; -} - -interface NativeComposerViewProps { - ref?: Ref; - placeholder?: string; - initialDraft?: string; - backdrop?: ComposerBackdrop; - attachments?: ComposerAttachment[]; - selectedModel?: ComposerMenuOption; - launchOptions?: ComposerMenuOption[]; - modeOptions?: ComposerModeOption[]; - selectedModeId?: string | null; - canStop?: boolean; - headerChips?: ComposerMenuOption[]; - quickKeys?: ComposerQuickKey[]; - sessionTabs?: ComposerSessionTab[]; - sessionTabLabels?: ComposerSessionTabLabels; - /** Null, never undefined — see the pass-through below. */ - quickKeysAction?: ComposerQuickKeysAction | null; - slashCommands?: ComposerSlashCommand[]; - showAttachments?: boolean; - autocapitalization?: "sentences" | "never"; - compactEditor?: boolean; - isSending?: boolean; - onSubmit?: (event: { nativeEvent: { text: string } }) => void; - onAttachmentsPress?: () => void; - onDictationError?: (event: { nativeEvent: { message: string } }) => void; - onModelPress?: () => void; - onLaunchOptionPress?: (event: { nativeEvent: { id: string } }) => void; - onModeSelect?: (event: { nativeEvent: { id: string } }) => void; - onStop?: () => void; - onChipPress?: (event: { nativeEvent: { id: string } }) => void; - onQuickKeyPress?: (event: { nativeEvent: { id: string } }) => void; - onSessionTabPress?: (event: { nativeEvent: { id: string } }) => void; - onSessionTabClose?: (event: { nativeEvent: { id: string } }) => void; - onSessionTabRename?: (event: { nativeEvent: { id: string } }) => void; - onSessionTabCopyId?: (event: { nativeEvent: { id: string } }) => void; - onNewSessionPress?: () => void; - onAllSessionsPress?: () => void; - onQuickKeysActionPress?: () => void; - onHeightChange?: (event: { nativeEvent: { height: number } }) => void; - onPaste?: (event: { nativeEvent: { items: ComposerPastedItem[] } }) => void; - onDraftChange?: (event: { nativeEvent: { text: string } }) => void; - onRemoveAttachment?: (event: { nativeEvent: { id: string } }) => void; - onAttachmentPress?: (event: { nativeEvent: { id: string } }) => void; - onExpandedChange?: (event: { nativeEvent: { expanded: boolean } }) => void; -} - -const NativeComposerView = - requireNativeView("Composer"); - -const NativeComposerModule = requireNativeModule<{ - presentImage: (uri: string) => Promise; - fileThumbnail: (uri: string, side: number) => Promise; -}>("Composer"); - -/** Opens an image full screen in the composer's own viewer. */ -export function presentImage(uri: string): Promise { - return NativeComposerModule.presentImage(uri); -} - -/** A PNG preview of a local file, as the composer's file card draws it. */ -export function fileThumbnail( - uri: string, - side: number, -): Promise { - return NativeComposerModule.fileThumbnail(uri, side); -} - -/** - * How the composer treats the screen behind it while expanded. - * - * `dim` matches the mocks: the composer owns the screen and an outside tap - * dismisses it. `passthrough` leaves the content behind live so it can be - * scrolled while the keyboard is up — what a chat transcript wants. In that - * mode the caller owns dismissal, since nothing intercepts the outside tap. - */ -export type ComposerBackdrop = "dim" | "passthrough"; - -/** - * One entry in a composer picker. - * - * `iconUri` may be a remote URL or a local file URI. What it must not be is a - * Metro asset reference — SwiftUI cannot read those. Resolve bundled art with - * `expo-asset` first; see `useAgentIconUri`. - */ -export interface ComposerMenuOption { - id: string; - label: string; - iconUri?: string; - /** - * Lead with a project avatar. Separate from `iconUri` because most projects - * have no logo and the app draws their initial instead of leaving a gap — - * the same thing `ProjectAvatar` does everywhere else. - */ - avatar?: boolean; - /** - * Render a step back, as a qualifier rather than as the subject. The branch - * belongs to the project name beside it and should not compete with it. - */ - muted?: boolean; -} - -/** - * One permission mode in the menu beside `+`. `symbol` is an SF Symbol name. - */ -export interface ComposerModeOption { - id: string; - label: string; - symbol: string; -} - -/** - * One item in the composer's tray. The tray stays in React Native — it is - * shared with the attachments sheet — so the composer renders a description of - * it and reports removals and taps back out. - */ -export interface ComposerAttachment { - id: string; - uri: string; - kind: "image" | "file"; - /** - * Shown on the file card. Documents are unidentifiable without it — they all - * draw the same glyph. Ignored for images, which show themselves. - */ - name?: string; - /** - * 0–1 while the file is still on its way to cloud storage, drawn as a ring - * over the thumbnail. Omitted once it has landed, or when nothing is - * uploading — a tray of settled attachments draws no rings. - */ - progress?: number; - /** - * The upload failed. Marks the thumbnail instead of the ring; the - * attachment stays in the tray because sending the message retries it. - */ - failed?: boolean; -} - -/** - * One key in the strip above the composer — the terminal's esc/tab/arrows. - * - * Deliberately carries no behaviour: what a key writes into the PTY stays with - * the terminal that owns it. The composer draws the mark and reports the id. - */ -export interface ComposerQuickKey { - id: string; - /** Monospaced label. Ignored when `symbol` is set. */ - label?: string; - /** SF Symbol name, e.g. `arrow.up`. */ - symbol?: string; - /** - * A hairline between groups rather than a key. Which keys belong together - * is the terminal's knowledge, so it arrives as data — but a divider still - * needs its own unique `id`, because the strip identifies entries by it. - */ - divider?: boolean; -} - -/** - * One session in the strip above the quick keys — the workspace's tab bar. - * - * Data only, like the quick keys: the composer draws a pill and reports which - * one was touched. Everything about what a session *is* stays with the caller. - */ -export interface ComposerSessionTab { - id: string; - label: string; - /** - * The agent's brand mark. Same rule as `ComposerMenuOption.iconUri`: a - * remote URL or a local file URI, never a Metro asset reference. Resolve - * bundled art with `expo-asset` first — see `useAgentIconUris`. Omit for a - * plain shell, which draws the session's initial instead. - */ - iconUri?: string; - selected?: boolean; - /** Desktop's StatusIndicator states; omit for a session with nothing to say. */ - attention?: "permission" | "working" | "failed" | "review"; -} - -/** - * The one static control beside the quick keys. - * - * Data only, like the keys: the composer draws a chip and says it was pressed. - * What it opens is the caller's to know — the workspace terminal points it at - * that workspace's pull requests, and nothing here names one. - * - * It sits ahead of the bar rather than inside it, so it holds still while the - * keys scroll. It takes its room only once it exists: the keys slide over the - * moment it arrives, animated by the composer. - */ -export interface ComposerQuickKeysAction { - /** SF Symbol name, e.g. `arrow.triangle.pull`. */ - symbol: string; - /** - * A mark to draw instead of `symbol`. Same rule as - * `ComposerSessionTab.iconUri`: a local file URI, never a Metro asset - * reference. Drawn as a template, so it takes `tint` like the symbol does, - * and `symbol` shows until it resolves. - */ - iconUri?: string; - /** - * Which accent the glyph takes. Omit for the same foreground the keys use. - * The name crosses the bridge and the composer owns the colour, the way - * `ComposerSessionTab.attention` does. - */ - tint?: "open" | "draft" | "queued" | "merged" | "closed"; - /** Accessibility label. Translated here; the composer has no catalog. */ - label: string; -} - -/** - * Every user-facing string the tab strip draws. - * - * They cross the bridge because the composer cannot translate — Lingui's macros - * and catalogs live here — and a hardcoded English menu item would be the one - * untranslated string on a translated screen. - */ -export interface ComposerSessionTabLabels { - /** Context menu: opens the prompt for the session's name. */ - rename: string; - /** Context menu: copies the session's id to the pasteboard. */ - copyId: string; - /** Context menu, destructive, and the close disc's accessibility label. */ - close: string; - newSession: string; - allSessions: string; - scrollToStart: string; -} - -/** - * One slash command or skill the active agent can run — the suggestion panel - * above the composer. Data only, like the quick keys: selection replaces the - * draft natively and reports back through `onDraftChange`. - */ -export interface ComposerSlashCommand { - id: string; - /** Full display name, namespace included (`agent-sdk-dev:new-sdk-app`). */ - name: string; - descriptionText?: string; - /** The sigil that opens and commits this entry: `/`, or `$` for Codex skills. */ - trigger: "/" | "$"; - /** Non-empty when the command takes arguments; a fully typed command with - * arguments keeps the panel closed so they can be typed in peace. */ - argumentHint?: string; - /** Harness-shipped commands sort after user-defined ones, like desktop. */ - isBuiltin?: boolean; - /** Alternate names; matched after the canonical name, like desktop. */ - aliases?: string[]; -} - -/** - * A file or image pasted into the field, already written to disk by the native - * side — the tray takes URIs, the same shape the pickers produce. - */ -export interface ComposerPastedItem { - uri: string; - name: string; - kind: "image" | "file"; -} - -export interface ComposerHandle { - /** Empties the draft. */ - clear: () => void; - /** - * Appends to the draft, for dictation. The composer owns the base text and - * does the join, so callers never have to read it back. - */ - appendDraft: (text: string) => void; - /** - * Re-opens the composer after something else took first responder — an - * attachments sheet, a picker — bringing the keyboard and draft back. - */ - focus: () => void; - blur: () => void; -} - -/** - * The strip and its strings arrive together or not at all. - * - * Split out of the props rather than left as two optional fields: the composer - * cannot translate, so tabs without labels render a context menu and - * accessibility labels that are empty strings — a caller can reach that state - * without any type error, and nothing about it looks wrong until VoiceOver - * reaches it. - */ -type ComposerSessionTabsProps = - | { - sessionTabs: ComposerSessionTab[]; - sessionTabLabels: ComposerSessionTabLabels; - } - | { sessionTabs?: undefined; sessionTabLabels?: undefined }; - -export type ComposerProps = ComposerBaseProps & ComposerSessionTabsProps; - -interface ComposerBaseProps { - placeholder?: string; - /** - * Whatever this surface had typed when it was last open, put back as the - * composer is set up. Read once by the caller and never changed after: this - * is a starting value, not a binding, and the composer owns its text from - * here on. There is deliberately no `value` prop — see `onDraftChange`. - */ - initialDraft?: string; - backdrop?: ComposerBackdrop; - attachments?: ComposerAttachment[]; - /** - * The selected agent, shown as brand mark + name. Omit to hide the picker — - * what the terminal surface wants. The list itself stays in React Native: - * the real pickers are `formSheet` routes with searchable lists. - */ - selectedModel?: ComposerMenuOption; - /** - * The selected agent's launch settings, drawn after it as their own - * chevron buttons — model, effort. Each reports its id on press; the - * lists stay in React Native like the agent's. Omit for agents with none. - */ - launchOptions?: ComposerMenuOption[]; - /** The agent's permission modes, as a native menu beside `+`. */ - modeOptions?: ComposerModeOption[]; - selectedModeId?: string; - /** The agent is working: with an empty draft, send becomes stop. */ - canStop?: boolean; - /** Frame 4's header row. Empty on the session surface (frame 13). */ - headerChips?: ComposerMenuOption[]; - /** - * Keys above the card — the terminal's esc/tab/arrows. Rendered natively - * rather than by the caller: as a React Native sibling the gap to the card - * had to guess a height it could not measure, and drifted every time the - * card grew. - */ - quickKeys?: ComposerQuickKey[]; - /** - * The one static control beside the quick keys. Omitted on every surface - * with nothing to link to — which is all of them but the workspace - * terminal. Independent of `quickKeys`: an action with the keys away still - * draws the row it belongs to. - */ - quickKeysAction?: ComposerQuickKeysAction; - /** - * What the active agent can run behind `/` (or `$`). Empty or omitted - * hides the suggestion panel — a plain shell, an agent without command - * discovery, or a host too old to answer all look the same here. - */ - slashCommands?: ComposerSlashCommand[]; - /** - * Offer the `+` button. A plain shell would try to *execute* an attachment - * path, so only agent sessions get it. - */ - showAttachments?: boolean; - /** `never` for the terminal — a shell command is not a sentence. */ - autocapitalization?: "sentences" | "never"; - /** - * Shrinks the expanded editor's floor by two lines, so the terminal screen - * can leave more of the transcript on screen when the composer is at its - * smallest. Omit for the generous floor the reference frames show. - */ - compactEditor?: boolean; - /** - * A submit is in flight. Send becomes a grey spinner and the mic steps - * aside. The caller owns this because only it knows when delivery finished. - */ - isSending?: boolean; - /** - * Never clears the composer — the caller clears through the ref once its own - * delivery succeeded, so a failed send keeps the draft. - */ - onSubmit?: (text: string) => void; - onAttachmentsPress?: () => void; - /** - * Dictation runs natively — the composer owns the recogniser, the permission - * prompt and the append — so there is no press to handle here. This only - * surfaces a failure so the caller can show its own alert. - */ - onDictationError?: (message: string) => void; - onModelPress?: () => void; - onLaunchOptionPress?: (id: string) => void; - onModeSelect?: (id: string) => void; - onStop?: () => void; - onChipPress?: (id: string) => void; - onQuickKeyPress?: (id: string) => void; - /** A tab was tapped — attach that session. */ - onSessionTabPress?: (id: string) => void; - /** - * Close was chosen, from the selected tab's disc or the press-and-hold - * menu. Nothing has been killed yet: the composer has no idea what closing - * costs, so the caller confirms. - */ - onSessionTabClose?: (id: string) => void; - /** - * Rename was chosen from the press-and-hold menu. The composer neither - * asks for the new name nor knows what a session's name is — a menu cannot - * take text, and the name is the host's — so the caller prompts and saves. - */ - onSessionTabRename?: (id: string) => void; - /** - * Copy id was chosen from the press-and-hold menu. The caller owns the - * pasteboard write and whatever it shows afterwards, so the confirmation - * matches every other copy on the screen. - */ - onSessionTabCopyId?: (id: string) => void; - onNewSessionPress?: () => void; - onAllSessionsPress?: () => void; - /** - * The control beside the quick keys was pressed. Only ever fires when - * `quickKeysAction` is set, so the caller that provided the chip is the one - * that hears about it. - */ - onQuickKeysActionPress?: () => void; - /** - * How much room the composer occupies above the bottom safe area — the - * session tabs, the card, the quick keys and the gaps between them. - * - * The composer draws in an overlay and takes no layout space, so a caller - * with content underneath cannot measure it. Excludes the keyboard, which - * the caller already tracks and gets a duration and curve for. - */ - onHeightChange?: (height: number) => void; - /** - * Files and images pasted into the field. A plain text field only ever takes - * strings, so the composer owns its text view to offer Paste for these and - * writes them out; adding them to the tray is the caller's job, because the - * tray is the caller's. - */ - onPaste?: (items: ComposerPastedItem[]) => void; - /** - * Every keystroke, so a caller can keep a shadow copy of the draft and put - * it back later. Outward only, like `onHeightChange`: the composer owns its - * text while it is live and takes nothing back mid-edit, which is why there - * is no `value` prop. Restore through the ref at mount instead. - */ - onDraftChange?: (text: string) => void; - onRemoveAttachment?: (id: string) => void; - /** - * Fires only for non-image attachments. Images open in the composer's own - * full-screen viewer — it already holds the URI, so routing the tap out and - * back would buy nothing — but only the app knows what to do with a document. - */ - onAttachmentPress?: (id: string) => void; - /** - * Fires whenever the composer opens or closes. Callers need it to restore - * the composer only when it was actually open — re-focusing unconditionally - * after a sheet pops the keyboard back up over a collapsed composer. - */ - onExpandedChange?: (expanded: boolean) => void; -} - -/** - * The native composer. Renders nothing in the React Native layout — it mounts a - * full-screen overlay over the screen's own view controller and draws there, so - * callers reserve room for the collapsed pill with a content inset rather than - * with layout. - * - * Every surface-specific difference arrives as data, never as children: the - * moment a `ReactNode` crosses this boundary the seam artifacts that made - * `GlassComposer` unmaintainable come back with it. See - * `plans/20260821-native-composer.md`. - */ -export const Composer = forwardRef( - function Composer( - { - placeholder = "", - initialDraft = "", - backdrop = "dim", - attachments, - selectedModel, - launchOptions, - modeOptions, - selectedModeId, - canStop = false, - headerChips, - quickKeys, - sessionTabs, - sessionTabLabels, - quickKeysAction, - slashCommands, - showAttachments = true, - autocapitalization = "sentences", - compactEditor = false, - isSending = false, - onSubmit, - onAttachmentsPress, - onDictationError, - onModelPress, - onLaunchOptionPress, - onModeSelect, - onStop, - onChipPress, - onQuickKeyPress, - onSessionTabPress, - onSessionTabClose, - onSessionTabRename, - onSessionTabCopyId, - onNewSessionPress, - onAllSessionsPress, - onQuickKeysActionPress, - onHeightChange, - onPaste, - onDraftChange, - onRemoveAttachment, - onAttachmentPress, - onExpandedChange, - }, - ref, - ) { - const nativeRef = useRef(null); - - useImperativeHandle(ref, () => ({ - clear: () => nativeRef.current?.clear(), - appendDraft: (text: string) => nativeRef.current?.appendDraft(text), - focus: () => nativeRef.current?.focus(), - blur: () => nativeRef.current?.blur(), - })); - - return ( - onSubmit?.(event.nativeEvent.text)} - onAttachmentsPress={onAttachmentsPress} - onDictationError={(event) => - onDictationError?.(event.nativeEvent.message) - } - onModelPress={onModelPress} - onLaunchOptionPress={(event) => - onLaunchOptionPress?.(event.nativeEvent.id) - } - onModeSelect={(event) => onModeSelect?.(event.nativeEvent.id)} - onStop={() => onStop?.()} - onChipPress={(event) => onChipPress?.(event.nativeEvent.id)} - onQuickKeyPress={(event) => onQuickKeyPress?.(event.nativeEvent.id)} - onSessionTabPress={(event) => onSessionTabPress?.(event.nativeEvent.id)} - onSessionTabClose={(event) => onSessionTabClose?.(event.nativeEvent.id)} - onSessionTabRename={(event) => - onSessionTabRename?.(event.nativeEvent.id) - } - onSessionTabCopyId={(event) => - onSessionTabCopyId?.(event.nativeEvent.id) - } - onNewSessionPress={onNewSessionPress} - onAllSessionsPress={onAllSessionsPress} - onQuickKeysActionPress={onQuickKeysActionPress} - onHeightChange={(event) => onHeightChange?.(event.nativeEvent.height)} - onPaste={(event) => onPaste?.(event.nativeEvent.items)} - onDraftChange={(event) => onDraftChange?.(event.nativeEvent.text)} - onRemoveAttachment={(event) => - onRemoveAttachment?.(event.nativeEvent.id) - } - onAttachmentPress={(event) => onAttachmentPress?.(event.nativeEvent.id)} - onExpandedChange={(event) => - onExpandedChange?.(event.nativeEvent.expanded) - } - /> - ); - }, -); +export { Composer, fileThumbnail, presentImage } from "./Composer"; +export type { + ComposerAttachment, + ComposerBackdrop, + ComposerComponent, + ComposerHandle, + ComposerMenuOption, + ComposerModeOption, + ComposerPastedItem, + ComposerProps, + ComposerQuickKey, + ComposerQuickKeysAction, + ComposerSessionTab, + ComposerSessionTabLabels, + ComposerSlashCommand, + FileThumbnail, + PresentImage, +} from "./Composer.types"; diff --git a/apps/mobile/modules/composer/src/nativeComposerAndroid.ts b/apps/mobile/modules/composer/src/nativeComposerAndroid.ts new file mode 100644 index 00000000000..8cd44152ffa --- /dev/null +++ b/apps/mobile/modules/composer/src/nativeComposerAndroid.ts @@ -0,0 +1,32 @@ +import { requireNativeModule } from "expo"; +import type { EventSubscription } from "expo-modules-core"; +import type { ComposerPastedItem } from "./Composer.types"; +import type { DictationErrorCode } from "./utils/dictation/dictation.types"; + +interface ComposerAndroidEvents { + onDictationPartial: (event: { text: string }) => void; + onDictationFinal: (event: { text: string }) => void; + onDictationLevel: (event: { level: number }) => void; + onDictationError: (event: { code: DictationErrorCode }) => void; + onDictationEnd: () => void; + onClipboardChange: (event: { hasAttachments: boolean }) => void; +} + +interface ComposerAndroidModule { + isDictationAvailable(): boolean; + requestMicrophonePermission(): Promise<{ granted: boolean }>; + startDictation(locale: string | null): Promise; + stopDictation(): Promise; + cancelDictation(): Promise; + clipboardHasAttachments(): boolean; + readClipboardAttachments(): Promise; + documentThumbnail(uri: string, sidePx: number): Promise; + addListener( + event: E, + listener: ComposerAndroidEvents[E], + ): EventSubscription; +} + +/** `modules/composer-android`: Android only, so only `.android` files import it. */ +export const ComposerAndroid = + requireNativeModule("ComposerAndroid"); diff --git a/apps/mobile/modules/composer/src/utils/clusterColumn/clusterColumn.ts b/apps/mobile/modules/composer/src/utils/clusterColumn/clusterColumn.ts new file mode 100644 index 00000000000..8d0c2c82557 --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/clusterColumn/clusterColumn.ts @@ -0,0 +1,3 @@ +import type { ClusterColumn } from "./clusterColumn.types"; + +export const clusterColumn: ClusterColumn = undefined; diff --git a/apps/mobile/modules/composer/src/utils/clusterColumn/clusterColumn.types.ts b/apps/mobile/modules/composer/src/utils/clusterColumn/clusterColumn.types.ts new file mode 100644 index 00000000000..7b05881e4a2 --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/clusterColumn/clusterColumn.types.ts @@ -0,0 +1,4 @@ +import type { ViewStyle } from "react-native"; + +/** Extra style for the composer's cluster: tabs, quick keys and card. */ +export type ClusterColumn = ViewStyle | undefined; diff --git a/apps/mobile/modules/composer/src/utils/clusterColumn/clusterColumn.web.ts b/apps/mobile/modules/composer/src/utils/clusterColumn/clusterColumn.web.ts new file mode 100644 index 00000000000..02af74970c3 --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/clusterColumn/clusterColumn.web.ts @@ -0,0 +1,8 @@ +import { COMPOSER_METRICS } from "../../constants"; +import type { ClusterColumn } from "./clusterColumn.types"; + +export const clusterColumn: ClusterColumn = { + width: "100%", + maxWidth: COMPOSER_METRICS.maxWidth, + alignSelf: "center", +}; diff --git a/apps/mobile/modules/composer/src/utils/clusterColumn/index.ts b/apps/mobile/modules/composer/src/utils/clusterColumn/index.ts new file mode 100644 index 00000000000..413fd7d8513 --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/clusterColumn/index.ts @@ -0,0 +1 @@ +export { clusterColumn } from "./clusterColumn"; diff --git a/apps/mobile/modules/composer/src/utils/composerKeyHandler/composerKeyHandler.ts b/apps/mobile/modules/composer/src/utils/composerKeyHandler/composerKeyHandler.ts new file mode 100644 index 00000000000..ba28b7ced57 --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/composerKeyHandler/composerKeyHandler.ts @@ -0,0 +1,3 @@ +import type { ComposerKeyHandler } from "./composerKeyHandler.types"; + +export const composerKeyHandler: ComposerKeyHandler = () => undefined; diff --git a/apps/mobile/modules/composer/src/utils/composerKeyHandler/composerKeyHandler.types.ts b/apps/mobile/modules/composer/src/utils/composerKeyHandler/composerKeyHandler.types.ts new file mode 100644 index 00000000000..accba3533ec --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/composerKeyHandler/composerKeyHandler.types.ts @@ -0,0 +1,12 @@ +import type { TextInputKeyPressEvent } from "react-native"; + +export interface ComposerKeysOptions { + onSubmit: () => void; + onDismiss: () => void; +} + +export type ComposerKeyPress = (event: TextInputKeyPressEvent) => void; + +export type ComposerKeyHandler = ( + options: ComposerKeysOptions, +) => ComposerKeyPress | undefined; diff --git a/apps/mobile/modules/composer/src/utils/composerKeyHandler/composerKeyHandler.web.test.ts b/apps/mobile/modules/composer/src/utils/composerKeyHandler/composerKeyHandler.web.test.ts new file mode 100644 index 00000000000..e5dfaf46cbf --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/composerKeyHandler/composerKeyHandler.web.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import type { TextInputKeyPressEvent } from "react-native"; +import { composerKeyHandler } from "./composerKeyHandler.web"; + +function press( + key: string, + { shift = false, meta = false, composing = false, coarse = false } = {}, +) { + (globalThis as { window?: unknown }).window = { + matchMedia: () => ({ matches: coarse }), + }; + const onSubmit = mock(() => {}); + const onDismiss = mock(() => {}); + const preventDefault = mock(() => {}); + const handler = composerKeyHandler({ onSubmit, onDismiss }); + handler?.({ + key, + shiftKey: shift, + altKey: false, + metaKey: meta, + ctrlKey: false, + preventDefault, + nativeEvent: { isComposing: composing, keyCode: composing ? 229 : 13 }, + } as unknown as TextInputKeyPressEvent); + return { + submitted: onSubmit.mock.calls.length, + dismissed: onDismiss.mock.calls.length, + prevented: preventDefault.mock.calls.length, + }; +} + +const originalWindow = (globalThis as { window?: unknown }).window; + +describe("composerKeyHandler on the web", () => { + afterEach(() => { + (globalThis as { window?: unknown }).window = originalWindow; + }); + + test("Enter sends instead of adding a line", () => { + expect(press("Enter")).toEqual({ + submitted: 1, + dismissed: 0, + prevented: 1, + }); + }); + + test("Shift+Enter and IME composition keep the newline", () => { + expect(press("Enter", { shift: true }).submitted).toBe(0); + expect(press("Enter", { composing: true }).submitted).toBe(0); + }); + + test("a touch keyboard's Enter adds a line, but Cmd+Enter still sends", () => { + expect(press("Enter", { coarse: true }).submitted).toBe(0); + expect(press("Enter", { coarse: true, meta: true }).submitted).toBe(1); + }); + + test("Escape closes the composer", () => { + expect(press("Escape")).toEqual({ + submitted: 0, + dismissed: 1, + prevented: 1, + }); + }); +}); diff --git a/apps/mobile/modules/composer/src/utils/composerKeyHandler/composerKeyHandler.web.ts b/apps/mobile/modules/composer/src/utils/composerKeyHandler/composerKeyHandler.web.ts new file mode 100644 index 00000000000..16abe64cba5 --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/composerKeyHandler/composerKeyHandler.web.ts @@ -0,0 +1,20 @@ +import type { KeyboardEvent } from "react"; +import type { ComposerKeyHandler } from "./composerKeyHandler.types"; + +/** Enter sends and Shift+Enter breaks the line, unless a touch keyboard is typing. */ +export const composerKeyHandler: ComposerKeyHandler = + ({ onSubmit, onDismiss }) => + (event) => { + const key = event as unknown as KeyboardEvent; + if (key.key === "Escape") { + key.preventDefault(); + onDismiss(); + return; + } + if (key.key !== "Enter" || key.shiftKey || key.altKey) return; + if (key.nativeEvent.isComposing || key.nativeEvent.keyCode === 229) return; + const modified = key.metaKey || key.ctrlKey; + if (!modified && window.matchMedia("(pointer: coarse)").matches) return; + key.preventDefault(); + onSubmit(); + }; diff --git a/apps/mobile/modules/composer/src/utils/composerKeyHandler/index.ts b/apps/mobile/modules/composer/src/utils/composerKeyHandler/index.ts new file mode 100644 index 00000000000..d11758acf03 --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/composerKeyHandler/index.ts @@ -0,0 +1 @@ +export { composerKeyHandler } from "./composerKeyHandler"; diff --git a/apps/mobile/modules/composer/src/utils/dictation/dictation.android.ts b/apps/mobile/modules/composer/src/utils/dictation/dictation.android.ts new file mode 100644 index 00000000000..4233d0254ee --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/dictation/dictation.android.ts @@ -0,0 +1,42 @@ +import { ComposerAndroid } from "../../nativeComposerAndroid"; +import type { Dictation } from "./dictation.types"; + +export const dictation: Dictation = { + available: ComposerAndroid.isDictationAvailable(), + start: async (listener) => { + const permission = await ComposerAndroid.requestMicrophonePermission(); + if (!permission.granted) { + listener.onError("permission"); + return null; + } + const subscriptions = [ + ComposerAndroid.addListener("onDictationPartial", ({ text }) => + listener.onPartial(text), + ), + ComposerAndroid.addListener("onDictationFinal", ({ text }) => + listener.onFinal(text), + ), + ComposerAndroid.addListener("onDictationLevel", ({ level }) => + listener.onLevel(level), + ), + ComposerAndroid.addListener("onDictationError", ({ code }) => + listener.onError(code), + ), + ComposerAndroid.addListener("onDictationEnd", () => { + for (const subscription of subscriptions) subscription.remove(); + listener.onEnd(); + }), + ]; + try { + await ComposerAndroid.startDictation(null); + } catch { + for (const subscription of subscriptions) subscription.remove(); + listener.onError("failed"); + return null; + } + return { + stop: () => void ComposerAndroid.stopDictation(), + cancel: () => void ComposerAndroid.cancelDictation(), + }; + }, +}; diff --git a/apps/mobile/modules/composer/src/utils/dictation/dictation.ts b/apps/mobile/modules/composer/src/utils/dictation/dictation.ts new file mode 100644 index 00000000000..1d06c62a666 --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/dictation/dictation.ts @@ -0,0 +1,6 @@ +import type { Dictation } from "./dictation.types"; + +export const dictation: Dictation = { + available: false, + start: async () => null, +}; diff --git a/apps/mobile/modules/composer/src/utils/dictation/dictation.types.ts b/apps/mobile/modules/composer/src/utils/dictation/dictation.types.ts new file mode 100644 index 00000000000..7fc7b827f9a --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/dictation/dictation.types.ts @@ -0,0 +1,31 @@ +export type DictationErrorCode = + | "permission" + | "network" + | "busy" + | "language" + | "failed"; + +export interface DictationListener { + /** The words heard since the last final result; replaced on each call. */ + onPartial: (text: string) => void; + /** A finished stretch of speech, to keep. */ + onFinal: (text: string) => void; + /** 0–1, roughly ten times a second while listening. */ + onLevel: (level: number) => void; + onError: (code: DictationErrorCode) => void; + /** Fires once, after stop, cancel or an error. */ + onEnd: () => void; +} + +export interface DictationSession { + /** Stops listening; the last words still arrive before `onEnd`. */ + stop: () => void; + cancel: () => void; +} + +export interface Dictation { + /** False hides the mic: there is no recogniser on this platform. */ + available: boolean; + /** Asks for the microphone first. Null when it was not allowed or did not start. */ + start: (listener: DictationListener) => Promise; +} diff --git a/apps/mobile/modules/composer/src/utils/dictation/dictation.web.ts b/apps/mobile/modules/composer/src/utils/dictation/dictation.web.ts new file mode 100644 index 00000000000..4227fbc382a --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/dictation/dictation.web.ts @@ -0,0 +1,106 @@ +import type { Dictation, DictationErrorCode } from "./dictation.types"; + +interface SpeechRecognitionResultLike { + isFinal: boolean; + 0: { transcript: string }; +} + +interface SpeechRecognitionLike { + continuous: boolean; + interimResults: boolean; + lang: string; + onresult: + | ((event: { + resultIndex: number; + results: ArrayLike; + }) => void) + | null; + onerror: ((event: { error: string }) => void) | null; + onend: (() => void) | null; + start: () => void; + stop: () => void; + abort: () => void; +} + +type SpeechRecognitionConstructor = new () => SpeechRecognitionLike; + +const Recognition: SpeechRecognitionConstructor | undefined = + typeof window === "undefined" + ? undefined + : (((window as unknown as Record).SpeechRecognition ?? + (window as unknown as Record) + .webkitSpeechRecognition) as + | SpeechRecognitionConstructor + | undefined); + +const ERROR_CODES: Record = { + "not-allowed": "permission", + "service-not-allowed": "permission", + "audio-capture": "permission", + network: "network", + "language-not-supported": "language", + "no-speech": null, + aborted: null, +}; + +export const dictation: Dictation = { + available: Recognition !== undefined, + start: async (listener) => { + if (!Recognition) return null; + const recognition = new Recognition(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = navigator.language; + let listening = true; + let failed = false; + recognition.onresult = (event) => { + let interim = ""; + for ( + let index = event.resultIndex; + index < event.results.length; + index++ + ) { + const result = event.results[index]; + if (!result) continue; + const text = result[0].transcript.trim(); + if (result.isFinal) { + if (text) listener.onFinal(text); + } else { + interim = interim ? `${interim} ${text}` : text; + } + } + listener.onPartial(interim); + }; + recognition.onerror = (event) => { + const code = ERROR_CODES[event.error]; + if (code === null) return; + failed = true; + listening = false; + listener.onError(code ?? "failed"); + }; + // Browsers end a session after a pause; keep going until stop. + recognition.onend = () => { + if (listening && !failed) { + recognition.start(); + return; + } + listener.onEnd(); + }; + try { + recognition.start(); + } catch { + listener.onError("failed"); + return null; + } + return { + stop: () => { + listening = false; + recognition.stop(); + }, + cancel: () => { + listening = false; + recognition.abort(); + }, + }; + }, +}; diff --git a/apps/mobile/modules/composer/src/utils/dictation/index.ts b/apps/mobile/modules/composer/src/utils/dictation/index.ts new file mode 100644 index 00000000000..cde0f85a5bd --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/dictation/index.ts @@ -0,0 +1,7 @@ +export { dictation } from "./dictation"; +export type { + Dictation, + DictationErrorCode, + DictationListener, + DictationSession, +} from "./dictation.types"; diff --git a/apps/mobile/modules/composer/src/utils/fileThumbnail/fileThumbnail.android.ts b/apps/mobile/modules/composer/src/utils/fileThumbnail/fileThumbnail.android.ts new file mode 100644 index 00000000000..da41cd66f84 --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/fileThumbnail/fileThumbnail.android.ts @@ -0,0 +1,16 @@ +import { PixelRatio } from "react-native"; +import type { FileThumbnail } from "../../Composer.types"; +import { ComposerAndroid } from "../../nativeComposerAndroid"; +import { imageThumbnail, isImageUri } from "./imageThumbnail"; + +export const fileThumbnail: FileThumbnail = async (uri, side) => { + if (isImageUri(uri)) return imageThumbnail(uri, side); + try { + return await ComposerAndroid.documentThumbnail( + uri, + PixelRatio.getPixelSizeForLayoutSize(side), + ); + } catch { + return null; + } +}; diff --git a/apps/mobile/modules/composer/src/utils/fileThumbnail/fileThumbnail.ts b/apps/mobile/modules/composer/src/utils/fileThumbnail/fileThumbnail.ts new file mode 100644 index 00000000000..791b503971c --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/fileThumbnail/fileThumbnail.ts @@ -0,0 +1,5 @@ +import type { FileThumbnail } from "../../Composer.types"; +import { imageThumbnail, isImageUri } from "./imageThumbnail"; + +export const fileThumbnail: FileThumbnail = async (uri, side) => + isImageUri(uri) ? imageThumbnail(uri, side) : null; diff --git a/apps/mobile/modules/composer/src/utils/fileThumbnail/imageThumbnail.ts b/apps/mobile/modules/composer/src/utils/fileThumbnail/imageThumbnail.ts new file mode 100644 index 00000000000..7f64f10771a --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/fileThumbnail/imageThumbnail.ts @@ -0,0 +1,20 @@ +import { ImageManipulator, SaveFormat } from "expo-image-manipulator"; +import { PixelRatio } from "react-native"; + +const IMAGE_EXTENSION = /\.(png|jpe?g|gif|webp|heic|heif|bmp|avif)(\?|$)/i; + +export function isImageUri(uri: string) { + return IMAGE_EXTENSION.test(uri); +} + +export async function imageThumbnail(uri: string, side: number) { + try { + const image = await ImageManipulator.manipulate(uri) + .resize({ width: PixelRatio.getPixelSizeForLayoutSize(side) }) + .renderAsync(); + const result = await image.saveAsync({ format: SaveFormat.PNG }); + return result.uri; + } catch { + return null; + } +} diff --git a/apps/mobile/modules/composer/src/utils/fileThumbnail/index.ts b/apps/mobile/modules/composer/src/utils/fileThumbnail/index.ts new file mode 100644 index 00000000000..6cad7d04b9d --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/fileThumbnail/index.ts @@ -0,0 +1 @@ +export { fileThumbnail } from "./fileThumbnail"; diff --git a/apps/mobile/modules/composer/src/utils/joinDraft/index.ts b/apps/mobile/modules/composer/src/utils/joinDraft/index.ts new file mode 100644 index 00000000000..edddaa4e6a2 --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/joinDraft/index.ts @@ -0,0 +1 @@ +export { joinDraft } from "./joinDraft"; diff --git a/apps/mobile/modules/composer/src/utils/joinDraft/joinDraft.test.ts b/apps/mobile/modules/composer/src/utils/joinDraft/joinDraft.test.ts new file mode 100644 index 00000000000..68f98ee832b --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/joinDraft/joinDraft.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { joinDraft } from "./joinDraft"; + +describe("joinDraft", () => { + test("puts a space between words", () => { + expect(joinDraft("fix the", " bug ")).toBe("fix the bug"); + }); + + test("adds nothing before punctuation or after a newline", () => { + expect(joinDraft("done", ".")).toBe("done."); + expect(joinDraft("first\n", "second")).toBe("first\nsecond"); + }); + + test("does not space Japanese or Chinese", () => { + expect(joinDraft("バグを", "直して")).toBe("バグを直して"); + }); + + test("ignores an empty addition and an empty draft", () => { + expect(joinDraft("keep", " ")).toBe("keep"); + expect(joinDraft("", "start")).toBe("start"); + }); +}); diff --git a/apps/mobile/modules/composer/src/utils/joinDraft/joinDraft.ts b/apps/mobile/modules/composer/src/utils/joinDraft/joinDraft.ts new file mode 100644 index 00000000000..33f520e325a --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/joinDraft/joinDraft.ts @@ -0,0 +1,19 @@ +const UNSPACED = /[\p{Script=Han} -〿぀-ヿ＀-￯]/u; +const PUNCTUATION = /\p{P}/u; + +/** + * `appendDraft`'s join, the same rule as the iOS `ComposerDictatedText.join`: + * a space between the two, except after whitespace, before punctuation, or + * next to a script that does not separate words. + */ +export function joinDraft(draft: string, text: string): string { + const addition = text.trim(); + if (!addition) return draft; + const base = draft.replace(/[ \t]+$/, ""); + const last = base.at(-1); + const first = addition[0]; + if (!last || !first) return base + addition; + if (/\s/.test(last) || PUNCTUATION.test(first)) return base + addition; + if (UNSPACED.test(last) || UNSPACED.test(first)) return base + addition; + return `${base} ${addition}`; +} diff --git a/apps/mobile/modules/composer/src/utils/slashSuggestions/index.ts b/apps/mobile/modules/composer/src/utils/slashSuggestions/index.ts new file mode 100644 index 00000000000..d1cbbdea641 --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/slashSuggestions/index.ts @@ -0,0 +1 @@ +export { committedSlashDraft, slashSuggestions } from "./slashSuggestions"; diff --git a/apps/mobile/modules/composer/src/utils/slashSuggestions/slashSuggestions.test.ts b/apps/mobile/modules/composer/src/utils/slashSuggestions/slashSuggestions.test.ts new file mode 100644 index 00000000000..e1f428a8fc4 --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/slashSuggestions/slashSuggestions.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import type { ComposerSlashCommand } from "../../Composer.types"; +import { committedSlashDraft, slashSuggestions } from "./slashSuggestions"; + +const command = ( + name: string, + extra: Partial = {}, +): ComposerSlashCommand => ({ id: `/${name}`, name, trigger: "/", ...extra }); + +const names = (matches: ComposerSlashCommand[] | null) => + matches?.map((match) => match.name) ?? null; + +describe("slashSuggestions", () => { + const commands = [ + command("review", { isBuiltin: true }), + command("preview"), + command("compact", { isBuiltin: true, aliases: ["shrink"] }), + command("deploy", { argumentHint: "" }), + command("plan", { trigger: "$" }), + ]; + + test("opens only on a bare trigger token", () => { + expect(names(slashSuggestions("/", commands))).toEqual([ + "deploy", + "preview", + "compact", + "review", + ]); + expect(slashSuggestions("fix /rev", commands)).toBeNull(); + expect(slashSuggestions("/review now", commands)).toBeNull(); + expect(slashSuggestions("/rev", [])).toBeNull(); + }); + + test("ranks exact, then prefix, then substring, then aliases", () => { + expect(names(slashSuggestions("/review", commands))).toEqual([ + "review", + "preview", + ]); + expect(names(slashSuggestions("/SHR", commands))).toEqual(["compact"]); + }); + + test("keeps the triggers apart", () => { + expect(names(slashSuggestions("$", commands))).toEqual(["plan"]); + expect(slashSuggestions("/plan", commands)).toBeNull(); + }); + + test("closes for a fully typed command that takes arguments", () => { + expect(slashSuggestions("/deploy", commands)).toBeNull(); + expect(names(slashSuggestions("/dep", commands))).toEqual(["deploy"]); + }); + + test("commits the token with a trailing space", () => { + expect(committedSlashDraft(command("deploy"))).toBe("/deploy "); + }); +}); diff --git a/apps/mobile/modules/composer/src/utils/slashSuggestions/slashSuggestions.ts b/apps/mobile/modules/composer/src/utils/slashSuggestions/slashSuggestions.ts new file mode 100644 index 00000000000..f5305870be9 --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/slashSuggestions/slashSuggestions.ts @@ -0,0 +1,75 @@ +import type { ComposerSlashCommand } from "../../Composer.types"; + +/** + * The commands to suggest for `draft`, or null to keep the panel closed. The + * same rules as the iOS `ComposerSlashMatching`: the draft must be a bare + * trigger token, and a fully typed command that takes arguments closes it. + */ +export function slashSuggestions( + draft: string, + commands: ComposerSlashCommand[], +): ComposerSlashCommand[] | null { + const trigger = draft[0]; + if ((trigger !== "/" && trigger !== "$") || commands.length === 0) { + return null; + } + const query = draft.slice(1); + if (/\s/.test(query)) return null; + const needle = query.toLowerCase(); + + const committed = commands.some( + (command) => + command.trigger === trigger && + (command.name.toLowerCase() === needle || + command.aliases?.some((alias) => alias.toLowerCase() === needle)) && + (command.argumentHint ?? "").trim().length > 0, + ); + if (committed) return null; + + const matches = commands + .filter((command) => command.trigger === trigger) + .map((command) => ({ command, rank: commandRank(command, needle) })) + .filter( + (match): match is { command: ComposerSlashCommand; rank: number } => + match.rank !== null, + ) + .sort((a, b) => { + if (a.rank !== b.rank) return a.rank - b.rank; + if (!!a.command.isBuiltin !== !!b.command.isBuiltin) { + return a.command.isBuiltin ? 1 : -1; + } + return a.command.name < b.command.name + ? -1 + : a.command.name > b.command.name + ? 1 + : 0; + }) + .map((match) => match.command); + return matches.length > 0 ? matches : null; +} + +/** What selecting `command` puts in the draft. */ +export function committedSlashDraft(command: ComposerSlashCommand): string { + return `${command.trigger}${command.name} `; +} + +function nameRank(name: string, query: string): number | null { + if (query === "") return 0; + const lower = name.toLowerCase(); + if (lower === query) return 0; + if (lower.startsWith(query)) return 1; + if (lower.includes(query)) return 2; + return null; +} + +function commandRank( + command: ComposerSlashCommand, + query: string, +): number | null { + const rank = nameRank(command.name, query); + if (rank !== null) return rank; + const aliasRanks = (command.aliases ?? []) + .map((alias) => nameRank(alias, query)) + .filter((value): value is number => value !== null); + return aliasRanks.length > 0 ? Math.min(...aliasRanks) + 3 : null; +} diff --git a/apps/mobile/modules/composer/src/utils/symbolIcon/index.ts b/apps/mobile/modules/composer/src/utils/symbolIcon/index.ts new file mode 100644 index 00000000000..1aed0641513 --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/symbolIcon/index.ts @@ -0,0 +1 @@ +export { symbolIcon } from "./symbolIcon"; diff --git a/apps/mobile/modules/composer/src/utils/symbolIcon/symbolIcon.ts b/apps/mobile/modules/composer/src/utils/symbolIcon/symbolIcon.ts new file mode 100644 index 00000000000..58b0dc38944 --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/symbolIcon/symbolIcon.ts @@ -0,0 +1,68 @@ +import { + ArrowDown, + ArrowLeft, + ArrowRight, + ArrowUp, + Ban, + Circle, + CircleHelp, + ClipboardList, + CornerDownLeft, + Eye, + FileText, + FlaskConical, + GitMerge, + GitPullRequest, + Hand, + LayoutGrid, + Lock, + type LucideIcon, + Map as MapIcon, + Pencil, + Search, + ShieldCheck, + Shuffle, + TestTubes, + ThumbsUp, + TrendingDown, + Zap, +} from "lucide-react-native"; + +const SYMBOL_ICONS: Record = { + "arrow.down": ArrowDown, + "arrow.left": ArrowLeft, + "arrow.right": ArrowRight, + "arrow.up": ArrowUp, + "arrow.triangle.merge": GitMerge, + "arrow.triangle.pull": GitPullRequest, + bolt: Zap, + "bolt.fill": Zap, + "chart.line.downtrend.xyaxis": TrendingDown, + "checkmark.shield": ShieldCheck, + "checkmark.shield.fill": ShieldCheck, + "doc.text": FileText, + eye: Eye, + flask: FlaskConical, + hand: Hand, + "hand.raised": Hand, + "hand.raised.fill": Hand, + "hand.thumbsup": ThumbsUp, + "list.bullet.clipboard": ClipboardList, + lock: Lock, + "lock.fill": Lock, + magnifyingglass: Search, + map: MapIcon, + nosign: Ban, + pencil: Pencil, + "questionmark.circle": CircleHelp, + return: CornerDownLeft, + shield: ShieldCheck, + shuffle: Shuffle, + "square.grid.2x2": LayoutGrid, + "testtube.2": TestTubes, +}; + +/** The lucide icon drawn on Android for an SF Symbol name in the contract. */ +export function symbolIcon(symbol: string): LucideIcon { + return SYMBOL_ICONS[symbol] ?? Circle; +} diff --git a/apps/mobile/modules/forward-swipe/src/ForwardSwipe.ios.tsx b/apps/mobile/modules/forward-swipe/src/ForwardSwipe.ios.tsx new file mode 100644 index 00000000000..f445cae4867 --- /dev/null +++ b/apps/mobile/modules/forward-swipe/src/ForwardSwipe.ios.tsx @@ -0,0 +1,22 @@ +import { requireNativeView } from "expo"; +import type { ForwardSwipeComponent } from "./ForwardSwipe.types"; + +interface NativeForwardSwipeProps { + enabled: boolean; + onSwipeBegin: () => void; + style: { width: number; height: number }; +} + +const NativeForwardSwipe = + requireNativeView("ForwardSwipe"); + +export const ForwardSwipe: ForwardSwipeComponent = ({ + enabled, + onSwipeBegin, +}) => ( + +); diff --git a/apps/mobile/modules/forward-swipe/src/ForwardSwipe.tsx b/apps/mobile/modules/forward-swipe/src/ForwardSwipe.tsx new file mode 100644 index 00000000000..737abb84bd5 --- /dev/null +++ b/apps/mobile/modules/forward-swipe/src/ForwardSwipe.tsx @@ -0,0 +1,38 @@ +import { View } from "react-native"; +import { GestureDetector, usePanGesture } from "react-native-gesture-handler"; +import type { ForwardSwipeComponent } from "./ForwardSwipe.types"; + +// Gesture navigation keeps the outer ~24dp for system Back. +const EDGE_WIDTH = 40; +const ACTIVATE_AFTER = 16; + +export const ForwardSwipe: ForwardSwipeComponent = ({ + enabled, + onSwipeBegin, +}) => { + // A 1px view with hit slop, not a 40dp strip: taps under the edge still + // reach the content, and only a leftward pan claims the touch. + const pan = usePanGesture({ + enabled, + runOnJS: true, + hitSlop: { left: EDGE_WIDTH }, + activeOffsetX: -ACTIVATE_AFTER, + failOffsetY: [-ACTIVATE_AFTER, ACTIVATE_AFTER], + onActivate: () => onSwipeBegin(), + }); + return ( + + + + ); +}; diff --git a/apps/mobile/modules/forward-swipe/src/ForwardSwipe.types.ts b/apps/mobile/modules/forward-swipe/src/ForwardSwipe.types.ts new file mode 100644 index 00000000000..d6493d65723 --- /dev/null +++ b/apps/mobile/modules/forward-swipe/src/ForwardSwipe.types.ts @@ -0,0 +1,12 @@ +import type { ComponentType } from "react"; + +export interface ForwardSwipeProps { + enabled: boolean; + onSwipeBegin: () => void; +} + +/** + * A pan from the enclosing screen's right edge calls `onSwipeBegin`. The + * screen that handler pushes then follows the finger. + */ +export type ForwardSwipeComponent = ComponentType; diff --git a/apps/mobile/modules/forward-swipe/src/index.tsx b/apps/mobile/modules/forward-swipe/src/index.tsx index 18ff380a74a..ee58e6a2a52 100644 --- a/apps/mobile/modules/forward-swipe/src/index.tsx +++ b/apps/mobile/modules/forward-swipe/src/index.tsx @@ -1,30 +1,2 @@ -import { requireNativeView } from "expo"; - -interface NativeForwardSwipeProps { - enabled: boolean; - onSwipeBegin: () => void; - style: { width: number; height: number }; -} - -const NativeForwardSwipe = - requireNativeView("ForwardSwipe"); - -/** - * A pan from the enclosing screen's right edge calls `onSwipeBegin`. The - * screen that handler pushes then follows the finger. - */ -export function ForwardSwipe({ - enabled, - onSwipeBegin, -}: { - enabled: boolean; - onSwipeBegin: () => void; -}) { - return ( - - ); -} +export { ForwardSwipe } from "./ForwardSwipe"; +export type { ForwardSwipeProps } from "./ForwardSwipe.types"; diff --git a/apps/mobile/modules/google-credential/.gitignore b/apps/mobile/modules/google-credential/.gitignore new file mode 100644 index 00000000000..0e1b04e90ed --- /dev/null +++ b/apps/mobile/modules/google-credential/.gitignore @@ -0,0 +1 @@ +android/build/ diff --git a/apps/mobile/modules/google-credential/android/build.gradle b/apps/mobile/modules/google-credential/android/build.gradle new file mode 100644 index 00000000000..957793a209d --- /dev/null +++ b/apps/mobile/modules/google-credential/android/build.gradle @@ -0,0 +1,21 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'sh.superset.mobile' +version = '0.0.1' + +expoModule { + v2 true +} + +android { + namespace "expo.modules.googlecredential" +} + +dependencies { + implementation "androidx.credentials:credentials:1.5.0" + implementation "androidx.credentials:credentials-play-services-auth:1.5.0" + implementation "com.google.android.libraries.identity.googleid:googleid:1.1.1" +} diff --git a/apps/mobile/modules/google-credential/android/src/main/java/expo/modules/googlecredential/GoogleCredentialModule.kt b/apps/mobile/modules/google-credential/android/src/main/java/expo/modules/googlecredential/GoogleCredentialModule.kt new file mode 100644 index 00000000000..63f41e34682 --- /dev/null +++ b/apps/mobile/modules/google-credential/android/src/main/java/expo/modules/googlecredential/GoogleCredentialModule.kt @@ -0,0 +1,46 @@ +package expo.modules.googlecredential + +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.NoCredentialException +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import expo.modules.kotlin.exception.CodedException +import expo.modules.kotlin.exception.Exceptions +import expo.modules.kotlin.functions.Coroutine +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class GoogleCredentialModule : Module() { + override fun definition() = ModuleDefinition { + Name("GoogleCredential") + + AsyncFunction("getIdToken") Coroutine { serverClientId: String, nonce: String -> + val activity = appContext.currentActivity ?: throw Exceptions.MissingActivity() + val option = GetGoogleIdOption.Builder() + .setServerClientId(serverClientId) + .setFilterByAuthorizedAccounts(false) + .setNonce(nonce) + .build() + val request = GetCredentialRequest.Builder().addCredentialOption(option).build() + val credential = try { + CredentialManager.create(activity).getCredential(activity, request).credential + } catch (e: GetCredentialCancellationException) { + throw CodedException("ERR_CANCELED", "The user canceled Google sign-in", e) + } catch (e: NoCredentialException) { + throw CodedException("ERR_NO_CREDENTIAL", "No Google account is available", e) + } catch (e: GetCredentialException) { + throw CodedException("ERR_GOOGLE_CREDENTIAL", e.message ?: e.type, e) + } + if (credential !is CustomCredential || + credential.type != GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL + ) { + throw CodedException("ERR_GOOGLE_CREDENTIAL", "Unexpected credential type ${credential.type}", null) + } + GoogleIdTokenCredential.createFrom(credential.data).idToken + } + } +} diff --git a/apps/mobile/modules/google-credential/expo-module.config.json b/apps/mobile/modules/google-credential/expo-module.config.json new file mode 100644 index 00000000000..15f84be1e34 --- /dev/null +++ b/apps/mobile/modules/google-credential/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["expo.modules.googlecredential.GoogleCredentialModule"] + } +} diff --git a/apps/mobile/modules/google-credential/package.json b/apps/mobile/modules/google-credential/package.json new file mode 100644 index 00000000000..185bbe3e68a --- /dev/null +++ b/apps/mobile/modules/google-credential/package.json @@ -0,0 +1,10 @@ +{ + "name": "@superset/google-credential", + "version": "0.0.1", + "main": "src/index.ts", + "private": true, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } +} diff --git a/apps/mobile/modules/google-credential/src/index.ts b/apps/mobile/modules/google-credential/src/index.ts new file mode 100644 index 00000000000..00e62824667 --- /dev/null +++ b/apps/mobile/modules/google-credential/src/index.ts @@ -0,0 +1,8 @@ +import { requireNativeModule } from "expo"; + +interface GoogleCredentialModule { + getIdToken(serverClientId: string, nonce: string): Promise; +} + +export const GoogleCredential = + requireNativeModule("GoogleCredential"); diff --git a/apps/mobile/modules/live-activity/.gitignore b/apps/mobile/modules/live-activity/.gitignore new file mode 100644 index 00000000000..0e1b04e90ed --- /dev/null +++ b/apps/mobile/modules/live-activity/.gitignore @@ -0,0 +1 @@ +android/build/ diff --git a/apps/mobile/modules/live-activity/android/build.gradle b/apps/mobile/modules/live-activity/android/build.gradle new file mode 100644 index 00000000000..dd438bd2388 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/build.gradle @@ -0,0 +1,19 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'sh.superset' +version = '0.0.1' + +expoModule { + v2 true +} + +android { + namespace "sh.superset.liveactivity" + defaultConfig { + versionCode 1 + versionName "0.0.1" + } +} diff --git a/apps/mobile/modules/live-activity/android/src/main/AndroidManifest.xml b/apps/mobile/modules/live-activity/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..c750be0985e --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/apps/mobile/modules/live-activity/android/src/main/java/sh/superset/liveactivity/AgentCard.kt b/apps/mobile/modules/live-activity/android/src/main/java/sh/superset/liveactivity/AgentCard.kt new file mode 100644 index 00000000000..56db8acef05 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/java/sh/superset/liveactivity/AgentCard.kt @@ -0,0 +1,221 @@ +package sh.superset.liveactivity + +import android.app.AlarmManager +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import android.graphics.Color +import android.net.Uri +import android.os.Build +import android.os.SystemClock +import android.view.View +import android.widget.RemoteViews +import androidx.core.app.NotificationManagerCompat +import java.io.File + +/** + * The Android form of the Lock Screen card: one ongoing notification. On + * Android 16+ it asks to be promoted to a Live Update, which pins it to the + * top of the shade and the Lock Screen and adds a status bar chip. + */ +internal object AgentCard { + const val TAG = "agent-card" + const val NOTIFICATION_ID = 1 + const val EXTRA_STALE = "stale" + /** Notification large icons draw at 48dp; 144px covers xxxhdpi at 3x. */ + const val ICON_PX = 144 + private const val CHANNEL_ID = "agent-status" + private const val SEPARATOR = " · " + /** Android shows at most three notification actions. */ + private const val MAX_ACTIONS = 3 + + private val stateColor = mapOf( + "permission" to Color.rgb(228, 182, 92), + "failed" to Color.rgb(228, 124, 117), + "review" to Color.rgb(115, 191, 136), + ) + + fun iconsDir(context: Context): File = + File(context.cacheDir, "agent-card-icons").apply { mkdirs() } + + fun isEnabled(context: Context): Boolean { + val manager = NotificationManagerCompat.from(context) + if (!manager.areNotificationsEnabled()) return false + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true + val channel = manager.getNotificationChannel(CHANNEL_ID) ?: return true + return channel.importance != NotificationManager.IMPORTANCE_NONE + } + + fun activeIds(context: Context): List { + val manager = context.getSystemService(NotificationManager::class.java) + return manager.activeNotifications + .filter { it.tag == TAG && it.id == NOTIFICATION_ID } + .map { TAG } + } + + fun post(context: Context, snapshot: AgentSnapshotRecord): String { + ensureChannel(context) + context.getSystemService(NotificationManager::class.java) + .notify(TAG, NOTIFICATION_ID, build(context, snapshot, stale = false)) + scheduleStale(context, snapshot) + return TAG + } + + fun end(context: Context) { + context.getSystemService(AlarmManager::class.java).cancel(staleIntent(context, null)) + NotificationManagerCompat.from(context).cancel(TAG, NOTIFICATION_ID) + } + + /** Never brings back a card the user swiped away or the app ended. */ + fun showStale(context: Context, notification: Notification) { + if (activeIds(context).isEmpty()) return + context.getSystemService(NotificationManager::class.java) + .notify(TAG, NOTIFICATION_ID, notification) + } + + private fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + // LOW: the card changes every few seconds and must never sound. A Live + // Update cannot use MIN. + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.agent_card_channel), + NotificationManager.IMPORTANCE_LOW, + ).apply { setShowBadge(false) } + context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + /** + * Updates come only from the app in the foreground, so the "Not updating" + * form is built now and posted by an alarm if no newer post replaces it. + */ + private fun scheduleStale(context: Context, snapshot: AgentSnapshotRecord) { + val alarms = context.getSystemService(AlarmManager::class.java) + if (snapshot.staleDetail.isNullOrEmpty() || snapshot.staleAfterSeconds <= 0) { + alarms.cancel(staleIntent(context, null)) + return + } + val stale = build(context, snapshot, stale = true) + val at = SystemClock.elapsedRealtime() + (snapshot.staleAfterSeconds * 1000).toLong() + alarms.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME, at, staleIntent(context, stale)) + } + + private fun staleIntent(context: Context, stale: Notification?): PendingIntent { + val intent = Intent(context, StaleCardReceiver::class.java) + stale?.let { intent.putExtra(EXTRA_STALE, it) } + return PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun build(context: Context, snapshot: AgentSnapshotRecord, stale: Boolean): Notification { + val rows = snapshot.rows + val top = rows.firstOrNull() + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(context, CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(context) + } + val title = top?.let { "${it.status}$SEPARATOR${it.name}" } ?: "" + val topMeta = top?.let { listOf(it.project, it.elapsed).filter(String::isNotEmpty) } + ?.joinToString(SEPARATOR) ?: "" + val collapsed = if (rows.size <= 1) { + topMeta + } else { + (rows.drop(1).joinToString(", ") { it.name } + (snapshot.more?.let { " $it" } ?: "")) + } + + builder + .setSmallIcon(R.drawable.agent_card_mark) + .setContentTitle(title) + .setContentText(collapsed) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(Notification.CATEGORY_STATUS) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setShowWhen(true) + .setWhen(System.currentTimeMillis()) + .setContentIntent(openIntent(context, rows.singleOrNull(), 0)) + if (stale) builder.setSubText(snapshot.staleDetail) + stateColor[snapshot.topState]?.let { builder.setColor(it) } + top?.iconFile?.let { file -> + BitmapFactory.decodeFile(File(iconsDir(context), file).path)?.let(builder::setLargeIcon) + } + + if (canPromote(context)) { + // A custom layout cannot be promoted, so a Live Update keeps the text + // template and gives the top rows their own tap targets as actions. + val others = rows.drop(1).map { listOf(it.status, it.name, it.elapsed).joinToString(SEPARATOR) } + val expanded = (listOf(topMeta) + others + listOfNotNull(snapshot.more)) + .filter(String::isNotEmpty) + .joinToString("\n") + builder.setStyle(Notification.BigTextStyle().bigText(expanded)) + if (rows.size > 1) { + rows.take(MAX_ACTIONS).forEachIndexed { index, row -> + builder.addAction( + Notification.Action.Builder(null, row.name, openIntent(context, row, index + 1)).build(), + ) + } + } + builder.setRequestPromotedOngoing(true) + top?.let { builder.setShortCriticalText(if (stale) snapshot.staleDetail else it.status) } + } else { + builder + .setStyle(Notification.DecoratedCustomViewStyle()) + .setCustomBigContentView(rowsView(context, snapshot)) + } + return builder.build() + } + + private fun canPromote(context: Context): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA && + context.getSystemService(NotificationManager::class.java).canPostPromotedNotifications() + + private fun rowsView(context: Context, snapshot: AgentSnapshotRecord): RemoteViews { + val view = RemoteViews(context.packageName, R.layout.agent_card_rows) + snapshot.rows.forEachIndexed { index, row -> + val rowView = RemoteViews(context.packageName, R.layout.agent_card_row).apply { + setTextViewText(R.id.agent_card_row_status, row.status) + stateColor[row.state]?.let { setTextColor(R.id.agent_card_row_status, it) } + setTextViewText(R.id.agent_card_row_name, row.name) + setTextViewText(R.id.agent_card_row_project, row.project) + setViewVisibility( + R.id.agent_card_row_project, + if (row.project.isEmpty()) View.GONE else View.VISIBLE, + ) + setTextViewText(R.id.agent_card_row_elapsed, row.elapsed) + setOnClickPendingIntent(R.id.agent_card_row, openIntent(context, row, index + 1)) + } + view.addView(R.id.agent_card_rows, rowView) + } + snapshot.more?.let { + view.setTextViewText(R.id.agent_card_more, it) + view.setViewVisibility(R.id.agent_card_more, View.VISIBLE) + } + return view + } + + /** Opens the row's session, or Home when the tap is not on one agent. */ + private fun openIntent(context: Context, row: AgentRowRecord?, requestCode: Int): PendingIntent { + val uri = if (row != null) { + Uri.parse("superset:///workspace/${Uri.encode(row.workspaceId)}?tab=${Uri.encode(row.id)}") + } else { + Uri.parse("superset:///") + } + val intent = Intent(Intent.ACTION_VIEW, uri).setPackage(context.packageName) + return PendingIntent.getActivity( + context, + requestCode, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } +} diff --git a/apps/mobile/modules/live-activity/android/src/main/java/sh/superset/liveactivity/LiveActivityModule.kt b/apps/mobile/modules/live-activity/android/src/main/java/sh/superset/liveactivity/LiveActivityModule.kt new file mode 100644 index 00000000000..4b806259df0 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/java/sh/superset/liveactivity/LiveActivityModule.kt @@ -0,0 +1,91 @@ +package sh.superset.liveactivity + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import expo.modules.kotlin.exception.CodedException +import expo.modules.kotlin.functions.Coroutine +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.records.Field +import expo.modules.kotlin.records.Record +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.net.URL + +class AgentRowRecord : Record { + @Field var id: String = "" + @Field var workspaceId: String = "" + @Field var name: String = "" + @Field var project: String = "" + @Field var iconFile: String? = null + @Field var status: String = "" + @Field var state: String = "working" + @Field var elapsed: String = "" + @Field var isQuiet: Boolean = false +} + +class AgentSnapshotRecord : Record { + @Field var rows: List = emptyList() + @Field var more: String? = null + @Field var totalCount: Int = 0 + @Field var topState: String = "working" + @Field var staleDetail: String? = null + @Field var machineName: String = "" + @Field var staleAfterSeconds: Double = 0.0 +} + +private class DisabledException : + CodedException("ERR_DISABLED", "Notifications are off", null) + +private class BadKeyException(key: String) : + CodedException("ERR_BAD_KEY", key, null) + +private class DecodeException : + CodedException("ERR_DECODE", "not an image", null) + +class LiveActivityModule : Module() { + private val context: Context + get() = appContext.reactContext ?: throw IllegalStateException("No React context") + + override fun definition() = ModuleDefinition { + Name("LiveActivity") + + Function("areActivitiesEnabled") { AgentCard.isEnabled(context) } + + Function("activeIds") { AgentCard.activeIds(context) } + + AsyncFunction("cacheIcon") Coroutine { key: String, url: String -> + // The key arrives from JS: a separator or a traversal component would + // write outside the icons directory. + val safe = key.replace(Regex("[^A-Za-z0-9._-]"), "_") + if (safe.isEmpty() || safe == "." || safe == "..") throw BadKeyException(key) + val file = "$safe.png" + val target = File(AgentCard.iconsDir(context), file) + if (target.exists()) return@Coroutine file + withContext(Dispatchers.IO) { + val source = URL(url).openStream().use { BitmapFactory.decodeStream(it) } + ?: throw DecodeException() + val side = AgentCard.ICON_PX + val scaled = Bitmap.createScaledBitmap(source, side, side, true) + val partial = File(target.parentFile, "$file.part") + FileOutputStream(partial).use { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) } + partial.renameTo(target) + } + file + } + + AsyncFunction("start") { snapshot: AgentSnapshotRecord -> + if (!AgentCard.isEnabled(context)) throw DisabledException() + AgentCard.post(context, snapshot) + } + + AsyncFunction("update") { id: String, snapshot: AgentSnapshotRecord -> + if (id in AgentCard.activeIds(context)) AgentCard.post(context, snapshot) + } + + AsyncFunction("endAll") { AgentCard.end(context) } + } +} diff --git a/apps/mobile/modules/live-activity/android/src/main/java/sh/superset/liveactivity/StaleCardReceiver.kt b/apps/mobile/modules/live-activity/android/src/main/java/sh/superset/liveactivity/StaleCardReceiver.kt new file mode 100644 index 00000000000..9075ceaccee --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/java/sh/superset/liveactivity/StaleCardReceiver.kt @@ -0,0 +1,20 @@ +package sh.superset.liveactivity + +import android.app.Notification +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build + +/** Swaps the card for its "Not updating" form when the app stops feeding it. */ +class StaleCardReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val stale = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(AgentCard.EXTRA_STALE, Notification::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(AgentCard.EXTRA_STALE) + } ?: return + AgentCard.showStale(context, stale) + } +} diff --git a/apps/mobile/modules/live-activity/android/src/main/res/drawable-hdpi/agent_card_mark.png b/apps/mobile/modules/live-activity/android/src/main/res/drawable-hdpi/agent_card_mark.png new file mode 100644 index 00000000000..8df1558d5e4 Binary files /dev/null and b/apps/mobile/modules/live-activity/android/src/main/res/drawable-hdpi/agent_card_mark.png differ diff --git a/apps/mobile/modules/live-activity/android/src/main/res/drawable-mdpi/agent_card_mark.png b/apps/mobile/modules/live-activity/android/src/main/res/drawable-mdpi/agent_card_mark.png new file mode 100644 index 00000000000..5d751590942 Binary files /dev/null and b/apps/mobile/modules/live-activity/android/src/main/res/drawable-mdpi/agent_card_mark.png differ diff --git a/apps/mobile/modules/live-activity/android/src/main/res/drawable-xhdpi/agent_card_mark.png b/apps/mobile/modules/live-activity/android/src/main/res/drawable-xhdpi/agent_card_mark.png new file mode 100644 index 00000000000..dd0d354107b Binary files /dev/null and b/apps/mobile/modules/live-activity/android/src/main/res/drawable-xhdpi/agent_card_mark.png differ diff --git a/apps/mobile/modules/live-activity/android/src/main/res/drawable-xxhdpi/agent_card_mark.png b/apps/mobile/modules/live-activity/android/src/main/res/drawable-xxhdpi/agent_card_mark.png new file mode 100644 index 00000000000..37238b53e2b Binary files /dev/null and b/apps/mobile/modules/live-activity/android/src/main/res/drawable-xxhdpi/agent_card_mark.png differ diff --git a/apps/mobile/modules/live-activity/android/src/main/res/drawable-xxxhdpi/agent_card_mark.png b/apps/mobile/modules/live-activity/android/src/main/res/drawable-xxxhdpi/agent_card_mark.png new file mode 100644 index 00000000000..6aef3493f1b Binary files /dev/null and b/apps/mobile/modules/live-activity/android/src/main/res/drawable-xxxhdpi/agent_card_mark.png differ diff --git a/apps/mobile/modules/live-activity/android/src/main/res/layout/agent_card_row.xml b/apps/mobile/modules/live-activity/android/src/main/res/layout/agent_card_row.xml new file mode 100644 index 00000000000..eb00e3c3e06 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/layout/agent_card_row.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/layout/agent_card_rows.xml b/apps/mobile/modules/live-activity/android/src/main/res/layout/agent_card_rows.xml new file mode 100644 index 00000000000..ed3d8b3a424 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/layout/agent_card_rows.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-cs/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-cs/strings.xml new file mode 100644 index 00000000000..57159e1bda1 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-cs/strings.xml @@ -0,0 +1,4 @@ + + + Stav agentů + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-de/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-de/strings.xml new file mode 100644 index 00000000000..15ec1652739 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-de/strings.xml @@ -0,0 +1,4 @@ + + + Agentenstatus + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-es/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-es/strings.xml new file mode 100644 index 00000000000..b258f2e79b3 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-es/strings.xml @@ -0,0 +1,4 @@ + + + Estado de los agentes + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-fr/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-fr/strings.xml new file mode 100644 index 00000000000..79745b7f3a3 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-fr/strings.xml @@ -0,0 +1,4 @@ + + + État des agents + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-in/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-in/strings.xml new file mode 100644 index 00000000000..87098bf7ec4 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-in/strings.xml @@ -0,0 +1,4 @@ + + + Status agen + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-it/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-it/strings.xml new file mode 100644 index 00000000000..a23773cf143 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-it/strings.xml @@ -0,0 +1,4 @@ + + + Stato degli agenti + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-ja/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-ja/strings.xml new file mode 100644 index 00000000000..73ec6cc4709 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-ja/strings.xml @@ -0,0 +1,4 @@ + + + エージェントの状態 + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-ko/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-ko/strings.xml new file mode 100644 index 00000000000..290ee4961ed --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-ko/strings.xml @@ -0,0 +1,4 @@ + + + 에이전트 상태 + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-nl/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-nl/strings.xml new file mode 100644 index 00000000000..9bb4785237d --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-nl/strings.xml @@ -0,0 +1,4 @@ + + + Agentstatus + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-pl/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-pl/strings.xml new file mode 100644 index 00000000000..c048588e002 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-pl/strings.xml @@ -0,0 +1,4 @@ + + + Stan agentów + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-pt-rBR/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 00000000000..537feb2ee1a --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,4 @@ + + + Status dos agentes + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-ru/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-ru/strings.xml new file mode 100644 index 00000000000..a02632e6806 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-ru/strings.xml @@ -0,0 +1,4 @@ + + + Статус агентов + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-tr/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-tr/strings.xml new file mode 100644 index 00000000000..1c4c6d84b0f --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-tr/strings.xml @@ -0,0 +1,4 @@ + + + Ajan durumu + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-vi/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-vi/strings.xml new file mode 100644 index 00000000000..708b4237cd3 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-vi/strings.xml @@ -0,0 +1,4 @@ + + + Trạng thái agent + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-zh-rCN/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 00000000000..44d70a8672c --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,4 @@ + + + 智能体状态 + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-zh-rTW/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 00000000000..a03982be117 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,4 @@ + + + 代理程式狀態 + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values/strings.xml new file mode 100644 index 00000000000..35eafdfefaa --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Agent status + diff --git a/apps/mobile/modules/live-activity/expo-module.config.json b/apps/mobile/modules/live-activity/expo-module.config.json index d76a075f733..c71615ff0fb 100644 --- a/apps/mobile/modules/live-activity/expo-module.config.json +++ b/apps/mobile/modules/live-activity/expo-module.config.json @@ -1,4 +1,5 @@ { - "platforms": ["apple"], - "apple": { "modules": ["LiveActivityModule"] } + "platforms": ["apple", "android"], + "apple": { "modules": ["LiveActivityModule"] }, + "android": { "modules": ["sh.superset.liveactivity.LiveActivityModule"] } } diff --git a/apps/mobile/modules/live-activity/src/LiveActivity.native.ts b/apps/mobile/modules/live-activity/src/LiveActivity.native.ts new file mode 100644 index 00000000000..9076bca2205 --- /dev/null +++ b/apps/mobile/modules/live-activity/src/LiveActivity.native.ts @@ -0,0 +1,4 @@ +import { requireNativeModule } from "expo"; +import type { LiveActivityModule } from "./LiveActivity.types"; + +export default requireNativeModule("LiveActivity"); diff --git a/apps/mobile/modules/live-activity/src/LiveActivity.ts b/apps/mobile/modules/live-activity/src/LiveActivity.ts new file mode 100644 index 00000000000..69fc1ee52d4 --- /dev/null +++ b/apps/mobile/modules/live-activity/src/LiveActivity.ts @@ -0,0 +1,12 @@ +import type { LiveActivityModule } from "./LiveActivity.types"; + +const LiveActivity: LiveActivityModule = { + areActivitiesEnabled: () => false, + activeIds: () => [], + cacheIcon: async () => "", + start: async () => "", + update: async () => {}, + endAll: async () => {}, +}; + +export default LiveActivity; diff --git a/apps/mobile/modules/live-activity/src/LiveActivity.types.ts b/apps/mobile/modules/live-activity/src/LiveActivity.types.ts new file mode 100644 index 00000000000..0948c063355 --- /dev/null +++ b/apps/mobile/modules/live-activity/src/LiveActivity.types.ts @@ -0,0 +1,42 @@ +export interface AgentRow { + /** Terminal id — selected as `?tab=` on the workspace route. */ + id: string; + /** Workspace the terminal belongs to; the deep link's real target. */ + workspaceId: string; + name: string; + project: string; + /** Filename returned by `cacheIcon`, or omitted to draw the initial. */ + iconFile?: string; + /** Pre-translated status word, e.g. "Needs you". */ + status: string; + state: "permission" | "working" | "failed" | "review"; + /** + * Time in the current state, already formatted — "12m", "1h", "3d". + * Formatted here rather than natively: SwiftUI's free-ticking timer can + * only render mm:ss, and units belong in Lingui's catalogs. + */ + elapsed: string; + isQuiet?: boolean; +} + +export interface AgentSnapshot { + rows: AgentRow[]; + more?: string; + /** Every agent, not just the rows that fit on the card. */ + totalCount: number; + topState: AgentRow["state"]; + staleDetail?: string; + machineName: string; + staleAfterSeconds?: number; +} + +/** A glanceable, system-drawn card of agent status outside the app. */ +export interface LiveActivityModule { + areActivitiesEnabled: () => boolean; + activeIds: () => string[]; + /** Downloads, downscales and caches a project icon where the card can read it. */ + cacheIcon: (key: string, url: string) => Promise; + start: (snapshot: AgentSnapshot) => Promise; + update: (id: string, snapshot: AgentSnapshot) => Promise; + endAll: () => Promise; +} diff --git a/apps/mobile/modules/live-activity/src/index.ts b/apps/mobile/modules/live-activity/src/index.ts index ba2ff5e3f21..fc017bf462e 100644 --- a/apps/mobile/modules/live-activity/src/index.ts +++ b/apps/mobile/modules/live-activity/src/index.ts @@ -1,66 +1,7 @@ -import { requireNativeModule } from "expo"; - -export interface AgentRow { - /** Terminal id — selected as `?tab=` on the workspace route. */ - id: string; - /** Workspace the terminal belongs to; the deep link's real target. */ - workspaceId: string; - name: string; - project: string; - /** Filename returned by `cacheIcon`, or omitted to draw the initial. */ - iconFile?: string; - /** Pre-translated status word, e.g. "Needs you". */ - status: string; - state: "permission" | "working" | "failed" | "review"; - /** - * Time in the current state, already formatted — "12m", "1h", "3d". - * Formatted here rather than in Swift: SwiftUI's free-ticking timer can - * only render mm:ss, and units belong in Lingui's catalogs. - */ - elapsed: string; - isQuiet?: boolean; -} - -export interface AgentSnapshot { - rows: AgentRow[]; - more?: string; - /** Every agent, not just the rows that fit on the card. */ - totalCount: number; - topState: AgentRow["state"]; - staleDetail?: string; - machineName: string; - staleAfterSeconds?: number; -} - -/** - * Row order for the card, most urgent first. - * - * Deliberately NOT desktop's `STATUS_PRIORITY`, which ranks `working` above - * `review`. On a glanceable card the question is "what wants me?", and a - * finished session waiting to be read wants you more than a busy one that - * does not. Rows are sorted by this, then by recency. - */ -export const CARD_PRIORITY: Record = { - permission: 4, - failed: 3, - review: 2, - working: 1, -}; - -export function orderRows(rows: AgentRow[]): AgentRow[] { - return [...rows].sort( - (a, b) => CARD_PRIORITY[b.state] - CARD_PRIORITY[a.state], - ); -} - -interface LiveActivityModule { - areActivitiesEnabled: () => boolean; - activeIds: () => string[]; - /** Downloads, downscales and caches a project icon into the App Group. */ - cacheIcon: (key: string, url: string) => Promise; - start: (snapshot: AgentSnapshot) => Promise; - update: (id: string, snapshot: AgentSnapshot) => Promise; - endAll: () => Promise; -} - -export default requireNativeModule("LiveActivity"); +export { default } from "./LiveActivity"; +export type { + AgentRow, + AgentSnapshot, + LiveActivityModule, +} from "./LiveActivity.types"; +export { CARD_PRIORITY, orderRows } from "./orderRows"; diff --git a/apps/mobile/modules/live-activity/src/orderRows.ts b/apps/mobile/modules/live-activity/src/orderRows.ts new file mode 100644 index 00000000000..2cbe3fc3d25 --- /dev/null +++ b/apps/mobile/modules/live-activity/src/orderRows.ts @@ -0,0 +1,22 @@ +import type { AgentRow } from "./LiveActivity.types"; + +/** + * Row order for the card, most urgent first. + * + * Deliberately NOT desktop's `STATUS_PRIORITY`, which ranks `working` above + * `review`. On a glanceable card the question is "what wants me?", and a + * finished session waiting to be read wants you more than a busy one that + * does not. Rows are sorted by this, then by recency. + */ +export const CARD_PRIORITY: Record = { + permission: 4, + failed: 3, + review: 2, + working: 1, +}; + +export function orderRows(rows: AgentRow[]): AgentRow[] { + return [...rows].sort( + (a, b) => CARD_PRIORITY[b.state] - CARD_PRIORITY[a.state], + ); +} diff --git a/apps/mobile/modules/symbol-button/src/SymbolButton.android.tsx b/apps/mobile/modules/symbol-button/src/SymbolButton.android.tsx new file mode 100644 index 00000000000..e9bf68b7971 --- /dev/null +++ b/apps/mobile/modules/symbol-button/src/SymbolButton.android.tsx @@ -0,0 +1,63 @@ +import MenuView from "@expo/ui/community/menu"; +import { Pressable, StyleSheet, View } from "react-native"; +import { useUniwind } from "uniwind"; +import { SymbolButtonGlyph } from "./components/SymbolButtonGlyph"; +import type { SymbolButtonComponent } from "./SymbolButton.types"; + +const DEFAULT_SIZE = 17; + +export const SymbolButton: SymbolButtonComponent = ({ + systemImage, + size = DEFAULT_SIZE, + tint, + accessibilityLabel, + enabled = true, + items, + onPress, + onSelect, + style, +}) => { + const { theme } = useUniwind(); + const content = ( + + ); + + if (!items || !enabled) { + return ( + + {content} + + ); + } + + const { width, height } = StyleSheet.flatten(style) ?? {}; + return ( + ({ id: item.id, title: item.title }))} + onPressAction={({ nativeEvent }) => onSelect?.(nativeEvent.event)} + > + + {content} + + + ); +}; diff --git a/apps/mobile/modules/symbol-button/src/SymbolButton.ios.tsx b/apps/mobile/modules/symbol-button/src/SymbolButton.ios.tsx new file mode 100644 index 00000000000..a7ebd57f2ec --- /dev/null +++ b/apps/mobile/modules/symbol-button/src/SymbolButton.ios.tsx @@ -0,0 +1,28 @@ +import { requireNativeView } from "expo"; +import type { + SymbolButtonComponent, + SymbolButtonProps, +} from "./SymbolButton.types"; + +type NativeSymbolButtonProps = Omit< + SymbolButtonProps, + "onPress" | "onSelect" +> & { + onTap?: () => void; + onSelect?: (event: { nativeEvent: { id: string } }) => void; +}; + +const NativeSymbolButton = + requireNativeView("SymbolButton"); + +export const SymbolButton: SymbolButtonComponent = ({ + onSelect, + onPress, + ...props +}) => ( + onSelect?.(event.nativeEvent.id)} + /> +); diff --git a/apps/mobile/modules/symbol-button/src/SymbolButton.tsx b/apps/mobile/modules/symbol-button/src/SymbolButton.tsx new file mode 100644 index 00000000000..51effc03ad3 --- /dev/null +++ b/apps/mobile/modules/symbol-button/src/SymbolButton.tsx @@ -0,0 +1,75 @@ +import { Pressable } from "react-native"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Icon } from "@/components/ui/icon"; +import { Text } from "@/components/ui/text"; +import { SymbolButtonGlyph } from "./components/SymbolButtonGlyph"; +import { SYMBOL_ICONS } from "./constants"; +import type { SymbolButtonComponent } from "./SymbolButton.types"; + +const DEFAULT_SIZE = 17; + +export const SymbolButton: SymbolButtonComponent = ({ + systemImage, + size = DEFAULT_SIZE, + tint, + accessibilityLabel, + enabled = true, + items, + onPress, + onSelect, + style, +}) => { + const content = ( + + ); + + if (!items) { + return ( + + {content} + + ); + } + + return ( + + + {content} + + + {items.map((item) => { + const itemGlyph = item.systemImage + ? SYMBOL_ICONS[item.systemImage] + : undefined; + return ( + onSelect?.(item.id)}> + {itemGlyph ? : null} + {item.title} + + ); + })} + + + ); +}; diff --git a/apps/mobile/modules/symbol-button/src/SymbolButton.types.ts b/apps/mobile/modules/symbol-button/src/SymbolButton.types.ts new file mode 100644 index 00000000000..e96c6a91f61 --- /dev/null +++ b/apps/mobile/modules/symbol-button/src/SymbolButton.types.ts @@ -0,0 +1,25 @@ +import type { ComponentType } from "react"; +import type { StyleProp, ViewStyle } from "react-native"; + +export interface SymbolMenuItem { + id: string; + title: string; + /** SF Symbol name. */ + systemImage?: string; +} + +export interface SymbolButtonProps { + /** SF Symbol name. */ + systemImage: string; + size?: number; + tint?: string; + accessibilityLabel?: string; + enabled?: boolean; + /** Turns the tap into a pull-down menu of these items. */ + items?: SymbolMenuItem[]; + onPress?: () => void; + onSelect?: (id: string) => void; + style?: StyleProp; +} + +export type SymbolButtonComponent = ComponentType; diff --git a/apps/mobile/modules/symbol-button/src/components/SymbolButtonGlyph/SymbolButtonGlyph.tsx b/apps/mobile/modules/symbol-button/src/components/SymbolButtonGlyph/SymbolButtonGlyph.tsx new file mode 100644 index 00000000000..01f4ed01563 --- /dev/null +++ b/apps/mobile/modules/symbol-button/src/components/SymbolButtonGlyph/SymbolButtonGlyph.tsx @@ -0,0 +1,26 @@ +import { View } from "react-native"; +import { Icon } from "@/components/ui/icon"; +import { SYMBOL_ICONS } from "../../constants"; +import type { SymbolButtonProps } from "../../SymbolButton.types"; + +type SymbolButtonGlyphProps = Pick< + SymbolButtonProps, + "systemImage" | "size" | "tint" | "enabled" +>; + +export function SymbolButtonGlyph({ + systemImage, + size, + tint, + enabled = true, +}: SymbolButtonGlyphProps) { + const glyph = SYMBOL_ICONS[systemImage]; + return ( + + {glyph ? : null} + + ); +} diff --git a/apps/mobile/modules/symbol-button/src/components/SymbolButtonGlyph/index.ts b/apps/mobile/modules/symbol-button/src/components/SymbolButtonGlyph/index.ts new file mode 100644 index 00000000000..d14f62bf94a --- /dev/null +++ b/apps/mobile/modules/symbol-button/src/components/SymbolButtonGlyph/index.ts @@ -0,0 +1 @@ +export { SymbolButtonGlyph } from "./SymbolButtonGlyph"; diff --git a/apps/mobile/modules/symbol-button/src/constants.ts b/apps/mobile/modules/symbol-button/src/constants.ts new file mode 100644 index 00000000000..d9498c33d59 --- /dev/null +++ b/apps/mobile/modules/symbol-button/src/constants.ts @@ -0,0 +1,31 @@ +import { + Ban, + CircleQuestionMark, + Ellipsis, + FlaskConical, + LayoutGrid, + type LucideIcon, + Map as MapIcon, + Search, + Shuffle, + TestTubes, + ThumbsUp, + Trash2, + TrendingDown, +} from "lucide-react-native"; + +/** The SF Symbol names callers pass, drawn with their closest lucide icon. */ +export const SYMBOL_ICONS: Record = { + "chart.line.downtrend.xyaxis": TrendingDown, + ellipsis: Ellipsis, + flask: FlaskConical, + "hand.thumbsup": ThumbsUp, + magnifyingglass: Search, + map: MapIcon, + nosign: Ban, + "questionmark.circle": CircleQuestionMark, + shuffle: Shuffle, + "square.grid.2x2": LayoutGrid, + "testtube.2": TestTubes, + trash: Trash2, +}; diff --git a/apps/mobile/modules/symbol-button/src/index.tsx b/apps/mobile/modules/symbol-button/src/index.tsx index 762eace7447..5b2cc46cebf 100644 --- a/apps/mobile/modules/symbol-button/src/index.tsx +++ b/apps/mobile/modules/symbol-button/src/index.tsx @@ -1,39 +1,2 @@ -import { requireNativeView } from "expo"; -import type { StyleProp, ViewStyle } from "react-native"; - -export interface SymbolMenuItem { - id: string; - title: string; - systemImage?: string; -} - -interface SymbolButtonProps { - systemImage: string; - size?: number; - tint?: string; - accessibilityLabel?: string; - enabled?: boolean; - items?: SymbolMenuItem[]; - onTap?: () => void; - onSelect?: (event: { nativeEvent: { id: string } }) => void; - style?: StyleProp; -} - -const NativeSymbolButton = requireNativeView("SymbolButton"); - -export function SymbolButton({ - onSelect, - onPress, - ...props -}: Omit & { - onSelect?: (id: string) => void; - onPress?: () => void; -}) { - return ( - onSelect?.(event.nativeEvent.id)} - /> - ); -} +export { SymbolButton } from "./SymbolButton"; +export type { SymbolButtonProps, SymbolMenuItem } from "./SymbolButton.types"; diff --git a/apps/mobile/modules/title-press/src/TitlePress.ios.tsx b/apps/mobile/modules/title-press/src/TitlePress.ios.tsx new file mode 100644 index 00000000000..8b6230c9c91 --- /dev/null +++ b/apps/mobile/modules/title-press/src/TitlePress.ios.tsx @@ -0,0 +1,15 @@ +import { requireNativeView } from "expo"; +import type { PressableTitle, TitlePressComponent } from "./TitlePress.types"; + +interface NativeTitlePressProps { + onTitlePress: () => void; + style: { width: number; height: number }; +} + +const NativeTitlePress = requireNativeView("TitlePress"); + +export const TitlePress: TitlePressComponent = ({ onPress }) => ( + +); + +export const pressableTitle: PressableTitle = () => undefined; diff --git a/apps/mobile/modules/title-press/src/TitlePress.tsx b/apps/mobile/modules/title-press/src/TitlePress.tsx new file mode 100644 index 00000000000..1ff9fa13d2c --- /dev/null +++ b/apps/mobile/modules/title-press/src/TitlePress.tsx @@ -0,0 +1,20 @@ +import { Pressable } from "react-native"; +import { Text } from "@/components/ui/text"; +import type { PressableTitle, TitlePressComponent } from "./TitlePress.types"; + +export const TitlePress: TitlePressComponent = () => null; + +export const pressableTitle: PressableTitle = + (onPress) => + ({ children }) => ( + + + {children} + + + ); diff --git a/apps/mobile/modules/title-press/src/TitlePress.types.ts b/apps/mobile/modules/title-press/src/TitlePress.types.ts new file mode 100644 index 00000000000..450dc3fcae6 --- /dev/null +++ b/apps/mobile/modules/title-press/src/TitlePress.types.ts @@ -0,0 +1,17 @@ +import type { NativeStackNavigationOptions } from "expo-router"; +import type { ComponentType } from "react"; + +export interface TitlePressProps { + onPress: () => void; +} + +/** Reports taps on the enclosing native stack screen's title. */ +export type TitlePressComponent = ComponentType; + +/** + * The `headerTitle` to set where the native bar cannot report taps itself. + * Undefined keeps the native title. + */ +export type PressableTitle = ( + onPress: () => void, +) => NativeStackNavigationOptions["headerTitle"]; diff --git a/apps/mobile/modules/title-press/src/index.tsx b/apps/mobile/modules/title-press/src/index.tsx index dcd584d9b1b..8ea6df2ff37 100644 --- a/apps/mobile/modules/title-press/src/index.tsx +++ b/apps/mobile/modules/title-press/src/index.tsx @@ -1,15 +1,6 @@ -import { requireNativeView } from "expo"; - -interface NativeTitlePressProps { - onTitlePress: () => void; - style: { width: number; height: number }; -} - -const NativeTitlePress = requireNativeView("TitlePress"); - -/** Reports taps on the enclosing native stack screen's title. */ -export function TitlePress({ onPress }: { onPress: () => void }) { - return ( - - ); -} +export { pressableTitle, TitlePress } from "./TitlePress"; +export type { + PressableTitle, + TitlePressComponent, + TitlePressProps, +} from "./TitlePress.types"; diff --git a/apps/mobile/modules/voice-android/.gitignore b/apps/mobile/modules/voice-android/.gitignore new file mode 100644 index 00000000000..0e1b04e90ed --- /dev/null +++ b/apps/mobile/modules/voice-android/.gitignore @@ -0,0 +1 @@ +android/build/ diff --git a/apps/mobile/modules/voice-android/android/build.gradle b/apps/mobile/modules/voice-android/android/build.gradle new file mode 100644 index 00000000000..00f19472bec --- /dev/null +++ b/apps/mobile/modules/voice-android/android/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'sh.superset' +version = '0.0.1' + +expoModule { + v2 true +} + +android { + namespace "sh.superset.voiceaudio" +} diff --git a/apps/mobile/modules/voice-android/android/src/main/AndroidManifest.xml b/apps/mobile/modules/voice-android/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..36a19f2d9c4 --- /dev/null +++ b/apps/mobile/modules/voice-android/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/modules/voice-android/android/src/main/java/sh/superset/voiceaudio/VoiceAudioModule.kt b/apps/mobile/modules/voice-android/android/src/main/java/sh/superset/voiceaudio/VoiceAudioModule.kt new file mode 100644 index 00000000000..75904109bac --- /dev/null +++ b/apps/mobile/modules/voice-android/android/src/main/java/sh/superset/voiceaudio/VoiceAudioModule.kt @@ -0,0 +1,181 @@ +package sh.superset.voiceaudio + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import expo.modules.kotlin.exception.Exceptions +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +/** + * Owns the audio route for the length of a voice call, as `VoiceAudioModule` + * does on iOS: communication mode, audio focus, the speaker unless a headset + * is connected, and events when Android takes the audio or the route moves. + * Port names follow iOS (`Speaker`, `Receiver`, `BluetoothHFP`, ...). + */ +class VoiceAudioModule : Module() { + private val audioManager: AudioManager + get() = (appContext.reactContext ?: throw Exceptions.ReactContextLost()) + .getSystemService(Context.AUDIO_SERVICE) as AudioManager + + private val mainHandler = Handler(Looper.getMainLooper()) + private var focusRequest: AudioFocusRequest? = null + private var focusHeld = false + private var observing = false + private var speakerPreferred = true + + private val focusListener = AudioManager.OnAudioFocusChangeListener { change -> + when (change) { + AudioManager.AUDIOFOCUS_LOSS, + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> + sendEvent("onInterruption", mapOf("phase" to "began", "shouldResume" to false)) + AudioManager.AUDIOFOCUS_GAIN -> + sendEvent("onInterruption", mapOf("phase" to "ended", "shouldResume" to true)) + } + } + + private val deviceCallback = object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array) = + routeChanged("newDeviceAvailable") + + override fun onAudioDevicesRemoved(removedDevices: Array) = + routeChanged("oldDeviceUnavailable") + } + + override fun definition() = ModuleDefinition { + Name("VoiceAudio") + + Events("onInterruption", "onRouteChange") + + AsyncFunction("configure") { + val manager = audioManager + manager.mode = AudioManager.MODE_IN_COMMUNICATION + requestFocus(manager) + speakerPreferred = true + applyRoute(manager) + if (!observing) { + observing = true + manager.registerAudioDeviceCallback(deviceCallback, mainHandler) + } + } + + AsyncFunction("preferSpeaker") { on: Boolean -> + speakerPreferred = on + applyRoute(audioManager) + } + + AsyncFunction("release") { + releaseAudio() + } + + Function("currentRoute") { + outputs(audioManager) + } + + OnDestroy { + runCatching { releaseAudio() } + } + } + + private fun requestFocus(manager: AudioManager) { + if (focusRequest != null || focusHeld) return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + @Suppress("DEPRECATION") + manager.requestAudioFocus(focusListener, AudioManager.STREAM_VOICE_CALL, AudioManager.AUDIOFOCUS_GAIN) + focusHeld = true + return + } + val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(), + ) + .setOnAudioFocusChangeListener(focusListener, mainHandler) + .build() + manager.requestAudioFocus(request) + focusRequest = request + } + + private fun releaseAudio() { + val manager = audioManager + if (observing) { + observing = false + manager.unregisterAudioDeviceCallback(deviceCallback) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + focusRequest?.let { manager.abandonAudioFocusRequest(it) } + } else if (focusHeld) { + @Suppress("DEPRECATION") + manager.abandonAudioFocus(focusListener) + } + focusRequest = null + focusHeld = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + manager.clearCommunicationDevice() + } else { + @Suppress("DEPRECATION") + manager.isSpeakerphoneOn = false + } + manager.mode = AudioManager.MODE_NORMAL + } + + private fun routeChanged(reason: String) { + val manager = audioManager + if (focusRequest != null || focusHeld) applyRoute(manager) + sendEvent("onRouteChange", mapOf("reason" to reason, "outputs" to outputs(manager))) + } + + private fun applyRoute(manager: AudioManager) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val devices = manager.availableCommunicationDevices + val headset = HEADSET_TYPES.firstNotNullOfOrNull { type -> devices.firstOrNull { it.type == type } } + val fallback = if (speakerPreferred) AudioDeviceInfo.TYPE_BUILTIN_SPEAKER else AudioDeviceInfo.TYPE_BUILTIN_EARPIECE + val target = headset ?: devices.firstOrNull { it.type == fallback } + if (target != null) manager.setCommunicationDevice(target) else manager.clearCommunicationDevice() + } else { + val headset = manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).any { it.type in HEADSET_TYPES } + @Suppress("DEPRECATION") + manager.isSpeakerphoneOn = speakerPreferred && !headset + } + } + + private fun outputs(manager: AudioManager): List { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return listOfNotNull(manager.communicationDevice?.let { portName(it.type) }) + } + @Suppress("DEPRECATION") + if (manager.isSpeakerphoneOn) return listOf("Speaker") + val headset = manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull { it.type in HEADSET_TYPES } + return listOf(headset?.let { portName(it.type) } ?: "Receiver") + } + + private fun portName(type: Int): String = when (type) { + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Speaker" + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "Receiver" + AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BluetoothHFP" + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "BluetoothA2DPOutput" + AudioDeviceInfo.TYPE_BLE_HEADSET -> "BluetoothLE" + AudioDeviceInfo.TYPE_WIRED_HEADSET, AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "Headphones" + AudioDeviceInfo.TYPE_USB_HEADSET -> "USBAudio" + else -> "Unknown" + } + + private companion object { + val HEADSET_TYPES = listOf( + AudioDeviceInfo.TYPE_BLUETOOTH_SCO, + AudioDeviceInfo.TYPE_BLE_HEADSET, + AudioDeviceInfo.TYPE_WIRED_HEADSET, + AudioDeviceInfo.TYPE_WIRED_HEADPHONES, + AudioDeviceInfo.TYPE_USB_HEADSET, + ) + } +} diff --git a/apps/mobile/modules/voice-android/expo-module.config.json b/apps/mobile/modules/voice-android/expo-module.config.json new file mode 100644 index 00000000000..588522d51cf --- /dev/null +++ b/apps/mobile/modules/voice-android/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["sh.superset.voiceaudio.VoiceAudioModule"] + } +} diff --git a/apps/mobile/modules/voice-android/package.json b/apps/mobile/modules/voice-android/package.json new file mode 100644 index 00000000000..5916b835de9 --- /dev/null +++ b/apps/mobile/modules/voice-android/package.json @@ -0,0 +1,9 @@ +{ + "name": "@superset/voice-android", + "version": "0.0.1", + "private": true, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } +} diff --git a/apps/mobile/modules/voice/src/VoiceAudio.android.ts b/apps/mobile/modules/voice/src/VoiceAudio.android.ts new file mode 100644 index 00000000000..d7a4cae1d85 --- /dev/null +++ b/apps/mobile/modules/voice/src/VoiceAudio.android.ts @@ -0,0 +1,37 @@ +import { requireOptionalNativeModule } from "expo"; +import type { EventSubscription } from "expo-modules-core"; +import type { + VoiceAudioApi, + VoiceInterruptionEvent, + VoiceRouteChangeEvent, +} from "./VoiceAudio.types"; + +interface VoiceAudioNativeModule { + configure(): Promise; + preferSpeaker(on: boolean): Promise; + release(): Promise; + currentRoute(): string[]; + addListener( + event: "onInterruption", + listener: (event: VoiceInterruptionEvent) => void, + ): EventSubscription; + addListener( + event: "onRouteChange", + listener: (event: VoiceRouteChangeEvent) => void, + ): EventSubscription; +} + +const native = + requireOptionalNativeModule("VoiceAudio"); + +export const VoiceAudio: VoiceAudioApi = { + available: native !== null, + configure: () => native?.configure() ?? Promise.resolve(), + preferSpeaker: (on) => native?.preferSpeaker(on) ?? Promise.resolve(), + release: () => native?.release() ?? Promise.resolve(), + currentRoute: () => native?.currentRoute() ?? [], + onInterruption: (listener) => + native?.addListener("onInterruption", listener) ?? null, + onRouteChange: (listener) => + native?.addListener("onRouteChange", listener) ?? null, +}; diff --git a/apps/mobile/modules/voice/src/VoiceAudio.ios.ts b/apps/mobile/modules/voice/src/VoiceAudio.ios.ts new file mode 100644 index 00000000000..89c0f99647b --- /dev/null +++ b/apps/mobile/modules/voice/src/VoiceAudio.ios.ts @@ -0,0 +1,39 @@ +import { requireOptionalNativeModule } from "expo"; +import type { EventSubscription } from "expo-modules-core"; +import type { + VoiceAudioApi, + VoiceInterruptionEvent, + VoiceRouteChangeEvent, +} from "./VoiceAudio.types"; + +interface VoiceAudioNativeModule { + configure(): Promise; + preferSpeaker(on: boolean): Promise; + release(): Promise; + currentRoute(): string[]; + addListener( + event: "onInterruption", + listener: (event: VoiceInterruptionEvent) => void, + ): EventSubscription; + addListener( + event: "onRouteChange", + listener: (event: VoiceRouteChangeEvent) => void, + ): EventSubscription; +} + +// Optional: a shell built before this module runs voice over WebRTC's own +// session, on the receiver, rather than not at all. +const native = + requireOptionalNativeModule("VoiceAudio"); + +export const VoiceAudio: VoiceAudioApi = { + available: native !== null, + configure: () => native?.configure() ?? Promise.resolve(), + preferSpeaker: (on) => native?.preferSpeaker(on) ?? Promise.resolve(), + release: () => native?.release() ?? Promise.resolve(), + currentRoute: () => native?.currentRoute() ?? [], + onInterruption: (listener) => + native?.addListener("onInterruption", listener) ?? null, + onRouteChange: (listener) => + native?.addListener("onRouteChange", listener) ?? null, +}; diff --git a/apps/mobile/modules/voice/src/VoiceAudio.ts b/apps/mobile/modules/voice/src/VoiceAudio.ts new file mode 100644 index 00000000000..56f648f3c5b --- /dev/null +++ b/apps/mobile/modules/voice/src/VoiceAudio.ts @@ -0,0 +1,11 @@ +import type { VoiceAudioApi } from "./VoiceAudio.types"; + +export const VoiceAudio: VoiceAudioApi = { + available: false, + configure: () => Promise.resolve(), + preferSpeaker: () => Promise.resolve(), + release: () => Promise.resolve(), + currentRoute: () => [], + onInterruption: () => null, + onRouteChange: () => null, +}; diff --git a/apps/mobile/modules/voice/src/VoiceAudio.types.ts b/apps/mobile/modules/voice/src/VoiceAudio.types.ts new file mode 100644 index 00000000000..376a5e7887b --- /dev/null +++ b/apps/mobile/modules/voice/src/VoiceAudio.types.ts @@ -0,0 +1,29 @@ +export interface VoiceInterruptionEvent { + phase: "began" | "ended"; + shouldResume: boolean; +} + +export interface VoiceRouteChangeEvent { + reason: string; + outputs: string[]; +} + +export interface VoiceSubscription { + remove(): void; +} + +/** The app's audio session for a voice call. */ +export interface VoiceAudioApi { + /** False when voice runs on WebRTC's own session instead. */ + available: boolean; + configure(): Promise; + preferSpeaker(on: boolean): Promise; + release(): Promise; + currentRoute(): string[]; + onInterruption( + listener: (event: VoiceInterruptionEvent) => void, + ): VoiceSubscription | null; + onRouteChange( + listener: (event: VoiceRouteChangeEvent) => void, + ): VoiceSubscription | null; +} diff --git a/apps/mobile/modules/voice/src/index.ts b/apps/mobile/modules/voice/src/index.ts index 893b7219043..c28cfbb7bc0 100644 --- a/apps/mobile/modules/voice/src/index.ts +++ b/apps/mobile/modules/voice/src/index.ts @@ -1,45 +1,7 @@ -import { requireOptionalNativeModule } from "expo"; -import type { EventSubscription } from "expo-modules-core"; - -export interface VoiceInterruptionEvent { - phase: "began" | "ended"; - shouldResume: boolean; -} - -export interface VoiceRouteChangeEvent { - reason: string; - outputs: string[]; -} - -interface VoiceAudioNativeModule { - configure(): Promise; - preferSpeaker(on: boolean): Promise; - release(): Promise; - currentRoute(): string[]; - addListener( - event: "onInterruption", - listener: (event: VoiceInterruptionEvent) => void, - ): EventSubscription; - addListener( - event: "onRouteChange", - listener: (event: VoiceRouteChangeEvent) => void, - ): EventSubscription; -} - -// Optional: a JS-only build (tests, a shell built before this module) runs -// voice over WebRTC's own session, on the receiver, rather than not at all. -const native = - requireOptionalNativeModule("VoiceAudio"); - -export const VoiceAudio = { - available: native !== null, - configure: () => native?.configure() ?? Promise.resolve(), - preferSpeaker: (on: boolean) => - native?.preferSpeaker(on) ?? Promise.resolve(), - release: () => native?.release() ?? Promise.resolve(), - currentRoute: () => native?.currentRoute() ?? [], - onInterruption: (listener: (event: VoiceInterruptionEvent) => void) => - native?.addListener("onInterruption", listener) ?? null, - onRouteChange: (listener: (event: VoiceRouteChangeEvent) => void) => - native?.addListener("onRouteChange", listener) ?? null, -}; +export { VoiceAudio } from "./VoiceAudio"; +export type { + VoiceAudioApi, + VoiceInterruptionEvent, + VoiceRouteChangeEvent, + VoiceSubscription, +} from "./VoiceAudio.types"; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index cb3d06fcd03..8bc5ce713fd 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -20,6 +20,7 @@ "dependencies": { "@better-auth/expo": "1.6.22", "@config-plugins/react-native-webrtc": "15.0.2", + "@expo/metro-runtime": "58.0.15", "@expo/ui": "58.0.25", "@expo/vector-icons": "15.1.1", "@formatjs/intl-locale": "5.3.10", @@ -58,6 +59,7 @@ "@rn-primitives/tooltip": "1.4.0", "@sentry/react-native": "8.28.0", "@shikijs/core": "4.2.0", + "@shikijs/engine-javascript": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shopify/flash-list": "2.3.2", @@ -117,6 +119,7 @@ "lucide-react-native": "0.562.0", "posthog-react-native": "4.63.2", "react": "19.3.0", + "react-dom": "19.3.0", "react-native": "0.88.1", "react-native-enriched-markdown": "0.6.0", "react-native-gesture-handler": "3.3.0", @@ -128,6 +131,7 @@ "react-native-shiki-engine": "0.3.12", "react-native-streamdown": "0.2.0", "react-native-svg": "15.16.0", + "react-native-web": "0.21.4", "react-native-webrtc": "124.0.8", "react-native-webview": "14.0.1", "react-native-worklets": "0.13.0", diff --git a/apps/mobile/plans/20261029-android-agent-card.md b/apps/mobile/plans/20261029-android-agent-card.md new file mode 100644 index 00000000000..d5570c28420 --- /dev/null +++ b/apps/mobile/plans/20261029-android-agent-card.md @@ -0,0 +1,150 @@ +# Android agent card (and push, planned only) + +Step 5 of Android support. Status: MVP implemented on branch `android/05-live-activity-push`. +Push is **not** implemented; the second half of this plan is the design for it. + +## Today on iOS + +- Contract: `modules/live-activity/src/LiveActivity.types.ts` (`LiveActivityModule`, + `AgentSnapshot`, `AgentRow`). +- Caller: `screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity`. Home polls host + terminals every ~5 s, builds a snapshot of agents that want attention (permission, failed, + review, working), keeps the four most urgent rows, and starts/updates/ends one card. It only + runs in the foreground; `staleAfterSeconds: 120` lets the card say "Not updating" when the app + stops feeding it. +- Native: ActivityKit Live Activity (`modules/live-activity/ios`) drawn by the WidgetKit + extension `targets/agentactivity`. Started with `pushType: nil`: no remote updates. + +## Closest Android equivalent + +| iOS | Android | +| --------------------------------- | --------------------------------------------------------------- | +| Live Activity on Lock Screen | Ongoing notification, `VISIBILITY_PUBLIC` | +| Dynamic Island compact view | Android 16+ Live Update status bar chip (`setShortCriticalText`) | +| Pinned at top of Lock Screen | Android 16+ promoted ongoing notification (Live Update) | +| `staleDate` → "Not updating" | `setWhen(now)` + `setShowWhen`: the shade shows "5m" by itself | +| Per-row `Link` deep links | One `contentIntent`; actions are at most three buttons | +| App Group icon cache | App `cacheDir`, read back as the large icon | +| ActivityKit push token | FCM data message that rebuilds the notification (see Push) | + +**Live Updates (Android 16, API 36).** A notification is promoted when it asks with +`setRequestPromotedOngoing(true)`, the manifest has the normal permission +`POST_PROMOTED_NOTIFICATIONS`, and it has promotable characteristics: ongoing, a content title, +Standard / BigText / Call / Progress / Metric style, not colorized, no custom `RemoteViews`, not a +group summary, channel importance above MIN. The user can turn promotion off per app +(`NotificationManager.canPostPromotedNotifications()`, settings intent +`ACTION_MANAGE_APP_PROMOTED_NOTIFICATIONS`). Source: +. + +`ProgressStyle` is the "progress-centric" template. Our card is a list of agents, not one +journey with a progress bar, so it uses **BigTextStyle** (promotable). `InboxStyle` would draw the +rows more cleanly but is not promotable, and a custom `RemoteViews` layout (the only way to get +per-row icons and colours like the iOS card) is also not promotable. We take promotion over +layout. + +## MVP (implemented) + +Same contract, no caller changes; iOS is unchanged. + +- `modules/live-activity/expo-module.config.json` adds `android`; the binding + `LiveActivity.ios.ts` is renamed `LiveActivity.native.ts` so both platforms load the native + module. `LiveActivity.ts` stays the fallback for web and `bun test`. +- `android/…/LiveActivityModule.kt` maps the contract: + - `areActivitiesEnabled`: app notifications on and the `agent-status` channel not blocked. + - `activeIds`: our notification (tag `agent-card`) in `getActiveNotifications()`. Like + ActivityKit, this is the source of truth across relaunches and user dismissal. + - `cacheIcon`: download, scale to 144 px, write `cacheDir/agent-card-icons/.png`. + - `start` / `update`: post the notification; `update` with an id that is gone does nothing. + - `endAll`: cancel it. +- `AgentCard.kt` builds it. Title "` · `" of the most urgent row; collapsed + text is the project and time for one agent, or the other names plus "+N more"; expanded + BigText has one line per row. Accent colour from `topState` (the iOS dark-mode tones). Tap + opens `superset:///workspace/?tab=` when one agent is on the card, Home + otherwise. On API 36+ it requests promotion and sets the top status as the chip text. +- Channel `agent-status`, `IMPORTANCE_LOW` (never sounds), name in Android string resources for + all 17 shipped locales. +- Small icon: the white bracket mark from `targets/agentactivity`, padded square, 5 densities. + +### Known gaps + +- **Runtime permission (done).** On Android 13+ `POST_NOTIFICATIONS` must be granted. Home asks + once, the first time a card would show (`useAgentLiveActivity/utils/requestCardPermission`); + a denial is remembered and the app never asks again. iOS needs no prompt. +- **"Not updating" (done).** Each post also builds the stale form (sub-text `staleDetail`) and + sets an inexact `AlarmManager` alarm at `staleAfterSeconds`. `StaleCardReceiver` posts it only + if the card is still showing; the next post replaces the alarm. +- **Row taps (done).** Below Android 16, or when the user turned promotion off, the expanded card + is a custom row layout (`DecoratedCustomViewStyle`) and each row opens its own session. A + promoted Live Update cannot use a custom layout, so there the top three rows become actions. +- **Not verified on Android 16.** The local AVD is API 33, so promotion and the chip are + compiled and gated on `SDK_INT >= 36` but have not been seen running. +- Android 14+ lets users swipe away ongoing notifications that are not foreground services. + That matches iOS, where the user can dismiss a Live Activity; the hook starts a new card on + the next change. + +## Push (not implemented) + +### What it replaces + +The card is fed only by the foreground poll in `useAgentLiveActivity`. The moment the phone +locks, the card freezes, and `staleAfterSeconds` exists only to admit that. Push replaces: + +1. the foreground-only feed of the card (iOS `pushType: nil`, Android local `notify`) with + server-driven updates while the app is backgrounded or killed; +2. the "Not updating" crutch as the normal state on a locked phone; +3. (new) a plain alert notification when an agent starts needing you and no card is running. + +### Token registration, both platforms + +One path for both: **Expo push tokens** (`ExponentPushToken[…]`) from `expo-notifications`, +which is already installed (`app.config.ts`: `enableBackgroundRemoteNotifications: false`). Expo +fans out to APNs and FCM v1. + +- **Credentials.** APNs key (.p8) and FCM v1 service account JSON uploaded to EAS + (`eas credentials`). Android also needs `android.googleServicesFile` in `app.config.ts` + (a `google-services.json` from a Firebase project for `sh.superset.mobile`). +- **Mobile.** `apps/mobile/lib/push/` (seam per the AGENTS convention: `registerPush.types.ts`, + `.native.ts`, fallback `.ts`), called once from the authenticated layout: + 1. ask permission (`requestPermissionsAsync`; on Android 13+ this is the `POST_NOTIFICATIONS` + prompt the card also needs), behind a soft prompt, never at cold start; + 2. `getExpoPushTokenAsync({ projectId })`; + 3. `push.registerToken` with token, platform, a stable install id, app version; + 4. re-register on `addPushTokenListener` and on every sign-in; `push.unregisterToken` on + sign-out. +- **Live Activity tokens (iOS only).** A Live Activity started with `pushType: .token` gets its + own per-activity APNs token (`activity.pushTokenUpdates`). Expo's push service is not + known to send `liveactivity` pushes (check before building), so plan on sending these + **directly to APNs** (token-based auth with the same .p8, + `apns-push-type: liveactivity`, topic `.push-type.liveactivity`). The contract + grows an optional `onPushToken(activityId, token)` event; Android has no equivalent token: a + high-priority FCM *data* message to the device token rebuilds the card in a + `FirebaseMessagingService` (or the expo-notifications background task), using + `AgentCard.post` with the snapshot from the payload. + +### Where it lives server-side + +- **DB** (`packages/db/src/schema`): `push_tokens` (id, user_id, organization_id, install_id, + platform `ios|android`, expo_token unique, live_activity_token nullable, app_version, + created_at, last_seen_at, disabled_at). Generated with the db-migrations skill. +- **tRPC** (`packages/trpc/src/router/push/`, mounted in the root router): + `registerToken`, `unregisterToken`, `registerLiveActivityToken` as `protectedProcedure`s. + New procedures only, so released clients are unaffected (trpc-compat). +- **Sender** (`packages/trpc/src/lib/push/` or a shared package): Expo push via + `expo-server-sdk` (batches of 100, receipts checked later; `DeviceNotRegistered` sets + `disabled_at`), plus a small APNs client for Live Activity updates. +- **Trigger.** The API does not know agent state today; the host-service does (agent lifecycle + events, `packages/host-service/src/events/map-event-type.ts`) and the phone reads it through + the relay. The host-service would report state *transitions* (not every poll) to a new host-authenticated procedure, e.g. + `push.agentStateChanged`, through its existing API client + (`packages/host-service/src/api/createApiClient`). The API rolls them up per user into the + same `AgentSnapshot` shape and sends: an alert on `→ permission` / `→ failed`, a silent card + update otherwise, rate-limited (APNs budgets Live Activity updates). +- **API env** (`apps/api/src/env.ts`): `EXPO_ACCESS_TOKEN`, `APNS_KEY_ID`, `APNS_TEAM_ID`, + `APNS_PRIVATE_KEY`. Follow `docs/environment-variables.md`. + +### Order + +1. Permission prompt + token registration + `push_tokens` (no sending): measurable opt-in. +2. Alert pushes on `permission` / `failed` transitions (both platforms through Expo). +3. Remote card updates: iOS Live Activity APNs, Android FCM data → `AgentCard`. +4. Remove `staleAfterSeconds` once remote updates keep the card fresh. diff --git a/apps/mobile/plans/20261029-android-composer.md b/apps/mobile/plans/20261029-android-composer.md new file mode 100644 index 00000000000..90c6a2555db --- /dev/null +++ b/apps/mobile/plans/20261029-android-composer.md @@ -0,0 +1,114 @@ +# Android composer + +This ExecPlan is a living document. Keep `Progress`, `Missing after the MVP`, and `Decision Log` +up to date. + +## Purpose + +`Composer.tsx`, the fallback that Android resolves to, renders `null`, so Android has no chat input +on Home or in a workspace. This plan gives Android a composer to the same contract +(`modules/composer/src/Composer.types.ts`) as the iOS view, without changing iOS. + +## Decision: React Native (`Composer.android.tsx`), not a Kotlin Expo module + +| | React Native `Composer.android.tsx` | Kotlin module (`modules/composer/android`) | +|---|---|---| +| Why iOS went native | Liquid Glass, SwiftUI keyboard avoidance, an overlay view controller with its own safe area. None of these exist on Android. | Would rebuild the same overlay with Compose and `WindowInsets.ime`, a second large codebase. | +| Overlay | An absolute-fill `box-none` view over the screen. The callers already render the composer as the last child of the screen root (`HomeScreen`, `WorkspaceScreen`), so it overlays without a portal. | A full-screen `ComposeView` added to the activity's decor view, plus hit-test passthrough. | +| Keyboard | SDK 58 is edge-to-edge, so the window does not resize: the composer pads itself by the keyboard height from `Keyboard` events. | `WindowInsetsAnimationCompat` gives a frame-exact animation. Better, but only visible as polish. | +| Theme and icons | `lucide-react-native` and the app's dark palette, the same art the rest of the app draws. | A second icon set and colour table in Kotlin. | +| Iteration | Metro reload. No Gradle build, which matters with one build slot shared by five agents. | A Gradle build per change. | +| Seams | Adds one file. `Composer.ios.tsx` and the `expo-module.config.json` platforms stay as they are. | Rename the binding to `Composer.native.tsx`, add `"android"` to `platforms`, and keep both natives in step on every prop. | +| `presentImage` / `fileThumbnail` | A viewer `Modal` hosted by the mounted composer; image thumbnails through `expo-image-manipulator`. No PDF preview. | Native `PdfRenderer` thumbnails and a system image viewer. | + +Pick React Native. The iOS reasons for going native are iOS-specific; on Android the cost is a second +native codebase and slow iteration for little visible gain. A Kotlin module stays open later for one +piece at a time, for example a `fileThumbnail` with `PdfRenderer` or dictation with +`SpeechRecognizer`, behind the same contract. + +## Shape + +- `modules/composer/src/Composer.android.tsx` exports `Composer`, `presentImage`, `fileThumbnail`, + annotated with the contract types. It imports nothing from `@/`, like the rest of the module. +- Expanded is "the text field has focus". Focus opens it, blur closes it, `onExpandedChange` reports + each change. `focus()`/`blur()` on the handle drive the field. +- Collapsed: one row, `+` · mini thumb with `+N` · one line of draft or the placeholder · send/stop. +- Expanded: header chips, attachment tray with ✕ and upload state, a multi-line editor (4-line + floor, 2 with `compactEditor`), then the toolbar: `+` · mode menu · agent picker · launch options · + send/stop. `dim` draws a scrim that blurs on tap; `passthrough` draws none. +- `onHeightChange` reports the card and the rows around it, without the keyboard and the bottom + safe area, as on iOS. +- SF Symbol names in the contract (`ComposerModeOption.symbol`, `ComposerQuickKey.symbol`, + `ComposerQuickKeysAction.symbol`) go through one map to lucide icons, with a fallback icon. + +## Missing after the MVP + +- Upload rings drawn as rings (the composer draws a dimmed thumbnail with a percentage) and an + animated collapse. +- The quick-keys action takes its room at once; iOS animates the keys sliding over. +- Paste through the system menu or a keyboard's image insert: RN 0.88 `TextInput` takes text only, + so images and files paste through the composer's own Paste control. +- Thumbnails for documents other than PDFs that sit in the app's cache (file URIs); content URIs + use the provider's own preview. + +## Progress + +- [x] (2026-10-29) Plan and decision. +- [x] (2026-10-29) MVP `Composer.android.tsx`. +- [x] (2026-10-29) Verified on the `Pixel_2_API_33` emulator through a temporary route that drove + every prop and handle method: collapsed and expanded states, the keyboard lift, `dim` and + `passthrough`, Back to collapse, tray remove and upload states, send held while uploading, + `isSending`, `canStop`/`onStop`, `clear`, `appendDraft`, the mode menu, `presentImage` and + `fileThumbnail`. Home and the workspace screen are not verified yet: they need a signed-in session + against a local API. +- [x] (2026-10-29) Header chips moved into the card, so the Home project chip no longer sits over the + list header behind the composer. +- [x] (2026-10-29) Session tabs (pills, attention dots, close disc, press-and-hold menu, scroll-back + chevron, new and all sessions), quick keys and the quick-keys action, and slash suggestions + (`utils/slashSuggestions`, the same matching as iOS `ComposerSlashMatching`). One bottom + `ComposerMenuSheet` serves the mode menu and the tab menu. +- [x] (2026-10-29) Verified on `Paradev_A2` (emulator-5556) against the local API: Home new chat + (chip in the card, keyboard lift, no slash panel since Home passes no commands); the real + `TerminalComposer` through a temporary route with stand-in tabs, keys and commands (every + callback, menu, chevron, slash filter and commit, submit and clear); the Page reply bar + `ComposerCard` with a seeded thread (lift, post). The workspace screen with a live host is not + verified: no host-service was connected. + +- [x] (2026-10-30) Dictation, paste, document thumbnails and swipe-down, behind seams in + `modules/composer/src`: `utils/dictation` (`.android.ts` on `SpeechRecognizer`, `.web.ts` on the + Web Speech API, `.ts` hides the mic), `hooks/useAttachmentPaste` (Android clipboard, the + browser's paste event), `utils/fileThumbnail` (`PdfRenderer` and the provider's preview on + Android). The Kotlin lives in `modules/composer-android`, an Android-only module, so iOS native + code and its fingerprint do not move. Verified on `Paradev_A3` (emulator-5558). + +## Surprises & Discoveries + +- Fabric flattens a layout-only `View`. The field's wrapper had a layout-only style when expanded + and `opacity: 0` when collapsed, so each change re-parented the field and Android moved focus to + the next focusable view. `collapsable={false}` keeps the wrapper. +- `keyboardDidShow` on Android reports the keyboard height without the navigation bar below it, so + the lift is `keyboard + insets.bottom`. +- Back lowers the keyboard without blurring the field, so the composer blurs on `keyboardDidHide`. +- `CI=1 expo start` turns off Metro's file watcher: a cold restart still gets the old bundle. +- The emulator shows the soft keyboard only after the first key input; it is not an app bug. +- `TerminalComposer` wrapped the composer in a plain `` with no size. The iOS view draws in + its own overlay, so it never mattered there; the Android overlay sized itself to that 0×0 view + and drew nothing. The wrapper now fills the screen with `box-none`. +- Google's recogniser reads `EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS` as an Integer and + ignores a Long, and ends each session after a pause; the module starts it again until stop. +- The Android attachments sheet settled before it hid, so settling unmounted it and `hide()` was + rejected: Files and Photos never opened. It hides first now. +- `android-dev.sh` reused its saved Metro port even when another worktree's Metro held it, and + loaded that worktree's bundle. It now reuses the port only while its own Metro runs or the port + is free. + +## Decision Log + +| Date | Decision | +|---|---| +| 2026-10-29 | React Native over a Kotlin module, for the reasons in the table above. | +| 2026-10-29 | Header chips sit inside the card, at its top, rather than floating above it. | +| 2026-10-29 | Context menus (tab press-and-hold, mode) are one bottom sheet; Android has no SwiftUI `Menu`. | +| 2026-10-30 | Native pieces go in a separate Android-only module (`modules/composer-android`), not `modules/composer/android`: adding `"android"` to the composer's module config would move the iOS fingerprint. | +| 2026-10-30 | Paste of images and files is a Paste control that shows while the clipboard holds them. Reading only the clip description keeps Android's "pasted" toast for the paste itself. | +| 2026-10-29 | Expanded means focused. The collapsed row shows a one-line label over a transparent field, rather than restyling the field, so the head of a long draft shows with an ellipsis, as on iOS. | diff --git a/apps/mobile/plans/20261029-android-launch-checklist.md b/apps/mobile/plans/20261029-android-launch-checklist.md new file mode 100644 index 00000000000..07faaf64163 --- /dev/null +++ b/apps/mobile/plans/20261029-android-launch-checklist.md @@ -0,0 +1,128 @@ +# Android launch checklist + +What stands between the Android work on `android-app-support-check-244611af` and a Play Store +release. Part 1 is for a person: accounts, keys and consoles that no agent can reach. Part 2 is +engineering that is known to be missing. Tick items off here as they land. + +Running the app locally needs none of this: `apps/mobile/scripts/android-dev.sh` (see +`.agents/skills/mobile-android-verification/SKILL.md`). + +## 0. Decide: the iOS release it starts + +- [ ] The last commit on the branch, "Android release config (changes the iOS runtime + fingerprint)", holds `app.config.ts`, `eas.json`, `apps/mobile/.gitignore`, + `packages/shared/src/app-identity.ts` and the `assetlinks.json` route. Those files feed the + iOS runtime version. Merging that commit to main makes `deploy.yml` build and submit a new + iOS binary, and installed iOS builds stop taking OTA updates until they update. Merge the + branch without it to ship the Android code dark, then merge it with a planned iOS release. + +## 1. Accounts and keys (a person) + +**Status 2026-10-29:** Superset Inc.'s organization developer account exists: developer name +"Paradev", account ID 8938663520557055179, owned by kiet@superset.sh. The website (superset.sh) is +verified. The organization document (IRS Letter 147C) and Kiet's ID are with Google for review; +the phone check and **Create app** unlock after Google emails kiet@superset.sh. + +Do Google Cloud in both projects that own a `GOOGLE_CLIENT_ID` web client: dev and production. + +### Signing + +- [x] **Upload keystore.** Done 2026-10-29 (EAS, default build credentials). SHA-1 + `D1:FE:00:E3:8F:27:B5:90:28:3D:3A:D0:D5:33:ED:D0:ED:65:BA:F1`, SHA-256 + `01:19:18:27:15:2A:D2:39:1B:FE:BC:D0:AD:A1:2D:89:28:1A:5C:42:04:EA:26:6C:0A:54:E8:47:23:E5:C1:93`. + Was: In `apps/mobile`: `eas credentials -p android`, production profile, + let EAS generate the keystore. Copy its SHA-1 and SHA-256. +- [ ] **Play Console app.** Create the app with package `sh.superset.mobile` and accept Play App + Signing. Copy the app signing key's SHA-1 and SHA-256 from Test and release → App + integrity. + +### Google sign-in (native, Credential Manager) + +- [ ] **Android OAuth clients** for `sh.superset.mobile`, one per signing key, in project + `fair-scout-481221-v0` (it holds both the Web and Web Dev clients). Debug and upload-key + clients created 2026-10-29; the Play App Signing one waits for the Play app. No client secret: + - Debug, dev project only (React Native's shared template keystore): + `5E:8F:16:06:2E:A3:CD:2C:4A:0D:54:78:76:BA:A6:F3:8C:AB:F6:25` + - Upload key SHA-1 (from the keystore step). + - Play App Signing SHA-1 (from the Play Console step). +- [x] **OAuth consent screen** (already live for web sign-in) complete (app name, support email). Without it the account sheet + does not show. +- [x] **`EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID`** Done 2026-10-29: production and preview use the Web + client, development uses Web Dev. Was: set to the project's web client ID in the EAS + `development`, `preview` and `production` environments. Empty means Android uses the + browser flow. Locally, copy `GOOGLE_CLIENT_ID` into it. + +Sign in with Apple is not offered on Android, by decision. GitHub stays browser-based on both +platforms. + +### App Links + +- [ ] Put both SHA-256 values (upload key and Play App Signing key, uppercase, colon-separated) in + `ANDROID_SIGNING_CERT_SHA256` in `packages/shared/src/constants.ts`, and deploy web. Check + on a device: `adb shell pm verify-app-links --re-verify sh.superset.mobile`, then + `adb shell pm get-app-links sh.superset.mobile`. + +### Play submission + +- [ ] **Service account.** In Google Cloud, create a service account with a JSON key; grant it + release permissions on the app in Play Console → Users and permissions. Upload the key with + `eas credentials -p android` (Google Service Account) so `eas submit` needs no key path. +- [ ] **First upload by hand.** Google rejects API uploads until one `.aab` is uploaded manually. + +### Store listing + +The listing lives in `apps/mobile/play-store.config.js`. Its text comes from the App Store listing in +`store.config.js`, and the icon and feature graphic are in `apps/mobile/store/android/`. +`bun apps/mobile/scripts/play-listing.ts` checks it against Play's limits. + +- [x] **Phone screenshots** — 3 drafts taken 2026-10-29 from the review account on production + (Home, workspace sheet, new task). Retake the new-task shot once the Android composer's project + chip stops overlapping the list header. Was: (2 to 8 PNGs) in `apps/mobile/store/android/phone/`. Take them from the + review demo account (`APP_REVIEW_EMAIL`), whose organization has sample sessions, never from + a real account. Use `apps/mobile/scripts/android-dev.sh --shot`. +- [ ] **Push text and images**: + `PLAY_SERVICE_ACCOUNT_KEY=key.json bun apps/mobile/scripts/play-listing.ts --push`. Needs + the service account above. The push code has not run against Play yet. +- [ ] **App content forms** in Play Console, answered in `play-store.config.js`: privacy policy, + app access (demo account from `APP_REVIEW_EMAIL` / `APP_REVIEW_PASSWORD`), ads (none), + target audience (18+), content rating, Data safety. Review the Data safety answers first: + PostHog session replay, Sentry, attached photos, and voice audio sent to OpenAI. +- [ ] **Category and tags**: Productivity; add developer tags in Play Console. + +**Review demo account.** `appreview@superset.sh` (team "App Review", `9617bc8e-…`) had read as +Free since the Oct 20 Autumn cutover, so App Store and Play reviewers would only see the paywall. +Fixed 2026-10-29: Autumn customer created and the Team plan attached with the seat price customized +to $0 (3 seats, 216,000 box GB-minutes, overage billing off, past-due ignored). The demo host +`acme-devbox` and its sample workspaces were already there. + +## 2. Engineering still missing + +### Before a public release + +- [x] **Query cache too large for Android storage.** Done: Android keeps the persisted query cache + in a file, not one AsyncStorage row, so the "Row too big to fit into CursorWindow" toast is gone. +- [ ] **Sign-in end to end.** GitHub and Google open their providers, but nobody has finished a + real login and the redirect back through `superset://` on Android. Native Google needs the + Google Cloud steps above first. +- [x] **Sign-in errors.** Done: a failed GitHub or Google sign-in shows its error (both platforms). +- [ ] **Screens not yet checked on a device:** the Page sheets (preview, comments, share), voice + sheets, line comment, and `ValueMenu` in share-invite. The PR checks and reviewers sheets + render on Android (2026-10-30, fixture data: the local dev account has no GitHub). +- [ ] **CI and EAS workflows.** `.eas/workflows/deploy-android.yml` builds and submits to the Play + internal track, run by hand. It moves into `deploy.yml` after the first manual upload and once + Android takes OTA updates (`RELEASE.md`). +- [x] **Home-screen name.** Done: `withAndroidLabel` sets the launcher label to the brand name. + +### Composer, after the MVP + +From `plans/20261029-android-composer.md`. Done: session tabs, quick keys, slash command +suggestions, dictation (`SpeechRecognizer`), paste of images and files, PDF thumbnails, swipe-down +to dismiss, and the quick-keys PR action. Voice mode owns the audio route on Android +(`modules/voice-android`). + +### Agent card and push + +From `plans/20261029-android-agent-card.md`. Done: the app asks for notification permission when +the first agent card would show, a stale card says "Not updating", and each row opens its own +session. Still open: the Android 16 Live Update features are untested (the AVD is API 33), and push +is planned there but not implemented on either platform. diff --git a/apps/mobile/plans/20261101-web-workspace-sidebar.md b/apps/mobile/plans/20261101-web-workspace-sidebar.md new file mode 100644 index 00000000000..9563a168c03 --- /dev/null +++ b/apps/mobile/plans/20261101-web-workspace-sidebar.md @@ -0,0 +1,51 @@ +# Web: a workspace list beside the open workspace + +Status: plan, not started. Follows `android/18-web-desktop-layout` (report 18, proposal P1). + +## Goal + +At 1100 px and wider, `/agents` keeps Home's workspace list in a left column while a workspace is +open. A click in the list swaps the workspace on the right; Home's own screen is unchanged. +Phones, tablets and the iOS/Android apps do not change. + +This is the web side of the split view in `20261028-iphone-duo-native.md` (workspaces in the +primary column, the workspace in the detail). Use the same shape so the two can share the list. + +## Why it is not in report 18 + +The list is not a component yet. `HomeScreen` (≈900 lines) owns the data and the rows together: +host or cloud scope, sort, pins, archived, collapsed projects, terminal attention, diff stats, +the paywall and the offline states. A sidebar that reuses it needs that split first, and the split +touches the iOS Home screen. That is a separate, reviewable change, not a layout seam. + +## Steps + +1. **Extract the list.** Move the data wiring out of `HomeScreen` into a hook + (`useHomeList`: entries, `renderListItem`, viewability) and a `WorkspaceList` component that + renders it. `HomeScreen` becomes header + scope bar + `WorkspaceList` + composer. Verify iOS + Home is pixel-identical (simulator screenshots before and after) and the fingerprint is unchanged. +2. **Add a compact row variant.** `WorkspaceRow` gets a `dense` form for a 280 px column: name, + branch, attention dot. It keeps `RowLink`, so links keep working. +3. **Add the web shell.** A `WorkspaceShell` seam in `lib/layout`: `.tsx` renders its children; + `.web.tsx` renders `[WorkspaceList | children]` when the window is ≥ 1100 px and the focused + route is under `/workspace/[id]`. Mount it around the `` in + `app/(authenticated)/_layout.tsx`, so the list stays mounted (scroll and collapse state survive) + while the stack changes screens. Highlight the row that matches the route's `id`. +4. **Navigate in place.** A plain click in the sidebar uses `router.replace` from one workspace to + another, so browser Back returns to Home rather than walking through every workspace opened. + ⌘-click still opens a tab (it is a `RowLink`). +5. **Keep the column.** The workspace keeps `useColumnInset`, computed from the remaining width + (a layout measurement, not the window), so chat and composer stay centred in the detail pane. + +## Checks + +- Web at 1440×900 and 1100×800: list beside chat and terminal; resize below 1100 hides it. +- Web at 1024 and 390: as in report 18. +- iOS fingerprint and Home screenshots unchanged; Android Home unchanged. +- Sheets (sessions, new session) still open over the whole page. + +## Open questions + +- Should the sidebar show sessions under each workspace (desktop app style) or workspaces only? + Start with workspaces only. +- Collapsible sidebar (a toggle in the header) or always on at ≥ 1100 px? Start always on. diff --git a/apps/mobile/play-store.config.js b/apps/mobile/play-store.config.js new file mode 100644 index 00000000000..f3228d89b88 --- /dev/null +++ b/apps/mobile/play-store.config.js @@ -0,0 +1,114 @@ +// The Google Play listing. Text comes from store.config.js (the App Store +// listing) so the two stores say the same thing; only Play's own fields live +// here. Pushed by scripts/play-listing.ts. The Data safety, content rating and +// app access answers have no API: enter them in Play Console from this file. + +const apple = require("./store.config.js").apple; +const appStore = apple.info["en-US"]; + +const shortDescription = + "Start, follow and review your coding agents from your phone."; + +// The App Store review notes argue against Apple's guidelines; Play reviewers +// need only the access steps. +const appAccessInstructions = [ + "On the sign-in screen tap 'Sign in with email' and use the demo account above.", + "Home lists workspaces and sessions. Open a session to read the agent transcript, chat with it, view the files it changed, and open the terminal.", + "Settings is reachable from the organization name in the top-left; account deletion is under Danger Zone.", + "Free accounts see a screen explaining Pro is required; the demo account's organization has Pro.", +].join("\n"); + +module.exports = { + packageName: "sh.superset.mobile", + defaultLanguage: "en-US", + listings: { + "en-US": { + title: appStore.title, + shortDescription, + fullDescription: appStore.description, + }, + }, + details: { + contactEmail: apple.review.email, + contactWebsite: appStore.supportUrl, + contactPhone: apple.review.phone, + }, + images: { + icon: "store/android/icon-512.png", + featureGraphic: "store/android/feature-graphic.png", + phoneScreenshots: "store/android/phone", + }, + + // Play Console > Policy > App content. Entered by hand. + privacyPolicyUrl: appStore.privacyPolicyUrl, + appCategory: "Productivity", + appAccess: { + restricted: true, + username: process.env.APP_REVIEW_EMAIL ?? "", + password: process.env.APP_REVIEW_PASSWORD ?? "", + instructions: appAccessInstructions, + }, + ads: false, + targetAudience: "18 and over", + contentRating: { + category: "Utility, Productivity, Communication, or Other", + violence: false, + sexualContent: false, + language: false, + controlledSubstances: false, + gambling: false, + // Users chat with their own AI agents and teammates' sessions. + userInteraction: true, + sharesLocation: false, + digitalPurchases: false, + }, + // What lib/posthog, lib/sentry, auth, attachments and voice send. + dataSafety: { + encryptedInTransit: true, + accountDeletionInApp: true, + accountDeletionUrl: "https://paradev.com/privacy", + collected: [ + { + type: "Personal info: Name, Email address, User IDs", + purposes: ["App functionality", "Account management", "Analytics"], + shared: false, + optional: false, + }, + { + type: "App activity: App interactions, Other user-generated content", + purposes: ["App functionality", "Analytics"], + shared: false, + optional: false, + note: "PostHog events and session replay; messages sent to agents", + }, + { + type: "Photos and videos: Photos", + purposes: ["App functionality"], + shared: false, + optional: true, + note: "Only images the user attaches to a message", + }, + { + type: "Audio: Voice or sound recordings", + purposes: ["App functionality"], + shared: true, + optional: true, + note: "Voice mode streams microphone audio to OpenAI's Realtime API; not stored by us", + }, + { + type: "App info and performance: Crash logs, Diagnostics", + purposes: ["Analytics"], + shared: false, + optional: false, + note: "Sentry, sendDefaultPii off", + }, + { + type: "Device or other IDs", + purposes: ["Analytics"], + shared: false, + optional: false, + note: "PostHog anonymous and device IDs", + }, + ], + }, +}; diff --git a/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx b/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx index 852d226a766..5b99abc9644 100644 --- a/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx +++ b/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx @@ -1,13 +1,10 @@ import { Trans } from "@lingui/react/macro"; import { BRAND } from "@superset/shared/brand"; import { COMPANY } from "@superset/shared/constants"; -import * as AppleAuthentication from "expo-apple-authentication"; -import * as Crypto from "expo-crypto"; import { useState } from "react"; import { Image, View } from "react-native"; import { Text } from "@/components/ui/text"; -import { signIn } from "@/lib/auth/client"; import { env } from "@/lib/env"; import { errorCopy } from "@/lib/errors"; import { openUrl } from "@/lib/open-url"; @@ -16,6 +13,16 @@ import { DevSignInOptions } from "./components/DevSignInOptions"; import { EmailSignInLink } from "./components/EmailSignInLink"; import type { SocialProvider } from "./components/SocialButton"; import { SocialButton } from "./components/SocialButton"; +import { signInInBrowser } from "./utils/signInInBrowser"; +import { signInWithApple } from "./utils/signInWithApple"; +import { signInWithGoogle } from "./utils/signInWithGoogle"; +import { SOCIAL_PROVIDERS } from "./utils/socialProviders"; + +const SIGN_IN: Record Promise> = { + apple: signInWithApple, + github: () => signInInBrowser("github"), + google: signInWithGoogle, +}; export function SignInScreen() { const appName = BRAND.NAME; @@ -24,61 +31,9 @@ export function SignInScreen() { const handleSignIn = async (provider: SocialProvider) => { setError(null); try { - await signIn.social({ - provider, - callbackURL: "/", - }); - } catch (err) { - console.error("[sign-in] Error:", err); - setError(errorCopy(err)); - } - }; - - const handleAppleSignIn = async () => { - setError(null); - try { - // Apple puts SHA256(nonce) in the identity token; the server gets the - // raw nonce and better-auth compares against the hashed claim. - const rawNonce = Crypto.randomUUID(); - const hashedNonce = await Crypto.digestStringAsync( - Crypto.CryptoDigestAlgorithm.SHA256, - rawNonce, - ); - const credential = await AppleAuthentication.signInAsync({ - requestedScopes: [ - AppleAuthentication.AppleAuthenticationScope.FULL_NAME, - AppleAuthentication.AppleAuthenticationScope.EMAIL, - ], - nonce: hashedNonce, - }); - if (!credential.identityToken) { - throw new Error("Apple did not return an identity token"); - } - await signIn.social({ - provider: "apple", - idToken: { - token: credential.identityToken, - nonce: rawNonce, - // Apple only sends name/email on the very first authorization - user: { - name: { - firstName: credential.fullName?.givenName ?? undefined, - lastName: credential.fullName?.familyName ?? undefined, - }, - email: credential.email ?? undefined, - }, - }, - callbackURL: "/", - }); + await SIGN_IN[provider](); } catch (err) { - if ( - err instanceof Error && - "code" in err && - err.code === "ERR_REQUEST_CANCELED" - ) { - return; - } - console.error("[sign-in] Apple error:", err); + console.error(`[sign-in] ${provider} error:`, err); setError(errorCopy(err)); } }; @@ -100,21 +55,14 @@ export function SignInScreen() { - - handleSignIn("github")} - className="w-4/5 max-w-sm" - /> - handleSignIn("google")} - className="w-4/5 max-w-sm" - /> + {SOCIAL_PROVIDERS.map((provider) => ( + handleSignIn(provider)} + className="w-4/5 max-w-sm" + /> + ))} {(__DEV__ || env.EXPO_PUBLIC_E2E === "1") && } diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInInBrowser/index.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInInBrowser/index.ts new file mode 100644 index 00000000000..9140a17116c --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInInBrowser/index.ts @@ -0,0 +1 @@ +export { signInInBrowser } from "./signInInBrowser"; diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInInBrowser/signInInBrowser.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInInBrowser/signInInBrowser.ts new file mode 100644 index 00000000000..c2c05d352f1 --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInInBrowser/signInInBrowser.ts @@ -0,0 +1,8 @@ +import { signIn } from "@/lib/auth/client"; +import { signInSocial } from "../signInSocial"; + +export async function signInInBrowser( + provider: "github" | "google", +): Promise { + await signInSocial(signIn.social, { provider, callbackURL: "/" }); +} diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInInBrowser/signInInBrowser.web.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInInBrowser/signInInBrowser.web.ts new file mode 100644 index 00000000000..1721c993874 --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInInBrowser/signInInBrowser.web.ts @@ -0,0 +1,12 @@ +import { signIn } from "@/lib/auth/client"; +import { signInSocial } from "../signInSocial"; + +// The provider sends the browser back to this page, not to the API's origin. +export async function signInInBrowser( + provider: "github" | "google", +): Promise { + await signInSocial(signIn.social, { + provider, + callbackURL: `${window.location.origin}${process.env.EXPO_BASE_URL ?? ""}/`, + }); +} diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInSocial/index.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInSocial/index.ts new file mode 100644 index 00000000000..c5c557312ab --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInSocial/index.ts @@ -0,0 +1 @@ +export { signInSocial } from "./signInSocial"; diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInSocial/signInSocial.test.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInSocial/signInSocial.test.ts new file mode 100644 index 00000000000..9292526d79f --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInSocial/signInSocial.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { signInSocial } from "./signInSocial"; + +describe("signInSocial", () => { + test("throws the server's message when sign-in fails", async () => { + const social = async () => ({ + error: { message: "Account is suspended", statusText: "Forbidden" }, + }); + await expect(signInSocial(social, { provider: "github" })).rejects.toThrow( + "Account is suspended", + ); + }); + + test("falls back to the status text when there is no message", async () => { + const social = async () => ({ error: { statusText: "Bad Gateway" } }); + await expect(signInSocial(social, { provider: "google" })).rejects.toThrow( + "Bad Gateway", + ); + }); + + test("resolves when sign-in succeeds or the browser is dismissed", async () => { + const social = async () => ({ error: null }); + await expect( + signInSocial(social, { provider: "github" }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInSocial/signInSocial.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInSocial/signInSocial.ts new file mode 100644 index 00000000000..9f40ec89f66 --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInSocial/signInSocial.ts @@ -0,0 +1,14 @@ +interface SocialSignInResult { + error: { message?: string; statusText?: string } | null; +} + +/** better-auth reports a failed sign-in in `res.error`; it does not throw. */ +export async function signInSocial( + social: (params: TParams) => Promise, + params: TParams, +): Promise { + const res = await social(params); + if (res.error) { + throw new Error(res.error.message || res.error.statusText); + } +} diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/index.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/index.ts new file mode 100644 index 00000000000..cc78aeb0bc8 --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/index.ts @@ -0,0 +1 @@ +export { signInWithApple } from "./signInWithApple"; diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ios.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ios.ts new file mode 100644 index 00000000000..89f315f659d --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ios.ts @@ -0,0 +1,53 @@ +import * as AppleAuthentication from "expo-apple-authentication"; +import * as Crypto from "expo-crypto"; +import { signIn } from "@/lib/auth/client"; +import { signInSocial } from "../signInSocial"; + +/** Resolves without signing in when the user cancels the system sheet. */ +export async function signInWithApple(): Promise { + // Apple puts SHA256(nonce) in the identity token; the server gets the + // raw nonce and better-auth compares against the hashed claim. + const rawNonce = Crypto.randomUUID(); + const hashedNonce = await Crypto.digestStringAsync( + Crypto.CryptoDigestAlgorithm.SHA256, + rawNonce, + ); + let credential: AppleAuthentication.AppleAuthenticationCredential; + try { + credential = await AppleAuthentication.signInAsync({ + requestedScopes: [ + AppleAuthentication.AppleAuthenticationScope.FULL_NAME, + AppleAuthentication.AppleAuthenticationScope.EMAIL, + ], + nonce: hashedNonce, + }); + } catch (err) { + if ( + err instanceof Error && + "code" in err && + err.code === "ERR_REQUEST_CANCELED" + ) { + return; + } + throw err; + } + if (!credential.identityToken) { + throw new Error("Apple did not return an identity token"); + } + await signInSocial(signIn.social, { + provider: "apple", + idToken: { + token: credential.identityToken, + nonce: rawNonce, + // Apple only sends name/email on the very first authorization + user: { + name: { + firstName: credential.fullName?.givenName ?? undefined, + lastName: credential.fullName?.familyName ?? undefined, + }, + email: credential.email ?? undefined, + }, + }, + callbackURL: "/", + }); +} diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ts new file mode 100644 index 00000000000..3029ccd9dd2 --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ts @@ -0,0 +1,2 @@ +// Sign in with Apple is offered on iOS only, so nothing calls this elsewhere. +export async function signInWithApple(): Promise {} diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInWithGoogle/index.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInWithGoogle/index.ts new file mode 100644 index 00000000000..fcd0c3c06c9 --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInWithGoogle/index.ts @@ -0,0 +1 @@ +export { signInWithGoogle } from "./signInWithGoogle"; diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInWithGoogle/signInWithGoogle.android.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInWithGoogle/signInWithGoogle.android.ts new file mode 100644 index 00000000000..ce64393cd4a --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInWithGoogle/signInWithGoogle.android.ts @@ -0,0 +1,31 @@ +import { GoogleCredential } from "@superset/google-credential"; +import * as Crypto from "expo-crypto"; +import { signIn } from "@/lib/auth/client"; +import { env } from "@/lib/env"; +import { signInInBrowser } from "../signInInBrowser"; +import { signInSocial } from "../signInSocial"; + +/** + * The Credential Manager account sheet. Falls back to the browser when the + * device has no Google account or the app has no web client ID to ask for. + */ +export async function signInWithGoogle(): Promise { + const serverClientId = env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID; + if (!serverClientId) return signInInBrowser("google"); + + const nonce = Crypto.randomUUID(); + let token: string; + try { + token = await GoogleCredential.getIdToken(serverClientId, nonce); + } catch (err) { + const code = (err as { code?: unknown } | null)?.code; + if (code === "ERR_CANCELED") return; + if (code === "ERR_NO_CREDENTIAL") return signInInBrowser("google"); + throw err; + } + await signInSocial(signIn.social, { + provider: "google", + idToken: { token, nonce }, + callbackURL: "/", + }); +} diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInWithGoogle/signInWithGoogle.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInWithGoogle/signInWithGoogle.ts new file mode 100644 index 00000000000..798bf8e6f72 --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInWithGoogle/signInWithGoogle.ts @@ -0,0 +1,5 @@ +import { signInInBrowser } from "../signInInBrowser"; + +export async function signInWithGoogle(): Promise { + await signInInBrowser("google"); +} diff --git a/apps/mobile/screens/(auth)/sign-in/utils/socialProviders/index.ts b/apps/mobile/screens/(auth)/sign-in/utils/socialProviders/index.ts new file mode 100644 index 00000000000..69e1ac9b2ca --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/socialProviders/index.ts @@ -0,0 +1 @@ +export { SOCIAL_PROVIDERS } from "./socialProviders"; diff --git a/apps/mobile/screens/(auth)/sign-in/utils/socialProviders/socialProviders.ios.ts b/apps/mobile/screens/(auth)/sign-in/utils/socialProviders/socialProviders.ios.ts new file mode 100644 index 00000000000..de0990bb99a --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/socialProviders/socialProviders.ios.ts @@ -0,0 +1,7 @@ +import type { SocialProvider } from "../../components/SocialButton"; + +export const SOCIAL_PROVIDERS: readonly SocialProvider[] = [ + "apple", + "github", + "google", +]; diff --git a/apps/mobile/screens/(auth)/sign-in/utils/socialProviders/socialProviders.ts b/apps/mobile/screens/(auth)/sign-in/utils/socialProviders/socialProviders.ts new file mode 100644 index 00000000000..845782c62ea --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/socialProviders/socialProviders.ts @@ -0,0 +1,4 @@ +import type { SocialProvider } from "../../components/SocialButton"; + +// Sign in with Apple is offered on iOS only. +export const SOCIAL_PROVIDERS: readonly SocialProvider[] = ["github", "google"]; diff --git a/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx index e589741d586..aa5fec25b6d 100644 --- a/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/filter/FilterScreen.tsx @@ -1,10 +1,11 @@ import Ionicons from "@expo/vector-icons/Ionicons"; import { useLingui } from "@lingui/react/macro"; import { i18n } from "@superset/i18n"; -import { Stack, useRouter } from "expo-router"; +import { useRouter } from "expo-router"; import { View } from "react-native"; import { Icon } from "@/components/ui/icon"; import { useTheme } from "@/hooks/useTheme"; +import { HeaderToolbar } from "@/lib/navigation"; import { SORT_OPTIONS, useWorkspacesFilterStore, @@ -29,9 +30,13 @@ export function FilterScreen() { return ( - - router.back()} /> - + + router.back()} + /> + openUrl(billingSettingsUrl(activeOrganizationId))} > - Upgrade on {COMPANY.DOMAIN} + Upgrade on {webHost()} diff --git a/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx index c52589e2b2a..f636c58856e 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx @@ -40,6 +40,8 @@ import { useOrgHosts } from "@/hooks/useOrgHosts"; import { useReadableInset } from "@/hooks/useReadableInset"; import { useSession } from "@/lib/auth/client"; import { errorCopy } from "@/lib/errors"; +import { listSideInset } from "@/lib/layout"; +import { contentScrollsUnderHeader, HeaderToolbar } from "@/lib/navigation"; import { useVoiceSession } from "@/lib/voice/useVoiceSession"; import { useVoiceActive } from "@/lib/voice/voiceStore"; import { useCloudFilters } from "@/screens/(authenticated)/(home)/hooks/useCloudFilters"; @@ -192,6 +194,7 @@ export function HomeScreen() { const columns = grid?.columns ?? 1; const cellWidth = grid?.cellWidth ?? 0; const listInset = grid?.inset ?? readableInset; + const sideInset = useMemo(() => listSideInset(listInset), [listInset]); const queryClient = useQueryClient(); useAppReviewPrompt(); const setTargetKey = useNewSessionPreferencesStore( @@ -703,25 +706,28 @@ export function HomeScreen() { const renderItem = useCallback( ({ item }: { item: HomeListEntry }) => { - if (item.kind !== "gridRow") return renderListItem(item, false); - return ( - - {item.cells.map((cell) => ( - - {renderListItem(cell, true)} - - ))} - - ); + const row = + item.kind !== "gridRow" ? ( + renderListItem(item, false) + ) : ( + + {item.cells.map((cell) => ( + + {renderListItem(cell, true)} + + ))} + + ); + return sideInset.row ? {row} : row; }, - [renderListItem, cellWidth], + [renderListItem, cellWidth, sideInset.row], ); // The native splash is still up until hideSplash() below; nothing to draw. @@ -773,7 +779,7 @@ export function HomeScreen() { offline — its list isn't shown, so there is nothing to search. */} ( {selectedHost && hostOffline && !voiceEnabled ? null : ( - + {voiceEnabled ? ( - { void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); @@ -805,9 +811,9 @@ export function HomeScreen() { }} /> ) : null} - + )} {selectedHost && hostOffline ? ( {scopeBar} + ) : ( + scopeBar + ) + } viewabilityConfig={VIEWABILITY_CONFIG} onViewableItemsChanged={onViewableItemsChanged} refreshControl={ diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/CloudWorkspaceRow/CloudWorkspaceRow.tsx b/apps/mobile/screens/(authenticated)/(home)/home/components/CloudWorkspaceRow/CloudWorkspaceRow.tsx index 865feb608c9..f5e1f577746 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/CloudWorkspaceRow/CloudWorkspaceRow.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/CloudWorkspaceRow/CloudWorkspaceRow.tsx @@ -1,6 +1,7 @@ import { useLingui } from "@lingui/react/macro"; import { useRouter } from "expo-router"; import { Pressable, View } from "react-native"; +import { RowLink } from "@/components/RowLink"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { Text } from "@/components/ui/text"; @@ -141,19 +142,23 @@ export function CloudWorkspaceRow({ ); - return archived ? ( - - {body} - - ) : ( - - {body} - + return ( + + {archived ? ( + + {body} + + ) : ( + + {body} + + )} + ); } diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/CloudWorkspaceRow/components/ArchivedCloudRowMenu/ArchivedCloudRowMenu.tsx b/apps/mobile/screens/(authenticated)/(home)/home/components/CloudWorkspaceRow/components/ArchivedCloudRowMenu/ArchivedCloudRowMenu.tsx index f49d68606c2..5224a62b8fd 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/CloudWorkspaceRow/components/ArchivedCloudRowMenu/ArchivedCloudRowMenu.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/CloudWorkspaceRow/components/ArchivedCloudRowMenu/ArchivedCloudRowMenu.tsx @@ -1,26 +1,20 @@ import { useLingui } from "@lingui/react/macro"; import { Link } from "expo-router"; -import { type ReactNode, useRef } from "react"; +import { useRef } from "react"; import type { ViewInstance } from "react-native"; import { Alert, View } from "react-native"; import { useCloudWorkspaceActions } from "@/hooks/useCloudWorkspaceActions"; import { itemFromCloudRow } from "@/hooks/useCloudWorkspaceItems"; -import type { CloudWorkspaceRow } from "@/hooks/useCloudWorkspaces"; -import type { HostWorkspacesCacheOps } from "@/hooks/useHostWorkspaces"; import { anchorOf } from "@/screens/(authenticated)/components/ToolbarAnchor"; import { useWorkspaceRowActions } from "../../../WorkspaceRow/hooks/useWorkspaceRowActions"; +import type { ArchivedCloudRowMenuProps } from "./ArchivedCloudRowMenu.types"; export function ArchivedCloudRowMenu({ row, cache, onCopied, children, -}: { - row: CloudWorkspaceRow; - cache: HostWorkspacesCacheOps; - onCopied: () => void; - children: ReactNode; -}) { +}: ArchivedCloudRowMenuProps) { const { t } = useLingui(); const { unarchive } = useCloudWorkspaceActions(); const rowRef = useRef(null); diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/CloudWorkspaceRow/components/ArchivedCloudRowMenu/ArchivedCloudRowMenu.types.ts b/apps/mobile/screens/(authenticated)/(home)/home/components/CloudWorkspaceRow/components/ArchivedCloudRowMenu/ArchivedCloudRowMenu.types.ts new file mode 100644 index 00000000000..10f596d0234 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/CloudWorkspaceRow/components/ArchivedCloudRowMenu/ArchivedCloudRowMenu.types.ts @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import type { CloudWorkspaceRow } from "@/hooks/useCloudWorkspaces"; +import type { HostWorkspacesCacheOps } from "@/hooks/useHostWorkspaces"; + +export interface ArchivedCloudRowMenuProps { + row: CloudWorkspaceRow; + cache: HostWorkspacesCacheOps; + onCopied: () => void; + children: ReactNode; +} diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/CloudWorkspaceRow/components/ArchivedCloudRowMenu/ArchivedCloudRowMenu.web.tsx b/apps/mobile/screens/(authenticated)/(home)/home/components/CloudWorkspaceRow/components/ArchivedCloudRowMenu/ArchivedCloudRowMenu.web.tsx new file mode 100644 index 00000000000..54aaf8646cc --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/CloudWorkspaceRow/components/ArchivedCloudRowMenu/ArchivedCloudRowMenu.web.tsx @@ -0,0 +1,54 @@ +import { useLingui } from "@lingui/react/macro"; +import { Alert } from "react-native"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { Text } from "@/components/ui/text"; +import { useCloudWorkspaceActions } from "@/hooks/useCloudWorkspaceActions"; +import { itemFromCloudRow } from "@/hooks/useCloudWorkspaceItems"; +import { useWorkspaceRowActions } from "../../../WorkspaceRow/hooks/useWorkspaceRowActions"; +import type { ArchivedCloudRowMenuProps } from "./ArchivedCloudRowMenu.types"; + +export function ArchivedCloudRowMenu({ + row, + cache, + onCopied, + children, +}: ArchivedCloudRowMenuProps) { + const { t } = useLingui(); + const { unarchive } = useCloudWorkspaceActions(); + const { copyId, shareWorkspace } = useWorkspaceRowActions( + itemFromCloudRow(row), + cache, + [], + row.status, + onCopied, + ); + return ( + + {children} + + + void unarchive(row.id).catch(() => + Alert.alert(t({ message: "Unarchive failed" })), + ) + } + > + {t({ message: "Unarchive" })} + + + + {t({ message: "Copy ID" })} + + shareWorkspace()}> + {t({ message: "Share" })} + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/CloudWorkspaceRow/components/CloudWorkspaceRowMenu/CloudWorkspaceRowMenu.tsx b/apps/mobile/screens/(authenticated)/(home)/home/components/CloudWorkspaceRow/components/CloudWorkspaceRowMenu/CloudWorkspaceRowMenu.tsx index 1edee598abb..a2170b306a9 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/CloudWorkspaceRow/components/CloudWorkspaceRowMenu/CloudWorkspaceRowMenu.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/CloudWorkspaceRow/components/CloudWorkspaceRowMenu/CloudWorkspaceRowMenu.tsx @@ -46,6 +46,7 @@ export function CloudWorkspaceRowMenu({ params: { id: row.id }, }) } + link={{ href: `/workspace/${row.id}`, onCopied }} > {children} diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx b/apps/mobile/screens/(authenticated)/(home)/home/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx index b87bea561ef..75749b7bb23 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx @@ -1,6 +1,6 @@ -import { Stack } from "expo-router"; import { Pressable } from "react-native"; import { Text } from "@/components/ui/text"; +import { HeaderToolbar } from "@/lib/navigation"; import { OrganizationAvatar } from "@/screens/(authenticated)/components/OrganizationAvatar"; export function OrganizationHeaderButton({ @@ -18,8 +18,8 @@ export function OrganizationHeaderButton({ // guess is wrong for everyone who belongs to more than one. if (isLoading) return null; return ( - - + + - - + + ); } diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/ProjectSectionHeader/ProjectSectionHeader.tsx b/apps/mobile/screens/(authenticated)/(home)/home/components/ProjectSectionHeader/ProjectSectionHeader.tsx index c1932d3201c..1ab38ab40ba 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/ProjectSectionHeader/ProjectSectionHeader.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/ProjectSectionHeader/ProjectSectionHeader.tsx @@ -46,7 +46,7 @@ export function ProjectSectionHeader({ onPress={onToggle} accessibilityLabel={`${name}, ${count} ${count === 1 ? "workspace" : "workspaces"}`} accessibilityState={{ expanded: !collapsed }} - className="flex-1 flex-row items-center gap-2.5 active:opacity-60" + className="web:hover:opacity-80 flex-1 flex-row items-center gap-2.5 active:opacity-60" > {/* Two icons rather than one rotated: a transform on the icon itself doesn't reach the underlying SVG, so it renders nothing. */} diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/ScopeBar/components/Chip/Chip.tsx b/apps/mobile/screens/(authenticated)/(home)/home/components/ScopeBar/components/Chip/Chip.tsx index bd9d6d5c386..16842157c7a 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/ScopeBar/components/Chip/Chip.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/ScopeBar/components/Chip/Chip.tsx @@ -18,7 +18,7 @@ export function Chip({ label, leading, onPress }: ChipProps) { onPress={onPress} accessibilityLabel={label} ph-label="scope-chip" - className="bg-secondary/60 flex-row items-center gap-1.5 rounded-full py-1.5 pl-3 pr-2.5 active:opacity-60" + className="bg-secondary/60 web:hover:bg-secondary flex-row items-center gap-1.5 rounded-full py-1.5 pl-3 pr-2.5 active:opacity-60" > {leading} diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/WorkspaceRow.tsx b/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/WorkspaceRow.tsx index 5f2f946d672..07e27d2ef26 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/WorkspaceRow.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/WorkspaceRow.tsx @@ -3,6 +3,7 @@ import type { SelectGithubPullRequest } from "@superset/db/schema"; import { useRouter } from "expo-router"; import { FolderGit2 } from "lucide-react-native"; import { Pressable, View } from "react-native"; +import { RowLink } from "@/components/RowLink"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { Text } from "@/components/ui/text"; @@ -79,142 +80,145 @@ export function WorkspaceRow({ ); const togglePin = usePinnedWorkspacesStore((state) => state.togglePin); + const href = `/workspace/${workspace.id}`; + return ( - togglePin(workspace.id)} - onRename={() => void renameWorkspace()} - onDelete={deleteWorkspace} - onCopyId={copyId} - onShare={shareWorkspace} - > - {/* Default press behavior on purpose: the system context-menu lift - owns the hold animation, and custom press feedback fights it. */} - - router.push(`/(authenticated)/workspace/${workspace.id}`) - } - ph-label="workspace-row" + + togglePin(workspace.id)} + onRename={() => void renameWorkspace()} + onDelete={deleteWorkspace} + onCopyId={copyId} + onShare={shareWorkspace} + link={{ href, onCopied }} > - {/* Desktop WorkspaceIcon semantics: working replaces the icon with + {/* Default press behavior on purpose: the system context-menu lift + owns the hold animation, and custom press feedback fights it. */} + router.push(`/(authenticated)${href}`)} + ph-label="workspace-row" + > + {/* Desktop WorkspaceIcon semantics: working replaces the icon with the braille spinner; other statuses overlay a corner ping on the base icon (PR state when one exists, else the workspace mark). */} - {rowAttention === "working" || cloudStatus === "provisioning" ? ( - - - - ) : ( - - {prIcon && pullRequest ? ( - + ) : ( - - ) : ( - - )} - {rowAttention === "permission" ? ( - - - - ) : rowAttention === "failed" || cloudStatus === "failed" ? ( - - - - ) : rowAttention === "review" ? ( - - ) : null} - - )} - - - {workspace.name} - - - {/* A workspace named after its branch says it twice otherwise — + )} + {rowAttention === "permission" ? ( + + + + ) : rowAttention === "failed" || cloudStatus === "failed" ? ( + + + + ) : rowAttention === "review" ? ( + + ) : null} + + )} + + + {workspace.name} + + + {/* A workspace named after its branch says it twice otherwise — common now that every project shows its `main`. */} - {workspace.branch === workspace.name ? null : ( - - {workspace.branch} - - )} - {diffStats && - (diffStats.additions > 0 || diffStats.deletions > 0) ? ( - <> - {workspace.branch === workspace.name ? null : ( - • - )} - - +{diffStats.additions} −{diffStats.deletions} + {workspace.branch === workspace.name ? null : ( + + {workspace.branch} - - ) : null} - - - {sessions.length > 0 ? ( - // Overlapping avatar-style stack — stays ~fixed-width as sessions - // grow instead of eating the row. - - {sessions.slice(0, MAX_SESSION_MARKS).map((session, index) => ( - 0 && "-ml-2", - )} - > - - - ))} - {sessions.length > MAX_SESSION_MARKS ? ( - - +{sessions.length - MAX_SESSION_MARKS} - - ) : null} + )} + {diffStats && + (diffStats.additions > 0 || diffStats.deletions > 0) ? ( + <> + {workspace.branch === workspace.name ? null : ( + • + )} + + +{diffStats.additions} −{diffStats.deletions} + + + ) : null} + - ) : null} - - + {sessions.length > 0 ? ( + // Overlapping avatar-style stack — stays ~fixed-width as sessions + // grow instead of eating the row. + + {sessions.slice(0, MAX_SESSION_MARKS).map((session, index) => ( + 0 && "-ml-2", + )} + > + + + ))} + {sessions.length > MAX_SESSION_MARKS ? ( + + +{sessions.length - MAX_SESSION_MARKS} + + ) : null} + + ) : null} + + + ); } diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx b/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx index 31440cd4897..17a32a987ca 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx @@ -1,9 +1,10 @@ import { useLingui } from "@lingui/react/macro"; import { Link } from "expo-router"; -import { type ReactNode, useRef } from "react"; +import { useRef } from "react"; import type { ViewInstance } from "react-native"; import { View } from "react-native"; import { anchorOf } from "@/screens/(authenticated)/components/ToolbarAnchor"; +import type { WorkspaceRowMenuProps } from "./WorkspaceRowMenu.types"; export function WorkspaceRowMenu({ pinned, @@ -18,22 +19,7 @@ export function WorkspaceRowMenu({ onCopyId, onShare, children, -}: { - pinned: boolean; - onTogglePin?: () => void; - canRename: boolean; - canDelete: boolean; - /** A cloud workspace is archived, not deleted. */ - isCloud: boolean; - isUnread: boolean; - onToggleUnread: () => void; - onRename: () => void; - onDelete: () => void; - onCopyId: () => void; - /** Gets the row's tag, so iPad's share popover points at the row. */ - onShare: (anchor?: number) => void; - children: ReactNode; -}) { +}: WorkspaceRowMenuProps) { const { t } = useLingui(); const rowRef = useRef(null); // Tap navigation lives on the row itself; the Link exists solely because diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.types.ts b/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.types.ts new file mode 100644 index 00000000000..92b7218b763 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.types.ts @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; + +export interface WorkspaceRowMenuProps { + pinned: boolean; + onTogglePin?: () => void; + canRename: boolean; + canDelete: boolean; + /** A cloud workspace is archived, not deleted. */ + isCloud: boolean; + isUnread: boolean; + onToggleUnread: () => void; + onRename: () => void; + onDelete: () => void; + onCopyId: () => void; + /** Gets the row's tag, so iPad's share popover points at the row. */ + onShare: (anchor?: number) => void; + /** The row's app path, for menus that can open or copy it as a link. */ + link?: { href: string; onCopied: () => void }; + children: ReactNode; +} diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.web.tsx b/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.web.tsx new file mode 100644 index 00000000000..65ee2d7f477 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.web.tsx @@ -0,0 +1,92 @@ +import { useLingui } from "@lingui/react/macro"; +import * as Clipboard from "expo-clipboard"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { Text } from "@/components/ui/text"; +import { appUrl } from "@/lib/app-url"; +import type { WorkspaceRowMenuProps } from "./WorkspaceRowMenu.types"; + +export function WorkspaceRowMenu({ + pinned, + onTogglePin, + canRename, + canDelete, + isCloud, + isUnread, + onToggleUnread, + onRename, + onDelete, + onCopyId, + onShare, + link, + children, +}: WorkspaceRowMenuProps) { + const { t } = useLingui(); + const url = link + ? new URL(appUrl(link.href), window.location.href).href + : null; + return ( + + {children} + + + + {isUnread + ? t({ message: "Mark as Read" }) + : t({ message: "Mark as Unread" })} + + + {onTogglePin ? ( + + + {pinned ? t({ message: "Unpin" }) : t({ message: "Pin" })} + + + ) : null} + {canRename ? ( + + {t({ message: "Rename" })} + + ) : null} + {canDelete && isCloud ? ( + + {t({ message: "Archive" })} + + ) : null} + {canDelete && !isCloud ? ( + + {t({ message: "Delete" })} + + ) : null} + + {url && link ? ( + <> + window.open(url, "_blank", "noopener")} + > + {t({ message: "Open in new tab" })} + + + void Clipboard.setStringAsync(url).then(link.onCopied) + } + > + {t({ message: "Copy link" })} + + + ) : null} + + {t({ message: "Copy ID" })} + + onShare()}> + {t({ message: "Share" })} + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/useAgentLiveActivity.ts b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/useAgentLiveActivity.ts index d92440e74d8..e8410eedba2 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/useAgentLiveActivity.ts +++ b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/useAgentLiveActivity.ts @@ -7,6 +7,7 @@ import LiveActivity, { import { useEffect, useRef } from "react"; import { AppState } from "react-native"; import type { TerminalAttention, TerminalRowData } from "../useHostTerminals"; +import { requestCardPermission } from "./utils/requestCardPermission"; /** * Rows the Lock Screen card can actually show. Four is the measured ceiling: @@ -79,7 +80,6 @@ export function useAgentLiveActivity({ } return; } - if (!LiveActivity.areActivitiesEnabled()) return; // A Live Activity can only be started from the foreground — // `ActivityAuthorizationError.visibility` otherwise. if (AppState.currentState !== "active") return; @@ -152,6 +152,9 @@ export function useAgentLiveActivity({ runId.current += 1; const generation = runId.current; const deliver = async () => { + // Asked here, not at launch: the first moment there is a card to show. + if (!(await requestCardPermission())) return; + if (generation !== runId.current) return; for (const row of shown) { const project = workspaceById.get(row.workspaceId)?.projectId; const icon = project ? projectById.get(project) : undefined; diff --git a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/utils/requestCardPermission/askOnce.test.ts b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/utils/requestCardPermission/askOnce.test.ts new file mode 100644 index 00000000000..fd92e92af0f --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/utils/requestCardPermission/askOnce.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { askOnce } from "./askOnce"; + +function deps({ allowedAfterAsk }: { allowedAfterAsk: boolean }) { + let allowed = false; + let asked = false; + let prompts = 0; + return { + get prompts() { + return prompts; + }, + isAllowed: () => allowed, + wasAsked: async () => asked, + markAsked: async () => { + asked = true; + }, + ask: async () => { + prompts += 1; + allowed = allowedAfterAsk; + }, + }; +} + +describe("askOnce", () => { + test("prompts the first time and reports the answer", async () => { + const granted = deps({ allowedAfterAsk: true }); + expect(await askOnce(granted)).toBe(true); + expect(granted.prompts).toBe(1); + }); + + test("never prompts again after a denial", async () => { + const denied = deps({ allowedAfterAsk: false }); + expect(await askOnce(denied)).toBe(false); + expect(await askOnce(denied)).toBe(false); + expect(denied.prompts).toBe(1); + }); + + test("does not prompt when already allowed", async () => { + const prompts: string[] = []; + const allowed = await askOnce({ + isAllowed: () => true, + wasAsked: async () => false, + markAsked: async () => {}, + ask: async () => prompts.push("asked"), + }); + expect(allowed).toBe(true); + expect(prompts).toEqual([]); + }); + + test("a failing prompt counts as not allowed", async () => { + const allowed = await askOnce({ + isAllowed: () => false, + wasAsked: async () => false, + markAsked: async () => {}, + ask: async () => { + throw new Error("no activity"); + }, + }); + expect(allowed).toBe(false); + }); +}); diff --git a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/utils/requestCardPermission/askOnce.ts b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/utils/requestCardPermission/askOnce.ts new file mode 100644 index 00000000000..1c6958b5825 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/utils/requestCardPermission/askOnce.ts @@ -0,0 +1,19 @@ +export interface AskOnceDeps { + isAllowed: () => boolean; + wasAsked: () => Promise; + markAsked: () => Promise; + ask: () => Promise; +} + +/** A denial is final: the card is a nicety, not worth a second prompt. */ +export async function askOnce(deps: AskOnceDeps): Promise { + try { + if (deps.isAllowed()) return true; + if (await deps.wasAsked()) return false; + await deps.markAsked(); + await deps.ask(); + return deps.isAllowed(); + } catch { + return false; + } +} diff --git a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/utils/requestCardPermission/index.ts b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/utils/requestCardPermission/index.ts new file mode 100644 index 00000000000..af0504f9c6a --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/utils/requestCardPermission/index.ts @@ -0,0 +1 @@ +export { requestCardPermission } from "./requestCardPermission"; diff --git a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/utils/requestCardPermission/requestCardPermission.android.ts b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/utils/requestCardPermission/requestCardPermission.android.ts new file mode 100644 index 00000000000..12cb1662470 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/utils/requestCardPermission/requestCardPermission.android.ts @@ -0,0 +1,21 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import LiveActivity from "@superset/live-activity"; +import { PermissionsAndroid, Platform } from "react-native"; +import { askOnce } from "./askOnce"; +import type { RequestCardPermission } from "./requestCardPermission.types"; + +const ASKED_KEY = "agent-card-notification-permission-asked"; + +export const requestCardPermission: RequestCardPermission = () => + askOnce({ + isAllowed: () => LiveActivity.areActivitiesEnabled(), + wasAsked: async () => (await AsyncStorage.getItem(ASKED_KEY)) !== null, + markAsked: () => AsyncStorage.setItem(ASKED_KEY, "1"), + // Below Android 13 notifications need no runtime permission. + ask: async () => { + if (Number(Platform.Version) < 33) return; + await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, + ); + }, + }); diff --git a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/utils/requestCardPermission/requestCardPermission.ts b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/utils/requestCardPermission/requestCardPermission.ts new file mode 100644 index 00000000000..df4dc5eefb9 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/utils/requestCardPermission/requestCardPermission.ts @@ -0,0 +1,6 @@ +import LiveActivity from "@superset/live-activity"; +import type { RequestCardPermission } from "./requestCardPermission.types"; + +// A Live Activity needs no permission prompt. +export const requestCardPermission: RequestCardPermission = async () => + LiveActivity.areActivitiesEnabled(); diff --git a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/utils/requestCardPermission/requestCardPermission.types.ts b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/utils/requestCardPermission/requestCardPermission.types.ts new file mode 100644 index 00000000000..ba64daafc78 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity/utils/requestCardPermission/requestCardPermission.types.ts @@ -0,0 +1,5 @@ +/** + * Asks the system, at most once per install, to let the agent card show. + * Resolves whether it may show now. Never throws. + */ +export type RequestCardPermission = () => Promise; diff --git a/apps/mobile/screens/(authenticated)/(home)/new-session/agent/AgentPickerScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/new-session/agent/AgentPickerScreen.tsx index d3faac99f3f..048903c3af3 100644 --- a/apps/mobile/screens/(authenticated)/(home)/new-session/agent/AgentPickerScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/new-session/agent/AgentPickerScreen.tsx @@ -2,7 +2,7 @@ import Ionicons from "@expo/vector-icons/Ionicons"; import { Trans, useLingui } from "@lingui/react/macro"; import { isCloudAgentId } from "@superset/shared/cloud-agent-launch"; import { HOST_AGENT_PRESETS } from "@superset/shared/host-agent-presets"; -import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useLocalSearchParams, useRouter } from "expo-router"; import { SquareTerminal } from "lucide-react-native"; import { Image, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -13,6 +13,7 @@ import { useOrgHosts } from "@/hooks/useOrgHosts"; import { useTheme } from "@/hooks/useTheme"; import { agentIconSource } from "@/lib/agent-icons"; import { hostServiceUrl } from "@/lib/host-service/client"; +import { HeaderToolbar } from "@/lib/navigation"; import { posthog } from "@/lib/posthog"; import { CLOUD_TARGET_ID } from "@/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useNewChatTargets"; import { useNewSessionPreferencesStore } from "@/screens/(authenticated)/(home)/home/components/NewChatWidget/stores/newSessionPreferencesStore"; @@ -140,9 +141,13 @@ export function AgentPickerScreen() { paddingBottom: insets.bottom + 8, }} > - - router.back()} /> - + + router.back()} + /> + {isLoading ? ( diff --git a/apps/mobile/screens/(authenticated)/(home)/new-session/branch/BranchPickerScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/new-session/branch/BranchPickerScreen.tsx index e9a175553c8..c1668239d78 100644 --- a/apps/mobile/screens/(authenticated)/(home)/new-session/branch/BranchPickerScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/new-session/branch/BranchPickerScreen.tsx @@ -1,7 +1,7 @@ import Ionicons from "@expo/vector-icons/Ionicons"; import { Trans, useLingui } from "@lingui/react/macro"; import { useQuery } from "@tanstack/react-query"; -import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useLocalSearchParams, useRouter } from "expo-router"; import { useMemo, useState } from "react"; import { Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -15,6 +15,7 @@ import { getHostServiceClientByUrl, hostServiceUrl, } from "@/lib/host-service/client"; +import { HeaderToolbar } from "@/lib/navigation"; import { posthog } from "@/lib/posthog"; import { apiClient } from "@/lib/trpc/client"; import { CLOUD_TARGET_ID } from "@/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useNewChatTargets"; @@ -141,9 +142,13 @@ export function BranchPickerScreen() { return ( <> - - router.back()} /> - + + router.back()} + /> + {/* The formSheet's content wrapper special-cases its direct subviews: it expects [header, ScrollView] and sizes the ScrollView to the remainder (react-native-screens RNSScreenContentWrapper). Any diff --git a/apps/mobile/screens/(authenticated)/(home)/new-session/environment/EnvironmentPickerScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/new-session/environment/EnvironmentPickerScreen.tsx index 6e7f27d380b..1974e682db3 100644 --- a/apps/mobile/screens/(authenticated)/(home)/new-session/environment/EnvironmentPickerScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/new-session/environment/EnvironmentPickerScreen.tsx @@ -1,6 +1,6 @@ import Ionicons from "@expo/vector-icons/Ionicons"; import { Trans, useLingui } from "@lingui/react/macro"; -import { Stack, useRouter } from "expo-router"; +import { useRouter } from "expo-router"; import { Layers } from "lucide-react-native"; import { Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { Text } from "@/components/ui/text"; import { useTheme } from "@/hooks/useTheme"; +import { HeaderToolbar } from "@/lib/navigation"; import { posthog } from "@/lib/posthog"; import { useNewSessionPreferencesStore } from "@/screens/(authenticated)/(home)/home/components/NewChatWidget/stores/newSessionPreferencesStore"; import { useCloudCreateSelection } from "@/screens/(authenticated)/(home)/hooks/useCloudCreateSelection"; @@ -53,9 +54,13 @@ export function EnvironmentPickerScreen() { paddingBottom: insets.bottom + 8, }} > - - router.back()} /> - + + router.back()} + /> + {environmentsQuery.isPending ? ( diff --git a/apps/mobile/screens/(authenticated)/(home)/new-session/project/ProjectPickerScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/new-session/project/ProjectPickerScreen.tsx index 119d363f15d..847f9bb9ca6 100644 --- a/apps/mobile/screens/(authenticated)/(home)/new-session/project/ProjectPickerScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/new-session/project/ProjectPickerScreen.tsx @@ -1,12 +1,13 @@ import Ionicons from "@expo/vector-icons/Ionicons"; -import { Trans } from "@lingui/react/macro"; -import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { Trans, useLingui } from "@lingui/react/macro"; +import { useLocalSearchParams, useRouter } from "expo-router"; import { Box } from "lucide-react-native"; import { Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Icon } from "@/components/ui/icon"; import { Text } from "@/components/ui/text"; import { useTheme } from "@/hooks/useTheme"; +import { HeaderToolbar } from "@/lib/navigation"; import { posthog } from "@/lib/posthog"; import { ProjectAvatar } from "@/screens/(authenticated)/(home)/filter/components/ProjectAvatar"; import { @@ -21,6 +22,7 @@ import { useNewSessionPreferencesStore } from "@/screens/(authenticated)/(home)/ * doesn't open this at all. */ export function ProjectPickerScreen() { + const { t } = useLingui(); const router = useRouter(); const routeParams = useLocalSearchParams<{ selectedKey?: string }>(); const theme = useTheme(); @@ -86,9 +88,13 @@ export function ProjectPickerScreen() { paddingBottom: insets.bottom + 8, }} > - - router.back()} /> - + + router.back()} + /> + {targets.length === 0 ? ( >>(); - const theme = useTheme(); - const searchBarRef = useRef(null); + const searchBarRef = useRef(null); const [query, setQuery] = useState(""); + const clearQuery = useCallback(() => setQuery(""), []); const sort = useWorkspacesFilterStore((store) => store.sort); const selectedHost = useSelectedHost(); const cloudScope = useWorkspaceScope() === "cloud"; @@ -175,32 +175,32 @@ export function SearchScreen() { }, [router], ); + const close = useCallback(() => router.back(), [router]); + const openFirst = useCallback(() => { + const first = results[0]; + if (first) open(first); + }, [results, open]); return ( <> - - + router.back()} + onPress={close} /> - - + setQuery(event.nativeEvent.text)} - onCancelButtonPress={() => setQuery("")} + onChangeText={setQuery} + onCancel={clearQuery} + onSubmit={openFirst} + onClose={close} /> open(workspace)} - ph-label="search-workspace-row" - > - - - - - - {workspace.name} - - - {projectName - ? `${projectName} · ${workspace.branch}` - : workspace.branch} - - - + + open(workspace)} + ph-label="search-workspace-row" + > + + + + + + {workspace.name} + + + {projectName + ? `${projectName} · ${workspace.branch}` + : workspace.branch} + + + + ); }} ListEmptyComponent={ diff --git a/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/SearchBar.ios.tsx b/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/SearchBar.ios.tsx new file mode 100644 index 00000000000..3a42988720e --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/SearchBar.ios.tsx @@ -0,0 +1,33 @@ +import { Stack } from "expo-router"; +import { useImperativeHandle, useRef } from "react"; +import type { SearchBarCommands } from "react-native-screens"; +import { useTheme } from "@/hooks/useTheme"; +import type { SearchBarComponent } from "./SearchBar.types"; + +export const SearchBar: SearchBarComponent = ({ + ref, + placeholder, + onChangeText, + onCancel, +}) => { + const theme = useTheme(); + const searchBarRef = useRef(null); + useImperativeHandle(ref, () => ({ + focus: () => searchBarRef.current?.focus(), + })); + return ( + onChangeText(event.nativeEvent.text)} + onCancelButtonPress={onCancel} + /> + ); +}; diff --git a/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/SearchBar.tsx b/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/SearchBar.tsx new file mode 100644 index 00000000000..e7ae4ce76c6 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/SearchBar.tsx @@ -0,0 +1,67 @@ +import { useLingui } from "@lingui/react/macro"; +import { type NativeStackHeaderProps, Stack } from "expo-router"; +import { X } from "lucide-react-native"; +import { useCallback, useImperativeHandle, useRef } from "react"; +import { + Pressable, + TextInput, + type TextInputInstance, + View, +} from "react-native"; +import { Icon } from "@/components/ui/icon"; +import { useTheme } from "@/hooks/useTheme"; +import { SheetHeaderSlot } from "@/lib/navigation"; +import { focusOnMount } from "./focusOnMount"; +import type { SearchBarComponent } from "./SearchBar.types"; + +/** A form sheet draws no native bar here, so the field is the header, drawn in JS. */ +export const SearchBar: SearchBarComponent = ({ + ref, + placeholder, + onChangeText, + onSubmit, + onClose, +}) => { + const { t } = useLingui(); + const theme = useTheme(); + const inputRef = useRef(null); + useImperativeHandle(ref, () => ({ + focus: () => inputRef.current?.focus(), + })); + const header = useCallback( + ({ options }: NativeStackHeaderProps) => ( + + + + + + + + + + + + ), + [placeholder, onChangeText, onSubmit, onClose, theme, t], + ); + return ; +}; diff --git a/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/SearchBar.types.ts b/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/SearchBar.types.ts new file mode 100644 index 00000000000..8645fa228c3 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/SearchBar.types.ts @@ -0,0 +1,19 @@ +import type { ReactNode, Ref } from "react"; + +export interface SearchBarHandle { + focus: () => void; +} + +export interface SearchBarProps { + ref?: Ref; + placeholder: string; + onChangeText: (text: string) => void; + onCancel: () => void; + /** Return pressed in the field. */ + onSubmit?: () => void; + /** Closes the sheet, where its header has no close button of its own. */ + onClose: () => void; +} + +/** The search sheet's query field, in its header. */ +export type SearchBarComponent = (props: SearchBarProps) => ReactNode; diff --git a/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/focusOnMount.ts b/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/focusOnMount.ts new file mode 100644 index 00000000000..7a7799d9298 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/focusOnMount.ts @@ -0,0 +1,4 @@ +import type { FocusOnMount } from "./focusOnMount.types"; + +// Android focuses on the sheet's transitionEnd instead: at mount the field is not in the header yet. +export const focusOnMount: FocusOnMount = false; diff --git a/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/focusOnMount.types.ts b/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/focusOnMount.types.ts new file mode 100644 index 00000000000..89eaada5ddd --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/focusOnMount.types.ts @@ -0,0 +1,2 @@ +/** Whether the field takes focus as it mounts, before any sheet transition. */ +export type FocusOnMount = boolean; diff --git a/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/focusOnMount.web.ts b/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/focusOnMount.web.ts new file mode 100644 index 00000000000..1d249a9871a --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/focusOnMount.web.ts @@ -0,0 +1,4 @@ +import type { FocusOnMount } from "./focusOnMount.types"; + +// No sheet transition runs on the web, so no transitionEnd arrives to focus it. +export const focusOnMount: FocusOnMount = true; diff --git a/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/index.ts b/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/index.ts new file mode 100644 index 00000000000..d43c7b00162 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/search/components/SearchBar/index.ts @@ -0,0 +1,2 @@ +export { SearchBar } from "./SearchBar"; +export type { SearchBarHandle } from "./SearchBar.types"; diff --git a/apps/mobile/screens/(authenticated)/components/AgentOptionsScreen/AgentOptionsScreen.tsx b/apps/mobile/screens/(authenticated)/components/AgentOptionsScreen/AgentOptionsScreen.tsx index eda5a9aeed0..976fb7fe435 100644 --- a/apps/mobile/screens/(authenticated)/components/AgentOptionsScreen/AgentOptionsScreen.tsx +++ b/apps/mobile/screens/(authenticated)/components/AgentOptionsScreen/AgentOptionsScreen.tsx @@ -6,6 +6,7 @@ import { Pressable, ScrollView } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/ui/text"; import { useTheme } from "@/hooks/useTheme"; +import { HeaderToolbar } from "@/lib/navigation"; import { posthog } from "@/lib/posthog"; import { useNewSessionPreferencesStore } from "@/screens/(authenticated)/(home)/home/components/NewChatWidget/stores/newSessionPreferencesStore"; import { OptionRow } from "@/screens/(authenticated)/components/OptionRow"; @@ -67,9 +68,13 @@ export function AgentOptionsScreen({ - - router.back()} /> - + + router.back()} + /> + {models !== undefined ? ( <> diff --git a/apps/mobile/screens/(authenticated)/components/HeaderNotice/HeaderNotice.tsx b/apps/mobile/screens/(authenticated)/components/HeaderNotice/HeaderNotice.tsx index ecdab188006..146d5c6adb3 100644 --- a/apps/mobile/screens/(authenticated)/components/HeaderNotice/HeaderNotice.tsx +++ b/apps/mobile/screens/(authenticated)/components/HeaderNotice/HeaderNotice.tsx @@ -1,6 +1,6 @@ -import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; import { useEffect, useRef } from "react"; import { Animated, Easing, View } from "react-native"; +import { GlassSurface } from "@/components/GlassSurface"; import { Text } from "@/components/ui/text"; const IN_MS = 180; @@ -59,20 +59,9 @@ export function HeaderNotice({ return ( - {isLiquidGlassAvailable() ? ( - - {label} - - ) : ( - {label} - )} + + {label} + ); } diff --git a/apps/mobile/screens/(authenticated)/components/ListRow/ListRow.tsx b/apps/mobile/screens/(authenticated)/components/ListRow/ListRow.tsx index 188b3e300b1..1603a89fadc 100644 --- a/apps/mobile/screens/(authenticated)/components/ListRow/ListRow.tsx +++ b/apps/mobile/screens/(authenticated)/components/ListRow/ListRow.tsx @@ -27,7 +27,7 @@ export function ListRow({ disabled={!onPress} // Not the row's own label: some of these are host names. ph-label="list-row" - className="flex-row items-center gap-3 py-4" + className="web:hover:opacity-80 flex-row items-center gap-3 py-4" style={ isLast ? undefined diff --git a/apps/mobile/screens/(authenticated)/components/ShareAccess/ShareAccessSheet/ShareAccessSheet.tsx b/apps/mobile/screens/(authenticated)/components/ShareAccess/ShareAccessSheet/ShareAccessSheet.tsx index 93ef17bc3bf..928ed4184fc 100644 --- a/apps/mobile/screens/(authenticated)/components/ShareAccess/ShareAccessSheet/ShareAccessSheet.tsx +++ b/apps/mobile/screens/(authenticated)/components/ShareAccess/ShareAccessSheet/ShareAccessSheet.tsx @@ -8,12 +8,13 @@ import { } from "@superset/shared/sharing"; import * as Clipboard from "expo-clipboard"; import * as Haptics from "expo-haptics"; -import { Stack, useRouter } from "expo-router"; +import { useRouter } from "expo-router"; import { Check, Link2, Search } from "lucide-react-native"; import { type ReactNode, useState } from "react"; import { Pressable, ScrollView, useWindowDimensions, View } from "react-native"; import { Icon } from "@/components/ui/icon"; import { Text } from "@/components/ui/text"; +import { HeaderToolbar } from "@/lib/navigation"; import type { GeneralAccess } from "../types"; import { granteeRefOf } from "../utils/granteeRefOf"; import { GeneralAccessRow } from "./components/GeneralAccessRow"; @@ -87,13 +88,13 @@ export function ShareAccessSheet({ return ( <> - - + router.back()} /> - + - - + router.back()} /> - + - - + router.back()} /> - + >>(); - const theme = useTheme(); - const searchBarRef = useRef(null); + const searchBarRef = useRef(null); const [query, setQuery] = useState(""); const [staged, setStaged] = useState([]); const [pickRoles, setPickRoles] = useState>({}); @@ -147,19 +148,14 @@ export function ShareInviteScreen({ label={current?.label} a11yLabel={t({ message: "Access for this person" })} size={14} - chevron="chevron.up.chevron.down" - > - {roles.map((option) => ( -