feat(invite): split user id into username and server fields and close prompt on successful invite

This commit is contained in:
heaven 2026-04-29 21:45:33 +03:00
parent 3cd1611ee2
commit 212d3e3482

View file

@ -1,5 +1,6 @@
import React, { import React, {
ChangeEventHandler, ChangeEventHandler,
ClipboardEventHandler,
FormEventHandler, FormEventHandler,
KeyboardEventHandler, KeyboardEventHandler,
useCallback, useCallback,
@ -44,6 +45,12 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { BreakWord } from '../../styles/Text.css'; import { BreakWord } from '../../styles/Text.css';
import { useAlive } from '../../hooks/useAlive'; import { useAlive } from '../../hooks/useAlive';
const FALLBACK_SERVER = 'vojo.chat';
// Anchored domain[:port] check. The shared `isServerName` regex is unanchored
// so it would accept trailing junk like `matrix.org/foo`.
const SERVER_NAME_REGEX = /^(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?::\d+)?$/;
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000, limit: 1000,
matchOptions: { matchOptions: {
@ -52,6 +59,21 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = {
}; };
const getUserIdString = (userId: string) => getMxIdLocalPart(userId) ?? userId; const getUserIdString = (userId: string) => getMxIdLocalPart(userId) ?? userId;
const buildUserId = (rawUsername: string, server: string): string | undefined => {
const username = rawUsername.trim().replace(/^@/, '');
const trimmedServer = server.trim();
if (
!username ||
/[@:\s]/.test(username) ||
!trimmedServer ||
!SERVER_NAME_REGEX.test(trimmedServer)
) {
return undefined;
}
const userId = `@${username}:${trimmedServer}`;
return isUserId(userId) ? userId : undefined;
};
type InviteUserProps = { type InviteUserProps = {
room: Room; room: Room;
requestClose: () => void; requestClose: () => void;
@ -61,9 +83,13 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const alive = useAlive(); const alive = useAlive();
const inputRef = useRef<HTMLInputElement>(null); const usernameInputRef = useRef<HTMLInputElement>(null);
const serverInputRef = useRef<HTMLInputElement>(null);
const directUsers = useDirectUsers(); const directUsers = useDirectUsers();
const [validUserId, setValidUserId] = useState<string>();
const userServer = getMxIdServer(mx.getSafeUserId()) ?? FALLBACK_SERVER;
const [validUserId, setValidUserId] = useState<string | undefined>(undefined);
const [invalidUserId, setInvalidUserId] = useState(false);
const filteredUsers = useMemo( const filteredUsers = useMemo(
() => () =>
@ -93,51 +119,110 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
const inviting = inviteState.status === AsyncStatus.Loading; const inviting = inviteState.status === AsyncStatus.Loading;
const handleReset = () => { const recomputeValidity = useCallback(() => {
if (inputRef.current) inputRef.current.value = ''; const username = usernameInputRef.current?.value.trim() ?? '';
setValidUserId(undefined); // A full MXID typed into the username field is valid only if its parsed
resetSearch(); // local/server pass the same strict checks as buildUserId — otherwise the
}; // submit button would enable for inputs the submit handler will reject.
if (isUserId(username)) {
const parsedLocal = getMxIdLocalPart(username);
const parsedServer = getMxIdServer(username);
setValidUserId(
parsedLocal && parsedServer ? buildUserId(parsedLocal, parsedServer) : undefined
);
return;
}
const server = serverInputRef.current?.value.trim() || userServer;
setValidUserId(buildUserId(username, server));
}, [userServer]);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => { const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault(); evt.preventDefault();
if (inviting) return;
setInvalidUserId(false);
const target = evt.target as HTMLFormElement | undefined; const target = evt.target as HTMLFormElement | undefined;
const usernameInput = target?.usernameInput as HTMLInputElement | undefined;
if (inviting || !validUserId) return; const serverInput = target?.serverInput as HTMLInputElement | undefined;
const reasonInput = target?.reasonInput as HTMLTextAreaElement | undefined; const reasonInput = target?.reasonInput as HTMLTextAreaElement | undefined;
let rawUsername = usernameInput?.value.trim() ?? '';
let server = serverInput?.value.trim() || userServer;
if (!usernameInput || !rawUsername) return;
// If user pasted a full MXID into the username field, split it across both
if (isUserId(rawUsername)) {
const parsedLocal = getMxIdLocalPart(rawUsername);
const parsedServer = getMxIdServer(rawUsername);
if (parsedLocal && parsedServer) {
rawUsername = parsedLocal;
server = parsedServer;
usernameInput.value = parsedLocal;
if (serverInput) serverInput.value = parsedServer;
}
}
const userId = buildUserId(rawUsername, server);
if (!userId) {
setInvalidUserId(true);
return;
}
const reason = reasonInput?.value.trim(); const reason = reasonInput?.value.trim();
invite(userId, reason || undefined)
invite(validUserId, reason || undefined).then(() => { .then(() => {
if (alive()) { if (alive()) requestClose();
handleReset(); })
if (reasonInput) reasonInput.value = ''; .catch(() => {
} // error surfaced via inviteState; swallow to keep DevTools clean
}); });
}; };
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => { const handleUsernameChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
setInvalidUserId(false);
const value = evt.currentTarget.value.trim(); const value = evt.currentTarget.value.trim();
if (isUserId(value)) {
setValidUserId(value); const term = value.startsWith('@') ? value.slice(1) : value;
if (term) {
search(term);
} else { } else {
setValidUserId(undefined); resetSearch();
const term = getMxIdLocalPart(value) ?? (value.startsWith('@') ? value.slice(1) : value);
if (term) {
search(term);
} else {
resetSearch();
}
} }
recomputeValidity();
}; };
const handleUserId = (userId: string) => { const handleUsernamePaste: ClipboardEventHandler<HTMLInputElement> = (evt) => {
if (inputRef.current) { const pasted = evt.clipboardData.getData('text').trim();
inputRef.current.value = userId; if (!isUserId(pasted)) return;
setValidUserId(userId); const parsedLocal = getMxIdLocalPart(pasted);
resetSearch(); const parsedServer = getMxIdServer(pasted);
inputRef.current.focus(); if (!parsedLocal || !parsedServer) return;
}
evt.preventDefault();
if (usernameInputRef.current) usernameInputRef.current.value = parsedLocal;
if (serverInputRef.current) serverInputRef.current.value = parsedServer;
resetSearch();
setInvalidUserId(false);
recomputeValidity();
};
const handleServerChange: ChangeEventHandler<HTMLInputElement> = () => {
setInvalidUserId(false);
recomputeValidity();
};
const handlePickUser = (userId: string) => {
const localPart = getMxIdLocalPart(userId);
const userIdServer = getMxIdServer(userId);
if (!localPart || !userIdServer) return;
if (usernameInputRef.current) usernameInputRef.current.value = localPart;
if (serverInputRef.current) serverInputRef.current.value = userIdServer;
resetSearch();
setInvalidUserId(false);
recomputeValidity();
usernameInputRef.current?.focus();
}; };
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => { const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
@ -148,7 +233,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
if (isKeyHotkey('tab', evt) && result && result.items.length > 0) { if (isKeyHotkey('tab', evt) && result && result.items.length > 0) {
evt.preventDefault(); evt.preventDefault();
const userId = result.items[0]; const userId = result.items[0];
handleUserId(userId); handlePickUser(userId);
} }
}; };
@ -157,7 +242,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
<OverlayCenter> <OverlayCenter>
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: () => inputRef.current, initialFocus: () => usernameInputRef.current,
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
onDeactivate: requestClose, onDeactivate: requestClose,
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
@ -189,77 +274,107 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
gap="400" gap="400"
> >
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">{t('Inbox.user_id')}</Text> <Box direction="Row" wrap="Wrap" gap="200">
<div> <Box grow="Yes" style={{ minWidth: toRem(120) }} direction="Column" gap="100">
<Input <Text size="L400">{t('Direct.username')}</Text>
size="500" <div>
ref={inputRef} <Input
onChange={handleSearchChange} size="500"
onKeyDown={handleKeyDown} radii="400"
placeholder={t('Inbox.user_id_placeholder')} ref={usernameInputRef}
name="userIdInput" onChange={handleUsernameChange}
variant="Background" onPaste={handleUsernamePaste}
disabled={inviting} onKeyDown={handleKeyDown}
autoComplete="off" placeholder={t('Direct.username_placeholder')}
required name="usernameInput"
/> variant="SurfaceVariant"
{result && result.items.length > 0 && ( disabled={inviting}
<FocusTrap autoComplete="off"
focusTrapOptions={{ required
initialFocus: false, />
onDeactivate: resetSearch, {result && result.items.length > 0 && (
returnFocusOnDeactivate: false, <FocusTrap
clickOutsideDeactivates: true, focusTrapOptions={{
allowOutsideClick: true, initialFocus: false,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt), onDeactivate: resetSearch,
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), returnFocusOnDeactivate: false,
escapeDeactivates: stopPropagation, clickOutsideDeactivates: true,
}} allowOutsideClick: true,
> isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
<Box style={{ position: 'relative' }}> isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
<Menu style={{ position: 'absolute', top: 0, zIndex: 1, width: '100%' }}> escapeDeactivates: stopPropagation,
<Scroll size="300" style={{ maxHeight: toRem(100) }}> }}
<div style={{ padding: config.space.S100 }}> >
{result.items.map((userId) => { <Box style={{ position: 'relative' }}>
const username = `${getMxIdLocalPart(userId)}`; <Menu
const userServer = getMxIdServer(userId); style={{ position: 'absolute', top: 0, zIndex: 1, width: '100%' }}
>
<Scroll size="300" style={{ maxHeight: toRem(100) }}>
<div style={{ padding: config.space.S100 }}>
{result.items.map((userId) => {
const username = `${getMxIdLocalPart(userId)}`;
const userIdServer = getMxIdServer(userId);
return ( return (
<MenuItem <MenuItem
key={userId} key={userId}
type="button" type="button"
size="300" size="300"
variant="Surface" variant="Surface"
radii="300" radii="300"
onClick={() => handleUserId(userId)} onClick={() => handlePickUser(userId)}
after={ after={
<Text size="T200" truncate> <Text size="T200" truncate>
{userServer} {userIdServer}
</Text> </Text>
} }
disabled={inviting} disabled={inviting}
> >
<Box grow="Yes"> <Box grow="Yes">
<Text size="T300" truncate> <Text size="T300" truncate>
<b> <b>
{queryHighlighRegex {queryHighlighRegex
? highlightText(queryHighlighRegex, [ ? highlightText(queryHighlighRegex, [
username ?? userId, username ?? userId,
]) ])
: username} : username}
</b> </b>
</Text> </Text>
</Box> </Box>
</MenuItem> </MenuItem>
); );
})} })}
</div> </div>
</Scroll> </Scroll>
</Menu> </Menu>
</Box> </Box>
</FocusTrap> </FocusTrap>
)} )}
</div> </div>
</Box>
<Box shrink="No" style={{ minWidth: toRem(150) }} direction="Column" gap="100">
<Text size="L400">{t('Direct.server')}</Text>
<Input
size="500"
radii="400"
ref={serverInputRef}
onChange={handleServerChange}
placeholder={userServer}
name="serverInput"
variant="SurfaceVariant"
disabled={inviting}
autoComplete="off"
/>
</Box>
</Box>
{invalidUserId && (
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="100">
<Icon src={Icons.Warning} filled size="50" />
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{t('Direct.invalid_user_id')}</b>
</Text>
</Box>
)}
</Box> </Box>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">{t('Inbox.reason_optional')}</Text> <Text size="L400">{t('Inbox.reason_optional')}</Text>