From 5b5cd9af1dae4a141904cbcaf4eefde8e8091bca Mon Sep 17 00:00:00 2001 From: Mari Lyndon Date: Tue, 21 Jan 2025 17:02:35 -0500 Subject: [PATCH] fix: Prevent IME-exiting Enter press from sending message on Safari On most browsers, pressing Enter to end IME composition produces this sequence of events: * keydown (keycode 229, key Processing/Unidentified, isComposing true) * compositionend * keyup (keycode 13, key Enter, isComposing false) On Safari, the sequence is different: * compositionend * keydown (keycode 229, key Enter, isComposing false) * keyup (keycode 13, key Enter, isComposing false) This causes Safari users to mistakenly send their messages when they press Enter to confirm their choice in an IME. The workaround is to treat the next keydown with keycode 229 as if it were part of the IME composition period if it occurs within a short time of the compositionend event. Fixes #2103, but needs confirmation from a Safari user. --- src/app/components/editor/Editor.tsx | 4 +++ src/app/features/room/Room.tsx | 3 +- src/app/features/room/RoomInput.tsx | 7 ++++ src/app/features/room/RoomTimeline.tsx | 2 ++ src/app/features/room/RoomView.tsx | 4 ++- .../features/room/message/MessageEditor.tsx | 7 ++++ .../useSafariCompositionTaggingForKeyDown.ts | 33 +++++++++++++++++++ src/app/organisms/search/Search.jsx | 4 +++ src/app/organisms/settings/Settings.jsx | 4 +++ src/app/pages/App.tsx | 2 ++ src/app/pages/auth/ServerPicker.tsx | 5 ++- src/app/utils/keyboard.ts | 20 +++++++++-- 12 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 src/app/hooks/useSafariCompositionTaggingForKeyDown.ts diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 044d083793..817af44180 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -23,6 +23,7 @@ import { RenderElement, RenderLeaf } from './Elements'; import { CustomElement } from './slate'; import * as css from './Editor.css'; import { toggleKeyboardShortcut } from './keyboard'; +import { isComposing } from '../../utils/keyboard'; const initialValue: CustomElement[] = [ { @@ -99,6 +100,9 @@ export const CustomEditor = forwardRef( const handleKeydown: KeyboardEventHandler = useCallback( (evt) => { + if (isComposing(evt.nativeEvent)) { + return + } onKeyDown?.(evt); const shortcutToggled = toggleKeyboardShortcut(editor, evt); if (shortcutToggled) evt.preventDefault(); diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index ee3e702740..0b05895c26 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -13,6 +13,7 @@ import { useKeyDown } from '../../hooks/useKeyDown'; import { markAsRead } from '../../../client/action/notifications'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoomMembers } from '../../hooks/useRoomMembers'; +import { isComposing } from '../../utils/keyboard'; export function Room() { const { eventId } = useParams(); @@ -28,7 +29,7 @@ export function Room() { window, useCallback( (evt) => { - if (isKeyHotkey('escape', evt)) { + if (!isComposing(evt) && isKeyHotkey('escape', evt)) { markAsRead(mx, room.roomId); } }, diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 4d43c7e964..a126793a00 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -109,6 +109,7 @@ import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { ReplyLayout, ThreadIndicator } from '../../components/message'; import { roomToParentsAtom } from '../../state/room/roomToParents'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { isComposing } from '../../utils/keyboard'; interface RoomInputProps { editor: Editor; @@ -333,6 +334,9 @@ export const RoomInput = forwardRef( const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { + if (isComposing(evt.nativeEvent)) { + return; + } if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) { evt.preventDefault(); submit(); @@ -347,6 +351,9 @@ export const RoomInput = forwardRef( const handleKeyUp: KeyboardEventHandler = useCallback( (evt) => { + if (isComposing(evt.nativeEvent)) { + return + } if (isKeyHotkey('escape', evt)) { evt.preventDefault(); return; diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 63b3d3e2cd..2d6c3512a3 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -117,6 +117,7 @@ import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { isComposing } from '../../utils/keyboard'; const TimelineFloat = as<'div', css.TimelineFloatVariants>( ({ position, className, ...props }, ref) => ( @@ -702,6 +703,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli useCallback( (evt) => { if ( + !isComposing(evt) && isKeyHotkey('arrowup', evt) && editableActiveElement() && document.activeElement?.getAttribute('data-editable-name') === 'RoomInput' && diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 250afc930b..38ed1484a1 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -19,6 +19,7 @@ import { RoomViewHeader } from './RoomViewHeader'; import { useKeyDown } from '../../hooks/useKeyDown'; import { editableActiveElement } from '../../utils/dom'; import navigation from '../../../client/state/navigation'; +import { isComposing } from '../../utils/keyboard'; const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { const { code } = evt; @@ -76,7 +77,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { if (editableActiveElement()) return; if ( document.body.lastElementChild?.className !== 'ReactModalPortal' || - navigation.isRawModalVisible + navigation.isRawModalVisible || + isComposing(evt) ) { return; } diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 0c99503086..1c97296ba7 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -52,6 +52,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getEditedEvent, trimReplyFromFormattedBody } from '../../../utils/room'; import { mobileOrTablet } from '../../../utils/user-agent'; +import { isComposing } from '../../../utils/keyboard'; type MessageEditorProps = { roomId: string; @@ -149,6 +150,9 @@ export const MessageEditor = as<'div', MessageEditorProps>( const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { + if (isComposing(evt.nativeEvent)) { + return; + } if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) { evt.preventDefault(); handleSave(); @@ -163,6 +167,9 @@ export const MessageEditor = as<'div', MessageEditorProps>( const handleKeyUp: KeyboardEventHandler = useCallback( (evt) => { + if (isComposing(evt.nativeEvent)) { + return; + } if (isKeyHotkey('escape', evt)) { evt.preventDefault(); return; diff --git a/src/app/hooks/useSafariCompositionTaggingForKeyDown.ts b/src/app/hooks/useSafariCompositionTaggingForKeyDown.ts new file mode 100644 index 0000000000..79b3202912 --- /dev/null +++ b/src/app/hooks/useSafariCompositionTaggingForKeyDown.ts @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; + +const actuallyComposingTag = Symbol("event is actually composing") + +export function isTaggedAsComposing(x: object): boolean { + return actuallyComposingTag in x +} + +export function useSafariCompositionTaggingForKeyDown(target: Window, {compositionEndThreshold = 500}: {compositionEndThreshold?: 500} = {}) { + useEffect(() => { + let compositionJustEndedAt: number | null = null + + function recordCompositionEnd(evt: CompositionEvent) { + compositionJustEndedAt = evt.timeStamp + } + + function interceptAndTagKeyDown(evt: KeyboardEvent) { + if (compositionJustEndedAt !== null + && evt.keyCode === 229 + && (evt.timeStamp - compositionJustEndedAt) < compositionEndThreshold) { + Object.assign(evt, { [actuallyComposingTag]: true }) + } + compositionJustEndedAt = null + } + + target.addEventListener('compositionend', recordCompositionEnd, { capture: true }) + target.addEventListener('keydown', interceptAndTagKeyDown, { capture: true }) + return () => { + target.removeEventListener('compositionend', recordCompositionEnd, { capture: true }) + target.removeEventListener('keydown', interceptAndTagKeyDown, { capture: true }) + } + }, [target, compositionEndThreshold]); +} \ No newline at end of file diff --git a/src/app/organisms/search/Search.jsx b/src/app/organisms/search/Search.jsx index ebdac3962e..46d15cf808 100644 --- a/src/app/organisms/search/Search.jsx +++ b/src/app/organisms/search/Search.jsx @@ -27,6 +27,7 @@ import { useKeyDown } from '../../hooks/useKeyDown'; import { openSearch } from '../../../client/action/navigation'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { factoryRoomIdByActivity } from '../../utils/sort'; +import { isComposing } from '../../utils/keyboard.js'; function useVisiblityToggle(setResult) { const [isOpen, setIsOpen] = useState(false); @@ -54,6 +55,9 @@ function useVisiblityToggle(setResult) { useKeyDown( window, useCallback((event) => { + if (isComposing(event)) { + return; + } // Ctrl/Cmd + if (event.ctrlKey || event.metaKey) { // open search modal diff --git a/src/app/organisms/settings/Settings.jsx b/src/app/organisms/settings/Settings.jsx index 6329a57fe0..5c58628f40 100644 --- a/src/app/organisms/settings/Settings.jsx +++ b/src/app/organisms/settings/Settings.jsx @@ -47,6 +47,7 @@ import { settingsAtom } from '../../state/settings'; import { isMacOS } from '../../utils/user-agent'; import { KeySymbol } from '../../utils/key-symbol'; import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { isComposing } from '../../utils/keyboard.js'; function AppearanceSection() { const [, updateState] = useState({}); @@ -78,6 +79,9 @@ function AppearanceSection() { }; const handleZoomEnter = (evt) => { + if (isComposing(evt.nativeEvent)) { + return; + } if (isKeyHotkey('escape', evt)) { evt.stopPropagation(); setCurrentZoom(pageZoom); diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index b16462dffd..db470a221d 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -10,11 +10,13 @@ import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig'; import { FeatureCheck } from './FeatureCheck'; import { createRouter } from './Router'; import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize'; +import { useSafariCompositionTaggingForKeyDown } from '../hooks/useSafariCompositionTaggingForKeyDown'; const queryClient = new QueryClient(); function App() { const screenSize = useScreenSize(); + useSafariCompositionTaggingForKeyDown(window); return ( diff --git a/src/app/pages/auth/ServerPicker.tsx b/src/app/pages/auth/ServerPicker.tsx index a2a78106cd..4f547beeba 100644 --- a/src/app/pages/auth/ServerPicker.tsx +++ b/src/app/pages/auth/ServerPicker.tsx @@ -22,7 +22,7 @@ import { import FocusTrap from 'focus-trap-react'; import { useDebounce } from '../../hooks/useDebounce'; -import { stopPropagation } from '../../utils/keyboard'; +import { isComposing, stopPropagation } from '../../utils/keyboard'; export function ServerPicker({ server, @@ -53,6 +53,9 @@ export function ServerPicker({ }; const handleKeyDown: KeyboardEventHandler = (evt) => { + if (isComposing(evt.nativeEvent)) { + return; + } if (evt.key === 'ArrowDown') { evt.preventDefault(); setServerMenuAnchor(undefined); diff --git a/src/app/utils/keyboard.ts b/src/app/utils/keyboard.ts index 46a951ffc0..bfc3f01673 100644 --- a/src/app/utils/keyboard.ts +++ b/src/app/utils/keyboard.ts @@ -1,5 +1,6 @@ import { isKeyHotkey } from 'is-hotkey'; import { KeyboardEventHandler } from 'react'; +import { isTaggedAsComposing } from '../hooks/useSafariCompositionTaggingForKeyDown'; export interface KeyboardEventLike { key: string; @@ -11,15 +12,28 @@ export interface KeyboardEventLike { preventDefault(): void; } +export function isComposing(evt: object): boolean { + if ('nativeEvent' in evt && typeof evt.nativeEvent === 'object' && evt.nativeEvent !== null) { + return isComposing(evt.nativeEvent) + } + if (isTaggedAsComposing(evt)) { + return true + } + if ('isComposing' in evt && typeof evt.isComposing === 'boolean') { + return evt.isComposing + } + return false +} + export const onTabPress = (evt: KeyboardEventLike, callback: () => void) => { - if (isKeyHotkey('tab', evt)) { + if (!isComposing(evt) && isKeyHotkey('tab', evt)) { evt.preventDefault(); callback(); } }; export const preventScrollWithArrowKey: KeyboardEventHandler = (evt) => { - if (isKeyHotkey(['arrowup', 'arrowright', 'arrowdown', 'arrowleft'], evt)) { + if (!isComposing(evt.nativeEvent) && isKeyHotkey(['arrowup', 'arrowright', 'arrowdown', 'arrowleft'], evt)) { evt.preventDefault(); } }; @@ -27,7 +41,7 @@ export const preventScrollWithArrowKey: KeyboardEventHandler = (evt) => { export const onEnterOrSpace = (callback: (evt: T) => void) => (evt: KeyboardEventLike) => { - if (isKeyHotkey('enter', evt) || isKeyHotkey('space', evt)) { + if (!isComposing(evt) && (isKeyHotkey('enter', evt) || isKeyHotkey('space', evt))) { evt.preventDefault(); callback(evt as T); }