feat(search): reach users by full address with an existence-checked confirm dialog, never offering chats with non-existent accounts

This commit is contained in:
heaven 2026-06-05 17:26:17 +03:00
parent c2f6baa712
commit d1d2c68393
5 changed files with 1173 additions and 466 deletions

View file

@ -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 = (
<Text size="L400" priority="300" style={{ padding: `${toRem(4)} ${toRem(10)}` }}>
{t('Search.by_address')}
</Text>
);
// 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 (
<Box direction="Column" gap="100" style={{ padding: `${toRem(4)} 0` }}>
{header}
<Box alignItems="Center" gap="200" style={{ padding: `${toRem(4)} ${toRem(10)}` }}>
<Icon size="100" src={Icons.Warning} style={{ color: color.Warning.Main }} />
<Box direction="Column" style={{ minWidth: 0 }}>
<Text size="T300" style={{ color: color.Warning.Main }}>
{t('Search.user_not_found', { server })}
</Text>
<Text as="span" size="T200" priority="300" truncate>
{addressPreview.userId}
</Text>
</Box>
</Box>
</Box>
);
}
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 (
<Box direction="Column" gap="100" style={{ padding: `${toRem(4)} 0` }}>
{header}
<button
type="button"
data-focus-index={addressIndex}
onClick={() =>
requestDirect({
userId: addressPreview.userId,
displayName: addressPreview.displayName,
avatarUrl: addressPreview.avatarUrl,
})
}
aria-pressed={focused}
style={{
...ROW_BASE_STYLE,
backgroundColor: focused ? color.Background.ContainerHover : 'transparent',
}}
>
<Avatar size="200" radii="400">
<UserAvatar
userId={addressPreview.userId}
src={avatarSrc}
alt={name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(name)}
</Text>
)}
/>
</Avatar>
<Box grow="Yes" alignItems="Center" gap="100" style={{ minWidth: 0 }}>
<Text size="T400" truncate>
{name}
</Text>
<Text as="span" size="T200" priority="300" truncate>
@{username}
</Text>
</Box>
{server && (
<Box shrink="No">
<Text size="T200" priority="300" truncate>
<b>{server}</b>
</Text>
</Box>
)}
</button>
{caption && (
<Text size="T200" priority="300" style={{ padding: `0 ${toRem(10)}` }}>
{caption}
</Text>
)}
</Box>
);
})()
: null;
return (
<Box
@ -167,8 +297,18 @@ export function InlineRoomSearch({ onClose }: Props) {
? t('Search.no_match_for_query', { query: result.query })
: t('Search.no_rooms_to_display')}
</Text>
<Text
size="T200"
align="Center"
priority="300"
style={{ maxWidth: toRem(260), marginTop: toRem(8) }}
>
{t('Search.address_hint')}
</Text>
</Box>
)}
{/* Typed address → always the top result. */}
{addressCard}
{roomsToRender.length > 0 && (
<Box direction="Column" gap="100" style={{ padding: `${toRem(4)} 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 (
<button
key={roomId}
type="button"
data-focus-index={index}
data-focus-index={focusIndex}
data-room-id={roomId}
data-space={room.isSpaceRoom()}
onClick={handleRoomClick}
@ -289,7 +430,7 @@ export function InlineRoomSearch({ onClose }: Props) {
{t('Search.people')}
</Text>
{peopleToRender.map((user, j) => {
const index = roomCount + j;
const index = roomIndexBase + roomCount + j;
const focused = listFocus.index === index;
const username = getMxIdLocalPart(user.userId) ?? user.userId;
const server = getMxIdServer(user.userId);
@ -297,32 +438,23 @@ export function InlineRoomSearch({ onClose }: Props) {
const avatarSrc = user.avatarUrl
? mxcUrlToHttp(mx, user.avatarUrl, useAuthentication, 32, 32, 'crop') ?? undefined
: undefined;
const creating = creatingUserId === user.userId;
return (
<button
key={user.userId}
type="button"
data-focus-index={index}
data-user-id={user.userId}
onClick={handleUserClick}
onClick={() =>
requestDirect({
userId: user.userId,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
})
}
aria-pressed={focused}
disabled={creating}
style={{
appearance: 'none',
WebkitAppearance: 'none',
border: 'none',
...ROW_BASE_STYLE,
backgroundColor: focused ? color.Background.ContainerHover : 'transparent',
color: color.Background.OnContainer,
borderRadius: toRem(12),
padding: `${toRem(8)} ${toRem(10)}`,
width: '100%',
display: 'flex',
alignItems: 'center',
gap: toRem(10),
cursor: creating ? 'default' : 'pointer',
textAlign: 'left',
font: 'inherit',
}}
>
<Avatar size="200" radii="400">
@ -345,38 +477,31 @@ export function InlineRoomSearch({ onClose }: Props) {
@{username}
</Text>
</Box>
<Box gap="100" alignItems="Center" shrink="No">
{creating ? (
<Spinner size="100" variant={focused ? 'Primary' : 'Secondary'} />
) : (
server && (
{server && (
<Box shrink="No">
<Text size="T200" priority="300" truncate>
<b>{server}</b>
</Text>
)
)}
</Box>
)}
</button>
);
})}
</Box>
)}
{/* 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 && (
<Box style={{ padding: `${toRem(8)} ${toRem(10)} ${toRem(4)}` }}>
<Text size="T200" priority="300">
{t('Search.other_server_hint')}
</Text>
</Box>
)}
</Scroll>
</Box>
{pendingDirect && (
<StartDirectDialog
key={pendingDirect.userId}
target={pendingDirect}
creating={creatingUserId === pendingDirect.userId}
errorText={dmErrorText}
onConfirm={confirmDirect}
onCancel={cancelDirect}
/>
)}
</Box>
);
}

View file

@ -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.060.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,9 +91,145 @@ 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 = (
<Text
size="L400"
priority="300"
style={{ padding: `${toRem(8)} ${toRem(8)} ${toRem(4)}` }}
>
{t('Search.by_address')}
</Text>
);
if (addressPreview.state === 'notfound') {
return (
<>
{header}
<Box
alignItems="Center"
gap="200"
style={{ padding: `${toRem(6)} ${toRem(10)} ${toRem(8)}` }}
>
<Icon size="100" src={Icons.Warning} style={{ color: color.Warning.Main }} />
<Box direction="Column" style={{ minWidth: 0 }}>
<Text size="T300" style={{ color: color.Warning.Main }}>
{t('Search.user_not_found', { server })}
</Text>
<Text
as="span"
size="T200"
priority="300"
truncate
style={{ fontFamily: 'var(--font-mono)' }}
>
{addressPreview.userId}
</Text>
</Box>
</Box>
</>
);
}
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}
<MenuItem
as="button"
data-focus-index={addressIndex}
onClick={() =>
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 && (
<Text
size="T200"
priority="300"
truncate
style={{ fontFamily: 'var(--font-mono)' }}
>
{server}
</Text>
)
}
before={
<Avatar size="200" radii="400">
<UserAvatar
userId={addressPreview.userId}
src={avatarSrc}
alt={name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(name)}
</Text>
)}
/>
</Avatar>
}
>
<Box grow="Yes" alignItems="Center" gap="100">
<Text size="T400" truncate>
{name}
</Text>
<Text
as="span"
size="T200"
priority="300"
truncate
style={{ fontFamily: 'var(--font-mono)' }}
>
@{username}
</Text>
</Box>
</MenuItem>
{caption && (
<Text
size="T200"
priority="300"
style={{ display: 'block', padding: `0 ${toRem(8)} ${toRem(4)}` }}
>
{caption}
</Text>
)}
</>
);
})()
: null;
return (
<>
<Overlay open>
<OverlayCenter>
<FocusTrap
@ -184,26 +328,26 @@ export function Search({ requestClose }: SearchProps) {
<Text size="H6" align="Center">
{result ? t('Search.no_match_found') : t('Search.no_rooms')}
</Text>
<Text size="T200" align="Center">
<Text size="T200" align="Center" style={{ maxWidth: toRem(300) }}>
{result
? t('Search.no_match_for_query', { query: result.query })
: t('Search.no_rooms_to_display')}
</Text>
{result && (
<Text
size="T200"
align="Center"
priority="300"
style={{ maxWidth: toRem(300) }}
style={{ maxWidth: toRem(300), marginTop: config.space.S200 }}
>
{t('Search.other_server_hint')}
{t('Search.address_hint')}
</Text>
)}
</Box>
)}
{!isEmpty && (
<Scroll ref={scrollRef} size="300" hideTrack>
<div style={{ padding: config.space.S200, paddingRight: config.space.S100 }}>
{/* Typed address → always the top result. */}
{addressCard}
{roomsToRender.map((roomId, index) => {
const room = getRoom(roomId);
if (!room) return null;
@ -225,13 +369,14 @@ export function Search({ requestClose }: SearchProps) {
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 (
<MenuItem
key={roomId}
as="button"
data-focus-index={index}
data-focus-index={focusIndex}
data-room-id={roomId}
data-space={room.isSpaceRoom()}
onClick={handleRoomClick}
@ -339,35 +484,43 @@ export function Search({ requestClose }: SearchProps) {
{t('Search.people')}
</Text>
{peopleToRender.map((user, j) => {
const index = roomCount + 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
? mxcUrlToHttp(
mx,
user.avatarUrl,
useAuthentication,
32,
32,
'crop'
) ?? undefined
: undefined;
const creating = creatingUserId === user.userId;
return (
<MenuItem
key={user.userId}
as="button"
data-focus-index={index}
data-user-id={user.userId}
onClick={handleUserClick}
onClick={() =>
requestDirect({
userId: user.userId,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
})
}
variant="Surface"
aria-pressed={focused}
radii="300"
disabled={creating}
style={{
backgroundColor: focused ? 'rgba(255, 255, 255, 0.06)' : undefined,
backgroundColor: focused
? 'rgba(255, 255, 255, 0.06)'
: undefined,
}}
after={
creating ? (
<Spinner size="100" variant="Secondary" />
) : (
server && (
<Text
size="T200"
@ -378,7 +531,6 @@ export function Search({ requestClose }: SearchProps) {
{server}
</Text>
)
)
}
before={
<Avatar size="200" radii="400">
@ -414,21 +566,6 @@ export function Search({ requestClose }: SearchProps) {
})}
</>
)}
{/* 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 && (
<Text
size="T200"
priority="300"
style={{ display: 'block', padding: `${toRem(8)} ${toRem(8)} ${toRem(4)}` }}
>
{t('Search.other_server_hint')}
</Text>
)}
</div>
</Scroll>
)}
@ -458,6 +595,17 @@ export function Search({ requestClose }: SearchProps) {
</FocusTrap>
</OverlayCenter>
</Overlay>
{pendingDirect && (
<StartDirectDialog
key={pendingDirect.userId}
target={pendingDirect}
creating={creatingUserId === pendingDirect.userId}
errorText={dmErrorText}
onConfirm={confirmDirect}
onCancel={cancelDirect}
/>
)}
</>
);
}

View file

@ -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 (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onCancel,
clickOutsideDeactivates: true,
allowOutsideClick: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" style={{ width: toRem(340), maxWidth: '90vw' }}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H5">{t('Search.start_dm_title')}</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel} aria-label={t('Search.clear')}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
{/* Identity preview */}
<Box
direction="Column"
alignItems="Center"
gap="200"
style={{ padding: `${config.space.S500} ${config.space.S400} ${config.space.S200}` }}
>
<Avatar size="500" radii="400">
<UserAvatar
userId={target.userId}
src={avatarSrc}
alt={name}
renderFallback={() => (
<Text as="span" size="H3">
{nameInitials(name)}
</Text>
)}
/>
</Avatar>
<Box direction="Column" alignItems="Center" gap="100" style={{ minWidth: 0 }}>
<Text size="H6" align="Center" truncate style={{ maxWidth: toRem(290) }}>
{name}
</Text>
<Text
size="T200"
priority="300"
align="Center"
truncate
style={{ maxWidth: toRem(290), fontFamily: 'var(--font-mono)' }}
>
{target.userId}
</Text>
{/* Existence / reachability status the honest answer to
"is there really such a user". */}
<Box alignItems="Center" gap="100" style={{ marginTop: toRem(2) }}>
{lookup === 'checking' && (
<>
<Spinner size="100" variant="Secondary" />
<Text size="T200" priority="300">
{t('Search.checking')}
</Text>
</>
)}
{lookup === 'found' && (
<>
<Icon size="50" src={Icons.Check} style={{ color: color.Success.Main }} />
<Text size="T200" style={{ color: color.Success.Main }}>
{remote ? t('Search.found_on_server', { server }) : t('Search.user_found')}
</Text>
</>
)}
{lookup === 'notfound' && (
<>
<Icon size="50" src={Icons.Warning} style={{ color: color.Warning.Main }} />
<Text size="T200" style={{ color: color.Warning.Main }}>
{t('Search.user_not_found', { server })}
</Text>
</>
)}
{lookup === 'error' && (
<>
<Icon size="50" src={Icons.Warning} style={{ color: color.Warning.Main }} />
<Text size="T200" style={{ color: color.Warning.Main }}>
{t('Search.user_unreachable', { server })}
</Text>
</>
)}
</Box>
</Box>
</Box>
{/* Options + action */}
<Box direction="Column" gap="300" style={{ padding: config.space.S400 }}>
<Box
as="label"
alignItems="Center"
gap="200"
style={{
padding: `${toRem(10)} ${toRem(12)}`,
borderRadius: toRem(12),
backgroundColor: color.Background.Container,
cursor: 'pointer',
}}
>
<Icon
size="100"
src={Icons.ShieldUser}
style={{ color: encrypt ? color.Success.Main : undefined }}
/>
<Box grow="Yes">
<Text size="T300">{t('Search.encrypt_label')}</Text>
</Box>
<Switch variant="Success" value={encrypt} onChange={setEncrypt} />
</Box>
{errorText && (
<Box alignItems="Center" gap="200" style={{ color: color.Critical.Main }}>
<Icon size="100" src={Icons.Warning} />
<Text size="T200" style={{ color: color.Critical.Main }}>
{errorText}
</Text>
</Box>
)}
<Button
variant="Primary"
radii="400"
onClick={() => onConfirm(encrypt)}
disabled={creating || blocked}
before={
creating ? <Spinner variant="Primary" fill="Solid" size="200" /> : undefined
}
>
<Text size="B400">{t('Search.start_dm_action')}</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View file

@ -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<RoomSearchUser[]>([]);
// Federated profile preview for a typed full address (see AddressPreview).
const [addressPreview, setAddressPreview] = useState<AddressPreview | null>(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<DmError | null>(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 {
if (term) {
const { results } = await mx.searchUserDirectory({ term, limit: DIRECTORY_LIMIT });
let users: RoomSearchUser[] = results.map((r) => ({
users = 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];
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);
} catch {
if (gen !== dirGenRef.current || !alive()) return;
setDirectoryResults([]);
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<string | null>(null);
const startDmWithUser = useCallback(
(userId: string) => {
setCreatingUserId(userId);
createDirect(userId)
const [pendingDirect, setPendingDirect] = useState<PendingDirect | null>(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<HTMLInputElement> = 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<HTMLButtonElement> = useCallback(
@ -339,15 +469,6 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) {
[onOpenRoomId]
);
const handleUserClick: MouseEventHandler<HTMLButtonElement> = 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,

View file

@ -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('#');