diff --git a/config/scripts/mobile-web-app/session/mobile-web-app-native-chat-touch-scroll-render.test.mjs b/config/scripts/mobile-web-app/session/mobile-web-app-native-chat-touch-scroll-render.test.mjs new file mode 100644 index 00000000000..72f2380b7ed --- /dev/null +++ b/config/scripts/mobile-web-app/session/mobile-web-app-native-chat-touch-scroll-render.test.mjs @@ -0,0 +1,141 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import * as esbuild from 'esbuild' +import { chromium } from 'playwright-core' +import { + MOBILE_WEB_APP_ROOT_RESET, + mobileWebAppBuildOptions +} from '../../build/mobile-web-app/build-mobile-web-app-bundle.mjs' +import { mobileWebAppDependenciesPresent } from '../mobile-web-app-bundle-dependencies.mjs' +import { createBundleServer, readShellCsp } from '../preview/mobile-web-app-render-harness.mjs' + +// The phone's web pages must pan the transcript by touch and give a pinch to its text-size detector, not page zoom. +const mobileDir = fileURLToPath(new URL('../../../../mobile', import.meta.url)) + +const entry = ` +import { createRoot } from 'react-dom/client' +import { Text, View } from 'react-native' +import { GestureHandlerRootView } from 'react-native-gesture-handler' +import { MobileNativeChatPageScroll } from './src/session/native-chat/transcript/MobileNativeChatPageScroll' +import { + MobileNativeChatPinchZoom, + useMobileNativeChatPinchGesture +} from './src/session/native-chat/transcript/mobile-native-chat-pinch-zoom' + +function Transcript() { + const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture('chat') + return + {fontScale} + + + + {Array.from({ length: 60 }, (_, i) => message {i})} + + + + +} +createRoot(document.getElementById('root')).render() +` + +const bundles = mobileWebAppDependenciesPresent() +const describeRender = bundles ? describe : describe.skip +let scratch +let server +let origin +let browser + +beforeAll(async () => { + if (!bundles) { + return + } + await mkdir(join(mobileDir, '.tmp'), { recursive: true }) + scratch = await mkdtemp(join(mobileDir, '.tmp', 'chat-touch-scroll-')) + const outDir = join(scratch, 'bundle') + await esbuild.build({ + ...mobileWebAppBuildOptions([]), + entryPoints: undefined, + stdin: { contents: entry, resolveDir: mobileDir, loader: 'tsx', sourcefile: 'transcript.tsx' }, + splitting: false, + write: true, + outdir: outDir, + entryNames: 'transcript', + metafile: false + }) + await writeFile( + join(outDir, 'index.html'), + '' + + '' + + `${MOBILE_WEB_APP_ROOT_RESET}` + + '
' + ) + const served = await createBundleServer({ outDir, cspHeader: await readShellCsp() }) + server = served.server + origin = served.origin + const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER + browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) }) +}, 180_000) + +afterAll(async () => { + await browser?.close() + server?.close() + if (scratch) { + await rm(scratch, { recursive: true, force: true }) + } +}) + +async function touch(page, steps) { + const cdp = await page.context().newCDPSession(page) + const [first, ...rest] = steps + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: first }) + for (const touchPoints of rest) { + await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints }) + await page.evaluate(() => new Promise(requestAnimationFrame)) + } + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) + await cdp.detach() +} + +const frames = (count, at) => Array.from({ length: count + 1 }, (_, step) => at(step / count)) + +async function openTranscript() { + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + hasTouch: true, + isMobile: true + }) + const page = await context.newPage() + await page.goto(`${origin}/`) + await page.getByTestId('transcript').waitFor() + return page +} + +describeRender('native chat transcript in the phone web pages', () => { + it('pans the transcript under one finger', async () => { + const page = await openTranscript() + await touch( + page, + frames(10, (t) => [{ x: 195, y: 200 + 300 * t }]) + ) + await expect + .poll(() => page.getByTestId('transcript').evaluate((element) => element.scrollTop)) + .toBeLessThan(-100) + await page.context().close() + }) + + it('gives a pinch to the text size, not to page zoom', async () => { + const page = await openTranscript() + await touch( + page, + frames(10, (t) => [ + { x: 195, y: 330 - 120 * t, id: 0 }, + { x: 195, y: 370 + 120 * t, id: 1 } + ]) + ) + await expect.poll(() => page.getByTestId('font-scale').textContent()).not.toBe('1') + expect(await page.evaluate(() => window.visualViewport.scale)).toBe(1) + await page.context().close() + }) +}) diff --git a/mobile/src/session/native-chat/MobileNativeChatView.tsx b/mobile/src/session/native-chat/MobileNativeChatView.tsx index 5e701c297d3..5fae6bdcdae 100644 --- a/mobile/src/session/native-chat/MobileNativeChatView.tsx +++ b/mobile/src/session/native-chat/MobileNativeChatView.tsx @@ -9,7 +9,7 @@ import { import { type NativeScrollEvent, type NativeSyntheticEvent, View } from 'react-native' import { MobileNativeChatSendErrorBanner } from './MobileNativeChatSendErrorBanner' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' +import { GestureHandlerRootView } from 'react-native-gesture-handler' import type { NativeChatMessage } from '../../../../src/shared/native-chat/native-chat-types' import type { NativeChatLiveTurnIndicator, @@ -31,7 +31,7 @@ import { } from './transcript/mobile-native-chat-render-data' import { useMobileNativeChatTransientData } from './transcript/use-mobile-native-chat-transient-data' import { useMobileNativeChatRowKeys } from './transcript/mobile-native-chat-row-keys' -import { useMobileNativeChatPinchGesture } from './transcript/use-mobile-native-chat-pinch-gesture' +import { useMobileNativeChatPinchGesture } from './transcript/mobile-native-chat-pinch-zoom' import { useMobileNativeChatTailFollow } from './transcript/use-mobile-native-chat-tail-follow' import { useMobileNativeChatTurnDisclosure } from './transcript/use-mobile-native-chat-turn-disclosure' import { mobileNativeChatComposerPlaceholder } from './composer/use-mobile-native-chat-input-lease' @@ -404,16 +404,15 @@ export function MobileNativeChatView({ roots={disclosures.roots} runPlacements={rowKeys.runPlacements} > - - - + + + + ) } return ( - + + + ) } diff --git a/mobile/src/session/native-chat/transcript/use-mobile-native-chat-pinch-gesture.ts b/mobile/src/session/native-chat/transcript/mobile-native-chat-pinch-zoom.tsx similarity index 64% rename from mobile/src/session/native-chat/transcript/use-mobile-native-chat-pinch-gesture.ts rename to mobile/src/session/native-chat/transcript/mobile-native-chat-pinch-zoom.tsx index 13dc49e9ee1..b6e60b0cc4e 100644 --- a/mobile/src/session/native-chat/transcript/use-mobile-native-chat-pinch-gesture.ts +++ b/mobile/src/session/native-chat/transcript/mobile-native-chat-pinch-zoom.tsx @@ -1,5 +1,5 @@ -import { useMemo, useRef, useState } from 'react' -import { Gesture } from 'react-native-gesture-handler' +import { useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react' +import { Gesture, GestureDetector } from 'react-native-gesture-handler' import type { LegacyComposedGesture } from 'react-native-gesture-handler' import { quantizeFontScale } from './mobile-native-chat-message-text' @@ -39,3 +39,21 @@ export function useMobileNativeChatPinchGesture(scopeKey: string): { return { fontScale, pinchGesture } } + +// Web: gesture handler 3 defaults to touch-action: none (froze touch scrolling); auto would let a pinch zoom the page instead of the text. +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: valid CSS that the handler copies verbatim to style.touchAction; its union lists single keywords only. +const PAN_WITHOUT_PAGE_ZOOM = 'pan-x pan-y' as ComponentProps['touchAction'] + +export function MobileNativeChatPinchZoom({ + gesture, + children +}: { + gesture: LegacyComposedGesture + children: ReactNode +}) { + return ( + + {children} + + ) +}