diff --git a/config/scripts/mobile-web-app-source-control-keyboard.test.mjs b/config/scripts/mobile-web-app-source-control-keyboard.test.mjs index e28a6af0234..3eb9ac824fd 100644 --- a/config/scripts/mobile-web-app-source-control-keyboard.test.mjs +++ b/config/scripts/mobile-web-app-source-control-keyboard.test.mjs @@ -25,17 +25,6 @@ const HUB = 'app/h/[hostId]/source-control/[worktreeId].tsx' const REVIEW = 'app/h/[hostId]/review/[worktreeId].tsx' const SEAM = 'src/platform/keyboard-occlusion.web.ts' -/** - * The one module that subscribes to the stub and is not the seam, exempt by name. - * - * `mounted-bottom-drawer.tsx` reads more than a height: `Keyboard.metrics()` for a sheet opened - * over an already-raised keyboard, and each event's `duration` to animate with it. The seam models - * neither, and the drawer is in C1's, C2's, C3's and C5's closures as well as these two, so moving - * it is a change to every page rather than to this domain. Its listeners are inert on the web the - * same way, which is exactly why the composer inside it takes its own padding here. - */ -const SUBSCRIBES_TO_THE_STUB = ['src/components/mounted-bottom-drawer.tsx'] - /** * The seam itself, which is the one place allowed to name the stub. * @@ -56,7 +45,9 @@ function keyboardSubscribers(closure, rootDir = mobileDir) { .filter((file) => !SEAM_FILES.includes(file)) .filter((file) => { try { - return readFileSync(join(rootDir, file), 'utf8').includes('Keyboard.addListener') + const source = readFileSync(join(rootDir, file), 'utf8') + // `metrics()` too: the stub has none, so a page module calling it throws on the spot. + return source.includes('Keyboard.addListener') || source.includes('Keyboard.metrics') } catch { return false } @@ -69,7 +60,7 @@ describeClosure( () => { it.each([HUB, REVIEW])('measures it through the seam and nowhere else: %s', async (route) => { const closure = await mobileWebAppRouteClosure(route) - expect(keyboardSubscribers(closure)).toEqual(SUBSCRIBES_TO_THE_STUB) + expect(keyboardSubscribers(closure)).toEqual([]) }) it.each([HUB, REVIEW])('carries the seam, so the rule is not vacuous: %s', async (route) => { @@ -102,17 +93,6 @@ describeClosure( rmSync(root, { recursive: true, force: true }) } }) - - it('names an exemption that is really in both closures, so it cannot outlive its subject', async () => { - const [hub, review] = await Promise.all([ - mobileWebAppRouteClosure(HUB), - mobileWebAppRouteClosure(REVIEW) - ]) - for (const file of SUBSCRIBES_TO_THE_STUB) { - expect(hub.local, file).toContain(file) - expect(review.local, file).toContain(file) - } - }) }, 240_000 ) diff --git a/mobile/src/components/mounted-bottom-drawer-keyboard-seam.test.tsx b/mobile/src/components/mounted-bottom-drawer-keyboard-seam.test.tsx new file mode 100644 index 00000000000..a8334280bb0 --- /dev/null +++ b/mobile/src/components/mounted-bottom-drawer-keyboard-seam.test.tsx @@ -0,0 +1,172 @@ +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +type Harness = { + /** What `currentSoftKeyboardHeight()` answers: a keyboard already up when the sheet opens. */ + current: number + show: ((height: number, duration: number) => void) | null + hide: ((duration: number) => void) | null + timings: { to: number; duration: number | undefined }[] + /** Every shared-value write: a seed lands here directly, without a timing. */ + writes: number[] +} + +const harness = vi.hoisted((): Harness => ({ + current: 0, + show: null, + hide: null, + timings: [], + writes: [] +})) + +vi.mock('../platform/keyboard-occlusion', () => ({ + currentSoftKeyboardHeight: () => harness.current, + subscribeSoftKeyboard: ( + show: (height: number, duration: number) => void, + hide: (duration: number) => void + ) => { + harness.show = show + harness.hide = hide + return () => { + harness.show = null + harness.hide = null + } + } +})) +vi.mock('../navigation/use-back-claim', () => ({ useBackClaim: () => {} })) +vi.mock('react-native', () => ({ + Keyboard: { dismiss: () => {} }, + Modal: 'Modal', + Platform: { OS: 'android', select: (options: { android?: unknown }) => options.android }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: T) => styles, absoluteFillObject: {} }, + View: 'View', + useWindowDimensions: () => ({ width: 412, height: 900 }) +})) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 24, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', () => { + const chain: Record = {} + for (const method of [ + 'activeOffsetY', + 'simultaneousWithExternalGesture', + 'onBegin', + 'onUpdate', + 'onEnd' + ]) { + chain[method] = () => chain + } + return { + Gesture: { Pan: () => chain, Native: () => chain }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView' + } +}) +vi.mock('react-native-reanimated', () => ({ + default: { View: 'AnimatedView', ScrollView: 'AnimatedScrollView' }, + useSharedValue: (initial: number) => { + let value = initial + return { + get value() { + return value + }, + set value(next: number) { + value = next + harness.writes.push(next) + } + } + }, + useAnimatedStyle: () => ({}), + useAnimatedScrollHandler: () => () => {}, + withSpring: (to: number) => to, + withTiming: (to: number, config?: { duration?: number }) => { + harness.timings.push({ to, duration: config?.duration }) + return to + }, + runOnJS: (fn: () => void) => fn, + interpolate: () => 0, + Extrapolation: { CLAMP: 'clamp' } +})) + +import { MountedBottomDrawer } from './mounted-bottom-drawer' + +function sheet(fillAvailable: boolean) { + return ( + {}} + onHidden={() => {}} + > + {null} + + ) +} + +function render(fillAvailable: boolean): ReactTestRenderer { + let renderer!: ReactTestRenderer + act(() => { + renderer = create(sheet(fillAvailable)) + }) + return renderer +} + +function keyboardShows(height: number, duration: number): void { + act(() => harness.show?.(height, duration)) +} + +function keyboardHides(duration: number): void { + act(() => harness.hide?.(duration)) +} + +function marginBottom(renderer: ReactTestRenderer): unknown { + const node = renderer.root.find((candidate) => candidate.props.testID === 'bottom-drawer-sheet') + const style: unknown[] = [node.props.style].flat(Infinity) + return Object.assign({}, ...style.filter((entry) => typeof entry === 'object' && entry !== null)) + .marginBottom +} + +describe('the drawer riding the keyboard seam', () => { + afterEach(() => { + harness.current = 0 + harness.timings.length = 0 + harness.writes.length = 0 + }) + + it('docks a fill sheet on a keyboard already up when it opens', () => { + harness.current = 300 + const renderer = render(true) + expect(marginBottom(renderer)).toBe(300) + act(() => renderer.unmount()) + }) + + it('does not seed a content-sized sheet from a keyboard already up', () => { + harness.current = 300 + const renderer = render(false) + expect(harness.writes).not.toContain(300) + // It still rides the keyboard's next event. + keyboardShows(310, 0) + expect(harness.timings).toContainEqual({ to: 310, duration: 250 }) + act(() => renderer.unmount()) + }) + + it('lifts with the event duration and drops back when the keyboard hides', () => { + const renderer = render(true) + keyboardShows(280, 120) + expect(marginBottom(renderer)).toBe(280) + expect(harness.timings).toContainEqual({ to: 280, duration: 120 }) + keyboardHides(0) + expect(marginBottom(renderer)).toBe(0) + // An event without a duration still animates. + expect(harness.timings).toContainEqual({ to: 0, duration: 250 }) + act(() => renderer.unmount()) + }) + + it('unsubscribes on unmount', () => { + const renderer = render(true) + expect(harness.show).not.toBeNull() + act(() => renderer.unmount()) + expect(harness.show).toBeNull() + }) +}) diff --git a/mobile/src/components/mounted-bottom-drawer-page-keyboard.web.test.tsx b/mobile/src/components/mounted-bottom-drawer-page-keyboard.web.test.tsx new file mode 100644 index 00000000000..a741f5c864d --- /dev/null +++ b/mobile/src/components/mounted-bottom-drawer-page-keyboard.web.test.tsx @@ -0,0 +1,120 @@ +// @vitest-environment happy-dom +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' + +// The page's module graph: react-native-web's own `Keyboard` (no `metrics`, an inert +// `addListener`) and the web sibling of the keyboard seam, as the page bundler resolves them. +vi.mock('react-native', async () => { + const React = await import('react') + const { default: Keyboard }: { default: unknown } = + // @ts-expect-error TS7016: react-native-web ships no type declarations. + await import('react-native-web/dist/exports/Keyboard') + return { + BackHandler: { addEventListener: () => ({ remove: () => {} }) }, + Keyboard, + Modal: 'Modal', + Platform: { OS: 'web', select: (options: { web?: unknown }) => options.web }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: T) => styles, absoluteFillObject: {} }, + View: 'View', + // Subscribed, as react-native-web's is: the shell shortening the WebView is a resize. + useWindowDimensions: () => { + const read = () => ({ width: window.innerWidth, height: window.innerHeight }) + const [size, setSize] = React.useState(read) + React.useEffect(() => { + const onResize = () => setSize(read()) + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, []) + return size + } + } +}) +vi.mock( + '../platform/keyboard-occlusion', + async () => await import('../platform/keyboard-occlusion.web') +) +vi.mock('../navigation/use-back-claim', () => ({ useBackClaim: () => {} })) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 24, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', () => { + const chain: Record = {} + for (const method of [ + 'activeOffsetY', + 'simultaneousWithExternalGesture', + 'onBegin', + 'onUpdate', + 'onEnd' + ]) { + chain[method] = () => chain + } + return { + Gesture: { Pan: () => chain, Native: () => chain }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView' + } +}) +vi.mock('react-native-reanimated', () => ({ + default: { View: 'AnimatedView', ScrollView: 'AnimatedScrollView' }, + useSharedValue: (initial: number) => ({ value: initial }), + useAnimatedStyle: () => ({}), + useAnimatedScrollHandler: () => () => {}, + withSpring: (to: number) => to, + withTiming: (to: number) => to, + runOnJS: (fn: () => void) => fn, + interpolate: () => 0, + Extrapolation: { CLAMP: 'clamp' } +})) + +import { MountedBottomDrawer } from './mounted-bottom-drawer' + +const RESTING_HEIGHT = 900 +const IME_HEIGHT = 300 + +function setWindowHeight(height: number): void { + Object.defineProperty(window, 'innerHeight', { configurable: true, value: height }) + window.dispatchEvent(new Event('resize')) +} + +function renderFillSheet(): ReactTestRenderer { + let renderer!: ReactTestRenderer + act(() => { + renderer = create( + {}} onHidden={() => {}}> + {null} + + ) + }) + return renderer +} + +function sheetStyle(renderer: ReactTestRenderer): Record { + const sheet = renderer.root.find((node) => node.props.testID === 'bottom-drawer-sheet') + const style: unknown[] = [sheet.props.style].flat(Infinity) + return Object.assign({}, ...style.filter((entry) => typeof entry === 'object' && entry !== null)) +} + +describe('a fill-mode sheet on the page', () => { + afterEach(() => setWindowHeight(RESTING_HEIGHT)) + + it('mounts against react-native-web, whose Keyboard has no metrics()', () => { + setWindowHeight(RESTING_HEIGHT) + const renderer = renderFillSheet() + expect(sheetStyle(renderer).marginBottom).toBe(0) + act(() => renderer.unmount()) + }) + + it('does not lift over a keyboard the shell already shortened the WebView for', () => { + setWindowHeight(RESTING_HEIGHT) + const renderer = renderFillSheet() + act(() => setWindowHeight(RESTING_HEIGHT - IME_HEIGHT)) + // The shortened window is the whole avoidance: the sheet's height follows it and nothing lifts. + expect(sheetStyle(renderer)).toMatchObject({ + marginBottom: 0, + height: RESTING_HEIGHT - IME_HEIGHT - 24 - 16 + }) + act(() => renderer.unmount()) + }) +}) diff --git a/mobile/src/components/mounted-bottom-drawer.tsx b/mobile/src/components/mounted-bottom-drawer.tsx index da135ab5eed..ea85c320861 100644 --- a/mobile/src/components/mounted-bottom-drawer.tsx +++ b/mobile/src/components/mounted-bottom-drawer.tsx @@ -28,6 +28,7 @@ import { bottomDrawerStyles as styles } from './bottom-drawer-styles' import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host' import { useResponsiveLayout } from '../layout/responsive-layout' import { useBackClaim } from '../navigation/use-back-claim' +import { currentSoftKeyboardHeight, subscribeSoftKeyboard } from '../platform/keyboard-occlusion' const DISMISS_THRESHOLD = 80 const SPRING_CONFIG = { damping: 28, stiffness: 400 } @@ -129,8 +130,9 @@ export function MountedBottomDrawer({ // Why: KeyboardAvoidingView and useAnimatedKeyboard are both unreliable // inside Modal (iOS ignores KAV; Android needs adjustNothing for - // useAnimatedKeyboard). Keyboard event listeners work on both platforms - // and give us the exact height to shift the drawer by. + // useAnimatedKeyboard). The keyboard seam's events work on both platforms + // and give the exact height; inside the shell's page none fire, because + // the shell shortens the WebView above the IME. useEffect(() => { // Pinned-under sheets stay visible for size but must not ride the keyboard — // only the top interactive sheet owns inset/lift. @@ -159,26 +161,24 @@ export function MountedBottomDrawer({ // keyboard before listeners attach. Seed only in fill mode so content-sized // outer sheets do not inherit a stale metrics height after an inner dismiss. if (fillAvailable) { - const existing = Keyboard.metrics() - if (existing != null && existing.height > 0) { - applyKeyboardHeight(existing.height) + const existing = currentSoftKeyboardHeight() + if (existing > 0) { + applyKeyboardHeight(existing) } } - const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow' - const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide' - - const onShow = Keyboard.addListener(showEvent, (e) => { - applyKeyboardHeight(e.endCoordinates.height, e.duration || 250) - }) - const onHide = Keyboard.addListener(hideEvent, (e) => { - setKeyboardInset(0) - keyboardOffset.value = withTiming(0, { duration: e.duration || 250 }) - }) + const unsubscribe = subscribeSoftKeyboard( + (height, duration) => { + applyKeyboardHeight(height, duration || 250) + }, + (duration) => { + setKeyboardInset(0) + keyboardOffset.value = withTiming(0, { duration: duration || 250 }) + } + ) return () => { - onShow.remove() - onHide.remove() + unsubscribe() keyboardOffset.value = 0 setKeyboardInset(0) } diff --git a/mobile/src/platform/keyboard-geometry-owner-census.test.ts b/mobile/src/platform/keyboard-geometry-owner-census.test.ts new file mode 100644 index 00000000000..25c90d0d1ee --- /dev/null +++ b/mobile/src/platform/keyboard-geometry-owner-census.test.ts @@ -0,0 +1,38 @@ +/** + * Who may ask React Native's `Keyboard` for geometry: the keyboard seam, and one named exception. + * + * react-native-web's `Keyboard` has no `metrics` and an `addListener` that never fires, so a page + * screen that asks it directly either throws (a fill sheet crashed the page on `metrics()`) or waits + * forever. The seam has a web sibling that answers from the window instead; everything else reads it. + */ +import { readFileSync } from 'node:fs' +import { relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { censusSourceFiles } from '../test-support/census-source-files' + +const MOBILE_DIR = fileURLToPath(new URL('../../', import.meta.url)) +const SRC_DIR = fileURLToPath(new URL('../', import.meta.url)) + +const SEAM = 'src/platform/keyboard-occlusion.ts' +/** A one-shot "open after the keyboard hides" with a timer fallback; it subscribes only while the + * seam reports a keyboard, which on the page is never, so there it cannot wait forever. */ +const TAB_SHEET_AFTER_HIDE = 'src/session/use-mobile-session-terminal-send-actions.ts' + +const asksKeyboard = (line: string): boolean => + line.includes('Keyboard.metrics(') || line.includes('Keyboard.addListener(') + +function callers(): string[] { + return censusSourceFiles(SRC_DIR) + .filter((file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file)) + .filter((file) => readFileSync(file, 'utf8').split('\n').some(asksKeyboard)) + .map((file) => relative(MOBILE_DIR, file)) + .sort() +} + +describe("React Native's Keyboard geometry", () => { + it('is read only by the keyboard seam and the tab sheet that waits for a hide', () => { + // Also the presence precondition: the seam itself must match, or the matcher is broken. + expect(callers()).toEqual([SEAM, TAB_SHEET_AFTER_HIDE].sort()) + }) +}) diff --git a/mobile/src/platform/keyboard-occlusion.test.tsx b/mobile/src/platform/keyboard-occlusion.test.tsx index 669ad24d9b5..526da87f12d 100644 --- a/mobile/src/platform/keyboard-occlusion.test.tsx +++ b/mobile/src/platform/keyboard-occlusion.test.tsx @@ -2,7 +2,7 @@ import { createElement } from 'react' import { act, create } from 'react-test-renderer' import { beforeEach, describe, expect, it, vi } from 'vitest' -type Listener = (event: { endCoordinates: { height: number } }) => void +type Listener = (event: { endCoordinates: { height: number }; duration: number }) => void type KeyboardHarness = { listeners: Map @@ -11,17 +11,21 @@ type KeyboardHarness = { /** Every call, not the surviving subscriptions: a hook that subscribes and unsubscribes still * costs a phone a render per keyboard event, which `listeners.size` alone would not show. */ addListenerCalls: number + /** What `Keyboard.metrics()` answers. */ + metrics: { height: number } | undefined } const keyboard = vi.hoisted((): KeyboardHarness => ({ listeners: new Map(), removed: [], platform: 'ios', - addListenerCalls: 0 + addListenerCalls: 0, + metrics: undefined })) vi.mock('react-native', () => ({ Keyboard: { + metrics: () => keyboard.metrics, addListener: (name: string, listener: Listener) => { keyboard.addListenerCalls += 1 keyboard.listeners.set(name, listener) @@ -41,6 +45,8 @@ vi.mock('react-native', () => ({ })) import { + currentSoftKeyboardHeight, + subscribeSoftKeyboard, useKeyboardAvoidingPadding, useKeyboardOcclusion, useSoftKeyboard, @@ -86,6 +92,7 @@ describe('the keyboard the phone reports', () => { keyboard.removed.length = 0 keyboard.platform = 'ios' keyboard.addListenerCalls = 0 + keyboard.metrics = undefined lift = 0 padding = 0 keyboardState = { height: 0, visible: false } @@ -104,11 +111,14 @@ describe('the keyboard the phone reports', () => { it('lifts by the height the event carries and drops back on hide', async () => { await mount() await act(async () => { - keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 336 } }) + keyboard.listeners.get('keyboardWillShow')?.({ + endCoordinates: { height: 336 }, + duration: 250 + }) }) expect(lift).toBe(336) await act(async () => { - keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 0 } }) + keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 0 }, duration: 0 }) }) expect(lift).toBe(0) }) @@ -116,7 +126,7 @@ describe('the keyboard the phone reports', () => { it('never reports a negative height, whatever the event says', async () => { await mount() await act(async () => { - keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: -10 } }) + keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: -10 }, duration: 0 }) }) expect(lift).toBe(0) }) @@ -144,11 +154,14 @@ describe('the keyboard the phone reports', () => { await mountComponent(StateHarness) expect(keyboard.addListenerCalls).toBe(2) await act(async () => { - keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 336 } }) + keyboard.listeners.get('keyboardWillShow')?.({ + endCoordinates: { height: 336 }, + duration: 250 + }) }) expect(keyboardState).toEqual({ height: 336, visible: true }) await act(async () => { - keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 0 } }) + keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 0 }, duration: 0 }) }) expect(keyboardState).toEqual({ height: 0, visible: false }) }) @@ -156,8 +169,34 @@ describe('the keyboard the phone reports', () => { it('calls a keyboard that reports no height open anyway, because the event is the fact', async () => { await mountComponent(StateHarness) await act(async () => { - keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 0 } }) + keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 0 }, duration: 0 }) }) expect(keyboardState).toEqual({ height: 0, visible: true }) }) + + it('hands a sheet each event with its duration, and removes both listeners', () => { + const calls: string[] = [] + const unsubscribe = subscribeSoftKeyboard( + (height, duration) => calls.push(`show ${height} ${duration}`), + (duration) => calls.push(`hide ${duration}`) + ) + keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 336 }, duration: 250 }) + keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 0 }, duration: 180 }) + expect(calls).toEqual(['show 336 250', 'hide 180']) + unsubscribe() + expect(keyboard.removed.sort()).toEqual(['keyboardWillHide', 'keyboardWillShow']) + }) + + it('reads a keyboard already up from metrics(), and 0 when there is none', () => { + expect(currentSoftKeyboardHeight()).toBe(0) + keyboard.metrics = { height: 291 } + expect(currentSoftKeyboardHeight()).toBe(291) + }) + + it('never seeds the hook from metrics(), so a stale iOS reading cannot open it', async () => { + // RN clears metrics() on didHide, after the willHide this hook closes on. + keyboard.metrics = { height: 291 } + await mountComponent(StateHarness) + expect(keyboardState).toEqual({ height: 0, visible: false }) + }) }) diff --git a/mobile/src/platform/keyboard-occlusion.ts b/mobile/src/platform/keyboard-occlusion.ts index ecbdfa3f7dc..6b0fc0deb2b 100644 --- a/mobile/src/platform/keyboard-occlusion.ts +++ b/mobile/src/platform/keyboard-occlusion.ts @@ -21,27 +21,49 @@ const CLOSED: SoftKeyboardState = { height: 0, visible: false } export function useSoftKeyboard(): SoftKeyboardState { const [keyboard, setKeyboard] = useState(CLOSED) - useEffect(() => { - const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow' - const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide' - - const onShow = Keyboard.addListener(showEvent, (event) => { + useEffect( + () => // The keyboard's own height already describes the obscured area; the consumer adds whatever // clearance it wants above it. Open is the event, not the height: a keyboard that reports 0 // is still one nobody wants the terminal re-fitted under. - setKeyboard({ height: Math.max(0, event.endCoordinates.height), visible: true }) - }) - const onHide = Keyboard.addListener(hideEvent, () => setKeyboard(CLOSED)) - - return () => { - onShow.remove() - onHide.remove() - } - }, []) + subscribeSoftKeyboard( + (height) => setKeyboard({ height: Math.max(0, height), visible: true }), + () => setKeyboard(CLOSED) + ), + [] + ) return keyboard } +/** + * The keyboard as events, for a consumer that animates with each one (the event's `duration`) rather + * than rendering from state. iOS is told `will`, Android `did`. + */ +export function subscribeSoftKeyboard( + onShow: (height: number, duration: number) => void, + onHide: (duration: number) => void +): () => void { + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow' + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide' + const show = Keyboard.addListener(showEvent, (event) => + onShow(event.endCoordinates.height, event.duration) + ) + const hide = Keyboard.addListener(hideEvent, (event) => onHide(event.duration)) + return () => { + show.remove() + hide.remove() + } +} + +/** + * The keyboard already up, which sends no show event to a late subscriber. React Native sets this on + * `did` events, so on iOS it can still read open between `willHide` and `didHide`. + */ +export function currentSoftKeyboardHeight(): number { + return Keyboard.metrics()?.height ?? 0 +} + /** The occluded strip alone, for the callers that lift by it and never ask whether it is open. */ export function useKeyboardOcclusion(): number { return useSoftKeyboard().height diff --git a/mobile/src/platform/keyboard-occlusion.web.test.tsx b/mobile/src/platform/keyboard-occlusion.web.test.tsx index 27dbe431e3a..11e96afff8f 100644 --- a/mobile/src/platform/keyboard-occlusion.web.test.tsx +++ b/mobile/src/platform/keyboard-occlusion.web.test.tsx @@ -3,6 +3,8 @@ import { createElement } from 'react' import { act, create } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { + currentSoftKeyboardHeight, + subscribeSoftKeyboard, useKeyboardAvoidingPadding, useKeyboardOcclusion, useSoftKeyboard, @@ -201,6 +203,65 @@ describe('the keyboard the browser reports', () => { }) }) +/** The pair a sheet animates with: events rather than state, each with duration 0. */ +describe('the keyboard as events, for a sheet', () => { + it('shows by the uncovered strip and hides once when it closes', () => { + const calls: string[] = [] + const unsubscribe = subscribeSoftKeyboard( + (height, duration) => calls.push(`show ${height} ${duration}`), + (duration) => calls.push(`hide ${duration}`) + ) + viewport?.resizeTo(464) + viewport?.resizeTo(LAYOUT_HEIGHT) + viewport?.resizeTo(LAYOUT_HEIGHT) + expect(calls).toEqual(['show 336 0', 'hide 0']) + unsubscribe() + expect(viewport?.counts).toEqual({ resize: 0, scroll: 0 }) + }) + + it('hides a keyboard that was already up when it subscribed', async () => { + viewport?.resizeTo(464) + const calls: string[] = [] + const unsubscribe = subscribeSoftKeyboard( + (height) => calls.push(`show ${height}`), + (duration) => calls.push(`hide ${duration}`) + ) + viewport?.resizeTo(LAYOUT_HEIGHT) + expect(calls).toEqual(['hide 0']) + unsubscribe() + // The hook seeds from the same strip, so it must come back down too. + viewport?.resizeTo(464) + await mount() + expect(lift).toBe(336) + await act(async () => viewport?.resizeTo(LAYOUT_HEIGHT)) + expect(lift).toBe(0) + }) + + it('stays silent when nothing is covered, which is the shell shortening the WebView', () => { + const calls: string[] = [] + const unsubscribe = subscribeSoftKeyboard( + () => calls.push('show'), + () => calls.push('hide') + ) + viewport?.resizeTo(LAYOUT_HEIGHT) + expect(calls).toEqual([]) + unsubscribe() + }) + + it('reads a keyboard already up, and 0 without a visual viewport', () => { + viewport?.resizeTo(464) + expect(currentSoftKeyboardHeight()).toBe(336) + Object.defineProperty(window, 'visualViewport', { value: undefined, configurable: true }) + expect(currentSoftKeyboardHeight()).toBe(0) + expect(() => + subscribeSoftKeyboard( + () => {}, + () => {} + )() + ).not.toThrow() + }) +}) + const LAYOUT_WIDTH = 400 let keyboardState: SoftKeyboardState = { height: 0, visible: false } diff --git a/mobile/src/platform/keyboard-occlusion.web.ts b/mobile/src/platform/keyboard-occlusion.web.ts index 03d6ec4d24f..05500f369ee 100644 --- a/mobile/src/platform/keyboard-occlusion.web.ts +++ b/mobile/src/platform/keyboard-occlusion.web.ts @@ -32,8 +32,8 @@ import { useEffect, useState } from 'react' * `scale` is read defensively because older WebViews do not implement it, and treating its absence * as zoomed would answer 0 for every keyboard on them. * - * No `visualViewport` at all is 0 rather than a guess — that guard is in the effect below, which - * is also the only thing that can act on it, and a second copy here was unreachable. + * No `visualViewport` at all is 0 rather than a guess — that guard is in the two readers below, + * which are the only things that can act on it. */ function occlusion(viewport: VisualViewport): number { if ((viewport.scale ?? 1) !== 1) { @@ -42,25 +42,59 @@ function occlusion(viewport: VisualViewport): number { return Math.max(0, window.innerHeight - (viewport.height + viewport.offsetTop)) } +/** Both shapes: `null` is what the DOM declares, `undefined` is a WebView without the property. */ +function visualViewport(): VisualViewport | undefined { + return window.visualViewport ?? undefined +} + +/** + * The strip as events, with duration 0: the browser has already moved the viewport by the time it + * says so. Inside the shell nothing fires, because the shell shortens the WebView above the IME and + * the strip stays 0; in a plain mobile browser the keyboard overlays the page and this is the lift. + */ +export function subscribeSoftKeyboard( + onShow: (height: number, duration: number) => void, + onHide: (duration: number) => void +): () => void { + const viewport = visualViewport() + if (viewport === undefined) { + return () => {} + } + let open = occlusion(viewport) > 0 + const read = (): void => { + const height = occlusion(viewport) + if (height > 0) { + open = true + onShow(height, 0) + } else if (open) { + open = false + onHide(0) + } + } + viewport.addEventListener('resize', read) + viewport.addEventListener('scroll', read) + + return () => { + viewport.removeEventListener('resize', read) + viewport.removeEventListener('scroll', read) + } +} + +/** A keyboard already up when a composer opens gets no event at all. */ +export function currentSoftKeyboardHeight(): number { + const viewport = visualViewport() + return viewport === undefined ? 0 : occlusion(viewport) +} + export function useKeyboardOcclusion(): number { const [keyboardLift, setKeyboardLift] = useState(0) useEffect(() => { - // Both shapes: `null` is what the DOM declares, `undefined` is a WebView without the property. - const viewport = window.visualViewport - if (viewport === null || viewport === undefined) { - return - } - const read = (): void => setKeyboardLift(occlusion(viewport)) - // Read once on mount: a composer opened while the keyboard is already up gets no event at all. - read() - viewport.addEventListener('resize', read) - viewport.addEventListener('scroll', read) - - return () => { - viewport.removeEventListener('resize', read) - viewport.removeEventListener('scroll', read) - } + setKeyboardLift(currentSoftKeyboardHeight()) + return subscribeSoftKeyboard( + (height) => setKeyboardLift(height), + () => setKeyboardLift(0) + ) }, []) return keyboardLift