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;
const reason = reasonInput?.value.trim();
invite(validUserId, reason || undefined).then(() => { let rawUsername = usernameInput?.value.trim() ?? '';
if (alive()) { let server = serverInput?.value.trim() || userServer;
handleReset();
if (reasonInput) reasonInput.value = ''; 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(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(); const value = evt.currentTarget.value.trim();
if (isUserId(value)) {
setValidUserId(value); const term = value.startsWith('@') ? value.slice(1) : value;
} else {
setValidUserId(undefined);
const term = getMxIdLocalPart(value) ?? (value.startsWith('@') ? value.slice(1) : value);
if (term) { if (term) {
search(term); search(term);
} else { } else {
resetSearch(); 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);
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(); resetSearch();
inputRef.current.focus(); 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,16 +274,20 @@ 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">
<Box grow="Yes" style={{ minWidth: toRem(120) }} direction="Column" gap="100">
<Text size="L400">{t('Direct.username')}</Text>
<div> <div>
<Input <Input
size="500" size="500"
ref={inputRef} radii="400"
onChange={handleSearchChange} ref={usernameInputRef}
onChange={handleUsernameChange}
onPaste={handleUsernamePaste}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={t('Inbox.user_id_placeholder')} placeholder={t('Direct.username_placeholder')}
name="userIdInput" name="usernameInput"
variant="Background" variant="SurfaceVariant"
disabled={inviting} disabled={inviting}
autoComplete="off" autoComplete="off"
required required
@ -217,12 +306,14 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
}} }}
> >
<Box style={{ position: 'relative' }}> <Box style={{ position: 'relative' }}>
<Menu style={{ position: 'absolute', top: 0, zIndex: 1, width: '100%' }}> <Menu
style={{ position: 'absolute', top: 0, zIndex: 1, width: '100%' }}
>
<Scroll size="300" style={{ maxHeight: toRem(100) }}> <Scroll size="300" style={{ maxHeight: toRem(100) }}>
<div style={{ padding: config.space.S100 }}> <div style={{ padding: config.space.S100 }}>
{result.items.map((userId) => { {result.items.map((userId) => {
const username = `${getMxIdLocalPart(userId)}`; const username = `${getMxIdLocalPart(userId)}`;
const userServer = getMxIdServer(userId); const userIdServer = getMxIdServer(userId);
return ( return (
<MenuItem <MenuItem
@ -231,10 +322,10 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
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}
@ -261,6 +352,30 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
)} )}
</div> </div>
</Box> </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"> <Box direction="Column" gap="100">
<Text size="L400">{t('Inbox.reason_optional')}</Text> <Text size="L400">{t('Inbox.reason_optional')}</Text>
<TextArea <TextArea