From 2ebe5fc728e2e1b1d7aa4ce36c959e232783338f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:33:11 +0100 Subject: [PATCH] fix: open the soft keyboard for the reply body on android --- src/components/RichTextEditor.tsx | 69 +++++++++++++------ .../__tests__/rich-text-editor-focus.test.ts | 61 ++++++++++++---- src/lib/editor-keyboard-handoff.ts | 42 +++++++++++ 3 files changed, 139 insertions(+), 33 deletions(-) create mode 100644 src/lib/editor-keyboard-handoff.ts diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index 437988d6..298db10a 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -1,10 +1,11 @@ import React from 'react'; -import { StyleSheet, View, Platform } from 'react-native'; +import { StyleSheet, View, Platform, TextInput } from 'react-native'; import { WebView, type WebViewMessageEvent } from 'react-native-webview'; import { useColors } from '../theme/colors'; import type { ThemePalette } from '../theme/tokens'; import { perfEndAfterPaint } from '../lib/perf-marks'; import { buildEditorHtml, isEditorNavigationAllowed, MIN_EDITOR_HEIGHT } from '../lib/editor-html'; +import { focusEditorWithKeyboard } from '../lib/editor-keyboard-handoff'; export type RichTextCommand = | 'bold' @@ -149,17 +150,24 @@ const RichTextEditor = React.forwardRef(function Ri const readyRef = React.useRef(false); const queuedRef = React.useRef([]); // Focusing the editor in the page only moves the caret there. Android - // opens the keyboard, and takes key focus off the composer's first button - // (Close: a hardware Space shut the composer), only for a WebView that holds - // native focus. iOS has keyboardDisplayRequiresUserAction for this. - const wantsNativeFocusRef = React.useRef(false); - const requestNativeFocus = React.useCallback(() => { - if (Platform.OS !== 'android') return; + // takes key focus off the composer's first button (Close: a hardware Space + // shut the composer) only for a WebView that holds native focus, and opens + // the keyboard only through the hidden field below (see + // lib/editor-keyboard-handoff). iOS has keyboardDisplayRequiresUserAction. + const handoffInputRef = React.useRef(null); + // A focus asked for before the page was up (a reply opening): its script. + const pendingFocusRef = React.useRef(null); + const focusWithKeyboard = React.useCallback((script: string) => { if (!readyRef.current) { - wantsNativeFocusRef.current = true; + pendingFocusRef.current = script; return; } - webViewRef.current?.requestFocus(); + focusEditorWithKeyboard(Platform.OS, script, { + focusHandoffInput: () => handoffInputRef.current?.focus(), + focusWebView: () => webViewRef.current?.requestFocus(), + runInPage: (s) => webViewRef.current?.injectJavaScript(`${s}; true;`), + schedule: (fn, ms) => { setTimeout(fn, ms); }, + }); }, []); const call = React.useCallback((script: string) => { if (!readyRef.current) { @@ -230,17 +238,15 @@ const RichTextEditor = React.forwardRef(function Ri call(`window.__rne && window.__rne.getHtml(${id})`); }), focusEnd: () => { - requestNativeFocus(); - call(`window.__rne && window.__rne.focusEnd()`); + focusWithKeyboard(`window.__rne && window.__rne.focusEnd()`); }, focus: () => { - requestNativeFocus(); - call(`window.__rne && window.__rne.focus()`); + focusWithKeyboard(`window.__rne && window.__rne.focus()`); }, blur: () => { call(`document.activeElement && document.activeElement.blur()`); }, - }), [call, requestNativeFocus]); + }), [call, focusWithKeyboard]); const onMessage = (event: WebViewMessageEvent) => { let data: { type: string; payload: unknown }; @@ -254,13 +260,12 @@ const RichTextEditor = React.forwardRef(function Ri perfEndAfterPaint('compose-open'); const queued = queuedRef.current; queuedRef.current = []; - // A focus asked for before the page was up (a reply opening) gets the - // view's native focus first, then the caret from the queued script. - if (wantsNativeFocusRef.current) { - wantsNativeFocusRef.current = false; - webViewRef.current?.requestFocus(); - } for (const script of queued) webViewRef.current?.injectJavaScript(`${script}; true;`); + // A focus asked for before the page was up (a reply opening) runs + // after the content it lands in, with the keyboard. + const pendingFocus = pendingFocusRef.current; + pendingFocusRef.current = null; + if (pendingFocus) focusWithKeyboard(pendingFocus); } switch (data.type) { case 'change': @@ -330,6 +335,20 @@ const RichTextEditor = React.forwardRef(function Ri containerStyle={styles.webviewContainer} onShouldStartLoadWithRequest={(request) => isEditorNavigationAllowed(request.url)} /> + {Platform.OS === 'android' ? ( + // Opens the keyboard for the editor (focusEditorWithKeyboard); never + // typed in, and hidden from screen readers. + + ) : null} ); }); @@ -349,5 +368,15 @@ function makeStyles(c: ThemePalette) { webviewContainer: { backgroundColor: 'transparent', }, + // Laid out (a view of no size can't take focus) but out of sight. + handoffInput: { + position: 'absolute', + left: 0, + top: 0, + width: 1, + height: 1, + opacity: 0, + padding: 0, + }, }); } diff --git a/src/lib/__tests__/rich-text-editor-focus.test.ts b/src/lib/__tests__/rich-text-editor-focus.test.ts index c53c49e4..4bd31973 100644 --- a/src/lib/__tests__/rich-text-editor-focus.test.ts +++ b/src/lib/__tests__/rich-text-editor-focus.test.ts @@ -1,27 +1,62 @@ import { describe, expect, it } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; +import { focusEditorWithKeyboard, KEYBOARD_HANDOFF_MS } from '../editor-keyboard-handoff'; // Reply opened with no keyboard on Android and focus on the composer's // Close button (a hardware Space shut the composer). 8bb7b607 focused the -// editor inside its page only; an Android WebView takes the keyboard and -// key focus only with native view focus, which nothing gave it. The suite -// has no React Native renderer, so this checks the editor's wiring. +// editor inside its page only; 461e4da0 gave the WebView native focus too, +// which put the caret in the body but still left the keyboard down +// (`mInputShown=false` on API 32 and 34): nothing asked the input method to +// show. A hidden TextInput's focus does, and the keyboard then moves over to +// the WebView. The suite has no React Native renderer, so the order of steps +// is checked on the helper and the editor's wiring on its source. const src = fs.readFileSync(path.resolve(__dirname, '..', '..', 'components/RichTextEditor.tsx'), 'utf8'); +function recorder() { + const steps: string[] = []; + const timers: Array<{ fn: () => void; ms: number }> = []; + return { + steps, + timers, + targets: { + focusHandoffInput: () => steps.push('input'), + focusWebView: () => steps.push('webview'), + runInPage: (s: string) => steps.push(`page:${s}`), + schedule: (fn: () => void, ms: number) => { timers.push({ fn, ms }); }, + }, + }; +} + describe('rich text editor focus', () => { - it('gives the WebView native focus before focusing the editor in the page', () => { - const handle = src.slice(src.indexOf('focusEnd: () =>'), src.indexOf('blur: () =>')); - expect(handle).toMatch(/focusEnd: \(\) => \{\s+requestNativeFocus\(\);\s+call\(`window\.__rne && window\.__rne\.focusEnd\(\)`\);/); - expect(handle).toMatch(/focus: \(\) => \{\s+requestNativeFocus\(\);\s+call\(`window\.__rne && window\.__rne\.focus\(\)`\);/); + it('opens the keyboard through the hidden field on Android, then hands focus to the WebView', () => { + const r = recorder(); + focusEditorWithKeyboard('android', 'focusEnd()', r.targets); + expect(r.steps).toEqual(['input', 'page:focusEnd()']); + expect(r.timers).toHaveLength(1); + expect(r.timers[0].ms).toBe(KEYBOARD_HANDOFF_MS); + r.timers[0].fn(); + expect(r.steps).toEqual(['input', 'page:focusEnd()', 'webview', 'page:focusEnd()']); }); - it('asks for native focus on Android, and holds the request until the page is up', () => { - const fn = src.slice(src.indexOf('const requestNativeFocus'), src.indexOf('const call = ')); - expect(fn).toMatch(/if \(Platform\.OS !== 'android'\) return;/); - expect(fn).toMatch(/if \(!readyRef\.current\) \{\s+wantsNativeFocusRef\.current = true;\s+return;\s+\}\s+webViewRef\.current\?\.requestFocus\(\);/); - const ready = src.slice(src.indexOf('if (!readyRef.current) {\r\n readyRef.current = true;'.replace(/\r\n/g, src.includes('\r\n') ? '\r\n' : '\n'))); - expect(ready).toMatch(/if \(wantsNativeFocusRef\.current\) \{\s+wantsNativeFocusRef\.current = false;\s+webViewRef\.current\?\.requestFocus\(\);\s+\}\s+for \(const script of queued\)/); + it('only focuses the editor in the page on iOS', () => { + const r = recorder(); + focusEditorWithKeyboard('ios', 'focus()', r.targets); + expect(r.steps).toEqual(['page:focus()']); + expect(r.timers).toHaveLength(0); + }); + + it('is how the editor focuses, also for a focus asked before the page was up', () => { + const handle = src.slice(src.indexOf('focusEnd: () =>'), src.indexOf('blur: () =>')); + expect(handle).toMatch(/focusEnd: \(\) => \{\s+focusWithKeyboard\(`window\.__rne && window\.__rne\.focusEnd\(\)`\);/); + expect(handle).toMatch(/focus: \(\) => \{\s+focusWithKeyboard\(`window\.__rne && window\.__rne\.focus\(\)`\);/); + expect(src).toMatch(/if \(!readyRef\.current\) \{\s+pendingFocusRef\.current = script;\s+return;\s+\}\s+focusEditorWithKeyboard\(Platform\.OS, script,/); + expect(src).toMatch(/if \(pendingFocus\) focusWithKeyboard\(pendingFocus\);/); + }); + + it('renders the hidden field on Android', () => { + expect(src).toMatch(/ handoffInputRef\.current\?\.focus\(\)/); }); }); diff --git a/src/lib/editor-keyboard-handoff.ts b/src/lib/editor-keyboard-handoff.ts new file mode 100644 index 00000000..b6067148 --- /dev/null +++ b/src/lib/editor-keyboard-handoff.ts @@ -0,0 +1,42 @@ +// Opening the soft keyboard for the composer's WebView editor on Android. +// Reply put the caret in the body but no keyboard came up (API 32 and 34, +// `mInputShown=false`): 461e4da0 gave the WebView native focus before the +// page focused the editor, but neither asks the input method to show. The +// WebView only does that for a tap, and react-native-webview's requestFocus +// is a plain View.requestFocus(). A React Native TextInput's focus does call +// InputMethodManager.showSoftInput, though. So the keyboard is opened by a +// hidden TextInput first; then the WebView takes native focus and the page +// focuses the editor, and the open keyboard moves over to it. iOS opens the +// keyboard for a focus from script (keyboardDisplayRequiresUserAction off). + +/** Time for the input method to come up for the hidden field before focus moves on. */ +export const KEYBOARD_HANDOFF_MS = 60; + +export interface KeyboardHandoffTargets { + /** Focus the hidden TextInput (opens the keyboard). */ + focusHandoffInput(): void; + /** Give the WebView native focus. */ + focusWebView(): void; + /** Run a script in the editor page. */ + runInPage(script: string): void; + schedule(fn: () => void, ms: number): void; +} + +/** + * Focus the editor with `script` (focus or focusEnd) and open the keyboard + * for it: on Android through the hidden field, elsewhere by the script alone. + */ +export function focusEditorWithKeyboard(platform: string, script: string, targets: KeyboardHandoffTargets): void { + if (platform !== 'android') { + targets.runInPage(script); + return; + } + targets.focusHandoffInput(); + // The caret goes where it should at once; the native focus follows once + // the keyboard is up, and the editor is focused again for it. + targets.runInPage(script); + targets.schedule(() => { + targets.focusWebView(); + targets.runInPage(script); + }, KEYBOARD_HANDOFF_MS); +}