From f3402d2cbf82b9dbe146954ab7414a4f96fda42c Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Wed, 29 Apr 2026 21:45:33 +0300 Subject: [PATCH] feat(invite): split user id into username and server fields and close prompt on successful invite --- .../invite-user-prompt/InviteUserPrompt.tsx | 327 ++++++++++++------ 1 file changed, 221 insertions(+), 106 deletions(-) diff --git a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx index a121d27a..8be4a016 100644 --- a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx +++ b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx @@ -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(null); + const usernameInputRef = useRef(null); + const serverInputRef = useRef(null); const directUsers = useDirectUsers(); - const [validUserId, setValidUserId] = useState(); + + const userServer = getMxIdServer(mx.getSafeUserId()) ?? FALLBACK_SERVER; + const [validUserId, setValidUserId] = useState(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 = (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 = (evt) => { + const handleUsernameChange: ChangeEventHandler = (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 = (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 = () => { + 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 = (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) { inputRef.current, + initialFocus: () => usernameInputRef.current, clickOutsideDeactivates: true, onDeactivate: requestClose, escapeDeactivates: stopPropagation, @@ -189,77 +274,107 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) { gap="400" > - {t('Inbox.user_id')} -
- - {result && result.items.length > 0 && ( - isKeyHotkey('arrowdown', evt), - isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), - escapeDeactivates: stopPropagation, - }} - > - - - -
- {result.items.map((userId) => { - const username = `${getMxIdLocalPart(userId)}`; - const userServer = getMxIdServer(userId); + + + {t('Direct.username')} +
+ + {result && result.items.length > 0 && ( + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + escapeDeactivates: stopPropagation, + }} + > + + + +
+ {result.items.map((userId) => { + const username = `${getMxIdLocalPart(userId)}`; + const userIdServer = getMxIdServer(userId); - return ( - handleUserId(userId)} - after={ - - {userServer} - - } - disabled={inviting} - > - - - - {queryHighlighRegex - ? highlightText(queryHighlighRegex, [ - username ?? userId, - ]) - : username} - - - - - ); - })} -
-
-
-
-
- )} -
+ return ( + handlePickUser(userId)} + after={ + + {userIdServer} + + } + disabled={inviting} + > + + + + {queryHighlighRegex + ? highlightText(queryHighlighRegex, [ + username ?? userId, + ]) + : username} + + + + + ); + })} +
+
+
+
+
+ )} +
+
+ + {t('Direct.server')} + + + + {invalidUserId && ( + + + + {t('Direct.invalid_user_id')} + + + )} {t('Inbox.reason_optional')}