From e512530d9d1cf577817b4047decd9e0ed10a715f Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Wed, 19 Aug 2026 20:02:50 +0200 Subject: [PATCH 1/2] fix(mobile): keep keyboard dismissed when tapping a timeline image --- .changeset/fix-mobile-image-tap-keyboard.md | 5 ++++ src/app/components/editor/Editor.test.tsx | 32 +++++++++++++++++++-- src/app/components/editor/Editor.tsx | 3 ++ 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-mobile-image-tap-keyboard.md diff --git a/.changeset/fix-mobile-image-tap-keyboard.md b/.changeset/fix-mobile-image-tap-keyboard.md new file mode 100644 index 0000000000..68bbc51fe0 --- /dev/null +++ b/.changeset/fix-mobile-image-tap-keyboard.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix the mobile keyboard reopening when tapping a timeline image. diff --git a/src/app/components/editor/Editor.test.tsx b/src/app/components/editor/Editor.test.tsx index d5202ba516..7d777c862c 100644 --- a/src/app/components/editor/Editor.test.tsx +++ b/src/app/components/editor/Editor.test.tsx @@ -225,7 +225,18 @@ describe('CustomEditor paste', () => { const focusableRival = () => document.body.appendChild(document.createElement('button')); describe('CustomEditor mobile keyboard', () => { - it('refocuses the editor when focus moves to a non-editable element on mobile', () => { + it('refocuses the editor when focus moves within the composer on mobile', () => { + platformState.isMobile = true; + const { container, editor } = renderEditor({ after: }); + const focusSpy = vi.spyOn(editor, 'focus'); + const editable = container.querySelector('.ProseMirror') as HTMLElement; + const composerButton = screen.getByRole('button', { name: 'Send' }); + editable.focus(); + composerButton.focus(); + expect(focusSpy).toHaveBeenCalledOnce(); + }); + + it('yields focus when it moves outside the composer on mobile', () => { platformState.isMobile = true; const { container, editor } = renderEditor(); const focusSpy = vi.spyOn(editor, 'focus'); @@ -234,7 +245,7 @@ describe('CustomEditor mobile keyboard', () => { try { editable.focus(); rival.focus(); - expect(focusSpy).toHaveBeenCalledOnce(); + expect(focusSpy).not.toHaveBeenCalled(); } finally { rival.remove(); } @@ -279,4 +290,21 @@ describe('CustomEditor mobile keyboard', () => { rival.remove(); } }); + + it('refocuses when an autocomplete menu holds focus so picking a suggestion keeps the keyboard', () => { + platformState.isMobile = true; + const { container, editor } = renderEditor(); + const focusSpy = vi.spyOn(editor, 'focus'); + const editable = container.querySelector('.ProseMirror') as HTMLElement; + const menuItem = document.createElement('button'); + menuItem.dataset.autocompleteMenu = 'true'; + document.body.appendChild(menuItem); + try { + editable.focus(); + menuItem.focus(); + expect(focusSpy).toHaveBeenCalledOnce(); + } finally { + menuItem.remove(); + } + }); }); diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index e522e1b545..95b7e686bf 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -216,6 +216,9 @@ export const CustomEditor = forwardRef( if (!isMobileOrTablet() || suppressBlurRefocusRef?.current) return; const next = event.relatedTarget as HTMLElement | null; if (!next || next.isContentEditable) return; + // Only reclaim focus when it moved within the composer, so taps on + // the timeline (e.g. images) dismiss the keyboard. + if (!rootRef.current?.contains(next) && !next.closest('[data-autocomplete-menu]')) return; editor.focus(); }, focus: () => { From 675fc837bf01c231ef15266c28c16637cb4f0799 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Wed, 19 Aug 2026 20:24:41 +0200 Subject: [PATCH 2/2] fix(mobile): stop soft keyboard flicker when deleting an emote --- .../fix-android-emote-backspace-keyboard.md | 5 ++ .../editor/prosemirrorController.test.tsx | 86 ++++++++++++++++++- .../editor/prosemirrorController.ts | 41 +++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-android-emote-backspace-keyboard.md diff --git a/.changeset/fix-android-emote-backspace-keyboard.md b/.changeset/fix-android-emote-backspace-keyboard.md new file mode 100644 index 0000000000..95af104ff2 --- /dev/null +++ b/.changeset/fix-android-emote-backspace-keyboard.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix the Android keyboard flickering when backspacing a native emote. diff --git a/src/app/components/editor/prosemirrorController.test.tsx b/src/app/components/editor/prosemirrorController.test.tsx index 16d68b45e4..9954e5f333 100644 --- a/src/app/components/editor/prosemirrorController.test.tsx +++ b/src/app/components/editor/prosemirrorController.test.tsx @@ -1,5 +1,5 @@ import { act, fireEvent, render } from '@testing-library/react'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { Selection } from 'prosemirror-state'; import type { EditorView } from 'prosemirror-view'; import type { EditorDocument } from './model'; @@ -10,14 +10,24 @@ import { BlockType } from './types'; // ProseMirror scrolls the selection into view after a transaction, which needs // client rects jsdom does not implement. const emptyClientRects = () => [] as unknown as DOMRectList; +const emptyClientRect = () => + ({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }) as DOMRect; beforeAll(() => { Element.prototype.getClientRects ??= emptyClientRects; (Text.prototype as unknown as Element).getClientRects ??= emptyClientRects; + (Range.prototype as unknown as Element).getClientRects ??= emptyClientRects; + (Range.prototype as unknown as Element).getBoundingClientRect ??= emptyClientRect; }); const doc = (...texts: string[]): EditorDocument => texts.map((text) => ({ type: BlockType.Paragraph, children: [{ text }] })); +const beforeinput = (el: HTMLElement, inputType: string) => { + const event = new Event('beforeinput', { bubbles: true, cancelable: true }); + Object.assign(event, { inputType }); + fireEvent(el, event); +}; + const mount = (initial: EditorDocument = doc('')) => { const controller = new ProseMirrorEditorController(initial); const result = render(); @@ -259,3 +269,77 @@ describe('ProseMirrorEditorController clear', () => { expect(editable).toHaveAttribute('data-placeholder-visible', 'true'); }); }); + +describe('Android backspace fallback', () => { + const originalUserAgent = navigator.userAgent; + + const setAndroid = (android: boolean) => { + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: android + ? 'Mozilla/5.0 (Linux; Android 14; Pixel 8) Mobile Safari/537.36' + : originalUserAgent, + }); + }; + + afterEach(() => { + setAndroid(false); + vi.useRealTimers(); + }); + + it('deletes backward via the state when the IME leaves the DOM untouched, without blurring', () => { + setAndroid(true); + vi.useFakeTimers(); + const { caretToEnd, controller, editable } = mount(doc('hi')); + caretToEnd(); + editable.focus(); + const onBlur = vi.fn<() => void>(); + editable.addEventListener('blur', onBlur); + + beforeinput(editable, 'deleteContentBackward'); + act(() => vi.advanceTimersByTime(50)); + + expect(controller.getDocument()).toEqual(doc('h')); + expect(onBlur).not.toHaveBeenCalled(); + }); + + it('leaves the deletion to the observer once the state already changed', () => { + setAndroid(true); + vi.useFakeTimers(); + const { caretToEnd, controller, editable, view } = mount(doc('hi')); + caretToEnd(); + + beforeinput(editable, 'deleteContentBackward'); + // The IME deleted in the DOM and the observer applied it first. + act(() => { + const from = view.state.selection.from; + view.dispatch(view.state.tr.delete(from - 1, from)); + vi.advanceTimersByTime(50); + }); + + expect(controller.getDocument()).toEqual(doc('h')); + }); + + it('does not intercept Android input other than backward deletes', () => { + setAndroid(true); + vi.useFakeTimers(); + const { caretToEnd, controller, editable } = mount(doc('hi')); + caretToEnd(); + + beforeinput(editable, 'insertText'); + act(() => vi.advanceTimersByTime(50)); + + expect(controller.getDocument()).toEqual(doc('hi')); + }); + + it('leaves non-Android devices to the built-in handler', () => { + vi.useFakeTimers(); + const { caretToEnd, controller, editable } = mount(doc('hi')); + caretToEnd(); + + beforeinput(editable, 'deleteContentBackward'); + act(() => vi.advanceTimersByTime(50)); + + expect(controller.getDocument()).toEqual(doc('hi')); + }); +}); diff --git a/src/app/components/editor/prosemirrorController.ts b/src/app/components/editor/prosemirrorController.ts index 483ae8f995..8a55d4e4d8 100644 --- a/src/app/components/editor/prosemirrorController.ts +++ b/src/app/components/editor/prosemirrorController.ts @@ -22,6 +22,41 @@ import { const isProseMirrorDocumentEmpty = (doc: ProseMirrorNode): boolean => doc.childCount === 1 && doc.firstChild?.content.size === 0; +// Mirrors prosemirror-view's own Android gate for its backspace fallback. +const isAndroid = (): boolean => /Android \d/.test(navigator.userAgent); + +const androidBackspaceKeyEvent = (): KeyboardEvent => + new KeyboardEvent('keydown', { + key: 'Backspace', + code: 'Backspace', + bubbles: true, + cancelable: true, + }); + +/** + * prosemirror-view resyncs an Android backspace that left the DOM untouched by + * blurring and refocusing the editable, which flashes the soft keyboard shut. + * Delete through the state instead. + */ +const handleAndroidDeleteBackward = (view: EditorView): void => { + const cursor = + view.state.selection instanceof TextSelection ? view.state.selection.$cursor : null; + if (!cursor || cursor.pos <= 0) return; + const pos = cursor.pos; + const contentSize = view.state.doc.content.size; + window.setTimeout(() => { + // The IME deleted in the DOM; leave it to the DOM observer. + const cursorAfter = + view.state.selection instanceof TextSelection ? view.state.selection.$cursor : null; + if (!cursorAfter || cursorAfter.pos !== pos) return; + if (view.state.doc.content.size !== contentSize) return; + // Prefer the keymap so atom nodes follow desktop Backspace behavior. + if (view.someProp('handleKeyDown', (handler) => handler(view, androidBackspaceKeyEvent()))) + return; + view.dispatch(view.state.tr.delete(pos - 1, pos)); + }, 50); +}; + export type EditorAutocompleteQuery = { from: number; prefix: TPrefix; @@ -165,6 +200,12 @@ export class ProseMirrorEditorController { this.domEventHandlers.blur?.(event as FocusEvent); return false; }, + beforeinput: (view, event) => { + if (!isAndroid() || (event as InputEvent).inputType !== 'deleteContentBackward') + return false; + handleAndroidDeleteBackward(view); + return true; + }, }, handlePaste: (view, event) => { const text = event.clipboardData?.getData('text/plain');