Skip to content

Commit

Permalink
fix: Prevent IME-exiting Enter press from sending message on Safari
Browse files Browse the repository at this point in the history
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 cinnyapp#2103, but needs confirmation from a Safari user.
programmablereya committed Jan 21, 2025
1 parent b524778 commit 5b5cd9a
Showing 12 changed files with 89 additions and 6 deletions.
4 changes: 4 additions & 0 deletions src/app/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement, CustomEditorProps>(

const handleKeydown: KeyboardEventHandler = useCallback(
(evt) => {
if (isComposing(evt.nativeEvent)) {
return
}
onKeyDown?.(evt);
const shortcutToggled = toggleKeyboardShortcut(editor, evt);
if (shortcutToggled) evt.preventDefault();
3 changes: 2 additions & 1 deletion src/app/features/room/Room.tsx
Original file line number Diff line number Diff line change
@@ -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);
}
},
7 changes: 7 additions & 0 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement, RoomInputProps>(

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<HTMLDivElement, RoomInputProps>(

const handleKeyUp: KeyboardEventHandler = useCallback(
(evt) => {
if (isComposing(evt.nativeEvent)) {
return
}
if (isKeyHotkey('escape', evt)) {
evt.preventDefault();
return;
2 changes: 2 additions & 0 deletions src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
@@ -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' &&
4 changes: 3 additions & 1 deletion src/app/features/room/RoomView.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 7 additions & 0 deletions src/app/features/room/message/MessageEditor.tsx
Original file line number Diff line number Diff line change
@@ -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;
33 changes: 33 additions & 0 deletions src/app/hooks/useSafariCompositionTaggingForKeyDown.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
4 changes: 4 additions & 0 deletions src/app/organisms/search/Search.jsx
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions src/app/organisms/settings/Settings.jsx
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 2 additions & 0 deletions src/app/pages/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ScreenSizeProvider value={screenSize}>
5 changes: 4 additions & 1 deletion src/app/pages/auth/ServerPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement> = (evt) => {
if (isComposing(evt.nativeEvent)) {
return;
}
if (evt.key === 'ArrowDown') {
evt.preventDefault();
setServerMenuAnchor(undefined);
20 changes: 17 additions & 3 deletions src/app/utils/keyboard.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isKeyHotkey } from 'is-hotkey';
import { KeyboardEventHandler } from 'react';
import { isTaggedAsComposing } from '../hooks/useSafariCompositionTaggingForKeyDown';

export interface KeyboardEventLike {
key: string;
@@ -11,23 +12,36 @@ 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();
}
};

export const onEnterOrSpace =
<T>(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);
}

0 comments on commit 5b5cd9a

Please sign in to comment.