mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: open the soft keyboard for the reply body on android
This commit is contained in:
@@ -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\(\)/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user