diff --git a/apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx b/apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx new file mode 100644 index 0000000000000..f628e495f9e29 --- /dev/null +++ b/apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx @@ -0,0 +1,84 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { KeyboardEventData } from "react-native-keyboard-controller"; +import { expect, it, vi } from "vite-plus/test"; + +const listeners = vi.hoisted(() => new Map void>>()); + +// Exercises the patched library source with only its native bindings replaced. +vi.mock("react-native-keyboard-controller/src/bindings", () => ({ + KeyboardEvents: { + addListener: (name: string, listener: (event: KeyboardEventData) => void) => { + const named = listeners.get(name) ?? new Set(); + named.add(listener); + listeners.set(name, named); + return { remove: () => named.delete(listener) }; + }, + }, + KeyboardControllerNative: { dismiss: () => {} }, +})); + +import { useKeyboardState } from "react-native-keyboard-controller/src/hooks/useKeyboardState"; +import { KeyboardController } from "react-native-keyboard-controller/src/module"; + +function emit(name: string, height: number) { + const event: KeyboardEventData = { + height, + duration: 0, + timestamp: 1, + target: 42, + type: "default", + appearance: "light", + }; + act(() => { + for (const listener of listeners.get(name) ?? []) listener(event); + }); +} + +function KeyboardState() { + const { isVisible, height } = useKeyboardState(); + return {JSON.stringify({ isVisible, height })}; +} + +// The library keeps keyboard state at module scope; every test starts and ends hidden. +function setup() { + emit("keyboardDidHide", 0); + return { [Symbol.dispose]: () => emit("keyboardDidHide", 0) }; +} + +function renderKeyboardState() { + const container = document.createElement("div"); + const root = createRoot(container); + act(() => root.render()); + return { container, [Symbol.dispose]: () => act(() => root.unmount()) }; +} + +// Android's syncKeyboardPosition repairs a nonanimated IME inset change with +// keyboardDidHide then keyboardDidShow, and no keyboardWillShow. +it("reports the keyboard shown after Android repairs the IME inset without keyboardWillShow", () => { + using _keyboard = setup(); + using screen = renderKeyboardState(); + + emit("keyboardDidShow", 376); + + expect({ + isVisible: KeyboardController.isVisible(), + height: KeyboardController.state().height, + hook: JSON.parse(screen.container.textContent ?? ""), + }).toEqual({ isVisible: true, height: 376, hook: { isVisible: true, height: 376 } }); +}); + +it("waits for the native hide before dismiss resolves after a didShow-only repair", async () => { + using _keyboard = setup(); + emit("keyboardDidShow", 376); + + let dismissed = false; + const dismissal = KeyboardController.dismiss().then(() => { + dismissed = true; + }); + await act(async () => {}); + + expect(dismissed).toBe(false); + emit("keyboardDidHide", 0); + await dismissal; +}); diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 2f6cc0d292a7f..70f0f38342892 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -25,7 +25,6 @@ import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/reactivity"; import { KeyboardController, - KeyboardEvents, KeyboardStickyView, useKeyboardState, } from "react-native-keyboard-controller"; @@ -90,6 +89,7 @@ import { } from "./terminalInput"; import { createTerminalPasteSession } from "./terminalPaste"; import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState"; +import { getKeyboardShowEpoch, subscribeKeyboardShowing } from "./terminalKeyboardShowing"; function TerminalHeader(props: { readonly subtitle: string; @@ -353,26 +353,6 @@ function readLiveTerminalSessions( })); } -// `useKeyboardState` only flips on willShow and didHide; the accessory must -// leave as soon as the keyboard starts hiding, so it follows the will-events. -let keyboardShowing = KeyboardController.isVisible(); -function subscribeKeyboardShowing(onChange: () => void) { - keyboardShowing = KeyboardController.isVisible(); - const willShow = KeyboardEvents.addListener("keyboardWillShow", () => { - keyboardShowing = true; - onChange(); - }); - const willHide = KeyboardEvents.addListener("keyboardWillHide", () => { - keyboardShowing = false; - onChange(); - }); - return () => { - willShow.remove(); - willHide.remove(); - }; -} -const getKeyboardShowing = () => keyboardShowing; - function ThreadTerminal(props: { readonly params: ThreadTerminalRouteScreenProps["route"]["params"]; readonly routeEnvironmentId: EnvironmentId | null; @@ -586,28 +566,25 @@ function ThreadTerminal(props: { { kind: "send", key: "dash", label: "-", data: "-" }, ]; }, [hostPlatform]); - const keyboardState = useKeyboardState((state) => ({ - height: state.height, - isVisible: state.isVisible, - })); - const keyboardShowing = useSyncExternalStore(subscribeKeyboardShowing, getKeyboardShowing); + const isKeyboardVisible = useKeyboardState((state) => state.isVisible); + const keyboardHeight = useKeyboardState((state) => state.height); + const keyboardShowEpoch = useSyncExternalStore(subscribeKeyboardShowing, getKeyboardShowEpoch); // The accessory follows the keyboard, except that the dismiss and show // buttons flip it ahead of the keyboard animation. - const [accessory, setAccessory] = useState({ keyboardShowing, dismissed: false }); - if (accessory.keyboardShowing !== keyboardShowing) { - setAccessory({ keyboardShowing, dismissed: !keyboardShowing }); + const [accessory, setAccessory] = useState({ keyboardShowEpoch, dismissed: false }); + if (accessory.keyboardShowEpoch !== keyboardShowEpoch) { + setAccessory({ keyboardShowEpoch, dismissed: keyboardShowEpoch === 0 }); } const isAccessoryDismissed = accessory.dismissed; - const isAccessoryVisible = canOperateTerminal && keyboardState.isVisible && !isAccessoryDismissed; + const isAccessoryVisible = canOperateTerminal && isKeyboardVisible && !isAccessoryDismissed; // Android's terminal owns an EditText; turning off autoFocus also clears its native focus. const terminalAutoFocus = Platform.OS === "android" ? !isAccessoryDismissed && - (!SHOWCASE_ENABLED || keyboardFocusRequest > 0 || keyboardState.isVisible) + (!SHOWCASE_ENABLED || keyboardFocusRequest > 0 || isKeyboardVisible) : !SHOWCASE_ENABLED; const terminalBottomInset = - (keyboardState.isVisible ? keyboardState.height : 0) + - (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0); + (isKeyboardVisible ? keyboardHeight : 0) + (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0); const terminalMenuSessions: ReadonlyArray = buildTerminalMenuSessions({ knownSessions: knownSessions ?? [], @@ -861,8 +838,7 @@ function ThreadTerminal(props: { // Measured top reservation is zero when UIKit already inset the body or moved the bar. paddingTop: Platform.OS === "ios" ? (nativeColumnMetrics?.safeArea.top ?? insets.top) : 0, - paddingBottom: - Platform.OS === "android" && !keyboardState.isVisible ? insets.bottom : 0, + paddingBottom: Platform.OS === "android" && !isKeyboardVisible ? insets.bottom : 0, }} > {!isEnvironmentReady ? ( @@ -957,7 +933,7 @@ function ThreadTerminal(props: { /> - {Platform.OS === "android" && !keyboardState.isVisible ? ( + {Platform.OS === "android" && !isKeyboardVisible ? ( {hasNativeTerminalSurface() ? (