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 { 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'} />
|
|
||||||
) : (
|
|
||||||
server && (
|
|
||||||
<Text size="T200" priority="300" truncate>
|
<Text size="T200" priority="300" truncate>
|
||||||
<b>{server}</b>
|
<b>{server}</b>
|
||||||
</Text>
|
</Text>
|
||||||
)
|
|
||||||
)}
|
|
||||||
</Box>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.06–0.08) row line).
|
// 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)';
|
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,9 +91,145 @@ 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>
|
<Overlay open>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
|
|
@ -184,26 +328,26 @@ export function Search({ requestClose }: SearchProps) {
|
||||||
<Text size="H6" align="Center">
|
<Text size="H6" align="Center">
|
||||||
{result ? t('Search.no_match_found') : t('Search.no_rooms')}
|
{result ? t('Search.no_match_found') : t('Search.no_rooms')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T200" align="Center">
|
<Text size="T200" align="Center" style={{ maxWidth: toRem(300) }}>
|
||||||
{result
|
{result
|
||||||
? 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>
|
||||||
{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. */}
|
||||||
|
{addressCard}
|
||||||
{roomsToRender.map((roomId, index) => {
|
{roomsToRender.map((roomId, index) => {
|
||||||
const room = getRoom(roomId);
|
const room = getRoom(roomId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
@ -225,13 +369,14 @@ export function Search({ requestClose }: SearchProps) {
|
||||||
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}
|
||||||
|
|
@ -339,35 +484,43 @@ export function Search({ requestClose }: SearchProps) {
|
||||||
{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,
|
||||||
|
user.avatarUrl,
|
||||||
|
useAuthentication,
|
||||||
|
32,
|
||||||
|
32,
|
||||||
|
'crop'
|
||||||
|
) ?? undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
const creating = creatingUserId === user.userId;
|
|
||||||
|
|
||||||
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({
|
||||||
|
userId: user.userId,
|
||||||
|
displayName: user.displayName,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
variant="Surface"
|
variant="Surface"
|
||||||
aria-pressed={focused}
|
aria-pressed={focused}
|
||||||
radii="300"
|
radii="300"
|
||||||
disabled={creating}
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: focused ? 'rgba(255, 255, 255, 0.06)' : undefined,
|
backgroundColor: focused
|
||||||
|
? 'rgba(255, 255, 255, 0.06)'
|
||||||
|
: undefined,
|
||||||
}}
|
}}
|
||||||
after={
|
after={
|
||||||
creating ? (
|
|
||||||
<Spinner size="100" variant="Secondary" />
|
|
||||||
) : (
|
|
||||||
server && (
|
server && (
|
||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
|
|
@ -378,7 +531,6 @@ export function Search({ requestClose }: SearchProps) {
|
||||||
{server}
|
{server}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
before={
|
before={
|
||||||
<Avatar size="200" radii="400">
|
<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>
|
</div>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
)}
|
)}
|
||||||
|
|
@ -458,6 +595,17 @@ export function Search({ requestClose }: SearchProps) {
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
</OverlayCenter>
|
</OverlayCenter>
|
||||||
</Overlay>
|
</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,
|
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 {
|
||||||
|
if (term) {
|
||||||
const { results } = await mx.searchUserDirectory({ term, limit: DIRECTORY_LIMIT });
|
const { results } = await mx.searchUserDirectory({ term, limit: DIRECTORY_LIMIT });
|
||||||
let users: RoomSearchUser[] = results.map((r) => ({
|
users = results.map((r) => ({
|
||||||
userId: r.user_id,
|
userId: r.user_id,
|
||||||
displayName: r.display_name,
|
displayName: r.display_name,
|
||||||
avatarUrl: r.avatar_url,
|
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 {
|
} 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;
|
if (gen !== dirGenRef.current || !alive()) return;
|
||||||
setDirectoryResults(users);
|
setDirectoryResults(users);
|
||||||
|
setAddressPreview(preview);
|
||||||
setDirectoryLoading(false);
|
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
|
// 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,
|
||||||
|
|
|
||||||
|
|
@ -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('#');
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue