287 lines
11 KiB
TypeScript
287 lines
11 KiB
TypeScript
import FocusTrap from 'focus-trap-react';
|
|
import {
|
|
Avatar,
|
|
Box,
|
|
config,
|
|
Icon,
|
|
Icons,
|
|
Input,
|
|
Line,
|
|
MenuItem,
|
|
Modal,
|
|
Overlay,
|
|
OverlayCenter,
|
|
Scroll,
|
|
Text,
|
|
toRem,
|
|
} from 'folds';
|
|
import React, { useCallback } from 'react';
|
|
import { Trans, useTranslation } from 'react-i18next';
|
|
import { isKeyHotkey } from 'is-hotkey';
|
|
import { useAtom } from 'jotai';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
|
import {
|
|
getAllParents,
|
|
getDirectRoomAvatarUrl,
|
|
getRoomAvatarUrl,
|
|
guessPerfectParent,
|
|
} from '../../utils/room';
|
|
import { highlightText } from '../../plugins/react-custom-html-parser';
|
|
import { nameInitials } from '../../utils/common';
|
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
|
import { getMxIdLocalPart, getMxIdServer } from '../../utils/matrix';
|
|
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
|
import { searchModalAtom } from '../../state/searchModal';
|
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|
import { KeySymbol } from '../../utils/key-symbol';
|
|
import { isMacOS } from '../../utils/user-agent';
|
|
import { getDmUserId, useRoomSearch } from './useRoomSearch';
|
|
|
|
type SearchProps = {
|
|
requestClose: () => void;
|
|
};
|
|
export function Search({ requestClose }: SearchProps) {
|
|
const { t } = useTranslation();
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
|
|
|
const openRoomId = useCallback(
|
|
(roomId: string, isSpace: boolean) => {
|
|
if (isSpace) navigateSpace(roomId);
|
|
else navigateRoom(roomId);
|
|
requestClose();
|
|
},
|
|
[navigateRoom, navigateSpace, requestClose]
|
|
);
|
|
|
|
const {
|
|
inputRef,
|
|
scrollRef,
|
|
roomsToRender,
|
|
result,
|
|
listFocus,
|
|
queryHighlightRegex,
|
|
handleInputChange,
|
|
handleInputKeyDown,
|
|
handleRoomClick,
|
|
getRoom,
|
|
mDirects,
|
|
orphanSpaces,
|
|
roomToParents,
|
|
roomToUnread,
|
|
myUserId,
|
|
} = useRoomSearch({ onOpenRoomId: openRoomId });
|
|
|
|
return (
|
|
<Overlay open>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: () => inputRef.current,
|
|
returnFocusOnDeactivate: false,
|
|
allowOutsideClick: true,
|
|
clickOutsideDeactivates: true,
|
|
onDeactivate: requestClose,
|
|
escapeDeactivates: (evt) => {
|
|
evt.stopPropagation();
|
|
return true;
|
|
},
|
|
}}
|
|
>
|
|
<Modal size="400" style={{ maxHeight: toRem(400), borderRadius: config.radii.R500 }}>
|
|
<Box
|
|
shrink="No"
|
|
style={{ padding: config.space.S400, paddingBottom: 0 }}
|
|
direction="Column"
|
|
>
|
|
<Input
|
|
ref={inputRef}
|
|
size="500"
|
|
variant="Background"
|
|
radii="400"
|
|
outlined
|
|
placeholder={t('Search.search')}
|
|
before={<Icon size="200" src={Icons.Search} />}
|
|
onChange={handleInputChange}
|
|
onKeyDown={handleInputKeyDown}
|
|
/>
|
|
</Box>
|
|
<Box grow="Yes">
|
|
{roomsToRender.length === 0 && (
|
|
<Box
|
|
style={{ paddingTop: config.space.S700 }}
|
|
grow="Yes"
|
|
alignItems="Center"
|
|
justifyContent="Center"
|
|
direction="Column"
|
|
gap="100"
|
|
>
|
|
<Text size="H6" align="Center">
|
|
{result ? t('Search.no_match_found') : t('Search.no_rooms')}
|
|
</Text>
|
|
<Text size="T200" align="Center">
|
|
{result
|
|
? t('Search.no_match_for_query', { query: result.query })
|
|
: t('Search.no_rooms_to_display')}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
{roomsToRender.length > 0 && (
|
|
<Scroll ref={scrollRef} size="300" hideTrack>
|
|
<div style={{ padding: config.space.S400, paddingRight: config.space.S200 }}>
|
|
{roomsToRender.map((roomId, index) => {
|
|
const room = getRoom(roomId);
|
|
if (!room) return null;
|
|
|
|
const dm = mDirects.has(roomId);
|
|
const dmUserId = dm ? getDmUserId(roomId, getRoom, myUserId) : undefined;
|
|
const dmUsername = dmUserId ? getMxIdLocalPart(dmUserId) : undefined;
|
|
const dmUserServer = dmUserId ? getMxIdServer(dmUserId) : undefined;
|
|
|
|
const allParents = getAllParents(roomToParents, roomId);
|
|
const orphanParents = allParents
|
|
? orphanSpaces.filter((o) => allParents.has(o))
|
|
: undefined;
|
|
const perfectOrphanParent =
|
|
orphanParents && guessPerfectParent(mx, roomId, orphanParents);
|
|
|
|
const exactParents = roomToParents.get(roomId);
|
|
const perfectParent =
|
|
exactParents && guessPerfectParent(mx, roomId, Array.from(exactParents));
|
|
|
|
const unread = roomToUnread.get(roomId);
|
|
|
|
return (
|
|
<MenuItem
|
|
key={roomId}
|
|
as="button"
|
|
data-focus-index={index}
|
|
data-room-id={roomId}
|
|
data-space={room.isSpaceRoom()}
|
|
onClick={handleRoomClick}
|
|
variant={listFocus.index === index ? 'Primary' : 'Surface'}
|
|
aria-pressed={listFocus.index === index}
|
|
radii="400"
|
|
after={
|
|
<Box gap="100">
|
|
{dmUserServer && (
|
|
<Text size="T200" priority="300" truncate>
|
|
<b>{dmUserServer}</b>
|
|
</Text>
|
|
)}
|
|
{!dm && perfectOrphanParent && (
|
|
<Text size="T200" priority="300" truncate>
|
|
<b>{getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent}</b>
|
|
</Text>
|
|
)}
|
|
{unread && (
|
|
<UnreadBadgeCenter>
|
|
<UnreadBadge
|
|
highlight={unread.highlight > 0}
|
|
count={unread.total}
|
|
/>
|
|
</UnreadBadgeCenter>
|
|
)}
|
|
</Box>
|
|
}
|
|
before={
|
|
<Avatar size="200" radii={dm ? '400' : '300'}>
|
|
{dm || room.isSpaceRoom() ? (
|
|
<RoomAvatar
|
|
roomId={room.roomId}
|
|
src={
|
|
dm
|
|
? getDirectRoomAvatarUrl(mx, room, 32, useAuthentication)
|
|
: getRoomAvatarUrl(mx, room, 32, useAuthentication)
|
|
}
|
|
alt={room.name}
|
|
renderFallback={() => (
|
|
<Text as="span" size="H6">
|
|
{nameInitials(room.name)}
|
|
</Text>
|
|
)}
|
|
/>
|
|
) : (
|
|
<RoomIcon
|
|
size="100"
|
|
joinRule={room.getJoinRule()}
|
|
roomType={room.getType()}
|
|
/>
|
|
)}
|
|
</Avatar>
|
|
}
|
|
>
|
|
<Box grow="Yes" alignItems="Center" gap="100">
|
|
<Text size="T400" truncate>
|
|
{queryHighlightRegex
|
|
? highlightText(queryHighlightRegex, [room.name])
|
|
: room.name}
|
|
</Text>
|
|
{dmUsername && (
|
|
<Text as="span" size="T200" priority="300" truncate>
|
|
@
|
|
{queryHighlightRegex
|
|
? highlightText(queryHighlightRegex, [dmUsername])
|
|
: dmUsername}
|
|
</Text>
|
|
)}
|
|
{!dm && perfectParent && perfectParent !== perfectOrphanParent && (
|
|
<Text size="T200" priority="300" truncate>
|
|
— {getRoom(perfectParent)?.name ?? perfectParent}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</MenuItem>
|
|
);
|
|
})}
|
|
</div>
|
|
</Scroll>
|
|
)}
|
|
</Box>
|
|
<Line size="300" />
|
|
<Box shrink="No" justifyContent="Center" style={{ padding: config.space.S200 }}>
|
|
<Text size="T200" priority="300">
|
|
<Trans
|
|
i18nKey="Search.help_text"
|
|
values={{ hotkey: `${isMacOS() ? KeySymbol.Command : 'Ctrl'} + k` }}
|
|
components={{ bold: <b /> }}
|
|
/>
|
|
</Text>
|
|
</Box>
|
|
</Modal>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
);
|
|
}
|
|
|
|
export function SearchModalRenderer() {
|
|
const [opened, setOpen] = useAtom(searchModalAtom);
|
|
|
|
useKeyDown(
|
|
window,
|
|
useCallback(
|
|
(event) => {
|
|
if (isKeyHotkey('mod+k', event)) {
|
|
event.preventDefault();
|
|
if (opened) {
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
|
|
const portalContainer = document.getElementById('portalContainer');
|
|
if (portalContainer && portalContainer.children.length > 0) {
|
|
return;
|
|
}
|
|
setOpen(true);
|
|
}
|
|
},
|
|
[opened, setOpen]
|
|
)
|
|
);
|
|
|
|
return opened && <Search requestClose={() => setOpen(false)} />;
|
|
}
|