diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 76bafc9e..ae46d2d0 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -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( 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( (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( setReplyDraft(undefined); } }, - [submit, setReplyDraft, enterForNewline, autocompleteQuery] + [submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing] ); const handleKeyUp: KeyboardEventHandler = useCallback( diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 29a4b837..9a7567aa 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -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>(); @@ -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 index 00000000..687a4927 --- /dev/null +++ b/src/app/hooks/useComposingCheck.ts @@ -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] + ); +} diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 0a919b57..52ec7f20 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -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 index 00000000..7235b491 --- /dev/null +++ b/src/app/state/lastCompositionEnd.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai'; + +export const lastCompositionEndAtom = atom(undefined);