New add existing room/space modal (#2451)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Tue, 19 Aug 2025 12:39:31 +0000 (18:09 +0530)
committerGitHub <noreply@github.com>
Tue, 19 Aug 2025 12:39:31 +0000 (22:39 +1000)
src/app/components/create-room/AdditionalCreatorInput.tsx
src/app/features/add-existing/AddExisting.tsx [new file with mode: 0644]
src/app/features/add-existing/index.ts [new file with mode: 0644]
src/app/features/create-room/CreateRoomModal.tsx
src/app/features/lobby/SpaceItem.tsx

index 51334b49017c800508423114ddf2140cd2db22d2..936b9b94431b959318d13315fbc1be026bb39641 100644 (file)
@@ -30,9 +30,7 @@ import { SettingTile } from '../setting-tile';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { stopPropagation } from '../../utils/keyboard';
 import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
-import { findAndReplace } from '../../utils/findAndReplace';
-import { highlightText } from '../../styles/CustomHtml.css';
-import { makeHighlightRegex } from '../../plugins/react-custom-html-parser';
+import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser';
 
 export const useAdditionalCreators = (defaultCreators?: string[]) => {
   const mx = useMatrixClient();
@@ -245,19 +243,9 @@ export function AdditionalCreatorInput({
                                   <Text size="T200" truncate>
                                     <b>
                                       {queryHighlighRegex
-                                        ? findAndReplace(
+                                        ? highlightText(queryHighlighRegex, [
                                             getMxIdLocalPart(userId) ?? userId,
-                                            queryHighlighRegex,
-                                            (match, pushIndex) => (
-                                              <span
-                                                key={`highlight-${pushIndex}`}
-                                                className={highlightText}
-                                              >
-                                                {match[0]}
-                                              </span>
-                                            ),
-                                            (txt) => txt
-                                          )
+                                          ])
                                         : getMxIdLocalPart(userId)}
                                     </b>
                                   </Text>
diff --git a/src/app/features/add-existing/AddExisting.tsx b/src/app/features/add-existing/AddExisting.tsx
new file mode 100644 (file)
index 0000000..cbae018
--- /dev/null
@@ -0,0 +1,375 @@
+import FocusTrap from 'focus-trap-react';
+import {
+  Avatar,
+  Box,
+  Button,
+  config,
+  Header,
+  Icon,
+  IconButton,
+  Icons,
+  Input,
+  Menu,
+  MenuItem,
+  Modal,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Scroll,
+  Spinner,
+  Text,
+} from 'folds';
+import React, {
+  ChangeEventHandler,
+  MouseEventHandler,
+  useCallback,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+import { useAtomValue } from 'jotai';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { Room } from 'matrix-js-sdk';
+import { stopPropagation } from '../../utils/keyboard';
+import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { allRoomsAtom } from '../../state/room-list/roomList';
+import { mDirectAtom } from '../../state/mDirectList';
+import { roomToParentsAtom } from '../../state/room/roomToParents';
+import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
+import { VirtualTile } from '../../components/virtualizer';
+import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
+import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
+import { nameInitials } from '../../utils/common';
+import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
+import { factoryRoomIdByAtoZ } from '../../utils/sort';
+import {
+  SearchItemStrGetter,
+  useAsyncSearch,
+  UseAsyncSearchOptions,
+} from '../../hooks/useAsyncSearch';
+import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { StateEvent } from '../../../types/matrix/room';
+import { getViaServers } from '../../plugins/via-servers';
+import { rateLimitedActions } from '../../utils/matrix';
+import { useAlive } from '../../hooks/useAlive';
+
+const SEARCH_OPTS: UseAsyncSearchOptions = {
+  limit: 500,
+  matchOptions: {
+    contain: true,
+  },
+  normalizeOptions: {
+    ignoreWhitespace: false,
+  },
+};
+
+type AddExistingModalProps = {
+  parentId: string;
+  space?: boolean;
+  requestClose: () => void;
+};
+export function AddExistingModal({ parentId, space, requestClose }: AddExistingModalProps) {
+  const mx = useMatrixClient();
+  const useAuthentication = useMediaAuthentication();
+  const alive = useAlive();
+
+  const mDirects = useAtomValue(mDirectAtom);
+  const spaces = useSpaces(mx, allRoomsAtom);
+  const rooms = useRooms(mx, allRoomsAtom, mDirects);
+  const directs = useDirects(mx, allRoomsAtom, mDirects);
+  const roomIdToParents = useAtomValue(roomToParentsAtom);
+  const scrollRef = useRef<HTMLDivElement>(null);
+
+  const [selected, setSelected] = useState<string[]>([]);
+
+  const allRoomsSet = useAllJoinedRoomsSet();
+  const getRoom = useGetRoom(allRoomsSet);
+
+  const allItems: string[] = useMemo(() => {
+    const rIds = space ? [...spaces] : [...rooms, ...directs];
+
+    return rIds
+      .filter((rId) => rId !== parentId && !roomIdToParents.get(rId)?.has(parentId))
+      .sort(factoryRoomIdByAtoZ(mx));
+  }, [spaces, rooms, directs, space, parentId, roomIdToParents, mx]);
+
+  const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
+    (rId) => getRoom(rId)?.name ?? rId,
+    [getRoom]
+  );
+
+  const [searchResult, searchRoom, resetSearch] = useAsyncSearch(
+    allItems,
+    getRoomNameStr,
+    SEARCH_OPTS
+  );
+  const queryHighlighRegex = searchResult?.query
+    ? makeHighlightRegex(searchResult.query.split(' '))
+    : undefined;
+
+  const items = searchResult ? searchResult.items : allItems;
+
+  const virtualizer = useVirtualizer({
+    count: items.length,
+    getScrollElement: () => scrollRef.current,
+    estimateSize: () => 32,
+    overscan: 5,
+  });
+  const vItems = virtualizer.getVirtualItems();
+
+  const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
+    const value = evt.currentTarget.value.trim();
+    if (!value) {
+      resetSearch();
+      return;
+    }
+    searchRoom(value);
+  };
+
+  const [applyState, applyChanges] = useAsyncCallback<undefined, Error, [Room[]]>(
+    useCallback(
+      async (selectedRooms) => {
+        await rateLimitedActions(selectedRooms, async (room) => {
+          const via = getViaServers(room);
+
+          await mx.sendStateEvent(
+            parentId,
+            StateEvent.SpaceChild as any,
+            {
+              auto_join: false,
+              suggested: false,
+              via,
+            },
+            room.roomId
+          );
+        });
+      },
+      [mx, parentId]
+    )
+  );
+  const applyingChanges = applyState.status === AsyncStatus.Loading;
+
+  const handleRoomClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+    const roomId = evt.currentTarget.getAttribute('data-room-id');
+    if (!roomId) return;
+    if (selected?.includes(roomId)) {
+      setSelected(selected?.filter((rId) => rId !== roomId));
+      return;
+    }
+    const addedRooms = [...(selected ?? [])];
+    addedRooms.push(roomId);
+    setSelected(addedRooms);
+  };
+
+  const handleApplyChanges = () => {
+    const selectedRooms = selected.map((rId) => getRoom(rId)).filter((room) => room !== undefined);
+    applyChanges(selectedRooms).then(() => {
+      if (alive()) {
+        setSelected([]);
+        requestClose();
+      }
+    });
+  };
+
+  const resetChanges = () => {
+    setSelected([]);
+  };
+
+  return (
+    <Overlay open backdrop={<OverlayBackdrop />}>
+      <OverlayCenter>
+        <FocusTrap
+          focusTrapOptions={{
+            initialFocus: false,
+            clickOutsideDeactivates: true,
+            onDeactivate: requestClose,
+            escapeDeactivates: stopPropagation,
+          }}
+        >
+          <Modal size="300">
+            <Box grow="Yes" direction="Column">
+              <Header
+                size="500"
+                style={{
+                  padding: config.space.S200,
+                  paddingLeft: config.space.S400,
+                }}
+              >
+                <Box grow="Yes">
+                  <Text size="H4">Add Existing</Text>
+                </Box>
+                <Box shrink="No">
+                  <IconButton size="300" radii="300" onClick={requestClose}>
+                    <Icon src={Icons.Cross} />
+                  </IconButton>
+                </Box>
+              </Header>
+              <Box grow="Yes">
+                <Scroll ref={scrollRef} size="300" hideTrack>
+                  <Box
+                    style={{ padding: config.space.S300, paddingRight: 0 }}
+                    direction="Column"
+                    gap="500"
+                  >
+                    <Box
+                      direction="Column"
+                      style={{ position: 'sticky', top: config.space.S300, zIndex: 1 }}
+                    >
+                      <Input
+                        onChange={handleSearchChange}
+                        before={<Icon size="200" src={Icons.Search} />}
+                        placeholder="Search"
+                        size="400"
+                        variant="Background"
+                        outlined
+                      />
+                    </Box>
+                    {vItems.length === 0 && (
+                      <Box
+                        style={{ paddingTop: config.space.S700 }}
+                        grow="Yes"
+                        alignItems="Center"
+                        justifyContent="Center"
+                        direction="Column"
+                        gap="100"
+                      >
+                        <Text size="H6" align="Center">
+                          {searchResult ? 'No Match Found' : `No ${space ? 'Spaces' : 'Rooms'}`}
+                        </Text>
+                        <Text size="T200" align="Center">
+                          {searchResult
+                            ? `No match found for "${searchResult.query}".`
+                            : `You do not have any ${space ? 'Spaces' : 'Rooms'} to display yet.`}
+                        </Text>
+                      </Box>
+                    )}
+                    <Box
+                      style={{
+                        position: 'relative',
+                        height: virtualizer.getTotalSize(),
+                      }}
+                    >
+                      {vItems.map((vItem) => {
+                        const roomId = items[vItem.index];
+                        const room = getRoom(roomId);
+                        if (!room) return null;
+                        const selectedItem = selected?.includes(roomId);
+                        const dm = mDirects.has(room.roomId);
+
+                        return (
+                          <VirtualTile
+                            virtualItem={vItem}
+                            style={{ paddingBottom: config.space.S100 }}
+                            ref={virtualizer.measureElement}
+                            key={vItem.index}
+                          >
+                            <MenuItem
+                              data-room-id={roomId}
+                              onClick={handleRoomClick}
+                              variant={selectedItem ? 'Success' : 'Surface'}
+                              size="400"
+                              radii="400"
+                              disabled={applyingChanges}
+                              aria-pressed={selectedItem}
+                              before={
+                                <Avatar size="200" radii={dm ? '400' : '300'}>
+                                  {dm || room.isSpaceRoom() ? (
+                                    <RoomAvatar
+                                      roomId={room.roomId}
+                                      src={
+                                        dm
+                                          ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
+                                          : getRoomAvatarUrl(mx, room, 96, useAuthentication)
+                                      }
+                                      alt={room.name}
+                                      renderFallback={() => (
+                                        <Text as="span" size="H6">
+                                          {nameInitials(room.name)}
+                                        </Text>
+                                      )}
+                                    />
+                                  ) : (
+                                    <RoomIcon size="200" joinRule={room.getJoinRule()} />
+                                  )}
+                                </Avatar>
+                              }
+                              after={selectedItem && <Icon size="200" src={Icons.Check} />}
+                            >
+                              <Box grow="Yes">
+                                <Text truncate size="T400">
+                                  {queryHighlighRegex
+                                    ? highlightText(queryHighlighRegex, [room.name])
+                                    : room.name}
+                                </Text>
+                              </Box>
+                            </MenuItem>
+                          </VirtualTile>
+                        );
+                      })}
+                    </Box>
+                    {selected.length > 0 && (
+                      <Menu
+                        style={{
+                          position: 'sticky',
+                          padding: config.space.S200,
+                          paddingLeft: config.space.S400,
+                          bottom: config.space.S400,
+                          left: config.space.S400,
+                          right: 0,
+                          zIndex: 1,
+                        }}
+                        variant="Success"
+                      >
+                        <Box alignItems="Center" gap="400">
+                          <Box grow="Yes" direction="Column">
+                            {applyState.status === AsyncStatus.Error ? (
+                              <Text size="T200">
+                                <b>Failed to apply changes! Please try again.</b>
+                              </Text>
+                            ) : (
+                              <Text size="T200">
+                                <b>Apply when ready. ({selected.length} Selected)</b>
+                              </Text>
+                            )}
+                          </Box>
+                          <Box shrink="No" gap="200">
+                            <Button
+                              size="300"
+                              variant="Success"
+                              fill="None"
+                              radii="300"
+                              disabled={applyingChanges}
+                              onClick={resetChanges}
+                            >
+                              <Text size="B300">Reset</Text>
+                            </Button>
+                            <Button
+                              size="300"
+                              variant="Success"
+                              radii="300"
+                              disabled={applyingChanges}
+                              before={
+                                applyingChanges && (
+                                  <Spinner variant="Success" fill="Solid" size="100" />
+                                )
+                              }
+                              onClick={handleApplyChanges}
+                            >
+                              <Text size="B300">Apply Changes</Text>
+                            </Button>
+                          </Box>
+                        </Box>
+                      </Menu>
+                    )}
+                  </Box>
+                </Scroll>
+              </Box>
+            </Box>
+          </Modal>
+        </FocusTrap>
+      </OverlayCenter>
+    </Overlay>
+  );
+}
diff --git a/src/app/features/add-existing/index.ts b/src/app/features/add-existing/index.ts
new file mode 100644 (file)
index 0000000..3607c06
--- /dev/null
@@ -0,0 +1 @@
+export * from './AddExisting';
index c1c9ba3e0fd59e43cd7a71f5c2fb34e868ee7609..c9919ba91cc2395358772923547a2fa9bee6f0f4 100644 (file)
@@ -54,7 +54,6 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
                   style={{
                     padding: config.space.S200,
                     paddingLeft: config.space.S400,
-                    borderBottomWidth: config.borderWidth.B300,
                   }}
                 >
                   <Box grow="Yes">
index e881a9717d77a34d2214a1e9e9c0b055cd0947ce..64a9790028ec372e95ed001b8925c4b2bdffa6e4 100644 (file)
@@ -30,12 +30,12 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 import * as css from './SpaceItem.css';
 import * as styleCss from './style.css';
 import { useDraggableItem } from './DnD';
-import { openSpaceAddExisting } from '../../../client/action/navigation';
 import { stopPropagation } from '../../utils/keyboard';
 import { mxcUrlToHttp } from '../../utils/matrix';
 import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
 import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
+import { AddExistingModal } from '../add-existing';
 
 function SpaceProfileLoading() {
   return (
@@ -243,6 +243,7 @@ function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileP
 function AddRoomButton({ item }: { item: HierarchyItem }) {
   const [cords, setCords] = useState<RectCords>();
   const openCreateRoomModal = useOpenCreateRoomModal();
+  const [addExisting, setAddExisting] = useState(false);
 
   const handleAddRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
     setCords(evt.currentTarget.getBoundingClientRect());
@@ -254,7 +255,7 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
   };
 
   const handleAddExisting = () => {
-    openSpaceAddExisting(item.roomId);
+    setAddExisting(true);
     setCords(undefined);
   };
 
@@ -300,6 +301,9 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
       >
         <Text size="B300">Add Room</Text>
       </Chip>
+      {addExisting && (
+        <AddExistingModal parentId={item.roomId} requestClose={() => setAddExisting(false)} />
+      )}
     </PopOut>
   );
 }
@@ -307,6 +311,7 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
 function AddSpaceButton({ item }: { item: HierarchyItem }) {
   const [cords, setCords] = useState<RectCords>();
   const openCreateSpaceModal = useOpenCreateSpaceModal();
+  const [addExisting, setAddExisting] = useState(false);
 
   const handleAddSpace: MouseEventHandler<HTMLButtonElement> = (evt) => {
     setCords(evt.currentTarget.getBoundingClientRect());
@@ -318,7 +323,7 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) {
   };
 
   const handleAddExisting = () => {
-    openSpaceAddExisting(item.roomId, true);
+    setAddExisting(true);
     setCords(undefined);
   };
   return (
@@ -363,6 +368,9 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) {
       >
         <Text size="B300">Add Space</Text>
       </Chip>
+      {addExisting && (
+        <AddExistingModal space parentId={item.roomId} requestClose={() => setAddExisting(false)} />
+      )}
     </PopOut>
   );
 }