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

This commit is contained in:
v.lagerev 2026-04-29 21:45:33 +03:00
parent 55cbecd4e7
commit f3402d2cbf

View file

@ -1,5 +1,6 @@
import React, {
ChangeEventHandler,
ClipboardEventHandler,
FormEventHandler,
KeyboardEventHandler,
useCallback,
@ -44,6 +45,12 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { BreakWord } from '../../styles/Text.css';
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 = {
limit: 1000,
matchOptions: {
@ -52,6 +59,21 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = {
};
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 = {
room: Room;
requestClose: () => void;
@ -61,9 +83,13 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
const mx = useMatrixClient();
const alive = useAlive();
const inputRef = useRef<HTMLInputElement>(null);
const usernameInputRef = useRef<HTMLInputElement>(null);
const serverInputRef = useRef<HTMLInputElement>(null);
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(
() =>
@ -93,51 +119,110 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
const inviting = inviteState.status === AsyncStatus.Loading;
const handleReset = () => {
if (inputRef.current) inputRef.current.value = '';
setValidUserId(undefined);
resetSearch();
};
const recomputeValidity = useCallback(() => {
const username = usernameInputRef.current?.value.trim() ?? '';
// A full MXID typed into the username field is valid only if its parsed
// 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) => {
evt.preventDefault();
if (inviting) return;
setInvalidUserId(false);
const target = evt.target as HTMLFormElement | undefined;
if (inviting || !validUserId) return;
const usernameInput = target?.usernameInput as HTMLInputElement | undefined;
const serverInput = target?.serverInput as HTMLInputElement | 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();
invite(validUserId, reason || undefined).then(() => {
if (alive()) {
handleReset();
if (reasonInput) reasonInput.value = '';
}
});
invite(userId, reason || undefined)
.then(() => {
if (alive()) requestClose();
})
.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();
if (isUserId(value)) {
setValidUserId(value);
const term = value.startsWith('@') ? value.slice(1) : value;
if (term) {
search(term);
} else {
setValidUserId(undefined);
const term = getMxIdLocalPart(value) ?? (value.startsWith('@') ? value.slice(1) : value);
if (term) {
search(term);
} else {
resetSearch();
}
resetSearch();
}
recomputeValidity();
};
const handleUserId = (userId: string) => {
if (inputRef.current) {
inputRef.current.value = userId;
setValidUserId(userId);
resetSearch();
inputRef.current.focus();
}
const handleUsernamePaste: ClipboardEventHandler<HTMLInputElement> = (evt) => {
const pasted = evt.clipboardData.getData('text').trim();
if (!isUserId(pasted)) return;
const parsedLocal = getMxIdLocalPart(pasted);
const parsedServer = getMxIdServer(pasted);
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) => {
@ -148,7 +233,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
if (isKeyHotkey('tab', evt) && result && result.items.length > 0) {
evt.preventDefault();
const userId = result.items[0];
handleUserId(userId);
handlePickUser(userId);
}
};
@ -157,7 +242,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: () => inputRef.current,
initialFocus: () => usernameInputRef.current,
clickOutsideDeactivates: true,
onDeactivate: requestClose,
escapeDeactivates: stopPropagation,
@ -189,77 +274,107 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
gap="400"
>
<Box direction="Column" gap="100">
<Text size="L400">{t('Inbox.user_id')}</Text>
<div>
<Input
size="500"
ref={inputRef}
onChange={handleSearchChange}
onKeyDown={handleKeyDown}
placeholder={t('Inbox.user_id_placeholder')}
name="userIdInput"
variant="Background"
disabled={inviting}
autoComplete="off"
required
/>
{result && result.items.length > 0 && (
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: resetSearch,
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true,
allowOutsideClick: true,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
escapeDeactivates: stopPropagation,
}}
>
<Box style={{ position: 'relative' }}>
<Menu 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 userServer = getMxIdServer(userId);
<Box direction="Row" wrap="Wrap" gap="200">
<Box grow="Yes" style={{ minWidth: toRem(120) }} direction="Column" gap="100">
<Text size="L400">{t('Direct.username')}</Text>
<div>
<Input
size="500"
radii="400"
ref={usernameInputRef}
onChange={handleUsernameChange}
onPaste={handleUsernamePaste}
onKeyDown={handleKeyDown}
placeholder={t('Direct.username_placeholder')}
name="usernameInput"
variant="SurfaceVariant"
disabled={inviting}
autoComplete="off"
required
/>
{result && result.items.length > 0 && (
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: resetSearch,
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true,
allowOutsideClick: true,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
escapeDeactivates: stopPropagation,
}}
>
<Box style={{ position: 'relative' }}>
<Menu
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 (
<MenuItem
key={userId}
type="button"
size="300"
variant="Surface"
radii="300"
onClick={() => handleUserId(userId)}
after={
<Text size="T200" truncate>
{userServer}
</Text>
}
disabled={inviting}
>
<Box grow="Yes">
<Text size="T300" truncate>
<b>
{queryHighlighRegex
? highlightText(queryHighlighRegex, [
username ?? userId,
])
: username}
</b>
</Text>
</Box>
</MenuItem>
);
})}
</div>
</Scroll>
</Menu>
</Box>
</FocusTrap>
)}
</div>
return (
<MenuItem
key={userId}
type="button"
size="300"
variant="Surface"
radii="300"
onClick={() => handlePickUser(userId)}
after={
<Text size="T200" truncate>
{userIdServer}
</Text>
}
disabled={inviting}
>
<Box grow="Yes">
<Text size="T300" truncate>
<b>
{queryHighlighRegex
? highlightText(queryHighlighRegex, [
username ?? userId,
])
: username}
</b>
</Text>
</Box>
</MenuItem>
);
})}
</div>
</Scroll>
</Menu>
</Box>
</FocusTrap>
)}
</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 direction="Column" gap="100">
<Text size="L400">{t('Inbox.reason_optional')}</Text>