From 461e4da0b2d09e703f354ca8b8e67c0bc634c784 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:44:37 +0100 Subject: [PATCH] fix: open the keyboard in the body when replying on Android --- src/components/RichTextEditor.tsx | 23 +++++++++++++++- .../__tests__/rich-text-editor-focus.test.ts | 27 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/lib/__tests__/rich-text-editor-focus.test.ts diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index 006c4eca..972f9fb0 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -137,6 +137,19 @@ const RichTextEditor = React.forwardRef(function Ri // posts its first message, then replay in order. 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; + if (!readyRef.current) { + wantsNativeFocusRef.current = true; + return; + } + webViewRef.current?.requestFocus(); + }, []); const call = React.useCallback((script: string) => { if (!readyRef.current) { queuedRef.current.push(script); @@ -203,15 +216,17 @@ const RichTextEditor = React.forwardRef(function Ri call(`window.__rne && window.__rne.getHtml(${id})`); }), focusEnd: () => { + requestNativeFocus(); call(`window.__rne && window.__rne.focusEnd()`); }, focus: () => { + requestNativeFocus(); call(`window.__rne && window.__rne.focus()`); }, blur: () => { call(`document.activeElement && document.activeElement.blur()`); }, - }), [call]); + }), [call, requestNativeFocus]); const onMessage = (event: WebViewMessageEvent) => { let data: { type: string; payload: unknown }; @@ -225,6 +240,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;`); } switch (data.type) { diff --git a/src/lib/__tests__/rich-text-editor-focus.test.ts b/src/lib/__tests__/rich-text-editor-focus.test.ts new file mode 100644 index 00000000..c53c49e4 --- /dev/null +++ b/src/lib/__tests__/rich-text-editor-focus.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// 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. + +const src = fs.readFileSync(path.resolve(__dirname, '..', '..', 'components/RichTextEditor.tsx'), 'utf8'); + +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('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\)/); + }); +});