diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx new file mode 100644 index 00000000..dd96aef8 --- /dev/null +++ b/src/app/features/search/Search.tsx @@ -0,0 +1,454 @@ +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, { + ChangeEventHandler, + KeyboardEventHandler, + MouseEventHandler, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { isKeyHotkey } from 'is-hotkey'; +import { useAtom, useAtomValue } from 'jotai'; +import { Room } from 'matrix-js-sdk'; +import { useDirects, useOrphanSpaces, useRooms, useSpaces } from '../../state/hooks/roomList'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { mDirectAtom } from '../../state/mDirectList'; +import { allRoomsAtom } from '../../state/room-list/roomList'; +import { + SearchItemStrGetter, + useAsyncSearch, + UseAsyncSearchOptions, +} from '../../hooks/useAsyncSearch'; +import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; +import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; +import { + getAllParents, + getDirectRoomAvatarUrl, + getRoomAvatarUrl, + guessPerfectParent, +} from '../../utils/room'; +import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser'; +import { factoryRoomIdByActivity } from '../../utils/sort'; +import { nameInitials } from '../../utils/common'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; +import { useListFocusIndex } from '../../hooks/useListFocusIndex'; +import { getMxIdLocalPart, getMxIdServer, guessDmRoomUserId } from '../../utils/matrix'; +import { roomToParentsAtom } from '../../state/room/roomToParents'; +import { roomToUnreadAtom } from '../../state/room/roomToUnread'; +import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; +import { searchModalAtom } from '../../state/searchModal'; +import { useKeyDown } from '../../hooks/useKeyDown'; +import navigation from '../../../client/state/navigation'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { KeySymbol } from '../../utils/key-symbol'; +import { isMacOS } from '../../utils/user-agent'; + +enum SearchRoomType { + Rooms = '#', + Spaces = '*', + Directs = '@', +} + +const getSearchPrefixToRoomType = (prefix: string): SearchRoomType | undefined => { + if (prefix === '#') return SearchRoomType.Rooms; + if (prefix === '*') return SearchRoomType.Spaces; + if (prefix === '@') return SearchRoomType.Directs; + return undefined; +}; + +const useTopActiveRooms = ( + searchRoomType: SearchRoomType | undefined, + rooms: string[], + directs: string[], + spaces: string[] +) => { + const mx = useMatrixClient(); + + return useMemo(() => { + if (searchRoomType === SearchRoomType.Spaces) { + return spaces; + } + if (searchRoomType === SearchRoomType.Directs) { + return [...directs].sort(factoryRoomIdByActivity(mx)).slice(0, 20); + } + if (searchRoomType === SearchRoomType.Rooms) { + return [...rooms].sort(factoryRoomIdByActivity(mx)).slice(0, 20); + } + return [...rooms, ...directs].sort(factoryRoomIdByActivity(mx)).slice(0, 20); + }, [mx, rooms, directs, spaces, searchRoomType]); +}; + +const getDmUserId = ( + roomId: string, + getRoom: (roomId: string) => Room | undefined, + myUserId: string +): string | undefined => { + const room = getRoom(roomId); + const targetUserId = room && guessDmRoomUserId(room, myUserId); + return targetUserId; +}; + +const useSearchTargetRooms = ( + searchRoomType: SearchRoomType | undefined, + rooms: string[], + directs: string[], + spaces: string[] +) => + useMemo(() => { + if (searchRoomType === undefined) { + return [...rooms, ...directs, ...spaces]; + } + if (searchRoomType === SearchRoomType.Rooms) return rooms; + if (searchRoomType === SearchRoomType.Spaces) return spaces; + if (searchRoomType === SearchRoomType.Directs) return directs; + + return []; + }, [rooms, spaces, directs, searchRoomType]); + +const SEARCH_OPTIONS: UseAsyncSearchOptions = { + matchOptions: { + contain: true, + }, + normalizeOptions: { + ignoreWhitespace: false, + }, +}; + +type SearchProps = { + requestClose: () => void; +}; +export function Search({ requestClose }: SearchProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const scrollRef = useRef(null); + const inputRef = useRef(null); + const { navigateRoom, navigateSpace } = useRoomNavigate(); + const roomToUnread = useAtomValue(roomToUnreadAtom); + + const [searchRoomType, setSearchRoomType] = useState(); + + const allRoomsSet = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allRoomsSet); + + const roomToParents = useAtomValue(roomToParentsAtom); + const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents); + const mDirects = useAtomValue(mDirectAtom); + const rooms = useRooms(mx, allRoomsAtom, mDirects); + const spaces = useSpaces(mx, allRoomsAtom); + const directs = useDirects(mx, allRoomsAtom, mDirects); + + const topActiveRooms = useTopActiveRooms(searchRoomType, rooms, directs, spaces); + const targetRooms = useSearchTargetRooms(searchRoomType, rooms, directs, spaces); + + const getTargetStr: SearchItemStrGetter = useCallback( + (roomId: string) => { + const roomName = getRoom(roomId)?.name ?? roomId; + if (mDirects.has(roomId)) { + const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId()); + const targetUsername = targetUserId && getMxIdLocalPart(targetUserId); + if (targetUsername) return [roomName, targetUsername]; + } + return roomName; + }, + [getRoom, mDirects, mx] + ); + + const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS); + const roomsToRender = result ? result.items : topActiveRooms; + const listFocus = useListFocusIndex(roomsToRender.length, 0); + + const queryHighlighRegex = result?.query + ? makeHighlightRegex(result.query.split(' ')) + : undefined; + + const openRoomId = (roomId: string, isSpace: boolean) => { + if (isSpace) navigateSpace(roomId); + else navigateRoom(roomId); + requestClose(); + }; + + const handleInputChange: ChangeEventHandler = (evt) => { + listFocus.reset(); + + const target = evt.currentTarget; + let value = target.value.trim(); + const prefix = value.match(/^[#@*]/)?.[0]; + const searchType = typeof prefix === 'string' && getSearchPrefixToRoomType(prefix); + if (searchType) { + value = value.slice(1); + setSearchRoomType(searchType); + } else { + setSearchRoomType(undefined); + } + + if (value === '') { + resetSearch(); + return; + } + search(value); + }; + + const handleInputKeyDown: KeyboardEventHandler = (evt) => { + const roomId = roomsToRender[listFocus.index]; + if (isKeyHotkey('enter', evt) && roomId) { + openRoomId(roomId, spaces.includes(roomId)); + return; + } + if (isKeyHotkey('arrowdown', evt)) { + evt.preventDefault(); + listFocus.next(); + return; + } + if (isKeyHotkey('arrowup', evt)) { + evt.preventDefault(); + listFocus.previous(); + } + }; + + const handleRoomClick: MouseEventHandler = (evt) => { + const target = evt.currentTarget; + const roomId = target.getAttribute('data-room-id'); + const isSpace = target.getAttribute('data-space') === 'true'; + if (!roomId) return; + openRoomId(roomId, isSpace); + }; + + useEffect(() => { + const scrollView = scrollRef.current; + const focusedItem = scrollView?.querySelector(`[data-focus-index="${listFocus.index}"]`); + + if (focusedItem && scrollView) { + focusedItem.scrollIntoView({ + block: 'center', + }); + } + }, [listFocus.index]); + + return ( + + + inputRef.current, + returnFocusOnDeactivate: false, + allowOutsideClick: true, + clickOutsideDeactivates: true, + onDeactivate: requestClose, + escapeDeactivates: (evt) => { + evt.stopPropagation(); + return true; + }, + }} + > + + + } + onChange={handleInputChange} + onKeyDown={handleInputKeyDown} + /> + + + {roomsToRender.length === 0 && ( + + + {result ? 'No Match Found' : `No Rooms'}`} + + + {result + ? `No match found for "${result.query}".` + : `You do not have any Rooms to display yet.`} + + + )} + {roomsToRender.length > 0 && ( + +
+ {roomsToRender.map((roomId, index) => { + const room = getRoom(roomId); + if (!room) return null; + + const dm = mDirects.has(roomId); + const dmUserId = dm && getDmUserId(roomId, getRoom, mx.getSafeUserId()); + const dmUsername = dmUserId && getMxIdLocalPart(dmUserId); + const dmUserServer = dmUserId && getMxIdServer(dmUserId); + + const allParents = getAllParents(roomToParents, roomId); + const orphanParents = + allParents && orphanSpaces.filter((o) => allParents.has(o)); + 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 ( + + {dmUserServer && ( + + {dmUserServer} + + )} + {!dm && perfectOrphanParent && ( + + {getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent} + + )} + {unread && ( + + 0} + count={unread.total} + /> + + )} + + } + before={ + + {dm || room.isSpaceRoom() ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + + )} + + } + > + + + {queryHighlighRegex + ? highlightText(queryHighlighRegex, [room.name]) + : room.name} + + {dmUsername && ( + + @ + {queryHighlighRegex + ? highlightText(queryHighlighRegex, [dmUsername]) + : dmUsername} + + )} + {!dm && perfectParent && perfectParent !== perfectOrphanParent && ( + + — {getRoom(perfectParent)?.name ?? perfectParent} + + )} + + + ); + })} +
+
+ )} +
+ + + + Type # for rooms, @ for DMs and * for spaces. Hotkey:{' '} + {isMacOS() ? KeySymbol.Command : 'Ctrl'} + k + + +
+
+
+
+ ); +} + +export function SearchModalRenderer() { + const [opened, setOpen] = useAtom(searchModalAtom); + + useKeyDown( + window, + useCallback( + (event) => { + if (isKeyHotkey('mod+k', event)) { + event.preventDefault(); + if (opened) { + setOpen(false); + return; + } + + // means some menu or modal window is open + const { lastChild } = document.body; + if ( + (lastChild && 'className' in lastChild && lastChild.className !== 'ReactModalPortal') || + navigation.isRawModalVisible + ) { + return; + } + setOpen(true); + } + }, + [opened, setOpen] + ) + ); + + return opened && setOpen(false)} />; +} diff --git a/src/app/features/search/index.ts b/src/app/features/search/index.ts new file mode 100644 index 00000000..addd5330 --- /dev/null +++ b/src/app/features/search/index.ts @@ -0,0 +1 @@ +export * from './Search'; diff --git a/src/app/hooks/useListFocusIndex.ts b/src/app/hooks/useListFocusIndex.ts new file mode 100644 index 00000000..e4f3bf2c --- /dev/null +++ b/src/app/hooks/useListFocusIndex.ts @@ -0,0 +1,36 @@ +import { useCallback, useState } from 'react'; + +export const useListFocusIndex = (size: number, initialIndex: number) => { + const [index, setIndex] = useState(initialIndex); + + const next = useCallback(() => { + setIndex((i) => { + const nextIndex = i + 1; + if (nextIndex >= size) { + return 0; + } + return nextIndex; + }); + }, [size]); + + const previous = useCallback(() => { + setIndex((i) => { + const previousIndex = i - 1; + if (previousIndex < 0) { + return size - 1; + } + return previousIndex; + }); + }, [size]); + + const reset = useCallback(() => { + setIndex(initialIndex); + }, [initialIndex]); + + return { + index, + next, + previous, + reset, + }; +}; diff --git a/src/app/organisms/pw/Dialogs.jsx b/src/app/organisms/pw/Dialogs.jsx index 7fb18daa..56eb6794 100644 --- a/src/app/organisms/pw/Dialogs.jsx +++ b/src/app/organisms/pw/Dialogs.jsx @@ -2,7 +2,6 @@ import React from 'react'; import ProfileViewer from '../profile-viewer/ProfileViewer'; import SpaceAddExisting from '../../molecules/space-add-existing/SpaceAddExisting'; -import Search from '../search/Search'; import CreateRoom from '../create-room/CreateRoom'; import JoinAlias from '../join-alias/JoinAlias'; @@ -15,7 +14,6 @@ function Dialogs() { - diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 247885c0..4c0e2a4a 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -67,6 +67,7 @@ import { CreateRoomModalRenderer } from '../features/create-room'; import { HomeCreateRoom } from './client/home/CreateRoom'; import { Create } from './client/create'; import { CreateSpaceModalRenderer } from '../features/create-space'; +import { SearchModalRenderer } from '../features/search'; export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { const { hashRouter } = clientConfig; @@ -131,6 +132,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) > + diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index 6139f1fe..a2e1b682 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -1,14 +1,11 @@ import React, { useRef } from 'react'; -import { Icon, Icons, Scroll } from 'folds'; +import { Scroll } from 'folds'; import { Sidebar, SidebarContent, SidebarStackSeparator, SidebarStack, - SidebarAvatar, - SidebarItemTooltip, - SidebarItem, } from '../../components/sidebar'; import { DirectTab, @@ -18,8 +15,8 @@ import { ExploreTab, SettingsTab, UnverifiedTab, + SearchTab, } from './sidebar'; -import { openSearch } from '../../../client/action/navigation'; import { CreateTab } from './sidebar/CreateTab'; export function SidebarNav() { @@ -46,23 +43,8 @@ export function SidebarNav() { <> - - - {(triggerRef) => ( - openSearch()} - > - - - )} - - - + - diff --git a/src/app/pages/client/sidebar/SearchTab.tsx b/src/app/pages/client/sidebar/SearchTab.tsx new file mode 100644 index 00000000..7ceb5c49 --- /dev/null +++ b/src/app/pages/client/sidebar/SearchTab.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Icon, Icons } from 'folds'; +import { useAtom } from 'jotai'; +import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; +import { searchModalAtom } from '../../../state/searchModal'; + +export function SearchTab() { + const [opened, setOpen] = useAtom(searchModalAtom); + + const open = () => setOpen(true); + + return ( + + + {(triggerRef) => ( + + + + )} + + + ); +} diff --git a/src/app/pages/client/sidebar/index.ts b/src/app/pages/client/sidebar/index.ts index 6460d1f2..d44cfaa2 100644 --- a/src/app/pages/client/sidebar/index.ts +++ b/src/app/pages/client/sidebar/index.ts @@ -5,3 +5,4 @@ export * from './InboxTab'; export * from './ExploreTab'; export * from './SettingsTab'; export * from './UnverifiedTab'; +export * from './SearchTab'; diff --git a/src/app/state/searchModal.ts b/src/app/state/searchModal.ts new file mode 100644 index 00000000..3893e5e3 --- /dev/null +++ b/src/app/state/searchModal.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai'; + +export const searchModalAtom = atom(false);