fix: open the soft keyboard for the reply body on android

This commit is contained in:
Linus Rath
2026-10-31 20:33:11 +01:00
parent 81b2a7c485
commit 2ebe5fc728
3 changed files with 139 additions and 33 deletions
+49 -20
View File
@@ -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<RichTextEditorHandle, Props>(function Ri
const readyRef = React.useRef(false);
const queuedRef = React.useRef<string[]>([]);
// 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<TextInput>(null);
// A focus asked for before the page was up (a reply opening): its script.
const pendingFocusRef = React.useRef<string | null>(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<RichTextEditorHandle, Props>(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<RichTextEditorHandle, Props>(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<RichTextEditorHandle, Props>(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.
<TextInput
ref={handoffInputRef}
style={styles.handoffInput}
caretHidden
contextMenuHidden
autoCorrect={false}
importantForAccessibility="no-hide-descendants"
accessibilityElementsHidden
pointerEvents="none"
/>
) : null}
</View>
);
});
@@ -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,
},
});
}
@@ -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(/<TextInput\s+ref=\{handoffInputRef\}/);
expect(src).toMatch(/focusHandoffInput: \(\) => handoffInputRef\.current\?\.focus\(\)/);
});
});
+42
View File
@@ -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);
}