Fix unknown rooms in space lobby (#2224)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Sat, 22 Feb 2025 08:24:33 +0000 (19:24 +1100)
committerGitHub <noreply@github.com>
Sat, 22 Feb 2025 08:24:33 +0000 (19:24 +1100)
* add hook to fetch one level of space hierarchy

* add enable param to level hierarchy hook

* improve HierarchyItem types

* fix type errors in lobby

* load space hierarachy per level

* fix menu item visibility

* fix unknown spaces over federation

* show inaccessible rooms only to admins

* fix unknown room renders loading content twice

* fix unknown room visible to normal user if space all room are unknown

* show no rooms card if space does not have any room

src/app/features/lobby/HierarchyItemMenu.tsx
src/app/features/lobby/Lobby.tsx
src/app/features/lobby/RoomItem.tsx
src/app/features/lobby/SpaceHierarchy.tsx [new file with mode: 0644]
src/app/features/lobby/SpaceItem.tsx
src/app/hooks/useSpaceHierarchy.ts
src/app/state/spaceRooms.ts

index 30a4f632e52d8519b784ff66db07778176205263..d1a7ec6bac3d4ebed15d7627a3d33e67f9427714 100644 (file)
@@ -155,7 +155,7 @@ function SettingsMenuItem({
   disabled?: boolean;
 }) {
   const handleSettings = () => {
-    if (item.space) {
+    if ('space' in item) {
       openSpaceSettings(item.roomId);
     } else {
       toggleRoomSettings(item.roomId);
@@ -271,7 +271,7 @@ export function HierarchyItemMenu({
                             </Text>
                           </MenuItem>
                           {promptLeave &&
-                            (item.space ? (
+                            ('space' in item ? (
                               <LeaveSpacePrompt
                                 roomId={item.roomId}
                                 onDone={handleRequestClose}
index 1ab669d256f6239c70eaa036ace581845410058c..757db239110e63dd11077407b540754b7dcae70f 100644 (file)
@@ -5,9 +5,15 @@ import { useAtom, useAtomValue } from 'jotai';
 import { useNavigate } from 'react-router-dom';
 import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
 import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
+import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
+import produce from 'immer';
 import { useSpace } from '../../hooks/useSpace';
 import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page';
-import { HierarchyItem, useSpaceHierarchy } from '../../hooks/useSpaceHierarchy';
+import {
+  HierarchyItem,
+  HierarchyItemSpace,
+  useSpaceHierarchy,
+} from '../../hooks/useSpaceHierarchy';
 import { VirtualTile } from '../../components/virtualizer';
 import { spaceRoomsAtom } from '../../state/spaceRooms';
 import { MembersDrawer } from '../room/MembersDrawer';
@@ -25,18 +31,15 @@ import {
   usePowerLevels,
   useRoomsPowerLevels,
 } from '../../hooks/usePowerLevels';
-import { RoomItemCard } from './RoomItem';
 import { mDirectAtom } from '../../state/mDirectList';
-import { SpaceItemCard } from './SpaceItem';
 import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
 import { useCategoryHandler } from '../../hooks/useCategoryHandler';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { allRoomsAtom } from '../../state/room-list/roomList';
 import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
 import { getSpaceRoomPath } from '../../pages/pathUtils';
-import { HierarchyItemMenu } from './HierarchyItemMenu';
 import { StateEvent } from '../../../types/matrix/room';
-import { AfterItemDropTarget, CanDropCallback, useDnDMonitor } from './DnD';
+import { CanDropCallback, useDnDMonitor } from './DnD';
 import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
 import { getStateEvent } from '../../utils/room';
 import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
@@ -49,6 +52,7 @@ import { useOrphanSpaces } from '../../state/hooks/roomList';
 import { roomToParentsAtom } from '../../state/room/roomToParents';
 import { AccountDataEvent } from '../../../types/matrix/accountData';
 import { useRoomMembers } from '../../hooks/useRoomMembers';
+import { SpaceHierarchy } from './SpaceHierarchy';
 
 export function Lobby() {
   const navigate = useNavigate();
@@ -81,6 +85,8 @@ export function Lobby() {
     return new Set(sideSpaces);
   }, [sidebarItems]);
 
+  const [spacesItems, setSpacesItem] = useState<Map<string, IHierarchyRoom>>(() => new Map());
+
   useElementSizeObserver(
     useCallback(() => heroSectionRef.current, []),
     useCallback((w, height) => setHeroSectionHeight(height), [])
@@ -107,19 +113,20 @@ export function Lobby() {
   );
 
   const [draggingItem, setDraggingItem] = useState<HierarchyItem>();
-  const flattenHierarchy = useSpaceHierarchy(
+  const hierarchy = useSpaceHierarchy(
     space.roomId,
     spaceRooms,
     getRoom,
     useCallback(
       (childId) =>
-        closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) || !!draggingItem?.space,
+        closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) ||
+        (draggingItem ? 'space' in draggingItem : false),
       [closedCategories, space.roomId, draggingItem]
     )
   );
 
   const virtualizer = useVirtualizer({
-    count: flattenHierarchy.length,
+    count: hierarchy.length,
     getScrollElement: () => scrollRef.current,
     estimateSize: () => 1,
     overscan: 2,
@@ -129,8 +136,17 @@ export function Lobby() {
 
   const roomsPowerLevels = useRoomsPowerLevels(
     useMemo(
-      () => flattenHierarchy.map((i) => mx.getRoom(i.roomId)).filter((r) => !!r) as Room[],
-      [mx, flattenHierarchy]
+      () =>
+        hierarchy
+          .flatMap((i) => {
+            const childRooms = Array.isArray(i.rooms)
+              ? i.rooms.map((r) => mx.getRoom(r.roomId))
+              : [];
+
+            return [mx.getRoom(i.space.roomId), ...childRooms];
+          })
+          .filter((r) => !!r) as Room[],
+      [mx, hierarchy]
     )
   );
 
@@ -142,8 +158,8 @@ export function Lobby() {
         return false;
       }
 
-      if (item.space) {
-        if (!container.item.space) return false;
+      if ('space' in item) {
+        if (!('space' in container.item)) return false;
         const containerSpaceId = space.roomId;
 
         if (
@@ -156,9 +172,8 @@ export function Lobby() {
         return true;
       }
 
-      const containerSpaceId = container.item.space
-        ? container.item.roomId
-        : container.item.parentId;
+      const containerSpaceId =
+        'space' in container.item ? container.item.roomId : container.item.parentId;
 
       const dropOutsideSpace = item.parentId !== containerSpaceId;
 
@@ -192,22 +207,22 @@ export function Lobby() {
   );
 
   const reorderSpace = useCallback(
-    (item: HierarchyItem, containerItem: HierarchyItem) => {
+    (item: HierarchyItemSpace, containerItem: HierarchyItem) => {
       if (!item.parentId) return;
 
-      const childItems = flattenHierarchy
-        .filter((i) => i.parentId && i.space)
+      const itemSpaces: HierarchyItemSpace[] = hierarchy
+        .map((i) => i.space)
         .filter((i) => i.roomId !== item.roomId);
 
-      const beforeIndex = childItems.findIndex((i) => i.roomId === containerItem.roomId);
+      const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId);
       const insertIndex = beforeIndex + 1;
 
-      childItems.splice(insertIndex, 0, {
+      itemSpaces.splice(insertIndex, 0, {
         ...item,
         content: { ...item.content, order: undefined },
       });
 
-      const currentOrders = childItems.map((i) => {
+      const currentOrders = itemSpaces.map((i) => {
         if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
           return i.content.order;
         }
@@ -217,21 +232,21 @@ export function Lobby() {
       const newOrders = orderKeys(lex, currentOrders);
 
       newOrders?.forEach((orderKey, index) => {
-        const itm = childItems[index];
+        const itm = itemSpaces[index];
         if (!itm || !itm.parentId) return;
         const parentPL = roomsPowerLevels.get(itm.parentId);
         const canEdit = parentPL && canEditSpaceChild(parentPL);
         if (canEdit && orderKey !== currentOrders[index]) {
           mx.sendStateEvent(
             itm.parentId,
-            StateEvent.SpaceChild,
+            StateEvent.SpaceChild as any,
             { ...itm.content, order: orderKey },
             itm.roomId
           );
         }
       });
     },
-    [mx, flattenHierarchy, lex, roomsPowerLevels, canEditSpaceChild]
+    [mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
   );
 
   const reorderRoom = useCallback(
@@ -240,13 +255,12 @@ export function Lobby() {
       if (!item.parentId) {
         return;
       }
-      const containerParentId: string = containerItem.space
-        ? containerItem.roomId
-        : containerItem.parentId;
+      const containerParentId: string =
+        'space' in containerItem ? containerItem.roomId : containerItem.parentId;
       const itemContent = item.content;
 
       if (item.parentId !== containerParentId) {
-        mx.sendStateEvent(item.parentId, StateEvent.SpaceChild, {}, item.roomId);
+        mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
       }
 
       if (
@@ -265,28 +279,29 @@ export function Lobby() {
           const allow =
             joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? [];
           allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
-          mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules, {
+          mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, {
             ...joinRuleContent,
             allow,
           });
         }
       }
 
-      const childItems = flattenHierarchy
-        .filter((i) => i.parentId === containerParentId && !i.space)
-        .filter((i) => i.roomId !== item.roomId);
+      const itemSpaces = Array.from(
+        hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? []
+      );
 
-      const beforeItem: HierarchyItem | undefined = containerItem.space ? undefined : containerItem;
-      const beforeIndex = childItems.findIndex((i) => i.roomId === beforeItem?.roomId);
+      const beforeItem: HierarchyItem | undefined =
+        'space' in containerItem ? undefined : containerItem;
+      const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId);
       const insertIndex = beforeIndex + 1;
 
-      childItems.splice(insertIndex, 0, {
+      itemSpaces.splice(insertIndex, 0, {
         ...item,
         parentId: containerParentId,
         content: { ...itemContent, order: undefined },
       });
 
-      const currentOrders = childItems.map((i) => {
+      const currentOrders = itemSpaces.map((i) => {
         if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
           return i.content.order;
         }
@@ -296,18 +311,18 @@ export function Lobby() {
       const newOrders = orderKeys(lex, currentOrders);
 
       newOrders?.forEach((orderKey, index) => {
-        const itm = childItems[index];
+        const itm = itemSpaces[index];
         if (itm && orderKey !== currentOrders[index]) {
           mx.sendStateEvent(
             containerParentId,
-            StateEvent.SpaceChild,
+            StateEvent.SpaceChild as any,
             { ...itm.content, order: orderKey },
             itm.roomId
           );
         }
       });
     },
-    [mx, flattenHierarchy, lex]
+    [mx, hierarchy, lex]
   );
 
   useDnDMonitor(
@@ -318,7 +333,7 @@ export function Lobby() {
         if (!canDrop(item, container)) {
           return;
         }
-        if (item.space) {
+        if ('space' in item) {
           reorderSpace(item, container.item);
         } else {
           reorderRoom(item, container.item);
@@ -328,8 +343,16 @@ export function Lobby() {
     )
   );
 
-  const addSpaceRoom = useCallback(
-    (roomId: string) => setSpaceRooms({ type: 'PUT', roomId }),
+  const handleSpacesFound = useCallback(
+    (sItems: IHierarchyRoom[]) => {
+      setSpaceRooms({ type: 'PUT', roomIds: sItems.map((i) => i.room_id) });
+      setSpacesItem((current) => {
+        const newItems = produce(current, (draft) => {
+          sItems.forEach((item) => draft.set(item.room_id, item));
+        });
+        return current.size === newItems.size ? current : newItems;
+      });
+    },
     [setSpaceRooms]
   );
 
@@ -394,121 +417,44 @@ export function Lobby() {
                       <LobbyHero />
                     </PageHeroSection>
                     {vItems.map((vItem) => {
-                      const item = flattenHierarchy[vItem.index];
+                      const item = hierarchy[vItem.index];
                       if (!item) return null;
-                      const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
-                      const userPLInItem = powerLevelAPI.getPowerLevel(
-                        itemPowerLevel,
-                        mx.getUserId() ?? undefined
-                      );
-                      const canInvite = powerLevelAPI.canDoAction(
-                        itemPowerLevel,
-                        'invite',
-                        userPLInItem
-                      );
-                      const isJoined = allJoinedRooms.has(item.roomId);
-
-                      const nextRoomId: string | undefined =
-                        flattenHierarchy[vItem.index + 1]?.roomId;
-
-                      const dragging =
-                        draggingItem?.roomId === item.roomId &&
-                        draggingItem.parentId === item.parentId;
-
-                      if (item.space) {
-                        const categoryId = makeLobbyCategoryId(space.roomId, item.roomId);
-                        const { parentId } = item;
-                        const parentPowerLevels = parentId
-                          ? roomsPowerLevels.get(parentId) ?? {}
-                          : undefined;
-
-                        return (
-                          <VirtualTile
-                            virtualItem={vItem}
-                            style={{
-                              paddingTop: vItem.index === 0 ? 0 : config.space.S500,
-                            }}
-                            ref={virtualizer.measureElement}
-                            key={vItem.index}
-                          >
-                            <SpaceItemCard
-                              item={item}
-                              joined={allJoinedRooms.has(item.roomId)}
-                              categoryId={categoryId}
-                              closed={closedCategories.has(categoryId) || !!draggingItem?.space}
-                              handleClose={handleCategoryClick}
-                              getRoom={getRoom}
-                              canEditChild={canEditSpaceChild(
-                                roomsPowerLevels.get(item.roomId) ?? {}
-                              )}
-                              canReorder={
-                                parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false
-                              }
-                              options={
-                                parentId &&
-                                parentPowerLevels && (
-                                  <HierarchyItemMenu
-                                    item={{ ...item, parentId }}
-                                    canInvite={canInvite}
-                                    joined={isJoined}
-                                    canEditChild={canEditSpaceChild(parentPowerLevels)}
-                                    pinned={sidebarSpaces.has(item.roomId)}
-                                    onTogglePin={togglePinToSidebar}
-                                  />
-                                )
-                              }
-                              before={item.parentId ? undefined : undefined}
-                              after={
-                                <AfterItemDropTarget
-                                  item={item}
-                                  nextRoomId={nextRoomId}
-                                  afterSpace
-                                  canDrop={canDrop}
-                                />
-                              }
-                              onDragging={setDraggingItem}
-                              data-dragging={dragging}
-                            />
-                          </VirtualTile>
-                        );
-                      }
-
-                      const parentPowerLevels = roomsPowerLevels.get(item.parentId) ?? {};
-                      const prevItem: HierarchyItem | undefined = flattenHierarchy[vItem.index - 1];
-                      const nextItem: HierarchyItem | undefined = flattenHierarchy[vItem.index + 1];
+                      const nextSpaceId = hierarchy[vItem.index + 1]?.space.roomId;
+
+                      const categoryId = makeLobbyCategoryId(space.roomId, item.space.roomId);
+
                       return (
                         <VirtualTile
                           virtualItem={vItem}
-                          style={{ paddingTop: config.space.S100 }}
+                          style={{
+                            paddingTop: vItem.index === 0 ? 0 : config.space.S500,
+                          }}
                           ref={virtualizer.measureElement}
                           key={vItem.index}
                         >
-                          <RoomItemCard
-                            item={item}
-                            onSpaceFound={addSpaceRoom}
-                            dm={mDirects.has(item.roomId)}
-                            firstChild={!prevItem || prevItem.space === true}
-                            lastChild={!nextItem || nextItem.space === true}
-                            onOpen={handleOpenRoom}
-                            getRoom={getRoom}
-                            canReorder={canEditSpaceChild(parentPowerLevels)}
-                            options={
-                              <HierarchyItemMenu
-                                item={item}
-                                canInvite={canInvite}
-                                joined={isJoined}
-                                canEditChild={canEditSpaceChild(parentPowerLevels)}
-                              />
-                            }
-                            after={
-                              <AfterItemDropTarget
-                                item={item}
-                                nextRoomId={nextRoomId}
-                                canDrop={canDrop}
-                              />
+                          <SpaceHierarchy
+                            spaceItem={item.space}
+                            summary={spacesItems.get(item.space.roomId)}
+                            roomItems={item.rooms}
+                            allJoinedRooms={allJoinedRooms}
+                            mDirects={mDirects}
+                            roomsPowerLevels={roomsPowerLevels}
+                            canEditSpaceChild={canEditSpaceChild}
+                            categoryId={categoryId}
+                            closed={
+                              closedCategories.has(categoryId) ||
+                              (draggingItem ? 'space' in draggingItem : false)
                             }
-                            data-dragging={dragging}
+                            handleClose={handleCategoryClick}
+                            draggingItem={draggingItem}
                             onDragging={setDraggingItem}
+                            canDrop={canDrop}
+                            nextSpaceId={nextSpaceId}
+                            getRoom={getRoom}
+                            pinned={sidebarSpaces.has(item.space.roomId)}
+                            togglePinToSidebar={togglePinToSidebar}
+                            onSpacesFound={handleSpacesFound}
+                            onOpenRoom={handleOpenRoom}
                           />
                         </VirtualTile>
                       );
index f8db3991e846a28bc585b62d2340f660629f081c..994cda05907d842cf1c4b3715845a93bc1ec5fb4 100644 (file)
@@ -1,4 +1,4 @@
-import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useRef } from 'react';
+import React, { MouseEventHandler, ReactNode, useCallback, useRef } from 'react';
 import {
   Avatar,
   Badge,
@@ -20,23 +20,20 @@ import {
 } from 'folds';
 import FocusTrap from 'focus-trap-react';
 import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
+import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
 import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
 import { SequenceCard } from '../../components/sequence-card';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
 import { millify } from '../../plugins/millify';
-import {
-  HierarchyRoomSummaryLoader,
-  LocalRoomSummaryLoader,
-} from '../../components/RoomSummaryLoader';
+import { LocalRoomSummaryLoader } from '../../components/RoomSummaryLoader';
 import { UseStateProvider } from '../../components/UseStateProvider';
 import { RoomTopicViewer } from '../../components/room-topic-viewer';
 import { onEnterOrSpace, stopPropagation } from '../../utils/keyboard';
-import { Membership, RoomType } from '../../../types/matrix/room';
+import { Membership } from '../../../types/matrix/room';
 import * as css from './RoomItem.css';
 import * as styleCss from './style.css';
 import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
-import { ErrorCode } from '../../cs-errorcode';
 import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
 import { ItemDraggableTarget, useDraggableItem } from './DnD';
 import { mxcUrlToHttp } from '../../utils/matrix';
@@ -125,13 +122,11 @@ function RoomProfileLoading() {
 
 type RoomProfileErrorProps = {
   roomId: string;
-  error: Error;
+  inaccessibleRoom: boolean;
   suggested?: boolean;
   via?: string[];
 };
-function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorProps) {
-  const privateRoom = error.name === ErrorCode.M_FORBIDDEN;
-
+function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProfileErrorProps) {
   return (
     <Box grow="Yes" gap="300">
       <Avatar>
@@ -142,7 +137,7 @@ function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorPro
           renderFallback={() => (
             <RoomIcon
               size="300"
-              joinRule={privateRoom ? JoinRule.Invite : JoinRule.Restricted}
+              joinRule={inaccessibleRoom ? JoinRule.Invite : JoinRule.Restricted}
               filled
             />
           )}
@@ -162,25 +157,18 @@ function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorPro
           )}
         </Box>
         <Box gap="200" alignItems="Center">
-          {privateRoom && (
-            <>
-              <Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
-                <Text size="L400">Private Room</Text>
-              </Badge>
-              <Line
-                variant="SurfaceVariant"
-                style={{ height: toRem(12) }}
-                direction="Vertical"
-                size="400"
-              />
-            </>
+          {inaccessibleRoom ? (
+            <Badge variant="Secondary" fill="Soft" radii="300" size="500">
+              <Text size="L400">Inaccessible</Text>
+            </Badge>
+          ) : (
+            <Text size="T200" truncate>
+              {roomId}
+            </Text>
           )}
-          <Text size="T200" truncate>
-            {roomId}
-          </Text>
         </Box>
       </Box>
-      {!privateRoom && <RoomJoinButton roomId={roomId} via={via} />}
+      {!inaccessibleRoom && <RoomJoinButton roomId={roomId} via={via} />}
     </Box>
   );
 }
@@ -288,23 +276,11 @@ function RoomProfile({
   );
 }
 
-function CallbackOnFoundSpace({
-  roomId,
-  onSpaceFound,
-}: {
-  roomId: string;
-  onSpaceFound: (roomId: string) => void;
-}) {
-  useEffect(() => {
-    onSpaceFound(roomId);
-  }, [roomId, onSpaceFound]);
-
-  return null;
-}
-
 type RoomItemCardProps = {
   item: HierarchyItem;
-  onSpaceFound: (roomId: string) => void;
+  loading: boolean;
+  error: Error | null;
+  summary: IHierarchyRoom | undefined;
   dm?: boolean;
   firstChild?: boolean;
   lastChild?: boolean;
@@ -320,10 +296,10 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
   (
     {
       item,
-      onSpaceFound,
+      loading,
+      error,
+      summary,
       dm,
-      firstChild,
-      lastChild,
       onOpen,
       options,
       before,
@@ -348,8 +324,6 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
     return (
       <SequenceCard
         className={css.RoomItemCard}
-        firstChild={firstChild}
-        lastChild={lastChild}
         variant="SurfaceVariant"
         gap="300"
         alignItems="Center"
@@ -367,7 +341,9 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
                   name={localSummary.name}
                   topic={localSummary.topic}
                   avatarUrl={
-                    dm ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication)
+                    dm
+                      ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
+                      : getRoomAvatarUrl(mx, room, 96, useAuthentication)
                   }
                   memberCount={localSummary.memberCount}
                   suggested={content.suggested}
@@ -395,46 +371,46 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
               )}
             </LocalRoomSummaryLoader>
           ) : (
-            <HierarchyRoomSummaryLoader roomId={roomId}>
-              {(summaryState) => (
-                <>
-                  {summaryState.status === AsyncStatus.Loading && <RoomProfileLoading />}
-                  {summaryState.status === AsyncStatus.Error && (
-                    <RoomProfileError
-                      roomId={roomId}
-                      error={summaryState.error}
-                      suggested={content.suggested}
-                      via={content.via}
-                    />
-                  )}
-                  {summaryState.status === AsyncStatus.Success && (
-                    <>
-                      {summaryState.data.room_type === RoomType.Space && (
-                        <CallbackOnFoundSpace
-                          roomId={summaryState.data.room_id}
-                          onSpaceFound={onSpaceFound}
-                        />
-                      )}
-                      <RoomProfile
+            <>
+              {!summary &&
+                (error ? (
+                  <RoomProfileError
+                    roomId={roomId}
+                    inaccessibleRoom={false}
+                    suggested={content.suggested}
+                    via={content.via}
+                  />
+                ) : (
+                  <>
+                    {loading && <RoomProfileLoading />}
+                    {!loading && (
+                      <RoomProfileError
                         roomId={roomId}
-                        name={summaryState.data.name || summaryState.data.canonical_alias || roomId}
-                        topic={summaryState.data.topic}
-                        avatarUrl={
-                          summaryState.data?.avatar_url
-                            ? mxcUrlToHttp(mx, summaryState.data.avatar_url, useAuthentication, 96, 96, 'crop') ??
-                            undefined
-                            : undefined
-                        }
-                        memberCount={summaryState.data.num_joined_members}
+                        inaccessibleRoom
                         suggested={content.suggested}
-                        joinRule={summaryState.data.join_rule}
-                        options={<RoomJoinButton roomId={roomId} via={content.via} />}
+                        via={content.via}
                       />
-                    </>
-                  )}
-                </>
+                    )}
+                  </>
+                ))}
+              {summary && (
+                <RoomProfile
+                  roomId={roomId}
+                  name={summary.name || summary.canonical_alias || roomId}
+                  topic={summary.topic}
+                  avatarUrl={
+                    summary?.avatar_url
+                      ? mxcUrlToHttp(mx, summary.avatar_url, useAuthentication, 96, 96, 'crop') ??
+                        undefined
+                      : undefined
+                  }
+                  memberCount={summary.num_joined_members}
+                  suggested={content.suggested}
+                  joinRule={summary.join_rule}
+                  options={<RoomJoinButton roomId={roomId} via={content.via} />}
+                />
               )}
-            </HierarchyRoomSummaryLoader>
+            </>
           )}
         </Box>
         {options}
diff --git a/src/app/features/lobby/SpaceHierarchy.tsx b/src/app/features/lobby/SpaceHierarchy.tsx
new file mode 100644 (file)
index 0000000..2c43282
--- /dev/null
@@ -0,0 +1,225 @@
+import React, { forwardRef, MouseEventHandler, useEffect, useMemo } from 'react';
+import { MatrixError, Room } from 'matrix-js-sdk';
+import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
+import { Box, config, Text } from 'folds';
+import {
+  HierarchyItem,
+  HierarchyItemRoom,
+  HierarchyItemSpace,
+  useFetchSpaceHierarchyLevel,
+} from '../../hooks/useSpaceHierarchy';
+import { IPowerLevels, powerLevelAPI } from '../../hooks/usePowerLevels';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { SpaceItemCard } from './SpaceItem';
+import { AfterItemDropTarget, CanDropCallback } from './DnD';
+import { HierarchyItemMenu } from './HierarchyItemMenu';
+import { RoomItemCard } from './RoomItem';
+import { RoomType } from '../../../types/matrix/room';
+import { SequenceCard } from '../../components/sequence-card';
+
+type SpaceHierarchyProps = {
+  summary: IHierarchyRoom | undefined;
+  spaceItem: HierarchyItemSpace;
+  roomItems?: HierarchyItemRoom[];
+  allJoinedRooms: Set<string>;
+  mDirects: Set<string>;
+  roomsPowerLevels: Map<string, IPowerLevels>;
+  canEditSpaceChild: (powerLevels: IPowerLevels) => boolean;
+  categoryId: string;
+  closed: boolean;
+  handleClose: MouseEventHandler<HTMLButtonElement>;
+  draggingItem?: HierarchyItem;
+  onDragging: (item?: HierarchyItem) => void;
+  canDrop: CanDropCallback;
+  nextSpaceId?: string;
+  getRoom: (roomId: string) => Room | undefined;
+  pinned: boolean;
+  togglePinToSidebar: (roomId: string) => void;
+  onSpacesFound: (spaceItems: IHierarchyRoom[]) => void;
+  onOpenRoom: MouseEventHandler<HTMLButtonElement>;
+};
+export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
+  (
+    {
+      summary,
+      spaceItem,
+      roomItems,
+      allJoinedRooms,
+      mDirects,
+      roomsPowerLevels,
+      canEditSpaceChild,
+      categoryId,
+      closed,
+      handleClose,
+      draggingItem,
+      onDragging,
+      canDrop,
+      nextSpaceId,
+      getRoom,
+      pinned,
+      togglePinToSidebar,
+      onOpenRoom,
+      onSpacesFound,
+    },
+    ref
+  ) => {
+    const mx = useMatrixClient();
+
+    const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(spaceItem.roomId, true);
+
+    const subspaces = useMemo(() => {
+      const s: Map<string, IHierarchyRoom> = new Map();
+      rooms.forEach((r) => {
+        if (r.room_type === RoomType.Space) {
+          s.set(r.room_id, r);
+        }
+      });
+      return s;
+    }, [rooms]);
+
+    const spacePowerLevels = roomsPowerLevels.get(spaceItem.roomId) ?? {};
+    const userPLInSpace = powerLevelAPI.getPowerLevel(
+      spacePowerLevels,
+      mx.getUserId() ?? undefined
+    );
+    const canInviteInSpace = powerLevelAPI.canDoAction(spacePowerLevels, 'invite', userPLInSpace);
+
+    const draggingSpace =
+      draggingItem?.roomId === spaceItem.roomId && draggingItem.parentId === spaceItem.parentId;
+
+    const { parentId } = spaceItem;
+    const parentPowerLevels = parentId ? roomsPowerLevels.get(parentId) ?? {} : undefined;
+
+    useEffect(() => {
+      onSpacesFound(Array.from(subspaces.values()));
+    }, [subspaces, onSpacesFound]);
+
+    let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId));
+    if (!canEditSpaceChild(spacePowerLevels)) {
+      // hide unknown rooms for normal user
+      childItems = childItems?.filter((i) => {
+        const forbidden = error instanceof MatrixError ? error.errcode === 'M_FORBIDDEN' : false;
+        const inaccessibleRoom = !rooms.get(i.roomId) && !fetching && (error ? forbidden : true);
+        return !inaccessibleRoom;
+      });
+    }
+
+    return (
+      <Box direction="Column" gap="100" ref={ref}>
+        <SpaceItemCard
+          summary={rooms.get(spaceItem.roomId) ?? summary}
+          loading={fetching}
+          item={spaceItem}
+          joined={allJoinedRooms.has(spaceItem.roomId)}
+          categoryId={categoryId}
+          closed={closed}
+          handleClose={handleClose}
+          getRoom={getRoom}
+          canEditChild={canEditSpaceChild(spacePowerLevels)}
+          canReorder={parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false}
+          options={
+            parentId &&
+            parentPowerLevels && (
+              <HierarchyItemMenu
+                item={{ ...spaceItem, parentId }}
+                canInvite={canInviteInSpace}
+                joined={allJoinedRooms.has(spaceItem.roomId)}
+                canEditChild={canEditSpaceChild(parentPowerLevels)}
+                pinned={pinned}
+                onTogglePin={togglePinToSidebar}
+              />
+            )
+          }
+          after={
+            <AfterItemDropTarget
+              item={spaceItem}
+              nextRoomId={closed ? nextSpaceId : childItems?.[0]?.roomId}
+              afterSpace
+              canDrop={canDrop}
+            />
+          }
+          onDragging={onDragging}
+          data-dragging={draggingSpace}
+        />
+        {childItems && childItems.length > 0 ? (
+          <Box direction="Column" gap="100">
+            {childItems.map((roomItem, index) => {
+              const roomSummary = rooms.get(roomItem.roomId);
+
+              const roomPowerLevels = roomsPowerLevels.get(roomItem.roomId) ?? {};
+              const userPLInRoom = powerLevelAPI.getPowerLevel(
+                roomPowerLevels,
+                mx.getUserId() ?? undefined
+              );
+              const canInviteInRoom = powerLevelAPI.canDoAction(
+                roomPowerLevels,
+                'invite',
+                userPLInRoom
+              );
+
+              const lastItem = index === childItems.length;
+              const nextRoomId = lastItem ? nextSpaceId : childItems[index + 1]?.roomId;
+
+              const roomDragging =
+                draggingItem?.roomId === roomItem.roomId &&
+                draggingItem.parentId === roomItem.parentId;
+
+              return (
+                <RoomItemCard
+                  key={roomItem.roomId}
+                  item={roomItem}
+                  loading={fetching}
+                  error={error}
+                  summary={roomSummary}
+                  dm={mDirects.has(roomItem.roomId)}
+                  onOpen={onOpenRoom}
+                  getRoom={getRoom}
+                  canReorder={canEditSpaceChild(spacePowerLevels)}
+                  options={
+                    <HierarchyItemMenu
+                      item={roomItem}
+                      canInvite={canInviteInRoom}
+                      joined={allJoinedRooms.has(roomItem.roomId)}
+                      canEditChild={canEditSpaceChild(spacePowerLevels)}
+                    />
+                  }
+                  after={
+                    <AfterItemDropTarget
+                      item={roomItem}
+                      nextRoomId={nextRoomId}
+                      canDrop={canDrop}
+                    />
+                  }
+                  data-dragging={roomDragging}
+                  onDragging={onDragging}
+                />
+              );
+            })}
+          </Box>
+        ) : (
+          childItems && (
+            <SequenceCard variant="SurfaceVariant" gap="300" alignItems="Center">
+              <Box
+                grow="Yes"
+                style={{
+                  padding: config.space.S700,
+                }}
+                direction="Column"
+                alignItems="Center"
+                justifyContent="Center"
+                gap="100"
+              >
+                <Text size="H5" align="Center">
+                  No Rooms
+                </Text>
+                <Text align="Center" size="T300" priority="300">
+                  This space does not contains rooms yet.
+                </Text>
+              </Box>
+            </SequenceCard>
+          )
+        )}
+      </Box>
+    );
+  }
+);
index deaf9ba58229fb847f41ab4deaf7cb62c891dfe1..0a4d9de5a067db97a7726b89e7a41fde50ab0a69 100644 (file)
@@ -19,19 +19,16 @@ import {
 import FocusTrap from 'focus-trap-react';
 import classNames from 'classnames';
 import { MatrixError, Room } from 'matrix-js-sdk';
+import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
 import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { RoomAvatar } from '../../components/room-avatar';
 import { nameInitials } from '../../utils/common';
-import {
-  HierarchyRoomSummaryLoader,
-  LocalRoomSummaryLoader,
-} from '../../components/RoomSummaryLoader';
+import { LocalRoomSummaryLoader } from '../../components/RoomSummaryLoader';
 import { getRoomAvatarUrl } from '../../utils/room';
 import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 import * as css from './SpaceItem.css';
 import * as styleCss from './style.css';
-import { ErrorCode } from '../../cs-errorcode';
 import { useDraggableItem } from './DnD';
 import { openCreateRoom, openSpaceAddExisting } from '../../../client/action/navigation';
 import { stopPropagation } from '../../utils/keyboard';
@@ -53,18 +50,11 @@ function SpaceProfileLoading() {
   );
 }
 
-type UnknownPrivateSpaceProfileProps = {
+type InaccessibleSpaceProfileProps = {
   roomId: string;
-  name?: string;
-  avatarUrl?: string;
   suggested?: boolean;
 };
-function UnknownPrivateSpaceProfile({
-  roomId,
-  name,
-  avatarUrl,
-  suggested,
-}: UnknownPrivateSpaceProfileProps) {
+function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfileProps) {
   return (
     <Chip
       as="span"
@@ -75,11 +65,9 @@ function UnknownPrivateSpaceProfile({
         <Avatar size="200" radii="300">
           <RoomAvatar
             roomId={roomId}
-            src={avatarUrl}
-            alt={name}
             renderFallback={() => (
               <Text as="span" size="H6">
-                {nameInitials(name)}
+                U
               </Text>
             )}
           />
@@ -88,11 +76,11 @@ function UnknownPrivateSpaceProfile({
     >
       <Box alignItems="Center" gap="200">
         <Text size="H4" truncate>
-          {name || 'Unknown'}
+          Unknown
         </Text>
 
         <Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
-          <Text size="L400">Private Space</Text>
+          <Text size="L400">Inaccessible</Text>
         </Badge>
         {suggested && (
           <Badge variant="Success" fill="Soft" radii="Pill" outlined>
@@ -104,20 +92,20 @@ function UnknownPrivateSpaceProfile({
   );
 }
 
-type UnknownSpaceProfileProps = {
+type UnjoinedSpaceProfileProps = {
   roomId: string;
   via?: string[];
   name?: string;
   avatarUrl?: string;
   suggested?: boolean;
 };
-function UnknownSpaceProfile({
+function UnjoinedSpaceProfile({
   roomId,
   via,
   name,
   avatarUrl,
   suggested,
-}: UnknownSpaceProfileProps) {
+}: UnjoinedSpaceProfileProps) {
   const mx = useMatrixClient();
 
   const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
@@ -376,6 +364,8 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) {
 }
 
 type SpaceItemCardProps = {
+  summary: IHierarchyRoom | undefined;
+  loading?: boolean;
   item: HierarchyItem;
   joined?: boolean;
   categoryId: string;
@@ -393,6 +383,8 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
   (
     {
       className,
+      summary,
+      loading,
       joined,
       closed,
       categoryId,
@@ -451,37 +443,31 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
                 }
               </LocalRoomSummaryLoader>
             ) : (
-              <HierarchyRoomSummaryLoader roomId={roomId}>
-                {(summaryState) => (
-                  <>
-                    {summaryState.status === AsyncStatus.Loading && <SpaceProfileLoading />}
-                    {summaryState.status === AsyncStatus.Error &&
-                      (summaryState.error.name === ErrorCode.M_FORBIDDEN ? (
-                        <UnknownPrivateSpaceProfile roomId={roomId} suggested={content.suggested} />
-                      ) : (
-                        <UnknownSpaceProfile
-                          roomId={roomId}
-                          via={item.content.via}
-                          suggested={content.suggested}
-                        />
-                      ))}
-                    {summaryState.status === AsyncStatus.Success && (
-                      <UnknownSpaceProfile
-                        roomId={roomId}
-                        via={item.content.via}
-                        name={summaryState.data.name || summaryState.data.canonical_alias || roomId}
-                        avatarUrl={
-                          summaryState.data?.avatar_url
-                            ? mxcUrlToHttp(mx, summaryState.data.avatar_url, useAuthentication, 96, 96, 'crop') ??
-                            undefined
-                            : undefined
-                        }
-                        suggested={content.suggested}
-                      />
-                    )}
-                  </>
+              <>
+                {!summary &&
+                  (loading ? (
+                    <SpaceProfileLoading />
+                  ) : (
+                    <InaccessibleSpaceProfile
+                      roomId={item.roomId}
+                      suggested={item.content.suggested}
+                    />
+                  ))}
+                {summary && (
+                  <UnjoinedSpaceProfile
+                    roomId={roomId}
+                    via={item.content.via}
+                    name={summary.name || summary.canonical_alias || roomId}
+                    avatarUrl={
+                      summary?.avatar_url
+                        ? mxcUrlToHttp(mx, summary.avatar_url, useAuthentication, 96, 96, 'crop') ??
+                          undefined
+                        : undefined
+                    }
+                    suggested={content.suggested}
+                  />
                 )}
-              </HierarchyRoomSummaryLoader>
+              </>
             )}
           </Box>
           {canEditChild && (
index c109cc21241e941b842066bc084389c666b4a63a..ad34e3f45826bc1ac040564ca5afb0fa44d4fad6 100644 (file)
@@ -1,6 +1,8 @@
 import { atom, useAtom, useAtomValue } from 'jotai';
-import { useCallback, useEffect, useState } from 'react';
-import { Room } from 'matrix-js-sdk';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { MatrixError, Room } from 'matrix-js-sdk';
+import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
+import { QueryFunction, useInfiniteQuery } from '@tanstack/react-query';
 import { useMatrixClient } from './useMatrixClient';
 import { roomToParentsAtom } from '../state/room/roomToParents';
 import { MSpaceChildContent, StateEvent } from '../../types/matrix/room';
@@ -8,22 +10,24 @@ import { getAllParents, getStateEvents, isValidChild } from '../utils/room';
 import { isRoomId } from '../utils/matrix';
 import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort';
 import { useStateEventCallback } from './useStateEventCallback';
+import { ErrorCode } from '../cs-errorcode';
+
+export type HierarchyItemSpace = {
+  roomId: string;
+  content: MSpaceChildContent;
+  ts: number;
+  space: true;
+  parentId?: string;
+};
 
-export type HierarchyItem =
-  | {
-      roomId: string;
-      content: MSpaceChildContent;
-      ts: number;
-      space: true;
-      parentId?: string;
-    }
-  | {
-      roomId: string;
-      content: MSpaceChildContent;
-      ts: number;
-      space?: false;
-      parentId: string;
-    };
+export type HierarchyItemRoom = {
+  roomId: string;
+  content: MSpaceChildContent;
+  ts: number;
+  parentId: string;
+};
+
+export type HierarchyItem = HierarchyItemSpace | HierarchyItemRoom;
 
 type GetRoomCallback = (roomId: string) => Room | undefined;
 
@@ -35,16 +39,16 @@ const getHierarchySpaces = (
   rootSpaceId: string,
   getRoom: GetRoomCallback,
   spaceRooms: Set<string>
-): HierarchyItem[] => {
-  const rootSpaceItem: HierarchyItem = {
+): HierarchyItemSpace[] => {
+  const rootSpaceItem: HierarchyItemSpace = {
     roomId: rootSpaceId,
     content: { via: [] },
     ts: 0,
     space: true,
   };
-  let spaceItems: HierarchyItem[] = [];
+  let spaceItems: HierarchyItemSpace[] = [];
 
-  const findAndCollectHierarchySpaces = (spaceItem: HierarchyItem) => {
+  const findAndCollectHierarchySpaces = (spaceItem: HierarchyItemSpace) => {
     if (spaceItems.find((item) => item.roomId === spaceItem.roomId)) return;
     const space = getRoom(spaceItem.roomId);
     spaceItems.push(spaceItem);
@@ -61,7 +65,7 @@ const getHierarchySpaces = (
       // or requesting room summary, we will look it into spaceRooms local
       // cache which we maintain as we load summary in UI.
       if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) {
-        const childItem: HierarchyItem = {
+        const childItem: HierarchyItemSpace = {
           roomId: childId,
           content: childEvent.getContent<MSpaceChildContent>(),
           ts: childEvent.getTs(),
@@ -85,28 +89,34 @@ const getHierarchySpaces = (
   return spaceItems;
 };
 
+export type SpaceHierarchy = {
+  space: HierarchyItemSpace;
+  rooms?: HierarchyItemRoom[];
+};
 const getSpaceHierarchy = (
   rootSpaceId: string,
   spaceRooms: Set<string>,
   getRoom: (roomId: string) => Room | undefined,
   closedCategory: (spaceId: string) => boolean
-): HierarchyItem[] => {
-  const spaceItems: HierarchyItem[] = getHierarchySpaces(rootSpaceId, getRoom, spaceRooms);
+): SpaceHierarchy[] => {
+  const spaceItems: HierarchyItemSpace[] = getHierarchySpaces(rootSpaceId, getRoom, spaceRooms);
 
-  const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => {
+  const hierarchy: SpaceHierarchy[] = spaceItems.map((spaceItem) => {
     const space = getRoom(spaceItem.roomId);
     if (!space || closedCategory(spaceItem.roomId)) {
-      return [spaceItem];
+      return {
+        space: spaceItem,
+      };
     }
     const childEvents = getStateEvents(space, StateEvent.SpaceChild);
-    const childItems: HierarchyItem[] = [];
+    const childItems: HierarchyItemRoom[] = [];
     childEvents.forEach((childEvent) => {
       if (!isValidChild(childEvent)) return;
       const childId = childEvent.getStateKey();
       if (!childId || !isRoomId(childId)) return;
       if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) return;
 
-      const childItem: HierarchyItem = {
+      const childItem: HierarchyItemRoom = {
         roomId: childId,
         content: childEvent.getContent<MSpaceChildContent>(),
         ts: childEvent.getTs(),
@@ -114,7 +124,11 @@ const getSpaceHierarchy = (
       };
       childItems.push(childItem);
     });
-    return [spaceItem, ...childItems.sort(hierarchyItemTs).sort(hierarchyItemByOrder)];
+
+    return {
+      space: spaceItem,
+      rooms: childItems.sort(hierarchyItemTs).sort(hierarchyItemByOrder),
+    };
   });
 
   return hierarchy;
@@ -125,7 +139,7 @@ export const useSpaceHierarchy = (
   spaceRooms: Set<string>,
   getRoom: (roomId: string) => Room | undefined,
   closedCategory: (spaceId: string) => boolean
-): HierarchyItem[] => {
+): SpaceHierarchy[] => {
   const mx = useMatrixClient();
   const roomToParents = useAtomValue(roomToParentsAtom);
 
@@ -163,7 +177,7 @@ const getSpaceJoinedHierarchy = (
   excludeRoom: (parentId: string, roomId: string) => boolean,
   sortRoomItems: (parentId: string, items: HierarchyItem[]) => HierarchyItem[]
 ): HierarchyItem[] => {
-  const spaceItems: HierarchyItem[] = getHierarchySpaces(rootSpaceId, getRoom, new Set());
+  const spaceItems: HierarchyItemSpace[] = getHierarchySpaces(rootSpaceId, getRoom, new Set());
 
   const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => {
     const space = getRoom(spaceItem.roomId);
@@ -182,14 +196,14 @@ const getSpaceJoinedHierarchy = (
 
     if (joinedRoomEvents.length === 0) return [];
 
-    const childItems: HierarchyItem[] = [];
+    const childItems: HierarchyItemRoom[] = [];
     joinedRoomEvents.forEach((childEvent) => {
       const childId = childEvent.getStateKey();
       if (!childId) return;
 
       if (excludeRoom(space.roomId, childId)) return;
 
-      const childItem: HierarchyItem = {
+      const childItem: HierarchyItemRoom = {
         roomId: childId,
         content: childEvent.getContent<MSpaceChildContent>(),
         ts: childEvent.getTs(),
@@ -251,3 +265,85 @@ export const useSpaceJoinedHierarchy = (
 
   return hierarchy;
 };
+
+// we will paginate until 5000 items
+const PER_PAGE_COUNT = 100;
+const MAX_AUTO_PAGE_COUNT = 50;
+export type FetchSpaceHierarchyLevelData = {
+  fetching: boolean;
+  error: Error | null;
+  rooms: Map<string, IHierarchyRoom>;
+};
+export const useFetchSpaceHierarchyLevel = (
+  roomId: string,
+  enable: boolean
+): FetchSpaceHierarchyLevelData => {
+  const mx = useMatrixClient();
+  const pageNoRef = useRef(0);
+
+  const fetchLevel: QueryFunction<
+    Awaited<ReturnType<typeof mx.getRoomHierarchy>>,
+    string[],
+    string | undefined
+  > = useCallback(
+    ({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, 1, false, pageParam),
+    [roomId, mx]
+  );
+
+  const queryResponse = useInfiniteQuery({
+    refetchOnMount: enable,
+    queryKey: [roomId, 'hierarchy_level'],
+    initialPageParam: undefined,
+    queryFn: fetchLevel,
+    getNextPageParam: (result) => {
+      if (result.next_batch) return result.next_batch;
+      return undefined;
+    },
+    retry: 5,
+    retryDelay: (failureCount, error) => {
+      if (error instanceof MatrixError && error.errcode === ErrorCode.M_LIMIT_EXCEEDED) {
+        const { retry_after_ms: delay } = error.data;
+        if (typeof delay === 'number') {
+          return delay;
+        }
+      }
+
+      return 500 * failureCount;
+    },
+  });
+
+  const { data, isLoading, isFetchingNextPage, error, fetchNextPage, hasNextPage } = queryResponse;
+
+  useEffect(() => {
+    if (
+      hasNextPage &&
+      pageNoRef.current <= MAX_AUTO_PAGE_COUNT &&
+      !error &&
+      data &&
+      data.pages.length > 0
+    ) {
+      pageNoRef.current += 1;
+      fetchNextPage();
+    }
+  }, [fetchNextPage, hasNextPage, data, error]);
+
+  const rooms: Map<string, IHierarchyRoom> = useMemo(() => {
+    const roomsMap: Map<string, IHierarchyRoom> = new Map();
+    if (!data) return roomsMap;
+
+    const rms = data.pages.flatMap((result) => result.rooms);
+    rms.forEach((r) => {
+      roomsMap.set(r.room_id, r);
+    });
+
+    return roomsMap;
+  }, [data]);
+
+  const fetching = isLoading || isFetchingNextPage;
+
+  return {
+    fetching,
+    error,
+    rooms,
+  };
+};
index 8480498d456497cd0643d486085ddecd90ab7ea9..94abe2bc8d601211c3d966115e6c3a67eb5fcdeb 100644 (file)
@@ -23,32 +23,37 @@ const baseSpaceRoomsAtom = atomWithLocalStorage<Set<string>>(
 type SpaceRoomsAction =
   | {
       type: 'PUT';
-      roomId: string;
+      roomIds: string[];
     }
   | {
       type: 'DELETE';
-      roomId: string;
+      roomIds: string[];
     };
 
 export const spaceRoomsAtom = atom<Set<string>, [SpaceRoomsAction], undefined>(
   (get) => get(baseSpaceRoomsAtom),
   (get, set, action) => {
-    if (action.type === 'DELETE') {
+    const current = get(baseSpaceRoomsAtom);
+    const { type, roomIds } = action;
+
+    if (type === 'DELETE' && roomIds.find((roomId) => current.has(roomId))) {
       set(
         baseSpaceRoomsAtom,
-        produce(get(baseSpaceRoomsAtom), (draft) => {
-          draft.delete(action.roomId);
+        produce(current, (draft) => {
+          roomIds.forEach((roomId) => draft.delete(roomId));
         })
       );
       return;
     }
-    if (action.type === 'PUT') {
-      set(
-        baseSpaceRoomsAtom,
-        produce(get(baseSpaceRoomsAtom), (draft) => {
-          draft.add(action.roomId);
-        })
-      );
+    if (type === 'PUT') {
+      const newEntries = roomIds.filter((roomId) => !current.has(roomId));
+      if (newEntries.length > 0)
+        set(
+          baseSpaceRoomsAtom,
+          produce(current, (draft) => {
+            newEntries.forEach((roomId) => draft.add(roomId));
+          })
+        );
     }
   }
 );