vojo/src/app/features/search/Search.tsx

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)} />;
}