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 { nameInitials } from '../../../utils/common';
import { getMxIdLocalPart, getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix'; import { getMxIdLocalPart, getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix';
import { getDmUserId, useRoomSearch } from '../../../features/search/useRoomSearch'; import { getDmUserId, useRoomSearch } from '../../../features/search/useRoomSearch';
import { StartDirectDialog } from '../../../features/search/StartDirectDialog';
import { SEARCH_FORM_BASE_PX } from '../geometry'; 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 = { type Props = {
onClose: () => void; onClose: () => void;
}; };
@ -56,9 +74,16 @@ export function InlineRoomSearch({ onClose }: Props) {
handleRoomClick, handleRoomClick,
peopleToRender, peopleToRender,
directoryLoading, directoryLoading,
handleUserClick, requestDirect,
creatingUserId, creatingUserId,
addressPreview,
dmError,
pendingDirect,
confirmDirect,
cancelDirect,
roomCount, roomCount,
roomIndexBase,
addressIndex,
getRoom, getRoom,
mDirects, mDirects,
orphanSpaces, orphanSpaces,
@ -75,7 +100,112 @@ export function InlineRoomSearch({ onClose }: Props) {
inputRef.current?.focus(); inputRef.current?.focus();
}, [inputRef]); }, [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 ( return (
<Box <Box
@ -167,8 +297,18 @@ export function InlineRoomSearch({ onClose }: Props) {
? t('Search.no_match_for_query', { query: result.query }) ? t('Search.no_match_for_query', { query: result.query })
: t('Search.no_rooms_to_display')} : t('Search.no_rooms_to_display')}
</Text> </Text>
<Text
size="T200"
align="Center"
priority="300"
style={{ maxWidth: toRem(260), marginTop: toRem(8) }}
>
{t('Search.address_hint')}
</Text>
</Box> </Box>
)} )}
{/* Typed address → always the top result. */}
{addressCard}
{roomsToRender.length > 0 && ( {roomsToRender.length > 0 && (
<Box direction="Column" gap="100" style={{ padding: `${toRem(4)} 0` }}> <Box direction="Column" gap="100" style={{ padding: `${toRem(4)} 0` }}>
{roomsToRender.map((roomId, index) => { {roomsToRender.map((roomId, index) => {
@ -192,13 +332,14 @@ export function InlineRoomSearch({ onClose }: Props) {
exactParents && guessPerfectParent(mx, roomId, Array.from(exactParents)); exactParents && guessPerfectParent(mx, roomId, Array.from(exactParents));
const unread = roomToUnread.get(roomId); const unread = roomToUnread.get(roomId);
const focused = listFocus.index === index; const focusIndex = roomIndexBase + index;
const focused = listFocus.index === focusIndex;
return ( return (
<button <button
key={roomId} key={roomId}
type="button" type="button"
data-focus-index={index} data-focus-index={focusIndex}
data-room-id={roomId} data-room-id={roomId}
data-space={room.isSpaceRoom()} data-space={room.isSpaceRoom()}
onClick={handleRoomClick} onClick={handleRoomClick}
@ -289,7 +430,7 @@ export function InlineRoomSearch({ onClose }: Props) {
{t('Search.people')} {t('Search.people')}
</Text> </Text>
{peopleToRender.map((user, j) => { {peopleToRender.map((user, j) => {
const index = roomCount + j; const index = roomIndexBase + roomCount + j;
const focused = listFocus.index === index; const focused = listFocus.index === index;
const username = getMxIdLocalPart(user.userId) ?? user.userId; const username = getMxIdLocalPart(user.userId) ?? user.userId;
const server = getMxIdServer(user.userId); const server = getMxIdServer(user.userId);
@ -297,32 +438,23 @@ export function InlineRoomSearch({ onClose }: Props) {
const avatarSrc = user.avatarUrl const avatarSrc = user.avatarUrl
? mxcUrlToHttp(mx, user.avatarUrl, useAuthentication, 32, 32, 'crop') ?? undefined ? mxcUrlToHttp(mx, user.avatarUrl, useAuthentication, 32, 32, 'crop') ?? undefined
: undefined; : undefined;
const creating = creatingUserId === user.userId;
return ( return (
<button <button
key={user.userId} key={user.userId}
type="button" type="button"
data-focus-index={index} data-focus-index={index}
data-user-id={user.userId} onClick={() =>
onClick={handleUserClick} requestDirect({
userId: user.userId,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
})
}
aria-pressed={focused} aria-pressed={focused}
disabled={creating}
style={{ style={{
appearance: 'none', ...ROW_BASE_STYLE,
WebkitAppearance: 'none',
border: 'none',
backgroundColor: focused ? color.Background.ContainerHover : 'transparent', 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"> <Avatar size="200" radii="400">
@ -345,38 +477,31 @@ export function InlineRoomSearch({ onClose }: Props) {
@{username} @{username}
</Text> </Text>
</Box> </Box>
<Box gap="100" alignItems="Center" shrink="No"> {server && (
{creating ? ( <Box shrink="No">
<Spinner size="100" variant={focused ? 'Primary' : 'Secondary'} /> <Text size="T200" priority="300" truncate>
) : ( <b>{server}</b>
server && ( </Text>
<Text size="T200" priority="300" truncate> </Box>
<b>{server}</b> )}
</Text>
)
)}
</Box>
</button> </button>
); );
})} })}
</Box> </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> </Scroll>
</Box> </Box>
{pendingDirect && (
<StartDirectDialog
key={pendingDirect.userId}
target={pendingDirect}
creating={creatingUserId === pendingDirect.userId}
errorText={dmErrorText}
onConfirm={confirmDirect}
onCancel={cancelDirect}
/>
)}
</Box> </Box>
); );
} }

View file

@ -37,6 +37,7 @@ import { searchModalAtom } from '../../state/searchModal';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { getDmUserId, useRoomSearch } from './useRoomSearch'; import { getDmUserId, useRoomSearch } from './useRoomSearch';
import { StartDirectDialog } from './StartDirectDialog';
// Dawn hairline divider (the canon's rgba(255,255,255,0.060.08) row line). // 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)'; const HAIRLINE = '1px solid rgba(255, 255, 255, 0.08)';
@ -72,9 +73,16 @@ export function Search({ requestClose }: SearchProps) {
handleRoomClick, handleRoomClick,
peopleToRender, peopleToRender,
directoryLoading, directoryLoading,
handleUserClick, requestDirect,
creatingUserId, creatingUserId,
addressPreview,
dmError,
pendingDirect,
confirmDirect,
cancelDirect,
roomCount, roomCount,
roomIndexBase,
addressIndex,
getRoom, getRoom,
mDirects, mDirects,
orphanSpaces, orphanSpaces,
@ -83,291 +91,436 @@ export function Search({ requestClose }: SearchProps) {
myUserId, myUserId,
} = useRoomSearch({ onOpenRoomId: openRoomId }); } = 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 ( return (
<Overlay open> <>
<OverlayCenter> <Overlay open>
<FocusTrap <OverlayCenter>
focusTrapOptions={{ <FocusTrap
initialFocus: () => inputRef.current, focusTrapOptions={{
returnFocusOnDeactivate: false, initialFocus: () => inputRef.current,
allowOutsideClick: true, returnFocusOnDeactivate: false,
clickOutsideDeactivates: true, allowOutsideClick: true,
onDeactivate: requestClose, clickOutsideDeactivates: true,
escapeDeactivates: (evt) => { onDeactivate: requestClose,
evt.stopPropagation(); escapeDeactivates: (evt) => {
return true; evt.stopPropagation();
}, return true;
}} },
>
<Modal
size="400"
style={{
// `size="400"` only sets max-width/height, so without an
// explicit width the modal shrinks to its content — making
// it narrow when empty and widening as result rows appear.
// Pin width to fill up to the size cap so it stays a stable
// width from the first keystroke.
width: '100%',
maxHeight: toRem(400),
borderRadius: config.radii.R400,
backgroundColor: '#181a20',
border: HAIRLINE,
boxShadow: '0 16px 48px rgba(0, 0, 0, 0.5)',
}} }}
> >
{/* Input row: hairline-divided, integrated (not a boxed Input) <Modal
size="400"
style={{
// `size="400"` only sets max-width/height, so without an
// explicit width the modal shrinks to its content — making
// it narrow when empty and widening as result rows appear.
// Pin width to fill up to the size cap so it stays a stable
// width from the first keystroke.
width: '100%',
maxHeight: toRem(400),
borderRadius: config.radii.R400,
backgroundColor: '#181a20',
border: HAIRLINE,
boxShadow: '0 16px 48px rgba(0, 0, 0, 0.5)',
}}
>
{/* Input row: hairline-divided, integrated (not a boxed Input)
`width: 100%` pins the row to the modal width so the `width: 100%` pins the row to the modal width so the
flex `input` always has space to fill otherwise the row flex `input` always has space to fill otherwise the row
collapses to its content (search icon) when empty and collapses to its content (search icon) when empty and
only widens as you type. */} only widens as you type. */}
<Box <Box
shrink="No" shrink="No"
alignItems="Center" alignItems="Center"
gap="200" gap="200"
style={{
width: '100%',
minWidth: 0,
padding: `${toRem(12)} ${config.space.S400}`,
borderBottom: HAIRLINE,
}}
>
<Icon size="200" src={Icons.Search} />
<input
ref={inputRef}
type="text"
placeholder={t('Search.search')}
autoComplete="off"
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
style={{ style={{
flex: 1, width: '100%',
appearance: 'none',
border: 'none',
outline: 'none',
background: 'transparent',
font: 'inherit',
fontSize: toRem(15),
color: color.Background.OnContainer,
minWidth: 0, minWidth: 0,
padding: `${toRem(12)} ${config.space.S400}`,
borderBottom: HAIRLINE,
}} }}
/> >
{directoryLoading ? ( <Icon size="200" src={Icons.Search} />
<Spinner size="200" variant="Secondary" /> <input
) : ( ref={inputRef}
query.length > 0 && ( type="text"
<IconButton placeholder={t('Search.search')}
type="button" autoComplete="off"
size="300" onChange={handleInputChange}
radii="Pill" onKeyDown={handleInputKeyDown}
variant="SurfaceVariant" style={{
fill="None" flex: 1,
onClick={clearSearch} appearance: 'none',
aria-label={t('Search.clear')} border: 'none',
outline: 'none',
background: 'transparent',
font: 'inherit',
fontSize: toRem(15),
color: color.Background.OnContainer,
minWidth: 0,
}}
/>
{directoryLoading ? (
<Spinner size="200" variant="Secondary" />
) : (
query.length > 0 && (
<IconButton
type="button"
size="300"
radii="Pill"
variant="SurfaceVariant"
fill="None"
onClick={clearSearch}
aria-label={t('Search.clear')}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
)
)}
</Box>
<Box grow="Yes">
{isEmpty && !directoryLoading && (
<Box
style={{ paddingTop: config.space.S700 }}
grow="Yes"
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
> >
<Icon size="100" src={Icons.Cross} /> <Text size="H6" align="Center">
</IconButton> {result ? t('Search.no_match_found') : t('Search.no_rooms')}
) </Text>
)} <Text size="T200" align="Center" style={{ maxWidth: toRem(300) }}>
</Box> {result
<Box grow="Yes"> ? t('Search.no_match_for_query', { query: result.query })
{isEmpty && !directoryLoading && ( : t('Search.no_rooms_to_display')}
<Box </Text>
style={{ paddingTop: config.space.S700 }}
grow="Yes"
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
>
<Text size="H6" align="Center">
{result ? t('Search.no_match_found') : t('Search.no_rooms')}
</Text>
<Text size="T200" align="Center">
{result
? t('Search.no_match_for_query', { query: result.query })
: t('Search.no_rooms_to_display')}
</Text>
{result && (
<Text <Text
size="T200" size="T200"
align="Center" align="Center"
priority="300" priority="300"
style={{ maxWidth: toRem(300) }} style={{ maxWidth: toRem(300), marginTop: config.space.S200 }}
> >
{t('Search.other_server_hint')} {t('Search.address_hint')}
</Text> </Text>
)} </Box>
</Box> )}
)} {!isEmpty && (
{!isEmpty && ( <Scroll ref={scrollRef} size="300" hideTrack>
<Scroll ref={scrollRef} size="300" hideTrack> <div style={{ padding: config.space.S200, paddingRight: config.space.S100 }}>
<div style={{ padding: config.space.S200, paddingRight: config.space.S100 }}> {/* Typed address → always the top result. */}
{roomsToRender.map((roomId, index) => { {addressCard}
const room = getRoom(roomId); {roomsToRender.map((roomId, index) => {
if (!room) return null; const room = getRoom(roomId);
if (!room) return null;
const dm = mDirects.has(roomId); const dm = mDirects.has(roomId);
const dmUserId = dm ? getDmUserId(roomId, getRoom, myUserId) : undefined; const dmUserId = dm ? getDmUserId(roomId, getRoom, myUserId) : undefined;
const dmUsername = dmUserId ? getMxIdLocalPart(dmUserId) : undefined; const dmUsername = dmUserId ? getMxIdLocalPart(dmUserId) : undefined;
const dmUserServer = dmUserId ? getMxIdServer(dmUserId) : undefined; const dmUserServer = dmUserId ? getMxIdServer(dmUserId) : undefined;
const allParents = getAllParents(roomToParents, roomId); const allParents = getAllParents(roomToParents, roomId);
const orphanParents = allParents const orphanParents = allParents
? orphanSpaces.filter((o) => allParents.has(o)) ? orphanSpaces.filter((o) => allParents.has(o))
: undefined; : undefined;
const perfectOrphanParent = const perfectOrphanParent =
orphanParents && guessPerfectParent(mx, roomId, orphanParents); orphanParents && guessPerfectParent(mx, roomId, orphanParents);
const exactParents = roomToParents.get(roomId); const exactParents = roomToParents.get(roomId);
const perfectParent = const perfectParent =
exactParents && guessPerfectParent(mx, roomId, Array.from(exactParents)); exactParents && guessPerfectParent(mx, roomId, Array.from(exactParents));
const unread = roomToUnread.get(roomId); const unread = roomToUnread.get(roomId);
const focused = listFocus.index === index; const focusIndex = roomIndexBase + index;
const focused = listFocus.index === focusIndex;
return ( return (
<MenuItem <MenuItem
key={roomId} key={roomId}
as="button" as="button"
data-focus-index={index} data-focus-index={focusIndex}
data-room-id={roomId} data-room-id={roomId}
data-space={room.isSpaceRoom()} data-space={room.isSpaceRoom()}
onClick={handleRoomClick} onClick={handleRoomClick}
variant="Surface" variant="Surface"
aria-pressed={focused} aria-pressed={focused}
radii="300" radii="300"
style={{ style={{
// Neutral focus highlight — a soft surface tint, // Neutral focus highlight — a soft surface tint,
// no accent colour (the violet selection read as // no accent colour (the violet selection read as
// garish next to the yellow match highlight). // garish next to the yellow match highlight).
backgroundColor: focused ? 'rgba(255, 255, 255, 0.06)' : undefined, backgroundColor: focused ? 'rgba(255, 255, 255, 0.06)' : undefined,
}} }}
after={ after={
<Box gap="100"> <Box gap="100">
{dmUserServer && ( {dmUserServer && (
<Text <Text
size="T200" size="T200"
priority="300" priority="300"
truncate truncate
style={{ fontFamily: 'var(--font-mono)' }} style={{ fontFamily: 'var(--font-mono)' }}
> >
{dmUserServer} {dmUserServer}
</Text> </Text>
)} )}
{!dm && perfectOrphanParent && ( {!dm && perfectOrphanParent && (
<Text <Text
size="T200" size="T200"
priority="300" priority="300"
truncate truncate
style={{ fontFamily: 'var(--font-mono)' }} style={{ fontFamily: 'var(--font-mono)' }}
> >
{getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent} {getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent}
</Text> </Text>
)} )}
{unread && ( {unread && (
<UnreadBadgeCenter> <UnreadBadgeCenter>
<UnreadBadge <UnreadBadge
highlight={unread.highlight > 0} highlight={unread.highlight > 0}
count={unread.total} count={unread.total}
/>
</UnreadBadgeCenter>
)}
</Box>
}
before={
<Avatar size="200" radii={dm ? '400' : '300'}>
{dm || room.isSpaceRoom() ? (
<RoomAvatar
roomId={room.roomId}
src={
dm
? getDirectRoomAvatarUrl(mx, room, 32, useAuthentication)
: getRoomAvatarUrl(mx, room, 32, useAuthentication)
}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/> />
</UnreadBadgeCenter> ) : (
<RoomIcon
size="100"
joinRule={room.getJoinRule()}
roomType={room.getType()}
/>
)}
</Avatar>
}
>
<Box grow="Yes" alignItems="Center" gap="100">
<Text size="T400" truncate>
{room.name}
</Text>
{dmUsername && (
<Text
as="span"
size="T200"
priority="300"
truncate
style={{ fontFamily: 'var(--font-mono)' }}
>
@{dmUsername}
</Text>
)}
{!dm && perfectParent && perfectParent !== perfectOrphanParent && (
<Text size="T200" priority="300" truncate>
{getRoom(perfectParent)?.name ?? perfectParent}
</Text>
)} )}
</Box> </Box>
} </MenuItem>
before={ );
<Avatar size="200" radii={dm ? '400' : '300'}> })}
{dm || room.isSpaceRoom() ? (
<RoomAvatar
roomId={room.roomId}
src={
dm
? getDirectRoomAvatarUrl(mx, room, 32, useAuthentication)
: getRoomAvatarUrl(mx, room, 32, useAuthentication)
}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon
size="100"
joinRule={room.getJoinRule()}
roomType={room.getType()}
/>
)}
</Avatar>
}
>
<Box grow="Yes" alignItems="Center" gap="100">
<Text size="T400" truncate>
{room.name}
</Text>
{dmUsername && (
<Text
as="span"
size="T200"
priority="300"
truncate
style={{ fontFamily: 'var(--font-mono)' }}
>
@{dmUsername}
</Text>
)}
{!dm && perfectParent && perfectParent !== perfectOrphanParent && (
<Text size="T200" priority="300" truncate>
{getRoom(perfectParent)?.name ?? perfectParent}
</Text>
)}
</Box>
</MenuItem>
);
})}
{/* ── People (homeserver user directory) ─────────── */} {/* ── People (homeserver user directory) ─────────── */}
{peopleToRender.length > 0 && ( {peopleToRender.length > 0 && (
<> <>
<Text <Text
size="L400" size="L400"
priority="300" priority="300"
style={{ padding: `${toRem(8)} ${toRem(8)} ${toRem(4)}` }} style={{ padding: `${toRem(8)} ${toRem(8)} ${toRem(4)}` }}
> >
{t('Search.people')} {t('Search.people')}
</Text> </Text>
{peopleToRender.map((user, j) => { {peopleToRender.map((user, j) => {
const index = roomCount + j; const index = roomIndexBase + roomCount + j;
const focused = listFocus.index === index; const focused = listFocus.index === index;
const username = getMxIdLocalPart(user.userId) ?? user.userId; const username = getMxIdLocalPart(user.userId) ?? user.userId;
const server = getMxIdServer(user.userId); const server = getMxIdServer(user.userId);
const name = user.displayName || username; const name = user.displayName || username;
const avatarSrc = user.avatarUrl const avatarSrc = user.avatarUrl
? mxcUrlToHttp(mx, user.avatarUrl, useAuthentication, 32, 32, 'crop') ?? ? mxcUrlToHttp(
undefined mx,
: undefined; user.avatarUrl,
const creating = creatingUserId === user.userId; useAuthentication,
32,
32,
'crop'
) ?? undefined
: undefined;
return ( return (
<MenuItem <MenuItem
key={user.userId} key={user.userId}
as="button" as="button"
data-focus-index={index} data-focus-index={index}
data-user-id={user.userId} onClick={() =>
onClick={handleUserClick} requestDirect({
variant="Surface" userId: user.userId,
aria-pressed={focused} displayName: user.displayName,
radii="300" avatarUrl: user.avatarUrl,
disabled={creating} })
style={{ }
backgroundColor: focused ? 'rgba(255, 255, 255, 0.06)' : undefined, variant="Surface"
}} aria-pressed={focused}
after={ radii="300"
creating ? ( style={{
<Spinner size="100" variant="Secondary" /> backgroundColor: focused
) : ( ? 'rgba(255, 255, 255, 0.06)'
: undefined,
}}
after={
server && ( server && (
<Text <Text
size="T200" size="T200"
@ -378,86 +531,81 @@ export function Search({ requestClose }: SearchProps) {
{server} {server}
</Text> </Text>
) )
) }
} before={
before={ <Avatar size="200" radii="400">
<Avatar size="200" radii="400"> <UserAvatar
<UserAvatar userId={user.userId}
userId={user.userId} src={avatarSrc}
src={avatarSrc} alt={name}
alt={name} renderFallback={() => (
renderFallback={() => ( <Text as="span" size="H6">
<Text as="span" size="H6"> {nameInitials(name)}
{nameInitials(name)} </Text>
</Text> )}
)} />
/> </Avatar>
</Avatar> }
} >
> <Box grow="Yes" alignItems="Center" gap="100">
<Box grow="Yes" alignItems="Center" gap="100"> <Text size="T400" truncate>
<Text size="T400" truncate> {name}
{name} </Text>
</Text> <Text
<Text as="span"
as="span" size="T200"
size="T200" priority="300"
priority="300" truncate
truncate style={{ fontFamily: 'var(--font-mono)' }}
style={{ fontFamily: 'var(--font-mono)' }} >
> @{username}
@{username} </Text>
</Text> </Box>
</Box> </MenuItem>
</MenuItem> );
); })}
})} </>
</> )}
)} </div>
</Scroll>
{/* Reach someone on another server: the directory )}
can't surface remote users you don't share a room </Box>
with, so when a query finds no people we teach the {/* ── Mono keyboard-hint footer (hairline-divided) ── */}
by-address escape hatch (typing a full <Box
@user:server yields a tappable result above). */} shrink="No"
{result && !directoryLoading && peopleToRender.length === 0 && ( justifyContent="Center"
<Text gap="300"
size="T200" style={{
priority="300" padding: `${toRem(8)} ${config.space.S300}`,
style={{ display: 'block', padding: `${toRem(8)} ${toRem(8)} ${toRem(4)}` }} borderTop: HAIRLINE,
> fontFamily: 'var(--font-mono)',
{t('Search.other_server_hint')} }}
</Text> >
)} <Text size="T200" priority="300" style={{ fontFamily: 'var(--font-mono)' }}>
</div> {`↑↓ ${t('Search.kbd_select')}`}
</Scroll> </Text>
)} <Text size="T200" priority="300" style={{ fontFamily: 'var(--font-mono)' }}>
</Box> {`${t('Search.kbd_open')}`}
{/* ── Mono keyboard-hint footer (hairline-divided) ── */} </Text>
<Box <Text size="T200" priority="300" style={{ fontFamily: 'var(--font-mono)' }}>
shrink="No" {`esc ${t('Search.kbd_close')}`}
justifyContent="Center" </Text>
gap="300" </Box>
style={{ </Modal>
padding: `${toRem(8)} ${config.space.S300}`, </FocusTrap>
borderTop: HAIRLINE, </OverlayCenter>
fontFamily: 'var(--font-mono)', </Overlay>
}} {pendingDirect && (
> <StartDirectDialog
<Text size="T200" priority="300" style={{ fontFamily: 'var(--font-mono)' }}> key={pendingDirect.userId}
{`↑↓ ${t('Search.kbd_select')}`} target={pendingDirect}
</Text> creating={creatingUserId === pendingDirect.userId}
<Text size="T200" priority="300" style={{ fontFamily: 'var(--font-mono)' }}> errorText={dmErrorText}
{`${t('Search.kbd_open')}`} onConfirm={confirmDirect}
</Text> onCancel={cancelDirect}
<Text size="T200" priority="300" style={{ fontFamily: 'var(--font-mono)' }}> />
{`esc ${t('Search.kbd_close')}`} )}
</Text> </>
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
); );
} }

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, useState,
} from 'react'; } from 'react';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { Room } from 'matrix-js-sdk'; import { MatrixError, Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useDirects, useOrphanSpaces, useRooms, useSpaces } from '../../state/hooks/roomList'; 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 { roomToParentsAtom } from '../../state/room/roomToParents';
import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { factoryRoomIdByActivity } from '../../utils/sort'; 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 { makeHighlightRegex } from '../../plugins/react-custom-html-parser';
import { useDebounce } from '../../hooks/useDebounce'; import { useDebounce } from '../../hooks/useDebounce';
import { useAlive } from '../../hooks/useAlive'; import { useAlive } from '../../hooks/useAlive';
import { useCreateDirect } from '../create-chat/useCreateDirect'; import { useCreateDirect } from '../create-chat/useCreateDirect';
import { ErrorCode } from '../../cs-errorcode';
export enum SearchRoomType { export enum SearchRoomType {
Rooms = '#', Rooms = '#',
@ -59,14 +65,47 @@ const DIRECTORY_LIMIT = 20;
const DIRECTORY_MIN_CHARS = 2; const DIRECTORY_MIN_CHARS = 2;
const DIRECTORY_DEBOUNCE_MS = 300; const DIRECTORY_DEBOUNCE_MS = 300;
// A person surfaced from the homeserver user directory (or an exact // A person surfaced from the homeserver user directory. Clicking one
// MXID the user typed). Clicking one opens-or-creates a DM. // opens-or-creates a DM.
export type RoomSearchUser = { export type RoomSearchUser = {
userId: string; userId: string;
displayName?: string; displayName?: string;
avatarUrl?: 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 = ( export const getDmUserId = (
roomId: string, roomId: string,
getRoom: (roomId: string) => Room | undefined, getRoom: (roomId: string) => Room | undefined,
@ -154,48 +193,67 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) {
// since bumped the counter — this serialises out-of-order network // since bumped the counter — this serialises out-of-order network
// responses without an AbortController. // responses without an AbortController.
const [directoryResults, setDirectoryResults] = useState<RoomSearchUser[]>([]); 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 [directoryLoading, setDirectoryLoading] = useState(false);
const dirGenRef = useRef(0); const dirGenRef = useRef(0);
// Current input text — drives the clear (✕) button's visibility. // Current input text — drives the clear (✕) button's visibility.
const [query, setQuery] = useState(''); 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( const runQuery = useCallback(
async (term: string, exactMxid: boolean, gen: number) => { async (term: string, parsedMxid: string | null, gen: number) => {
if (gen !== dirGenRef.current) return; if (gen !== dirGenRef.current) return;
// Local homeserver directory list (display-name / localpart match).
let users: RoomSearchUser[] = [];
try { try {
const { results } = await mx.searchUserDirectory({ term, limit: DIRECTORY_LIMIT }); if (term) {
let users: RoomSearchUser[] = results.map((r) => ({ const { results } = await mx.searchUserDirectory({ term, limit: DIRECTORY_LIMIT });
userId: r.user_id, users = results.map((r) => ({
displayName: r.display_name, userId: r.user_id,
avatarUrl: r.avatar_url, 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 (gen !== dirGenRef.current || !alive()) return;
setDirectoryResults(users);
setDirectoryLoading(false);
} catch { } catch {
if (gen !== dirGenRef.current || !alive()) return; users = [];
setDirectoryResults([]);
setDirectoryLoading(false);
} }
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 // People already in a DM with us are reached through their existing
// room row, so drop them (and self) from the directory section to // 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] [directoryResults, mx, myUserId, mDirects]
); );
// Keyboard focus spans both sections: indices [0, roomCount) are // The typed-address card always renders at the TOP (it's the most
// rooms, [roomCount, roomCount + peopleCount) are people. // 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 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 const queryHighlightRegex = result?.query
? makeHighlightRegex(result.query.split(' ')) ? makeHighlightRegex(result.query.split(' '))
: undefined; : undefined;
// Per-user DM-in-flight marker so the clicked row can show a spinner // Clicking a person (directory hit or typed address) opens a small
// while `createRoom` round-trips (opening an existing DM is instant). // 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 [creatingUserId, setCreatingUserId] = useState<string | null>(null);
const startDmWithUser = useCallback( const [pendingDirect, setPendingDirect] = useState<PendingDirect | null>(null);
(userId: string) => {
setCreatingUserId(userId); const requestDirect = useCallback((target: PendingDirect) => {
createDirect(userId) 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) => { .then((roomId) => {
if (!alive()) return; if (!alive()) return;
setCreatingUserId(null); setCreatingUserId(null);
setPendingDirect(null);
onOpenRoomId(roomId, false); onOpenRoomId(roomId, false);
}) })
.catch(() => { .catch((err: unknown) => {
if (alive()) setCreatingUserId(null); 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( const handleInputChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(evt) => { (evt) => {
listFocus.reset(); listFocus.reset();
setDmError(null);
setQuery(evt.currentTarget.value); setQuery(evt.currentTarget.value);
const raw = evt.currentTarget.value.trim(); const raw = evt.currentTarget.value.trim();
// A full `@local:server` is treated as an exact MXID (directory // A deliberate full address (`@a:b`, `a:b`, `a@b`) becomes a
// term + profile fallback), NOT as the `@` directs-filter prefix — // federated profile preview, NOT the `@` directs-filter prefix —
// so pasting someone's id finds exactly them. // so pasting (or loosely typing) someone's address finds exactly
const exactMxid = isUserId(raw); // them. Bare `@name` (no server) stays the directs-filter prefix.
const parsedMxid = parseUserAddress(raw);
let value = raw; let value = raw;
let nextType: SearchRoomType | undefined; let nextType: SearchRoomType | undefined;
if (!exactMxid) { if (parsedMxid) {
const prefix = value.match(/^[#@*]/)?.[0]; // 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; nextType = prefix ? PREFIX_TO_TYPE[prefix] : undefined;
if (nextType) value = value.slice(1); if (nextType) value = raw.slice(1);
} }
setSearchRoomType(nextType); setSearchRoomType(nextType);
@ -263,25 +364,23 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) {
if (value === '') resetSearch(); if (value === '') resetSearch();
else search(value); else search(value);
// Directory people search — only when nothing scopes us away from // Directory people + address preview — only when nothing scopes us
// people (`#` rooms / `*` spaces suppress it). Bump the generation // away from people (`#` rooms / `*` spaces suppress it). Bump the
// so any in-flight request from a prior keystroke is discarded. // 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; dirGenRef.current += 1;
const directoryEnabled = nextType === undefined || nextType === SearchRoomType.Directs; const directoryEnabled = nextType === undefined || nextType === SearchRoomType.Directs;
const dirTerm = exactMxid ? raw : value; if (directoryEnabled && (parsedMxid || value.length >= DIRECTORY_MIN_CHARS)) {
if (
directoryEnabled &&
dirTerm !== '' &&
(exactMxid || dirTerm.length >= DIRECTORY_MIN_CHARS)
) {
setDirectoryLoading(true); setDirectoryLoading(true);
debouncedRunDirectory(dirTerm, exactMxid, dirGenRef.current); debouncedRunQuery(value, parsedMxid, dirGenRef.current);
} else { } else {
setDirectoryResults([]); setDirectoryResults([]);
setAddressPreview(null);
setDirectoryLoading(false); setDirectoryLoading(false);
} }
}, },
[listFocus, search, resetSearch, debouncedRunDirectory] [listFocus, search, resetSearch, debouncedRunQuery]
); );
// Wipe the whole query in one tap (the ✕ button): reset local search, // Wipe the whole query in one tap (the ✕ button): reset local search,
@ -293,7 +392,9 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) {
resetSearch(); resetSearch();
dirGenRef.current += 1; dirGenRef.current += 1;
setDirectoryResults([]); setDirectoryResults([]);
setAddressPreview(null);
setDirectoryLoading(false); setDirectoryLoading(false);
setDmError(null);
listFocus.reset(); listFocus.reset();
const input = inputRef.current; const input = inputRef.current;
if (input) { if (input) {
@ -306,12 +407,29 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) {
(evt) => { (evt) => {
if (isKeyHotkey('enter', evt)) { if (isKeyHotkey('enter', evt)) {
const { index } = listFocus; const { index } = listFocus;
if (index < roomCount) { if (addressActionable && addressPreview && index === addressIndex) {
const roomId = roomsToRender[index]; // 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)); if (roomId) onOpenRoomId(roomId, spaces.includes(roomId));
} else { } else {
const user = peopleToRender[index - roomCount]; const user = peopleToRender[ri - roomCount];
if (user) startDmWithUser(user.userId); if (user) {
requestDirect({
userId: user.userId,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
});
}
} }
return; return;
} }
@ -325,7 +443,19 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) {
listFocus.previous(); 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( const handleRoomClick: MouseEventHandler<HTMLButtonElement> = useCallback(
@ -339,15 +469,6 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) {
[onOpenRoomId] [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. // Auto-scroll the highlighted item into view as the user arrows.
useEffect(() => { useEffect(() => {
const scrollView = scrollRef.current; const scrollView = scrollRef.current;
@ -373,11 +494,25 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) {
// People (user directory) section: // People (user directory) section:
peopleToRender, peopleToRender,
directoryLoading, directoryLoading,
handleUserClick, // Open the confirm dialog for a person (directory hit or address card).
requestDirect,
creatingUserId, creatingUserId,
// Index in the flat focus list where the People section begins, so // Typed-address card (always rendered at the top) + whether it's
// consumers can map a user's array position to its focus index. // 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, roomCount,
roomIndexBase,
addressIndex,
// Per-row context the consumer needs to render rows: // Per-row context the consumer needs to render rows:
getRoom, getRoom,
mDirects, 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('@'); 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 isRoomId = (id: string): boolean => id.startsWith('!');
export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#'); export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#');