From f67f08f1db39de04e2d9af5ab3d6bc57fa312e17 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Mon, 6 Jul 2026 16:06:50 +1000 Subject: [PATCH] feat: Enhance space hierarchy management by implementing asynchronous room fetching and improving room handling logic --- src/app/features/lobby/Lobby.tsx | 53 ++++-- src/app/features/lobby/RoomItem.tsx | 4 +- src/app/features/lobby/SpaceHierarchy.tsx | 23 ++- src/app/hooks/useSpaceHierarchy.ts | 205 ++++++++++++++++++---- src/app/pages/client/space/Space.tsx | 9 + 5 files changed, 238 insertions(+), 56 deletions(-) diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index 2207888..6d9a362 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -1,4 +1,4 @@ -import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react'; +import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Box, Chip, IconButton, Line, Scroll, Spinner, Text, config } from 'folds'; import { Icon, Icons } from '../../components/icons'; import { useVirtualizer } from '@tanstack/react-virtual'; @@ -13,6 +13,8 @@ import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../com import { HierarchyItem, HierarchyItemSpace, + fetchAllHierarchyRooms, + isHierarchySpaceRoom, useSpaceHierarchy, } from '../../hooks/useSpaceHierarchy'; import { VirtualTile } from '../../components/virtualizer'; @@ -41,7 +43,7 @@ import { getSpaceRoomPath } from '../../pages/pathUtils'; import { StateEvent } from '../../../types/matrix/room'; import { CanDropCallback, useDnDMonitor } from './DnD'; import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable'; -import { getStateEvent } from '../../utils/room'; +import { getStateEvent, shouldShowForumLobby } from '../../utils/room'; import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories'; import { makeCinnySpacesContent, @@ -161,6 +163,7 @@ function SpaceCardLobby() { const allRooms = useAtomValue(allRoomsAtom); const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); const space = useSpace(); + const isForum = shouldShowForumLobby(space); const spacePowerLevels = usePowerLevels(space); const lex = useMemo(() => new ASCIILexicalTable(' '.charCodeAt(0), '~'.charCodeAt(0), 6), []); const members = useRoomMembers(mx, space.roomId); @@ -189,6 +192,30 @@ function SpaceCardLobby() { const [spacesItems, setSpacesItem] = useState>(() => new Map()); + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const all = await fetchAllHierarchyRooms(mx, space.roomId, 3); + if (cancelled) return; + setSpaceRooms({ + type: 'PUT', + roomIds: all.filter(isHierarchySpaceRoom).map((r) => r.room_id), + }); + setSpacesItem((current) => + produce(current, (draft) => { + all.forEach((r) => draft.set(r.room_id, r)); + }) + ); + } catch { + // hierarchy may be unavailable for some memberships + } + })(); + return () => { + cancelled = true; + }; + }, [mx, space.roomId, setSpaceRooms]); + useElementSizeObserver( useCallback(() => heroSectionRef.current, []), useCallback((w, height) => setHeroSectionHeight(height), []) @@ -206,7 +233,8 @@ function SpaceCardLobby() { closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) || (draggingItem ? 'space' in draggingItem : false), [closedCategories, space.roomId, draggingItem] - ) + ), + spacesItems ); const virtualizer = useVirtualizer({ @@ -400,14 +428,16 @@ function SpaceCardLobby() { ); 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; + (items: IHierarchyRoom[]) => { + setSpaceRooms({ + type: 'PUT', + roomIds: items.filter(isHierarchySpaceRoom).map((i) => i.room_id), }); + setSpacesItem((current) => + produce(current, (draft) => { + items.forEach((item) => draft.set(item.room_id, item)); + }) + ); }, [setSpaceRooms] ); @@ -491,6 +521,7 @@ function SpaceCardLobby() { ); diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx index 1cce168..264ff3b 100644 --- a/src/app/features/lobby/RoomItem.tsx +++ b/src/app/features/lobby/RoomItem.tsx @@ -267,7 +267,7 @@ type RoomItemCardProps = { dm?: boolean; firstChild?: boolean; lastChild?: boolean; - onOpen: MouseEventHandler; + onOpen?: MouseEventHandler; options?: ReactNode; before?: ReactNode; after?: ReactNode; @@ -332,7 +332,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>( suggested={content.suggested} joinRule={localSummary.joinRule} options={ - joined ? ( + joined && onOpen ? ( ; spaceItem: HierarchyItemSpace; roomItems?: HierarchyItemRoom[]; allJoinedRooms: Set; @@ -39,12 +41,13 @@ type SpaceHierarchyProps = { pinned: boolean; togglePinToSidebar: (roomId: string) => void; onSpacesFound: (spaceItems: IHierarchyRoom[]) => void; - onOpenRoom: MouseEventHandler; + onOpenRoom?: MouseEventHandler; }; export const SpaceHierarchy = forwardRef( ( { summary, + hierarchySummaries, spaceItem, roomItems, allJoinedRooms, @@ -73,7 +76,7 @@ export const SpaceHierarchy = forwardRef( const subspaces = useMemo(() => { const s: Map = new Map(); rooms.forEach((r) => { - if (r.room_type === RoomType.Space || r.room_type === RoomType.Forum) { + if (isHierarchySpaceRoom(r)) { s.set(r.room_id, r); } }); @@ -97,8 +100,8 @@ export const SpaceHierarchy = forwardRef( getRoomPermissionsAPI(parentCreators, parentPowerLevels); useEffect(() => { - onSpacesFound(Array.from(subspaces.values())); - }, [subspaces, onSpacesFound]); + onSpacesFound(Array.from(rooms.values())); + }, [rooms, onSpacesFound]); // Build a global set of all sub-room IDs by checking ALL joined rooms // This ensures sub-rooms are hidden even if their parent isn't in this space @@ -134,7 +137,11 @@ export const SpaceHierarchy = forwardRef( // 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); + const inaccessibleRoom = + !rooms.get(i.roomId) && + !hierarchySummaries.has(i.roomId) && + !fetching && + (error ? forbidden : true); return !inaccessibleRoom; }); } @@ -185,7 +192,7 @@ export const SpaceHierarchy = forwardRef( {childItems && childItems.length > 0 ? ( {childItems.map((roomItem, index) => { - const roomSummary = rooms.get(roomItem.roomId); + const roomSummary = rooms.get(roomItem.roomId) ?? hierarchySummaries.get(roomItem.roomId); const roomPowerLevels = roomsPowerLevels.get(roomItem.roomId) ?? {}; @@ -238,7 +245,7 @@ export const SpaceHierarchy = forwardRef( {subRoomIds.map((subRoomId) => { const subRoom = mx.getRoom(subRoomId); - const subRoomSummary = rooms.get(subRoomId); + const subRoomSummary = rooms.get(subRoomId) ?? hierarchySummaries.get(subRoomId); const subRoomPowerLevels = roomsPowerLevels.get(subRoomId) ?? {}; const subRoomItem: HierarchyItemRoom = { roomId: subRoomId, diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index 7219e0d..eae4975 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -1,11 +1,11 @@ import { atom, useAtom, useAtomValue } from 'jotai'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { MatrixError, Room } from 'matrix-js-sdk'; +import { MatrixClient, 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'; +import { MSpaceChildContent, RoomType, StateEvent } from '../../types/matrix/room'; import { getAllParents, getStateEvents, isSpace, isValidChild } from '../utils/room'; import { isRoomId } from '../utils/matrix'; import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort'; @@ -35,10 +35,88 @@ const hierarchyItemTs: SortFunc = (a, b) => byTsOldToNew(a.ts, b. const hierarchyItemByOrder: SortFunc = (a, b) => byOrderKey(a.content.order, b.content.order); +// we will paginate until 5000 items +const PER_PAGE_COUNT = 100; +const MAX_AUTO_PAGE_COUNT = 50; + +export function buildParentByRoomId(rooms: Iterable): Map { + const parentByRoomId = new Map(); + for (const room of rooms) { + if (!room?.room_id || !Array.isArray(room.children_state)) continue; + for (const childState of room.children_state) { + if (childState?.type !== StateEvent.SpaceChild) continue; + if (typeof childState.state_key !== 'string') continue; + parentByRoomId.set(childState.state_key, room.room_id); + } + } + return parentByRoomId; +} + +/** Space or forum container in hierarchy API (room_type may be absent on some servers). */ +export function isHierarchySpaceRoom(room: IHierarchyRoom | undefined): boolean { + if (!room) return false; + if (room.room_type === RoomType.Space || room.room_type === RoomType.Forum) return true; + if (Array.isArray(room.children_state)) { + return room.children_state.some((c) => c?.type === StateEvent.SpaceChild); + } + return false; +} + +export async function fetchAllHierarchyRooms( + mx: MatrixClient, + spaceId: string, + maxDepth = 3 +): Promise { + const allRooms: IHierarchyRoom[] = []; + let nextBatch: string | undefined; + + for (let page = 0; page < MAX_AUTO_PAGE_COUNT; page += 1) { + const result = await mx.getRoomHierarchy(spaceId, PER_PAGE_COUNT, maxDepth, false, nextBatch); + allRooms.push(...result.rooms); + nextBatch = result.next_batch; + if (!nextBatch) break; + } + + return allRooms; +} + +const isChildSpace = ( + childId: string, + getRoom: GetRoomCallback, + spaceRooms: Set, + hierarchySummaries: Map +): boolean => + isSpace(getRoom(childId) ?? null) || + spaceRooms.has(childId) || + isHierarchySpaceRoom(hierarchySummaries.get(childId)); + +const getChildRoomsFromSummaries = ( + spaceItemId: string, + hierarchySummaries: Map, + parentByRoomId: Map, + spaceRooms: Set, + getRoom: GetRoomCallback +): HierarchyItemRoom[] => { + const childItems: HierarchyItemRoom[] = []; + hierarchySummaries.forEach((summary, roomId) => { + if (parentByRoomId.get(roomId) !== spaceItemId) return; + if (isChildSpace(roomId, getRoom, spaceRooms, hierarchySummaries)) return; + + childItems.push({ + roomId, + content: { via: summary.via ?? [] }, + ts: 0, + parentId: spaceItemId, + }); + }); + return childItems; +}; + const getHierarchySpaces = ( rootSpaceId: string, getRoom: GetRoomCallback, - spaceRooms: Set + spaceRooms: Set, + hierarchySummaries: Map ): HierarchyItemSpace[] => { const rootSpaceItem: HierarchyItemSpace = { roomId: rootSpaceId, @@ -47,33 +125,47 @@ const getHierarchySpaces = ( space: true, }; let spaceItems: HierarchyItemSpace[] = []; + const parentByRoomId = buildParentByRoomId(hierarchySummaries.values()); const findAndCollectHierarchySpaces = (spaceItem: HierarchyItemSpace) => { if (spaceItems.find((item) => item.roomId === spaceItem.roomId)) return; const space = getRoom(spaceItem.roomId); spaceItems.push(spaceItem); - if (!space) return; - const childEvents = getStateEvents(space, StateEvent.SpaceChild); + if (space) { + const childEvents = getStateEvents(space, StateEvent.SpaceChild); - childEvents.forEach((childEvent) => { - if (!isValidChild(childEvent)) return; - const childId = childEvent.getStateKey(); - if (!childId || !isRoomId(childId)) return; + childEvents.forEach((childEvent) => { + if (!isValidChild(childEvent)) return; + const childId = childEvent.getStateKey(); + if (!childId || !isRoomId(childId)) return; - // because we can not find if a childId is space without joining - // or requesting room summary, we will look it into spaceRooms local - // cache which we maintain as we load summary in UI. - if (isSpace(getRoom(childId) ?? null) || spaceRooms.has(childId)) { - const childItem: HierarchyItemSpace = { - roomId: childId, - content: childEvent.getContent(), - ts: childEvent.getTs(), - space: true, - parentId: spaceItem.roomId, - }; - findAndCollectHierarchySpaces(childItem); - } + if (isChildSpace(childId, getRoom, spaceRooms, hierarchySummaries)) { + const childItem: HierarchyItemSpace = { + roomId: childId, + content: childEvent.getContent(), + ts: childEvent.getTs(), + space: true, + parentId: spaceItem.roomId, + }; + findAndCollectHierarchySpaces(childItem); + } + }); + } + + // Unjoined sub-spaces: discover children from hierarchy API summaries. + hierarchySummaries.forEach((summary, roomId) => { + if (parentByRoomId.get(roomId) !== spaceItem.roomId) return; + if (!isChildSpace(roomId, getRoom, spaceRooms, hierarchySummaries)) return; + if (spaceItems.find((item) => item.roomId === roomId)) return; + + findAndCollectHierarchySpaces({ + roomId, + content: { via: summary.via ?? [] }, + ts: 0, + space: true, + parentId: spaceItem.roomId, + }); }); }; findAndCollectHierarchySpaces(rootSpaceItem); @@ -97,13 +189,35 @@ const getSpaceHierarchy = ( rootSpaceId: string, spaceRooms: Set, getRoom: (roomId: string) => Room | undefined, - closedCategory: (spaceId: string) => boolean + closedCategory: (spaceId: string) => boolean, + hierarchySummaries: Map ): SpaceHierarchy[] => { - const spaceItems: HierarchyItemSpace[] = getHierarchySpaces(rootSpaceId, getRoom, spaceRooms); + const spaceItems: HierarchyItemSpace[] = getHierarchySpaces( + rootSpaceId, + getRoom, + spaceRooms, + hierarchySummaries + ); + const parentByRoomId = buildParentByRoomId(hierarchySummaries.values()); const hierarchy: SpaceHierarchy[] = spaceItems.map((spaceItem) => { const space = getRoom(spaceItem.roomId); if (!space || closedCategory(spaceItem.roomId)) { + if (!space && !closedCategory(spaceItem.roomId) && hierarchySummaries.size > 0) { + const summaryRooms = getChildRoomsFromSummaries( + spaceItem.roomId, + hierarchySummaries, + parentByRoomId, + spaceRooms, + getRoom + ); + if (summaryRooms.length > 0) { + return { + space: spaceItem, + rooms: summaryRooms.sort(hierarchyItemTs).sort(hierarchyItemByOrder), + }; + } + } return { space: spaceItem, }; @@ -114,7 +228,7 @@ const getSpaceHierarchy = ( if (!isValidChild(childEvent)) return; const childId = childEvent.getStateKey(); if (!childId || !isRoomId(childId)) return; - if (isSpace(getRoom(childId) ?? null) || spaceRooms.has(childId)) return; + if (isChildSpace(childId, getRoom, spaceRooms, hierarchySummaries)) return; const childItem: HierarchyItemRoom = { roomId: childId, @@ -125,6 +239,20 @@ const getSpaceHierarchy = ( childItems.push(childItem); }); + const summaryRooms = getChildRoomsFromSummaries( + spaceItem.roomId, + hierarchySummaries, + parentByRoomId, + spaceRooms, + getRoom + ); + const roomIds = new Set(childItems.map((i) => i.roomId)); + summaryRooms.forEach((item) => { + if (!roomIds.has(item.roomId)) { + childItems.push(item); + } + }); + return { space: spaceItem, rooms: childItems.sort(hierarchyItemTs).sort(hierarchyItemByOrder), @@ -138,19 +266,22 @@ export const useSpaceHierarchy = ( spaceId: string, spaceRooms: Set, getRoom: (roomId: string) => Room | undefined, - closedCategory: (spaceId: string) => boolean + closedCategory: (spaceId: string) => boolean, + hierarchySummaries: Map ): SpaceHierarchy[] => { const mx = useMatrixClient(); const roomToParents = useAtomValue(roomToParentsAtom); const [hierarchyAtom] = useState(() => - atom(getSpaceHierarchy(spaceId, spaceRooms, getRoom, closedCategory)) + atom(getSpaceHierarchy(spaceId, spaceRooms, getRoom, closedCategory, hierarchySummaries)) ); const [hierarchy, setHierarchy] = useAtom(hierarchyAtom); useEffect(() => { - setHierarchy(getSpaceHierarchy(spaceId, spaceRooms, getRoom, closedCategory)); - }, [mx, spaceId, spaceRooms, setHierarchy, getRoom, closedCategory]); + setHierarchy( + getSpaceHierarchy(spaceId, spaceRooms, getRoom, closedCategory, hierarchySummaries) + ); + }, [mx, spaceId, spaceRooms, setHierarchy, getRoom, closedCategory, hierarchySummaries]); useStateEventCallback( mx, @@ -161,10 +292,12 @@ export const useSpaceHierarchy = ( if (!eventRoomId) return; if (spaceId === eventRoomId || getAllParents(roomToParents, eventRoomId).has(spaceId)) { - setHierarchy(getSpaceHierarchy(spaceId, spaceRooms, getRoom, closedCategory)); + setHierarchy( + getSpaceHierarchy(spaceId, spaceRooms, getRoom, closedCategory, hierarchySummaries) + ); } }, - [spaceId, roomToParents, setHierarchy, spaceRooms, getRoom, closedCategory] + [spaceId, roomToParents, setHierarchy, spaceRooms, getRoom, closedCategory, hierarchySummaries] ) ); @@ -177,7 +310,12 @@ const getSpaceJoinedHierarchy = ( excludeRoom: (parentId: string, roomId: string) => boolean, sortRoomItems: (parentId: string, items: HierarchyItem[]) => HierarchyItem[] ): HierarchyItem[] => { - const spaceItems: HierarchyItemSpace[] = getHierarchySpaces(rootSpaceId, getRoom, new Set()); + const spaceItems: HierarchyItemSpace[] = getHierarchySpaces( + rootSpaceId, + getRoom, + new Set(), + new Map() + ); const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => { const space = getRoom(spaceItem.roomId); @@ -266,9 +404,6 @@ 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; diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 193c16c..1c1eabc 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -339,6 +339,7 @@ export function Space() { // Forum feed unless user opened manage, search, or a room (not tied to URL alias encoding). const feedActive = isForum && !lobbySelected && !searchSelected && !selectedRoomId; const showForumFeedSidebar = feedActive; + const forumManageMode = isForum && lobbySelected; if (showForumFeedSidebar) { return ( @@ -351,6 +352,14 @@ export function Space() { ); } + if (forumManageMode) { + return ( + + + + ); + } + const spacePath = getCanonicalAliasOrRoomId(mx, space.roomId); return (