From 867a47218a7150b8c1dd652b815136a50813c087 Mon Sep 17 00:00:00 2001 From: Mari Date: Wed, 24 Sep 2025 23:35:42 -0400 Subject: [PATCH 1/7] fix: Prevent IME-exiting Enter press from sending message on Safari (#2175) 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 | 7 ++- .../features/room/message/MessageEditor.tsx | 9 +++- src/app/hooks/useComposingCheck.ts | 47 +++++++++++++++++++ src/app/pages/App.tsx | 2 + src/app/state/lastCompositionEnd.ts | 3 ++ 5 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 src/app/hooks/useComposingCheck.ts create mode 100644 src/app/state/lastCompositionEnd.ts 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); From b78f6f23b568bdfa4f20aa7ddc9505dfd3558599 Mon Sep 17 00:00:00 2001 From: Ginger Date: Wed, 24 Sep 2025 23:41:35 -0400 Subject: [PATCH 2/7] Add support to mark videos as spoilers (#2255) * Add support for MSC4193: Spoilers on Media * Clarify variable names and wording * Restore list atom * Improve spoilered image UX with autoload off * Use `aria-pressed` to indicate attachment spoiler state * Improve spoiler button tooltip wording, keep reveal button from conflicting with load errors * Make it possible to mark videos as spoilers * Allow videos to be marked as spoilers when uploaded * Apply requested changes * Show a loading spinner on spoiled media when unblurred --------- Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com> --- src/app/components/RenderMessageContent.tsx | 6 +- .../components/message/MsgTypeRenderers.tsx | 4 ++ .../message/content/ImageContent.tsx | 2 +- .../message/content/VideoContent.tsx | 49 +++++++++++-- .../upload-card/UploadCardRenderer.tsx | 72 +++++++++++++++---- src/app/features/room/msgContent.ts | 3 +- 6 files changed, 112 insertions(+), 24 deletions(-) diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 2457e5e3..4cfcb7dc 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -209,13 +209,11 @@ export function RenderMessageContent({ ( + renderVideoContent={({ body, info, ...props }) => ( ( diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 15c95ddf..a40ecae1 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -224,6 +224,8 @@ type RenderVideoContentProps = { mimeType: string; url: string; encInfo?: IEncryptedFile; + markedAsSpoiler?: boolean; + spoilerReason?: string; }; type MVideoProps = { content: IVideoContent; @@ -274,6 +276,8 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: mimeType: safeMimeType, url: mxcUrl, encInfo: content.file, + markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME], + spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME], })} diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index cc0c0c91..84e3709e 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -214,7 +214,7 @@ export const ImageContent = as<'div', ImageContentProps>( )} {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) && !load && - !markedAsSpoiler && ( + !blurred && ( diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index 3b613803..52073ac1 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -3,6 +3,7 @@ import { Badge, Box, Button, + Chip, Icon, Icons, Spinner, @@ -47,6 +48,8 @@ type VideoContentProps = { info: IVideoInfo & IThumbnailContent; encInfo?: EncryptedAttachmentInfo; autoPlay?: boolean; + markedAsSpoiler?: boolean; + spoilerReason?: string; renderThumbnail?: () => ReactNode; renderVideo: (props: RenderVideoProps) => ReactNode; }; @@ -60,6 +63,8 @@ export const VideoContent = as<'div', VideoContentProps>( info, encInfo, autoPlay, + markedAsSpoiler, + spoilerReason, renderThumbnail, renderVideo, ...props @@ -72,6 +77,7 @@ export const VideoContent = as<'div', VideoContentProps>( const [load, setLoad] = useState(false); const [error, setError] = useState(false); + const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { @@ -114,11 +120,15 @@ export const VideoContent = as<'div', VideoContentProps>( /> )} {renderThumbnail && !load && ( - + {renderThumbnail()} )} - {!autoPlay && srcState.status === AsyncStatus.Idle && ( + {!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (