feat(invite): split user id into username and server fields and close prompt on successful invite
This commit is contained in:
parent
3cd1611ee2
commit
212d3e3482
1 changed files with 221 additions and 106 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue