fix: Prevent IME-exiting Enter press from sending message on Safari (#2175)
authorMari <reya.git@reya.zone>
Thu, 25 Sep 2025 03:35:42 +0000 (23:35 -0400)
committerGitHub <noreply@github.com>
Thu, 25 Sep 2025 03:35:42 +0000 (09:05 +0530)
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/features/room/RoomInput.tsx
src/app/features/room/message/MessageEditor.tsx
src/app/hooks/useComposingCheck.ts [new file with mode: 0644]
src/app/pages/App.tsx
src/app/state/lastCompositionEnd.ts [new file with mode: 0644]

index 76bafc9e02f6b0b2592dde1b4c4eb0cfa121d2bc..ae46d2d0989dc20a160745f4b6959052e3958f21 100644 (file)
@@ -116,6 +116,7 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
 import { useTheme } from '../../hooks/useTheme';
 import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
 import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
+import { useComposingCheck } from '../../hooks/useComposingCheck';
 
 interface RoomInputProps {
   editor: Editor;
@@ -217,6 +218,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
     const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
     const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
 
+    const isComposing = useComposingCheck();
+
     useElementSizeObserver(
       useCallback(() => document.body, []),
       useCallback((width) => setHideStickerBtn(width < 500), [])
@@ -380,7 +383,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
       (evt) => {
         if (
           (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
-          !evt.nativeEvent.isComposing
+          !isComposing(evt)
         ) {
           evt.preventDefault();
           submit();
@@ -394,7 +397,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
           setReplyDraft(undefined);
         }
       },
-      [submit, setReplyDraft, enterForNewline, autocompleteQuery]
+      [submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing]
     );
 
     const handleKeyUp: KeyboardEventHandler = useCallback(
index 29a4b83713ffd29057ed3f3d38004db2bb1199a5..9a7567aacb66e72221410e9c6c76f727a272f790 100644 (file)
@@ -53,6 +53,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 import { useMatrixClient } from '../../../hooks/useMatrixClient';
 import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
 import { mobileOrTablet } from '../../../utils/user-agent';
+import { useComposingCheck } from '../../../hooks/useComposingCheck';
 
 type MessageEditorProps = {
   roomId: string;
@@ -69,6 +70,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
     const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
     const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
     const [toolbar, setToolbar] = useState(globalToolbar);
+    const isComposing = useComposingCheck();
 
     const [autocompleteQuery, setAutocompleteQuery] =
       useState<AutocompleteQuery<AutocompletePrefix>>();
@@ -163,7 +165,10 @@ export const MessageEditor = as<'div', MessageEditorProps>(
 
     const handleKeyDown: KeyboardEventHandler = useCallback(
       (evt) => {
-        if ((isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) && !evt.nativeEvent.isComposing) {
+        if (
+          (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
+          !isComposing(evt)
+        ) {
           evt.preventDefault();
           handleSave();
         }
@@ -172,7 +177,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
           onCancel();
         }
       },
-      [onCancel, handleSave, enterForNewline]
+      [onCancel, handleSave, enterForNewline, isComposing]
     );
 
     const handleKeyUp: KeyboardEventHandler = useCallback(
diff --git a/src/app/hooks/useComposingCheck.ts b/src/app/hooks/useComposingCheck.ts
new file mode 100644 (file)
index 0000000..687a492
--- /dev/null
@@ -0,0 +1,47 @@
+import { useCallback, useEffect } from 'react';
+import { useAtomValue, useSetAtom } from 'jotai';
+import { lastCompositionEndAtom } from '../state/lastCompositionEnd';
+
+interface TimeStamped {
+  readonly timeStamp: number;
+}
+
+export function useCompositionEndTracking(): void {
+  const setLastCompositionEnd = useSetAtom(lastCompositionEndAtom);
+
+  const recordCompositionEnd = useCallback(
+    (evt: TimeStamped) => {
+      setLastCompositionEnd(evt.timeStamp);
+    },
+    [setLastCompositionEnd]
+  );
+
+  useEffect(() => {
+    window.addEventListener('compositionend', recordCompositionEnd, { capture: true });
+    return () => {
+      window.removeEventListener('compositionend', recordCompositionEnd, { capture: true });
+    };
+  });
+}
+
+interface IsComposingLike {
+  readonly timeStamp: number;
+  readonly keyCode: number;
+  readonly nativeEvent: {
+    readonly isComposing?: boolean;
+  };
+}
+
+export function useComposingCheck({
+  compositionEndThreshold = 500,
+}: { compositionEndThreshold?: number } = {}): (evt: IsComposingLike) => boolean {
+  const compositionEnd = useAtomValue(lastCompositionEndAtom);
+  return useCallback(
+    (evt: IsComposingLike): boolean =>
+      evt.nativeEvent.isComposing ||
+      (evt.keyCode === 229 &&
+        typeof compositionEnd !== 'undefined' &&
+        evt.timeStamp - compositionEnd < compositionEndThreshold),
+    [compositionEndThreshold, compositionEnd]
+  );
+}
index 0a919b577544ed983d1c5254652d1d53777426a4..52ec7f206eea48595afabd1a99fd080322ea7c88 100644 (file)
@@ -11,11 +11,13 @@ import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig';
 import { FeatureCheck } from './FeatureCheck';
 import { createRouter } from './Router';
 import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
+import { useCompositionEndTracking } from '../hooks/useComposingCheck';
 
 const queryClient = new QueryClient();
 
 function App() {
   const screenSize = useScreenSize();
+  useCompositionEndTracking();
 
   const portalContainer = document.getElementById('portalContainer') ?? undefined;
 
diff --git a/src/app/state/lastCompositionEnd.ts b/src/app/state/lastCompositionEnd.ts
new file mode 100644 (file)
index 0000000..7235b49
--- /dev/null
@@ -0,0 +1,3 @@
+import { atom } from 'jotai';
+
+export const lastCompositionEndAtom = atom<number | undefined>(undefined);