diff --git a/src/app/components/stream-header/forms/InlineRoomSearch.tsx b/src/app/components/stream-header/forms/InlineRoomSearch.tsx index e2e9c8cd..80f9e928 100644 --- a/src/app/components/stream-header/forms/InlineRoomSearch.tsx +++ b/src/app/components/stream-header/forms/InlineRoomSearch.tsx @@ -16,8 +16,26 @@ import { import { nameInitials } from '../../../utils/common'; import { getMxIdLocalPart, getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix'; import { getDmUserId, useRoomSearch } from '../../../features/search/useRoomSearch'; +import { StartDirectDialog } from '../../../features/search/StartDirectDialog'; import { SEARCH_FORM_BASE_PX } from '../geometry'; +// Shared inline result-row chrome (rooms / people / address card). +const ROW_BASE_STYLE: React.CSSProperties = { + appearance: 'none', + WebkitAppearance: 'none', + border: 'none', + color: color.Background.OnContainer, + borderRadius: toRem(12), + padding: `${toRem(8)} ${toRem(10)}`, + width: '100%', + display: 'flex', + alignItems: 'center', + gap: toRem(10), + cursor: 'pointer', + textAlign: 'left', + font: 'inherit', +}; + type Props = { onClose: () => void; }; @@ -56,9 +74,16 @@ export function InlineRoomSearch({ onClose }: Props) { handleRoomClick, peopleToRender, directoryLoading, - handleUserClick, + requestDirect, creatingUserId, + addressPreview, + dmError, + pendingDirect, + confirmDirect, + cancelDirect, roomCount, + roomIndexBase, + addressIndex, getRoom, mDirects, orphanSpaces, @@ -75,7 +100,112 @@ export function InlineRoomSearch({ onClose }: Props) { inputRef.current?.focus(); }, [inputRef]); - const isEmpty = roomsToRender.length === 0 && peopleToRender.length === 0; + const isEmpty = roomsToRender.length === 0 && peopleToRender.length === 0 && !addressPreview; + + // Surfaced inside the confirm dialog (the only place a DM is created). + let dmErrorText: string | undefined; + if (dmError) { + dmErrorText = dmError.rateLimited ? t('Search.dm_rate_limited') : t('Search.dm_failed'); + } + + // Built once, placed above the rooms (explicit address — primary + // result) or below People (a bare-name compose suggestion). + const addressCard = addressPreview + ? (() => { + const username = getMxIdLocalPart(addressPreview.userId) ?? addressPreview.userId; + const server = getMxIdServer(addressPreview.userId); + const header = ( + + {t('Search.by_address')} + + ); + + // Clean 404 → no such account: a non-clickable note, so you can't + // start a chat (and a ghost room) with someone who doesn't exist. + if (addressPreview.state === 'notfound') { + return ( + + {header} + + + + + {t('Search.user_not_found', { server })} + + + {addressPreview.userId} + + + + + ); + } + + const focused = listFocus.index === addressIndex; + const resolved = addressPreview.state === 'resolved'; + const name = addressPreview.displayName || username; + const avatarSrc = + resolved && addressPreview.avatarUrl + ? mxcUrlToHttp(mx, addressPreview.avatarUrl, useAuthentication, 32, 32, 'crop') ?? + undefined + : undefined; + const caption = resolved ? undefined : t('Search.user_unreachable', { server }); + return ( + + {header} + + {caption && ( + + {caption} + + )} + + ); + })() + : null; return ( + + {t('Search.address_hint')} + )} + {/* Typed address → always the top result. */} + {addressCard} {roomsToRender.length > 0 && ( {roomsToRender.map((roomId, index) => { @@ -192,13 +332,14 @@ export function InlineRoomSearch({ onClose }: Props) { exactParents && guessPerfectParent(mx, roomId, Array.from(exactParents)); const unread = roomToUnread.get(roomId); - const focused = listFocus.index === index; + const focusIndex = roomIndexBase + index; + const focused = listFocus.index === focusIndex; return ( ); })} )} - - {/* ── Reach someone on another server ──────────────────── - The directory can't surface users on other homeservers you - don't already share a room with, so when a query finds no - people we teach the by-address escape hatch: typing a full - `@user:server` produces a tappable result above (via the - user-directory / profile lookup) that opens a DM. */} - {result && !directoryLoading && peopleToRender.length === 0 && ( - - - {t('Search.other_server_hint')} - - - )} + + {pendingDirect && ( + + )} ); } diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx index 2b8b88e7..0d59c50e 100644 --- a/src/app/features/search/Search.tsx +++ b/src/app/features/search/Search.tsx @@ -37,6 +37,7 @@ import { searchModalAtom } from '../../state/searchModal'; import { useKeyDown } from '../../hooks/useKeyDown'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { getDmUserId, useRoomSearch } from './useRoomSearch'; +import { StartDirectDialog } from './StartDirectDialog'; // Dawn hairline divider (the canon's rgba(255,255,255,0.06–0.08) row line). const HAIRLINE = '1px solid rgba(255, 255, 255, 0.08)'; @@ -72,9 +73,16 @@ export function Search({ requestClose }: SearchProps) { handleRoomClick, peopleToRender, directoryLoading, - handleUserClick, + requestDirect, creatingUserId, + addressPreview, + dmError, + pendingDirect, + confirmDirect, + cancelDirect, roomCount, + roomIndexBase, + addressIndex, getRoom, mDirects, orphanSpaces, @@ -83,291 +91,436 @@ export function Search({ requestClose }: SearchProps) { myUserId, } = useRoomSearch({ onOpenRoomId: openRoomId }); - const isEmpty = roomsToRender.length === 0 && peopleToRender.length === 0; + const isEmpty = roomsToRender.length === 0 && peopleToRender.length === 0 && !addressPreview; + + // Surfaced inside the confirm dialog (the only place a DM is created). + let dmErrorText: string | undefined; + if (dmError) { + dmErrorText = dmError.rateLimited ? t('Search.dm_rate_limited') : t('Search.dm_failed'); + } + + // The typed-address card, rendered at the top of the results. A clean + // 404 (`notfound`) is a non-clickable note — you can't start a chat with + // an account that doesn't exist. `resolved` (exists) and `unreachable` + // (couldn't verify) are clickable and open the confirm dialog. + const addressCard = addressPreview + ? (() => { + const username = getMxIdLocalPart(addressPreview.userId) ?? addressPreview.userId; + const server = getMxIdServer(addressPreview.userId); + const header = ( + + {t('Search.by_address')} + + ); + + if (addressPreview.state === 'notfound') { + return ( + <> + {header} + + + + + {t('Search.user_not_found', { server })} + + + {addressPreview.userId} + + + + + ); + } + + const focused = listFocus.index === addressIndex; + const resolved = addressPreview.state === 'resolved'; + const name = addressPreview.displayName || username; + const avatarSrc = + resolved && addressPreview.avatarUrl + ? mxcUrlToHttp(mx, addressPreview.avatarUrl, useAuthentication, 32, 32, 'crop') ?? + undefined + : undefined; + const caption = resolved ? undefined : t('Search.user_unreachable', { server }); + return ( + <> + {header} + + requestDirect({ + userId: addressPreview.userId, + displayName: addressPreview.displayName, + avatarUrl: addressPreview.avatarUrl, + }) + } + variant="Surface" + aria-pressed={focused} + radii="300" + style={{ backgroundColor: focused ? 'rgba(255, 255, 255, 0.06)' : undefined }} + after={ + server && ( + + {server} + + ) + } + before={ + + ( + + {nameInitials(name)} + + )} + /> + + } + > + + + {name} + + + @{username} + + + + {caption && ( + + {caption} + + )} + + ); + })() + : null; return ( - - - inputRef.current, - returnFocusOnDeactivate: false, - allowOutsideClick: true, - clickOutsideDeactivates: true, - onDeactivate: requestClose, - escapeDeactivates: (evt) => { - evt.stopPropagation(); - return true; - }, - }} - > - + + + inputRef.current, + returnFocusOnDeactivate: false, + allowOutsideClick: true, + clickOutsideDeactivates: true, + onDeactivate: requestClose, + escapeDeactivates: (evt) => { + evt.stopPropagation(); + return true; + }, }} > - {/* ── Input row: hairline-divided, integrated (not a boxed Input) ── + + {/* ── Input row: hairline-divided, integrated (not a boxed Input) ── `width: 100%` pins the row to the modal width so the flex `input` always has space to fill — otherwise the row collapses to its content (search icon) when empty and only widens as you type. */} - - - - {directoryLoading ? ( - - ) : ( - query.length > 0 && ( - + + + {directoryLoading ? ( + + ) : ( + query.length > 0 && ( + + + + ) + )} + + + {isEmpty && !directoryLoading && ( + - - - ) - )} - - - {isEmpty && !directoryLoading && ( - - - {result ? t('Search.no_match_found') : t('Search.no_rooms')} - - - {result - ? t('Search.no_match_for_query', { query: result.query }) - : t('Search.no_rooms_to_display')} - - {result && ( + + {result ? t('Search.no_match_found') : t('Search.no_rooms')} + + + {result + ? t('Search.no_match_for_query', { query: result.query }) + : t('Search.no_rooms_to_display')} + - {t('Search.other_server_hint')} + {t('Search.address_hint')} - )} - - )} - {!isEmpty && ( - -
- {roomsToRender.map((roomId, index) => { - const room = getRoom(roomId); - if (!room) return null; + + )} + {!isEmpty && ( + +
+ {/* Typed address → always the top result. */} + {addressCard} + {roomsToRender.map((roomId, index) => { + const room = getRoom(roomId); + if (!room) return null; - const dm = mDirects.has(roomId); - const dmUserId = dm ? getDmUserId(roomId, getRoom, myUserId) : undefined; - const dmUsername = dmUserId ? getMxIdLocalPart(dmUserId) : undefined; - const dmUserServer = dmUserId ? getMxIdServer(dmUserId) : undefined; + const dm = mDirects.has(roomId); + const dmUserId = dm ? getDmUserId(roomId, getRoom, myUserId) : undefined; + const dmUsername = dmUserId ? getMxIdLocalPart(dmUserId) : undefined; + const dmUserServer = dmUserId ? getMxIdServer(dmUserId) : undefined; - const allParents = getAllParents(roomToParents, roomId); - const orphanParents = allParents - ? orphanSpaces.filter((o) => allParents.has(o)) - : undefined; - const perfectOrphanParent = - orphanParents && guessPerfectParent(mx, roomId, orphanParents); + const allParents = getAllParents(roomToParents, roomId); + const orphanParents = allParents + ? orphanSpaces.filter((o) => allParents.has(o)) + : undefined; + const perfectOrphanParent = + orphanParents && guessPerfectParent(mx, roomId, orphanParents); - const exactParents = roomToParents.get(roomId); - const perfectParent = - exactParents && guessPerfectParent(mx, roomId, Array.from(exactParents)); + const exactParents = roomToParents.get(roomId); + const perfectParent = + exactParents && guessPerfectParent(mx, roomId, Array.from(exactParents)); - const unread = roomToUnread.get(roomId); - const focused = listFocus.index === index; + const unread = roomToUnread.get(roomId); + const focusIndex = roomIndexBase + index; + const focused = listFocus.index === focusIndex; - return ( - - {dmUserServer && ( - - {dmUserServer} - - )} - {!dm && perfectOrphanParent && ( - - {getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent} - - )} - {unread && ( - - 0} - count={unread.total} + return ( + + {dmUserServer && ( + + {dmUserServer} + + )} + {!dm && perfectOrphanParent && ( + + {getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent} + + )} + {unread && ( + + 0} + count={unread.total} + /> + + )} + + } + before={ + + {dm || room.isSpaceRoom() ? ( + ( + + {nameInitials(room.name)} + + )} /> - + ) : ( + + )} + + } + > + + + {room.name} + + {dmUsername && ( + + @{dmUsername} + + )} + {!dm && perfectParent && perfectParent !== perfectOrphanParent && ( + + — {getRoom(perfectParent)?.name ?? perfectParent} + )} - } - before={ - - {dm || room.isSpaceRoom() ? ( - ( - - {nameInitials(room.name)} - - )} - /> - ) : ( - - )} - - } - > - - - {room.name} - - {dmUsername && ( - - @{dmUsername} - - )} - {!dm && perfectParent && perfectParent !== perfectOrphanParent && ( - - — {getRoom(perfectParent)?.name ?? perfectParent} - - )} - - - ); - })} + + ); + })} - {/* ── People (homeserver user directory) ─────────── */} - {peopleToRender.length > 0 && ( - <> - - {t('Search.people')} - - {peopleToRender.map((user, j) => { - const index = roomCount + j; - const focused = listFocus.index === index; - const username = getMxIdLocalPart(user.userId) ?? user.userId; - const server = getMxIdServer(user.userId); - const name = user.displayName || username; - const avatarSrc = user.avatarUrl - ? mxcUrlToHttp(mx, user.avatarUrl, useAuthentication, 32, 32, 'crop') ?? - undefined - : undefined; - const creating = creatingUserId === user.userId; + {/* ── People (homeserver user directory) ─────────── */} + {peopleToRender.length > 0 && ( + <> + + {t('Search.people')} + + {peopleToRender.map((user, j) => { + const index = roomIndexBase + roomCount + j; + const focused = listFocus.index === index; + const username = getMxIdLocalPart(user.userId) ?? user.userId; + const server = getMxIdServer(user.userId); + const name = user.displayName || username; + const avatarSrc = user.avatarUrl + ? mxcUrlToHttp( + mx, + user.avatarUrl, + useAuthentication, + 32, + 32, + 'crop' + ) ?? undefined + : undefined; - return ( - - ) : ( + return ( + + requestDirect({ + userId: user.userId, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + }) + } + variant="Surface" + aria-pressed={focused} + radii="300" + style={{ + backgroundColor: focused + ? 'rgba(255, 255, 255, 0.06)' + : undefined, + }} + after={ server && ( ) - ) - } - before={ - - ( - - {nameInitials(name)} - - )} - /> - - } - > - - - {name} - - - @{username} - - - - ); - })} - - )} - - {/* Reach someone on another server: the directory - can't surface remote users you don't share a room - with, so when a query finds no people we teach the - by-address escape hatch (typing a full - @user:server yields a tappable result above). */} - {result && !directoryLoading && peopleToRender.length === 0 && ( - - {t('Search.other_server_hint')} - - )} -
-
- )} - - {/* ── Mono keyboard-hint footer (hairline-divided) ── */} - - - {`↑↓ ${t('Search.kbd_select')}`} - - - {`↵ ${t('Search.kbd_open')}`} - - - {`esc ${t('Search.kbd_close')}`} - - - - - - + } + before={ + + ( + + {nameInitials(name)} + + )} + /> + + } + > + + + {name} + + + @{username} + + + + ); + })} + + )} +
+
+ )} +
+ {/* ── Mono keyboard-hint footer (hairline-divided) ── */} + + + {`↑↓ ${t('Search.kbd_select')}`} + + + {`↵ ${t('Search.kbd_open')}`} + + + {`esc ${t('Search.kbd_close')}`} + + +
+
+
+
+ {pendingDirect && ( + + )} + ); } diff --git a/src/app/features/search/StartDirectDialog.tsx b/src/app/features/search/StartDirectDialog.tsx new file mode 100644 index 00000000..35cca46f --- /dev/null +++ b/src/app/features/search/StartDirectDialog.tsx @@ -0,0 +1,257 @@ +import React, { useEffect, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Avatar, + Box, + Button, + Dialog, + Header, + Icon, + IconButton, + Icons, + Overlay, + OverlayBackdrop, + OverlayCenter, + Spinner, + Switch, + Text, + color, + config, + toRem, +} from 'folds'; +import { useTranslation } from 'react-i18next'; +import { MatrixError } from 'matrix-js-sdk'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useAlive } from '../../hooks/useAlive'; +import { UserAvatar } from '../../components/user-avatar'; +import { nameInitials } from '../../utils/common'; +import { getMxIdLocalPart, getMxIdServer, mxcUrlToHttp } from '../../utils/matrix'; +import { stopPropagation } from '../../utils/keyboard'; +import { ErrorCode } from '../../cs-errorcode'; +import type { PendingDirect } from './useRoomSearch'; + +type StartDirectDialogProps = { + target: PendingDirect; + // True while `confirmDirect` is round-tripping `createRoom`. + creating: boolean; + // Already-localized DM-create failure, or undefined. + errorText?: string; + onConfirm: (encrypt: boolean) => void; + onCancel: () => void; +}; + +// Small centered confirm step between tapping a person in search and +// actually opening/creating the DM. Shows who you're about to message +// (avatar + name + `@user:server`, with a cross-server note) and the +// per-chat E2EE choice. Dawn/Fleet `Dialog` surface, like the app's +// other prompts. +export function StartDirectDialog({ + target, + creating, + errorText, + onConfirm, + onCancel, +}: StartDirectDialogProps) { + const { t } = useTranslation(); + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const alive = useAlive(); + + const [encrypt, setEncrypt] = useState(false); + // Seed from whatever the clicked row knew, then refine with a fresh + // profile lookup so a cross-server avatar/name always renders (the + // homeserver proxies the remote `GET /profile` over federation). + const [displayName, setDisplayName] = useState(target.displayName); + const [avatarUrl, setAvatarUrl] = useState(target.avatarUrl); + // Whether the homeserver could confirm this user exists. A (federated, + // best-effort) profile lookup is the only existence signal Matrix has: + // found — the server returned a profile → the user exists. + // notfound — 404 → no such user on that server. + // error — unreachable / federation off / timeout → can't tell. + const [lookup, setLookup] = useState<'checking' | 'found' | 'notfound' | 'error'>('checking'); + + useEffect(() => { + setLookup('checking'); + mx.getProfileInfo(target.userId) + .then((profile) => { + if (!alive()) return; + if (profile.displayname) setDisplayName(profile.displayname); + if (profile.avatar_url) setAvatarUrl(profile.avatar_url); + setLookup('found'); + }) + .catch((err: unknown) => { + if (!alive()) return; + if (err instanceof MatrixError && err.errcode === ErrorCode.M_NOT_FOUND) { + setLookup('notfound'); + } else { + setLookup('error'); + } + }); + }, [mx, alive, target.userId]); + + // Only a clean 404 blocks the create (no such account → no ghost room). + // `error` (server unreachable / couldn't verify) stays allowed — we got + // here from an actionable card, so the account may well exist. + const blocked = lookup === 'notfound'; + + const username = getMxIdLocalPart(target.userId) ?? target.userId; + const server = getMxIdServer(target.userId); + const myServer = getMxIdServer(mx.getSafeUserId()); + const remote = !!server && !!myServer && server !== myServer; + const name = displayName || username; + const avatarSrc = avatarUrl + ? mxcUrlToHttp(mx, avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined + : undefined; + + return ( + }> + + + +
+ + {t('Search.start_dm_title')} + + + + +
+ + {/* Identity preview */} + + + ( + + {nameInitials(name)} + + )} + /> + + + + {name} + + + {target.userId} + + {/* Existence / reachability status — the honest answer to + "is there really such a user". */} + + {lookup === 'checking' && ( + <> + + + {t('Search.checking')} + + + )} + {lookup === 'found' && ( + <> + + + {remote ? t('Search.found_on_server', { server }) : t('Search.user_found')} + + + )} + {lookup === 'notfound' && ( + <> + + + {t('Search.user_not_found', { server })} + + + )} + {lookup === 'error' && ( + <> + + + {t('Search.user_unreachable', { server })} + + + )} + + + + + {/* Options + action */} + + + + + {t('Search.encrypt_label')} + + + + + {errorText && ( + + + + {errorText} + + + )} + + + +
+
+
+
+ ); +} diff --git a/src/app/features/search/useRoomSearch.ts b/src/app/features/search/useRoomSearch.ts index f9a7ac68..8aa48a74 100644 --- a/src/app/features/search/useRoomSearch.ts +++ b/src/app/features/search/useRoomSearch.ts @@ -9,7 +9,7 @@ import { useState, } from 'react'; import { isKeyHotkey } from 'is-hotkey'; -import { Room } from 'matrix-js-sdk'; +import { MatrixError, Room } from 'matrix-js-sdk'; import { useAtomValue } from 'jotai'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useDirects, useOrphanSpaces, useRooms, useSpaces } from '../../state/hooks/roomList'; @@ -25,11 +25,17 @@ import { allRoomsAtom } from '../../state/room-list/roomList'; import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { factoryRoomIdByActivity } from '../../utils/sort'; -import { getDMRoomFor, getMxIdLocalPart, guessDmRoomUserId, isUserId } from '../../utils/matrix'; +import { + getDMRoomFor, + getMxIdLocalPart, + guessDmRoomUserId, + parseUserAddress, +} from '../../utils/matrix'; import { makeHighlightRegex } from '../../plugins/react-custom-html-parser'; import { useDebounce } from '../../hooks/useDebounce'; import { useAlive } from '../../hooks/useAlive'; import { useCreateDirect } from '../create-chat/useCreateDirect'; +import { ErrorCode } from '../../cs-errorcode'; export enum SearchRoomType { Rooms = '#', @@ -59,14 +65,47 @@ const DIRECTORY_LIMIT = 20; const DIRECTORY_MIN_CHARS = 2; const DIRECTORY_DEBOUNCE_MS = 300; -// A person surfaced from the homeserver user directory (or an exact -// MXID the user typed). Clicking one opens-or-creates a DM. +// A person surfaced from the homeserver user directory. Clicking one +// opens-or-creates a DM. export type RoomSearchUser = { userId: string; displayName?: string; avatarUrl?: string; }; +// An inline "message this address" card for an explicitly typed full +// `@name:server`. The profile lookup is the only existence signal Matrix +// has, so the three states map to its outcome (verified against the +// Matrix spec `profile.yaml` + Synapse: 200 = the account exists even +// with an empty `{}` profile; a clean 404 = no such account): +// • `resolved` — 200 → the account exists → messageable. +// • `unreachable` — the lookup failed for a NON-404 reason (server down / +// federation off / restricted) → we genuinely can't +// tell, so we still let the user try (never false-block). +// • `notfound` — a clean 404 M_NOT_FOUND → no such account → NOT +// messageable; rendered as a non-clickable note so we +// can't create a chat (and a ghost room) for someone +// who doesn't exist. +// We NEVER fabricate an address from a bare name — that's how you'd end up +// inviting made-up users; reaching a non-directory user requires typing +// their full address (which then resolves through this same path). +export type AddressPreview = { + state: 'resolved' | 'unreachable' | 'notfound'; + userId: string; + displayName?: string; + avatarUrl?: string; +}; + +// Failure of the open-or-create-DM round-trip, surfaced under the input +// instead of being swallowed. `rateLimited` flags a 429 (the only case +// worth its own message); everything else is a generic create failure. +export type DmError = { rateLimited?: boolean }; + +// The person a pending "start chat" confirmation dialog is about. Seeded +// with whatever the clicked row already knows; the dialog refines it with +// a fresh profile lookup (so a cross-server avatar/name always shows). +export type PendingDirect = { userId: string; displayName?: string; avatarUrl?: string }; + export const getDmUserId = ( roomId: string, getRoom: (roomId: string) => Room | undefined, @@ -154,48 +193,67 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) { // since bumped the counter — this serialises out-of-order network // responses without an AbortController. const [directoryResults, setDirectoryResults] = useState([]); + // Federated profile preview for a typed full address (see AddressPreview). + const [addressPreview, setAddressPreview] = useState(null); const [directoryLoading, setDirectoryLoading] = useState(false); const dirGenRef = useRef(0); // Current input text — drives the clear (✕) button's visibility. const [query, setQuery] = useState(''); + // Surfaced open-or-create-DM failure (cleared on the next keystroke). + const [dmError, setDmError] = useState(null); - const runDirectory = useCallback( - async (term: string, exactMxid: boolean, gen: number) => { + const runQuery = useCallback( + async (term: string, parsedMxid: string | null, gen: number) => { if (gen !== dirGenRef.current) return; + + // Local homeserver directory list (display-name / localpart match). + let users: RoomSearchUser[] = []; try { - const { results } = await mx.searchUserDirectory({ term, limit: DIRECTORY_LIMIT }); - let users: RoomSearchUser[] = results.map((r) => ({ - userId: r.user_id, - displayName: r.display_name, - avatarUrl: r.avatar_url, - })); - // Exact MXID the directory didn't surface (not indexed / opted - // out / a federated user the server only knows by id): resolve - // the profile so they're still reachable, and soft-add the bare - // id if even that fails so the user can always start the chat. - if (exactMxid && isUserId(term) && !users.some((u) => u.userId === term)) { - try { - const profile = await mx.getProfileInfo(term); - users = [ - { userId: term, displayName: profile.displayname, avatarUrl: profile.avatar_url }, - ...users, - ]; - } catch { - users = [{ userId: term }, ...users]; - } + if (term) { + const { results } = await mx.searchUserDirectory({ term, limit: DIRECTORY_LIMIT }); + users = results.map((r) => ({ + userId: r.user_id, + displayName: r.display_name, + avatarUrl: r.avatar_url, + })); } - if (gen !== dirGenRef.current || !alive()) return; - setDirectoryResults(users); - setDirectoryLoading(false); } catch { - if (gen !== dirGenRef.current || !alive()) return; - setDirectoryResults([]); - setDirectoryLoading(false); + users = []; } + if (gen !== dirGenRef.current || !alive()) return; + + // Address card — ONLY for an explicitly typed full address (never + // fabricated from a bare name). 200 → resolved; a clean 404 → + // notfound (no such account → non-messageable note, no ghost room); + // any other failure → unreachable (can't tell → still messageable). + let preview: AddressPreview | null = null; + if ( + parsedMxid && + parsedMxid !== myUserId && + !getDMRoomFor(mx, parsedMxid) && + !users.some((u) => u.userId === parsedMxid) + ) { + try { + const profile = await mx.getProfileInfo(parsedMxid); + preview = { + state: 'resolved', + userId: parsedMxid, + displayName: profile.displayname, + avatarUrl: profile.avatar_url, + }; + } catch (err) { + const notFound = err instanceof MatrixError && err.errcode === ErrorCode.M_NOT_FOUND; + preview = { state: notFound ? 'notfound' : 'unreachable', userId: parsedMxid }; + } + } + if (gen !== dirGenRef.current || !alive()) return; + setDirectoryResults(users); + setAddressPreview(preview); + setDirectoryLoading(false); }, - [mx, alive] + [mx, alive, myUserId] ); - const debouncedRunDirectory = useDebounce(runDirectory, { wait: DIRECTORY_DEBOUNCE_MS }); + const debouncedRunQuery = useDebounce(runQuery, { wait: DIRECTORY_DEBOUNCE_MS }); // People already in a DM with us are reached through their existing // room row, so drop them (and self) from the directory section to @@ -212,50 +270,93 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) { [directoryResults, mx, myUserId, mDirects] ); - // Keyboard focus spans both sections: indices [0, roomCount) are - // rooms, [roomCount, roomCount + peopleCount) are people. + // The typed-address card always renders at the TOP (it's the most + // specific thing the user asked for). Only an ACTIONABLE card (the + // account exists, or we couldn't verify) is clickable and takes a + // keyboard-focus slot — a clean 404 renders as a non-focusable "not + // found" note, so the focus list stays: [address?, rooms…, people…]. const roomCount = roomsToRender.length; - const listFocus = useListFocusIndex(roomCount + peopleToRender.length, 0); + const peopleCount = peopleToRender.length; + const addressActionable = + addressPreview?.state === 'resolved' || addressPreview?.state === 'unreachable'; + const roomIndexBase = addressActionable ? 1 : 0; + // -1 when there's no clickable address card. + const addressIndex = addressActionable ? 0 : -1; + const focusCount = roomCount + peopleCount + (addressActionable ? 1 : 0); + const listFocus = useListFocusIndex(focusCount, 0); const queryHighlightRegex = result?.query ? makeHighlightRegex(result.query.split(' ')) : undefined; - // Per-user DM-in-flight marker so the clicked row can show a spinner - // while `createRoom` round-trips (opening an existing DM is instant). + // Clicking a person (directory hit or typed address) opens a small + // confirm dialog instead of creating the chat outright — the user gets + // an identity preview (avatar/name/server) and an E2EE choice first. + // `confirmDirect` does the actual open-or-create. `creatingUserId` + // drives the dialog button's in-flight spinner. const [creatingUserId, setCreatingUserId] = useState(null); - const startDmWithUser = useCallback( - (userId: string) => { - setCreatingUserId(userId); - createDirect(userId) + const [pendingDirect, setPendingDirect] = useState(null); + + const requestDirect = useCallback((target: PendingDirect) => { + setDmError(null); + setPendingDirect(target); + }, []); + + const cancelDirect = useCallback(() => { + setPendingDirect(null); + setDmError(null); + }, []); + + const confirmDirect = useCallback( + (encrypt: boolean) => { + const target = pendingDirect; + if (!target) return; + setDmError(null); + setCreatingUserId(target.userId); + createDirect(target.userId, { encrypt }) .then((roomId) => { if (!alive()) return; setCreatingUserId(null); + setPendingDirect(null); onOpenRoomId(roomId, false); }) - .catch(() => { - if (alive()) setCreatingUserId(null); + .catch((err: unknown) => { + if (!alive()) return; + setCreatingUserId(null); + // Surface the failure instead of swallowing it. `isRateLimitError` + // also catches a 429 carrying `M_UNKNOWN`/no errcode. We don't show + // a countdown — the retry window is unreliable (header vs body, + // usually sub-minute) and a wrong "0 min" reads worse than a plain + // "try again shortly". + setDmError({ rateLimited: err instanceof MatrixError && err.isRateLimitError() }); }); }, - [createDirect, alive, onOpenRoomId] + [pendingDirect, createDirect, alive, onOpenRoomId] ); const handleInputChange: ChangeEventHandler = useCallback( (evt) => { listFocus.reset(); + setDmError(null); setQuery(evt.currentTarget.value); const raw = evt.currentTarget.value.trim(); - // A full `@local:server` is treated as an exact MXID (directory - // term + profile fallback), NOT as the `@` directs-filter prefix — - // so pasting someone's id finds exactly them. - const exactMxid = isUserId(raw); + // A deliberate full address (`@a:b`, `a:b`, `a@b`) becomes a + // federated profile preview, NOT the `@` directs-filter prefix — + // so pasting (or loosely typing) someone's address finds exactly + // them. Bare `@name` (no server) stays the directs-filter prefix. + const parsedMxid = parseUserAddress(raw); + let value = raw; let nextType: SearchRoomType | undefined; - if (!exactMxid) { - const prefix = value.match(/^[#@*]/)?.[0]; + if (parsedMxid) { + // Address mode: still fuzzy-search local rooms/DMs by the + // localpart, but apply no prefix scope. + value = getMxIdLocalPart(parsedMxid) ?? raw; + } else { + const prefix = raw.match(/^[#@*]/)?.[0]; nextType = prefix ? PREFIX_TO_TYPE[prefix] : undefined; - if (nextType) value = value.slice(1); + if (nextType) value = raw.slice(1); } setSearchRoomType(nextType); @@ -263,25 +364,23 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) { if (value === '') resetSearch(); else search(value); - // Directory people search — only when nothing scopes us away from - // people (`#` rooms / `*` spaces suppress it). Bump the generation - // so any in-flight request from a prior keystroke is discarded. + // Directory people + address preview — only when nothing scopes us + // away from people (`#` rooms / `*` spaces suppress it). Bump the + // generation so any in-flight request from a prior keystroke is + // discarded. An address always runs (the user clearly means that + // person); a plain term waits for the minimum length. dirGenRef.current += 1; const directoryEnabled = nextType === undefined || nextType === SearchRoomType.Directs; - const dirTerm = exactMxid ? raw : value; - if ( - directoryEnabled && - dirTerm !== '' && - (exactMxid || dirTerm.length >= DIRECTORY_MIN_CHARS) - ) { + if (directoryEnabled && (parsedMxid || value.length >= DIRECTORY_MIN_CHARS)) { setDirectoryLoading(true); - debouncedRunDirectory(dirTerm, exactMxid, dirGenRef.current); + debouncedRunQuery(value, parsedMxid, dirGenRef.current); } else { setDirectoryResults([]); + setAddressPreview(null); setDirectoryLoading(false); } }, - [listFocus, search, resetSearch, debouncedRunDirectory] + [listFocus, search, resetSearch, debouncedRunQuery] ); // Wipe the whole query in one tap (the ✕ button): reset local search, @@ -293,7 +392,9 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) { resetSearch(); dirGenRef.current += 1; setDirectoryResults([]); + setAddressPreview(null); setDirectoryLoading(false); + setDmError(null); listFocus.reset(); const input = inputRef.current; if (input) { @@ -306,12 +407,29 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) { (evt) => { if (isKeyHotkey('enter', evt)) { const { index } = listFocus; - if (index < roomCount) { - const roomId = roomsToRender[index]; + if (addressActionable && addressPreview && index === addressIndex) { + // The actionable address card (top): open the confirm dialog. + // A `notfound` card isn't focusable, so it never lands here. + requestDirect({ + userId: addressPreview.userId, + displayName: addressPreview.displayName, + avatarUrl: addressPreview.avatarUrl, + }); + return; + } + const ri = index - roomIndexBase; + if (ri >= 0 && ri < roomCount) { + const roomId = roomsToRender[ri]; if (roomId) onOpenRoomId(roomId, spaces.includes(roomId)); } else { - const user = peopleToRender[index - roomCount]; - if (user) startDmWithUser(user.userId); + const user = peopleToRender[ri - roomCount]; + if (user) { + requestDirect({ + userId: user.userId, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + }); + } } return; } @@ -325,7 +443,19 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) { listFocus.previous(); } }, - [roomsToRender, roomCount, peopleToRender, listFocus, onOpenRoomId, spaces, startDmWithUser] + [ + roomsToRender, + roomCount, + roomIndexBase, + peopleToRender, + addressPreview, + addressActionable, + addressIndex, + listFocus, + onOpenRoomId, + spaces, + requestDirect, + ] ); const handleRoomClick: MouseEventHandler = useCallback( @@ -339,15 +469,6 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) { [onOpenRoomId] ); - const handleUserClick: MouseEventHandler = useCallback( - (evt) => { - const userId = evt.currentTarget.getAttribute('data-user-id'); - if (!userId) return; - startDmWithUser(userId); - }, - [startDmWithUser] - ); - // Auto-scroll the highlighted item into view as the user arrows. useEffect(() => { const scrollView = scrollRef.current; @@ -373,11 +494,25 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) { // People (user directory) section: peopleToRender, directoryLoading, - handleUserClick, + // Open the confirm dialog for a person (directory hit or address card). + requestDirect, creatingUserId, - // Index in the flat focus list where the People section begins, so - // consumers can map a user's array position to its focus index. + // Typed-address card (always rendered at the top) + whether it's + // actionable (clickable/focusable — `notfound` is a plain note) + + // surfaced DM-create error. + addressPreview, + addressActionable, + dmError, + // Pending confirm-dialog target + its actions. + pendingDirect, + confirmDirect, + cancelDirect, + // Flat focus-index layout so consumers can tag rows: the actionable + // address card sits at `addressIndex` (0, at the top), then rooms + // start at `roomIndexBase`, then people right after. roomCount, + roomIndexBase, + addressIndex, // Per-row context the consumer needs to render rows: getRoom, mDirects, diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 1c8f4b01..e9c0802c 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -34,6 +34,48 @@ export const getMxIdLocalPart = (userId: string): string | undefined => matchMxI export const isUserId = (id: string): boolean => validMxId(id) && id.startsWith('@'); +// Anchored Matrix server name: a fully-qualified dotted domain with an +// optional port. Deliberately stricter than `isServerName`/`DOMAIN_REGEX`, +// which is an UNANCHORED substring test — that would let junk-wrapped +// strings like `matrix.org#frag`, `(matrix.org)` or a trailing-dot +// `matrix.org.` pass and produce a malformed MXID. IPs / single-label +// hosts aren't accepted here on purpose — those rare cases go through the +// explicit add-by-address form, not the forgiving inline parse. +const ADDRESS_SERVER_REGEX = /^(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?::\d{1,5})?$/; + +// Loosely parse a user-typed address into a canonical `@localpart:server` +// MXID, or `null` when the input isn't a deliberate full address. Two +// forms are accepted: the strict `@name:server` (also covers a paste), +// and the email-like `name@server`. The bare `name:server` form is +// intentionally NOT accepted — a single `word:word.word` token is +// indistinguishable from an everyday note or filename (`re:meeting.notes`) +// and would otherwise fire a spurious federated profile lookup. The server +// half must FULLY match `ADDRESS_SERVER_REGEX`. A bare localpart with no +// server returns `null` — reaching someone by id alone is the explicit +// add-by-address form's job (it supplies the default homeserver). Used by +// the search panel to surface a federated profile preview the instant a +// full address is typed. +export const parseUserAddress = (raw: string): string | null => { + const input = raw.trim(); + if (!input) return null; + + // Strict MXID first (also covers a pasted `@name:server`). + if (isUserId(input)) return input; + + // `name@server` (email-like). Split on the LAST `@` so a stray leading + // `@` is tolerated and stripped as part of the localpart. + const at = input.lastIndexOf('@'); + if (at > 0) { + const localpart = input.slice(0, at).replace(/^@/, ''); + const server = input.slice(at + 1); + if (localpart && !/[@:\s]/.test(localpart) && ADDRESS_SERVER_REGEX.test(server)) { + return `@${localpart}:${server}`; + } + } + + return null; +}; + export const isRoomId = (id: string): boolean => id.startsWith('!'); export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#');