Files
cinny/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx
T
Lotus CIandClaude Opus 5.5 4323babfb9
CI / Build & Quality Checks (push) Successful in 1m47s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 2s
CI / Playwright smoke (e2e) (push) Successful in 8m35s
fix(composer): a fully typed user ID + Enter sends as typed
d2503332 made Enter pick the mention suggestion, which also caught
"/kick @alice:server" + Enter (the e2e "/kick failure is reported" test):
typing a complete ID opens the mention list too, and Enter picked it instead
of running the command. Enter now picks only while a partial name is being
typed (no ':' in the query); a complete @user:server or #alias:server sends
as typed, like before.

Chromium e2e 19 passed; "@bo" + Enter still picks, ":smi" + Enter still
sends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 18:20:04 -04:00

210 lines
7.2 KiB
TypeScript

import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react';
import { Editor } from 'slate';
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu';
import { useRoomMembers } from '../../../hooks/useRoomMembers';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import {
SearchItemStrGetter,
UseAsyncSearchOptions,
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';
import { getMemberName, getMemberSearchStr } from '../../../utils/room';
import { UserAvatar } from '../../user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Membership } from '../../../../types/matrix/room';
import { PresenceRingAvatar } from '../../presence';
import { AvatarDecoration } from '../../avatar-decoration/AvatarDecoration';
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
const userIdFromQueryText = (mx: MatrixClient, text: string) =>
isUserId(`@${text}`)
? `@${text}`
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
function UnknownMentionItem({
userId,
name,
handleAutocomplete,
}: {
userId: string;
name: string;
handleAutocomplete: MentionAutoCompleteHandler;
}) {
return (
<MenuItem
as="button"
radii="300"
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
onTabPress(evt, () => handleAutocomplete(userId, name))
}
onClick={() => handleAutocomplete(userId, name)}
before={
<AvatarDecoration userId={userId}>
<PresenceRingAvatar userId={userId}>
<Avatar size="200">
<UserAvatar
userId={userId}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
}
>
<Text style={{ flexGrow: 1 }} size="B400">
{name}
</Text>
</MenuItem>
);
}
type UserMentionAutocompleteProps = {
room: Room;
editor: Editor;
query: AutocompleteQuery<string>;
requestClose: () => void;
};
const withAllowedMembership = (member: RoomMember): boolean =>
member.membership === Membership.Join ||
member.membership === Membership.Invite ||
member.membership === Membership.Knock;
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
matchOptions: {
contain: true,
},
};
const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId;
const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
getMemberSearchStr(m, query, mxIdToName);
export function UserMentionAutocomplete({
room,
editor,
query,
requestClose,
}: UserMentionAutocompleteProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const roomId: string = room.roomId!;
const roomAliasOrId = room.getCanonicalAlias() || roomId;
const members = useRoomMembers(mx, roomId);
const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS);
const autoCompleteMembers = (result ? result.items.slice(0, 20) : members.slice(0, 20)).filter(
withAllowedMembership,
);
useEffect(() => {
if (query.text) search(query.text);
else resetSearch();
}, [query.text, search, resetSearch]);
const handleAutocomplete: MentionAutoCompleteHandler = (uId, name) => {
const mentionEl = createMentionElement(
uId,
name.startsWith('@') ? name : `@${name}`,
mx.getUserId() === uId || roomAliasOrId === uId,
);
replaceWithElement(editor, query.range, mentionEl);
moveCursor(editor, true);
requestClose();
};
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));
// Enter picks while a partial name is being typed ("hey @bo"). Once a full
// user ID has been typed ("/kick @alice:server"), Enter sends as typed.
useAutocompleteEnter(!query.text.includes(':'), pickTop);
const getName = (member: RoomMember) => getMemberName(room, member.userId);
return (
<AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
{query.text === 'room' && (
<UnknownMentionItem
userId={roomAliasOrId}
name="@room"
handleAutocomplete={handleAutocomplete}
/>
)}
{autoCompleteMembers.length === 0 ? (
<UnknownMentionItem
userId={userIdFromQueryText(mx, query.text)}
name={userIdFromQueryText(mx, query.text)}
handleAutocomplete={handleAutocomplete}
/>
) : (
autoCompleteMembers.map((roomMember) => {
const avatarMxcUrl = roomMember.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication)
: undefined;
return (
<MenuItem
key={roomMember.userId}
as="button"
radii="300"
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
onTabPress(evt, () => handleAutocomplete(roomMember.userId, getName(roomMember)))
}
onClick={() => handleAutocomplete(roomMember.userId, getName(roomMember))}
after={
<Text size="T200" priority="300" truncate>
{roomMember.userId}
</Text>
}
before={
<AvatarDecoration userId={roomMember.userId}>
<PresenceRingAvatar userId={roomMember.userId}>
<Avatar size="200">
<UserAvatar
userId={roomMember.userId}
src={avatarUrl ?? undefined}
alt={getName(roomMember)}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
}
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>
{getName(roomMember)}
</Text>
</MenuItem>
);
})
)}
</AutocompleteMenu>
);
}