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, {
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue