From d2503332909676036d66190aab8ad4afb2ad4c73 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 25 Sep 2026 18:13:47 -0400 Subject: [PATCH] fix(composer): Enter picks the @-mention / #room / command suggestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the mention list open, pressing Enter sent the half-typed text ("hey @bo") instead of inserting the highlighted person — found while investigating the "composer hit a snag" report; only Tab picked. Enter now picks the top suggestion for people, rooms and commands, as in Discord, Slack and Element. useAutocompleteEnter listens in the window capture phase (so it runs before the composer's submit handler) and only while the list has suggestions — with no command match Enter still sends. Emoji suggestions are deliberately left on Tab only, so "lol :p" + Enter still sends rather than inserting an emoji. IME composition and Shift/Ctrl+Enter are untouched. Tab now inserts the member's display name, same as a click. Verified: "@bo" + Enter inserts the mention and sends nothing; the next Enter sends "hey @bob hi" with m.mentions; ":smi" + Enter still sends. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../autocomplete/RoomMentionAutocomplete.tsx | 28 ++++++++------- .../autocomplete/UserMentionAutocomplete.tsx | 34 +++++++++++-------- src/app/features/room/CommandAutocomplete.tsx | 18 +++++----- src/app/hooks/useAutocompleteEnter.ts | 28 +++++++++++++++ 4 files changed, 71 insertions(+), 37 deletions(-) create mode 100644 src/app/hooks/useAutocompleteEnter.ts diff --git a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx index a3243ba61..91fffbab0 100644 --- a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx @@ -12,6 +12,7 @@ import { AutocompleteMenu } from './AutocompleteMenu'; import { getMxIdServer, isRoomAlias } from '../../../utils/matrix'; import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch'; import { onTabPress } from '../../../utils/keyboard'; +import { useAutocompleteEnter } from '../../../hooks/useAutocompleteEnter'; import { useKeyDown } from '../../../hooks/useKeyDown'; import { mDirectAtom } from '../../../state/mDirectList'; import { allRoomsAtom } from '../../../state/room-list/roomList'; @@ -126,19 +127,20 @@ export function RoomMentionAutocomplete({ requestClose(); }; - useKeyDown(window, (evt: KeyboardEvent) => { - onTabPress(evt, () => { - if (autoCompleteRoomIds.length === 0) { - const alias = roomAliasFromQueryText(mx, query.text); - handleAutocomplete(alias, alias); - return; - } - const rId = autoCompleteRoomIds[0]; - const r = mx.getRoom(rId); - const name = r?.name ?? rId; - handleAutocomplete(r?.getCanonicalAlias() ?? rId, name); - }); - }); + const pickTop = () => { + if (autoCompleteRoomIds.length === 0) { + const alias = roomAliasFromQueryText(mx, query.text); + handleAutocomplete(alias, alias); + return; + } + const rId = autoCompleteRoomIds[0]; + const r = mx.getRoom(rId); + const name = r?.name ?? rId; + handleAutocomplete(r?.getCanonicalAlias() ?? rId, name); + }; + + useKeyDown(window, (evt: KeyboardEvent) => onTabPress(evt, pickTop)); + useAutocompleteEnter(true, pickTop); return ( Rooms} requestClose={requestClose}> diff --git a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx index 663e49fb6..9ab348290 100644 --- a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx @@ -13,6 +13,7 @@ import { useAsyncSearch, } from '../../../hooks/useAsyncSearch'; import { onTabPress } from '../../../utils/keyboard'; +import { useAutocompleteEnter } from '../../../hooks/useAutocompleteEnter'; import { createMentionElement, moveCursor, replaceWithElement } from '../utils'; import { useKeyDown } from '../../../hooks/useKeyDown'; import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix'; @@ -124,21 +125,24 @@ export function UserMentionAutocomplete({ requestClose(); }; - useKeyDown(window, (evt: KeyboardEvent) => { - onTabPress(evt, () => { - if (query.text === 'room') { - handleAutocomplete(roomAliasOrId, '@room'); - return; - } - if (autoCompleteMembers.length === 0) { - const userId = userIdFromQueryText(mx, query.text); - handleAutocomplete(userId, userId); - return; - } - const roomMember = autoCompleteMembers[0]; - handleAutocomplete(roomMember.userId, roomMember.name); - }); - }); + const pickTop = () => { + if (query.text === 'room') { + handleAutocomplete(roomAliasOrId, '@room'); + return; + } + if (autoCompleteMembers.length === 0) { + const userId = userIdFromQueryText(mx, query.text); + handleAutocomplete(userId, userId); + return; + } + const roomMember = autoCompleteMembers[0]; + handleAutocomplete(roomMember.userId, getMemberName(room, roomMember.userId)); + }; + + useKeyDown(window, (evt: KeyboardEvent) => onTabPress(evt, pickTop)); + // The list always shows a suggestion (a member, or the typed id), so Enter + // always picks rather than sending "hey @bo". + useAutocompleteEnter(true, pickTop); const getName = (member: RoomMember) => getMemberName(room, member.userId); diff --git a/src/app/features/room/CommandAutocomplete.tsx b/src/app/features/room/CommandAutocomplete.tsx index 891c00f4a..a24f20c5e 100644 --- a/src/app/features/room/CommandAutocomplete.tsx +++ b/src/app/features/room/CommandAutocomplete.tsx @@ -14,6 +14,7 @@ import { UseAsyncSearchOptions, useAsyncSearch } from '../../hooks/useAsyncSearc import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useKeyDown } from '../../hooks/useKeyDown'; import { onTabPress } from '../../utils/keyboard'; +import { useAutocompleteEnter } from '../../hooks/useAutocompleteEnter'; type CommandAutoCompleteHandler = (commandName: string) => void; @@ -60,15 +61,14 @@ export function CommandAutocomplete({ requestClose(); }; - useKeyDown(window, (evt: KeyboardEvent) => { - onTabPress(evt, () => { - if (autoCompleteNames.length === 0) { - return; - } - const cmdName = autoCompleteNames[0]; - handleAutocomplete(cmdName); - }); - }); + const pickTop = () => { + if (autoCompleteNames.length === 0) return; + handleAutocomplete(autoCompleteNames[0]); + }; + + useKeyDown(window, (evt: KeyboardEvent) => onTabPress(evt, pickTop)); + // Only while suggestions show: with no match Enter must still send. + useAutocompleteEnter(autoCompleteNames.length > 0, pickTop); return autoCompleteNames.length === 0 ? null : ( void): void { + const selectRef = useRef(onSelect); + selectRef.current = onSelect; + + useEffect(() => { + if (!active) return undefined; + const onKeyDown = (evt: KeyboardEvent) => { + if (evt.isComposing || !isKeyHotkey('enter', evt)) return; + evt.preventDefault(); + evt.stopPropagation(); + selectRef.current(); + }; + window.addEventListener('keydown', onKeyDown, true); + return () => window.removeEventListener('keydown', onKeyDown, true); + }, [active]); +}