mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 06:20:28 +03:00 
			
		
		
		
	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.
This commit is contained in:
		
							parent
							
								
									31c6d13fdf
								
							
						
					
					
						commit
						beff09aedb
					
				
					 5 changed files with 64 additions and 4 deletions
				
			
		| 
						 | 
				
			
			@ -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(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										47
									
								
								src/app/hooks/useComposingCheck.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/app/hooks/useComposingCheck.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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]
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										3
									
								
								src/app/state/lastCompositionEnd.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/app/state/lastCompositionEnd.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
import { atom } from 'jotai';
 | 
			
		||||
 | 
			
		||||
export const lastCompositionEndAtom = atom<number | undefined>(undefined);
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue