Add new `ctrl/cmd - k` search modal (#2467)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Wed, 27 Aug 2025 12:25:49 +0000 (17:55 +0530)
committerGitHub <noreply@github.com>
Wed, 27 Aug 2025 12:25:49 +0000 (22:25 +1000)
* add new search modal

* remove search modal from searchTab

* fix member avatar load for space with 2 member

* use media authentication when rendering avatar

* fix hotkey for macos

* add @ in username

* replace subspace minus separator with em dash

src/app/features/search/Search.tsx [new file with mode: 0644]
src/app/features/search/index.ts [new file with mode: 0644]
src/app/hooks/useListFocusIndex.ts [new file with mode: 0644]
src/app/organisms/pw/Dialogs.jsx
src/app/pages/Router.tsx
src/app/pages/client/SidebarNav.tsx
src/app/pages/client/sidebar/SearchTab.tsx [new file with mode: 0644]
src/app/pages/client/sidebar/index.ts
src/app/state/searchModal.ts [new file with mode: 0644]

diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx
new file mode 100644 (file)
index 0000000..dd96aef
--- /dev/null
@@ -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<HTMLDivElement>(null);
+  const inputRef = useRef<HTMLInputElement>(null);
+  const { navigateRoom, navigateSpace } = useRoomNavigate();
+  const roomToUnread = useAtomValue(roomToUnreadAtom);
+
+  const [searchRoomType, setSearchRoomType] = useState<SearchRoomType>();
+
+  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<string> = 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<HTMLInputElement> = (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<HTMLInputElement> = (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<HTMLButtonElement> = (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 (
+    <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="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 ? 'No Match Found' : `No Rooms'}`}
+                  </Text>
+                  <Text size="T200" align="Center">
+                    {result
+                      ? `No match found for "${result.query}".`
+                      : `You do not have any Rooms to display yet.`}
+                  </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, 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 (
+                        <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()}
+                                  space={room.isSpaceRoom()}
+                                />
+                              )}
+                            </Avatar>
+                          }
+                        >
+                          <Box grow="Yes" alignItems="Center" gap="100">
+                            <Text size="T400" truncate>
+                              {queryHighlighRegex
+                                ? highlightText(queryHighlighRegex, [room.name])
+                                : room.name}
+                            </Text>
+                            {dmUsername && (
+                              <Text as="span" size="T200" priority="300" truncate>
+                                @
+                                {queryHighlighRegex
+                                  ? highlightText(queryHighlighRegex, [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">
+                Type <b>#</b> for rooms, <b>@</b> for DMs and <b>*</b> for spaces. Hotkey:{' '}
+                <b>{isMacOS() ? KeySymbol.Command : 'Ctrl'} + k</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;
+          }
+
+          // 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 && <Search requestClose={() => setOpen(false)} />;
+}
diff --git a/src/app/features/search/index.ts b/src/app/features/search/index.ts
new file mode 100644 (file)
index 0000000..addd533
--- /dev/null
@@ -0,0 +1 @@
+export * from './Search';
diff --git a/src/app/hooks/useListFocusIndex.ts b/src/app/hooks/useListFocusIndex.ts
new file mode 100644 (file)
index 0000000..e4f3bf2
--- /dev/null
@@ -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,
+  };
+};
index 7fb18daa338d80f1fcb7678b4e822bc252280d15..56eb67946f618b167e271e58f02811af74751257 100644 (file)
@@ -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() {
       <CreateRoom />
       <JoinAlias />
       <SpaceAddExisting />
-      <Search />
 
       <ReusableDialog />
     </>
index 247885c0fb55d535eb5489130f96c082349a3102..4c0e2a4aa45c1839b2a7ca2adc03154675d4d6c8 100644 (file)
@@ -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)
                       >
                         <Outlet />
                       </ClientLayout>
+                      <SearchModalRenderer />
                       <UserRoomProfileRenderer />
                       <CreateRoomModalRenderer />
                       <CreateSpaceModalRenderer />
index 6139f1fed3c4320ef807f4fd0e9c8125401d1db8..a2e1b6820770b0c2952ba6e0d53b84f0f0fe4e0e 100644 (file)
@@ -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() {
           <>
             <SidebarStackSeparator />
             <SidebarStack>
-              <SidebarItem>
-                <SidebarItemTooltip tooltip="Search">
-                  {(triggerRef) => (
-                    <SidebarAvatar
-                      as="button"
-                      ref={triggerRef}
-                      outlined
-                      onClick={() => openSearch()}
-                    >
-                      <Icon src={Icons.Search} />
-                    </SidebarAvatar>
-                  )}
-                </SidebarItemTooltip>
-              </SidebarItem>
-
+              <SearchTab />
               <UnverifiedTab />
-
               <InboxTab />
               <SettingsTab />
             </SidebarStack>
diff --git a/src/app/pages/client/sidebar/SearchTab.tsx b/src/app/pages/client/sidebar/SearchTab.tsx
new file mode 100644 (file)
index 0000000..7ceb5c4
--- /dev/null
@@ -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 (
+    <SidebarItem active={opened}>
+      <SidebarItemTooltip tooltip="Search">
+        {(triggerRef) => (
+          <SidebarAvatar as="button" ref={triggerRef} outlined onClick={open}>
+            <Icon src={Icons.Search} filled={opened} />
+          </SidebarAvatar>
+        )}
+      </SidebarItemTooltip>
+    </SidebarItem>
+  );
+}
index 6460d1f27dc45113d0bdca106ea8c8b46934cb5b..d44cfaa26e62412b30a14fb942134157536a42a0 100644 (file)
@@ -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 (file)
index 0000000..3893e5e
--- /dev/null
@@ -0,0 +1,3 @@
+import { atom } from 'jotai';
+
+export const searchModalAtom = atom<boolean>(false);