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:
parent
c2f6baa712
commit
d1d2c68393
5 changed files with 1173 additions and 466 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { searchModalAtom } from '../../state/searchModal';
|
|||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { getDmUserId, useRoomSearch } from './useRoomSearch';
|
||||
import { StartDirectDialog } from './StartDirectDialog';
|
||||
|
||||
// Dawn hairline divider (the canon's rgba(255,255,255,0.06–0.08) row line).
|
||||
const HAIRLINE = '1px solid rgba(255, 255, 255, 0.08)';
|
||||
|
|
@ -72,9 +73,16 @@ export function Search({ requestClose }: SearchProps) {
|
|||
handleRoomClick,
|
||||
peopleToRender,
|
||||
directoryLoading,
|
||||
handleUserClick,
|
||||
requestDirect,
|
||||
creatingUserId,
|
||||
addressPreview,
|
||||
dmError,
|
||||
pendingDirect,
|
||||
confirmDirect,
|
||||
cancelDirect,
|
||||
roomCount,
|
||||
roomIndexBase,
|
||||
addressIndex,
|
||||
getRoom,
|
||||
mDirects,
|
||||
orphanSpaces,
|
||||
|
|
@ -83,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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
257
src/app/features/search/StartDirectDialog.tsx
Normal file
257
src/app/features/search/StartDirectDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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('#');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue