From c0799142d6f667c782c0551e3625e6eca1512ae9 Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Thu, 19 Jun 2025 22:36:52 +0200 Subject: [PATCH 01/34] Fix room header members icon not filled when enabled --- src/app/features/room/RoomViewHeader.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 352ae4b58..edc3170e1 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -34,7 +34,7 @@ import { RoomTopicViewer } from '../../components/room-topic-viewer'; import { StateEvent } from '../../../types/matrix/room'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoom } from '../../hooks/useRoom'; -import { useSetSetting, useSetting } from '../../state/hooks/settings'; +import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils'; @@ -230,7 +230,7 @@ export function RoomViewHeader() { ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; - const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); + const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); const handleSearchClick = () => { const searchParams: _SearchPathSearchParams = { @@ -410,7 +410,7 @@ export function RoomViewHeader() { > {(triggerRef) => ( setPeopleDrawer((drawer) => !drawer)}> - + )} From 2fae418132d27e576ef7e801402fd85306597648 Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Fri, 27 Jun 2025 10:05:56 +0200 Subject: [PATCH 02/34] fix hierarchy indenting and order --- src/app/hooks/useSpaceHierarchy.ts | 57 ++++++++++++++++------------ src/app/pages/client/space/Space.tsx | 47 +++++++++++++++++------ 2 files changed, 68 insertions(+), 36 deletions(-) diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index ad34e3f45..34f5e1235 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -1,6 +1,6 @@ import { atom, useAtom, useAtomValue } from 'jotai'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { MatrixError, Room } from 'matrix-js-sdk'; +import { MatrixError, MatrixEvent, 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'; @@ -18,6 +18,7 @@ export type HierarchyItemSpace = { ts: number; space: true; parentId?: string; + depth: number; }; export type HierarchyItemRoom = { @@ -25,6 +26,7 @@ export type HierarchyItemRoom = { content: MSpaceChildContent; ts: number; parentId: string; + depth: number; }; export type HierarchyItem = HierarchyItemSpace | HierarchyItemRoom; @@ -35,6 +37,10 @@ const hierarchyItemTs: SortFunc = (a, b) => byTsOldToNew(a.ts, b. const hierarchyItemByOrder: SortFunc = (a, b) => byOrderKey(a.content.order, b.content.order); +const childEventTs: SortFunc = (a, b) => byTsOldToNew(a.getTs(), b.getTs()); +const childEventByOrder: SortFunc = (a, b) => + byOrderKey(a.getContent().order, b.getContent().order); + const getHierarchySpaces = ( rootSpaceId: string, getRoom: GetRoomCallback, @@ -45,8 +51,9 @@ const getHierarchySpaces = ( content: { via: [] }, ts: 0, space: true, + depth: 0, }; - let spaceItems: HierarchyItemSpace[] = []; + const spaceItems: HierarchyItemSpace[] = []; const findAndCollectHierarchySpaces = (spaceItem: HierarchyItemSpace) => { if (spaceItems.find((item) => item.roomId === spaceItem.roomId)) return; @@ -55,37 +62,37 @@ const getHierarchySpaces = ( if (!space) return; const childEvents = getStateEvents(space, StateEvent.SpaceChild); + childEvents + .filter((childEvent) => { + if (!isValidChild(childEvent)) return false; + const childId = childEvent.getStateKey(); + if (!childId || !isRoomId(childId)) return false; + + // 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. + return getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId); + }) + .sort(childEventTs) + .sort(childEventByOrder); 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 (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) { - const childItem: HierarchyItemSpace = { - roomId: childId, - content: childEvent.getContent(), - ts: childEvent.getTs(), - space: true, - parentId: spaceItem.roomId, - }; - findAndCollectHierarchySpaces(childItem); - } + const childItem: HierarchyItemSpace = { + roomId: childId, + content: childEvent.getContent(), + ts: childEvent.getTs(), + space: true, + parentId: spaceItem.roomId, + depth: spaceItem.depth + 1, + }; + findAndCollectHierarchySpaces(childItem); }); }; findAndCollectHierarchySpaces(rootSpaceItem); - spaceItems = [ - rootSpaceItem, - ...spaceItems - .filter((item) => item.roomId !== rootSpaceId) - .sort(hierarchyItemTs) - .sort(hierarchyItemByOrder), - ]; - return spaceItems; }; @@ -121,6 +128,7 @@ const getSpaceHierarchy = ( content: childEvent.getContent(), ts: childEvent.getTs(), parentId: spaceItem.roomId, + depth: spaceItem.depth, }; childItems.push(childItem); }); @@ -208,6 +216,7 @@ const getSpaceJoinedHierarchy = ( content: childEvent.getContent(), ts: childEvent.getTs(), parentId: spaceItem.roomId, + depth: spaceItem.depth, }; childItems.push(childItem); }); diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index d10094646..2868701f4 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -318,7 +318,8 @@ export function Space() { useCallback( (parentId, roomId) => { if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) { - return false; + // REWORK HOW THIS WORKS? + return false; // This does not account for sub-subspaces, best way to do? - first fix useSpaceHie... } const showRoom = roomToUnread.has(roomId) || roomId === selectedRoomId; if (showRoom) return false; @@ -346,6 +347,12 @@ export function Space() { const getToLink = (roomId: string) => getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); + const getCategoryPadding = (depth: number): string | undefined => { + if (depth === 0) return undefined; + if (depth === 1) return config.space.S400; + return config.space.S200; + }; + return ( @@ -392,12 +399,17 @@ export function Space() { }} > {virtualizer.getVirtualItems().map((vItem) => { - const { roomId } = hierarchy[vItem.index] ?? {}; + const { roomId, depth } = hierarchy[vItem.index] ?? {}; const room = mx.getRoom(roomId); if (!room) return null; + const paddingLeft = `calc((${depth} - 1) * ${config.space.S200})`; + if (room.isSpaceRoom()) { const categoryId = makeNavCategoryId(space.roomId, roomId); + const closed = closedCategories.has(categoryId); + + const paddingTop = getCategoryPadding(depth); return ( -
+
{roomId === space.roomId ? 'Rooms' : room?.name} @@ -422,14 +440,19 @@ export function Space() { return ( - +
+ +
); })} From 6b3841cd2bd741fe002995efdd21d85a2527b707 Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Sat, 28 Jun 2025 02:31:41 +0200 Subject: [PATCH 03/34] Partially fix collapse behaviour --- src/app/hooks/useSpaceHierarchy.ts | 16 +++++- src/app/pages/client/space/Space.tsx | 86 +++++++++++++++++++++++++--- 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index 34f5e1235..81fa81725 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -44,6 +44,7 @@ const childEventByOrder: SortFunc = (a, b) => const getHierarchySpaces = ( rootSpaceId: string, getRoom: GetRoomCallback, + excludeRoom: (parentId: string, roomId: string) => boolean, spaceRooms: Set ): HierarchyItemSpace[] => { const rootSpaceItem: HierarchyItemSpace = { @@ -79,6 +80,7 @@ const getHierarchySpaces = ( childEvents.forEach((childEvent) => { const childId = childEvent.getStateKey(); if (!childId || !isRoomId(childId)) return; + if (excludeRoom(spaceItem.roomId, childId)) return; const childItem: HierarchyItemSpace = { roomId: childId, @@ -106,7 +108,12 @@ const getSpaceHierarchy = ( getRoom: (roomId: string) => Room | undefined, closedCategory: (spaceId: string) => boolean ): SpaceHierarchy[] => { - const spaceItems: HierarchyItemSpace[] = getHierarchySpaces(rootSpaceId, getRoom, spaceRooms); + const spaceItems: HierarchyItemSpace[] = getHierarchySpaces( + rootSpaceId, + getRoom, + () => false, + spaceRooms + ); const hierarchy: SpaceHierarchy[] = spaceItems.map((spaceItem) => { const space = getRoom(spaceItem.roomId); @@ -185,7 +192,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, + excludeRoom, + new Set() + ); const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => { const space = getRoom(spaceItem.roomId); diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 2868701f4..076484ca3 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -292,6 +292,7 @@ export function Space() { const scrollRef = useRef(null); const mDirects = useAtomValue(mDirectAtom); const roomToUnread = useAtomValue(roomToUnreadAtom); + const roomToParents = useAtomValue(roomToParentsAtom); const allRooms = useAtomValue(allRoomsAtom); const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); const notificationPreferences = useRoomsNotificationPreferencesContext(); @@ -312,24 +313,93 @@ export function Space() { [mx, allJoinedRooms] ); + /** + * Recursively checks if a given parentId (and its ancestors) is in a closed category. + * + * @param spaceId - The root space ID to check against. + * @param parentId - The parent room or space ID to start the check from. + * @returns True if parentId or any ancestor is in a closed category. + */ + const getInClosedCategories = useCallback( + (spaceId: string, parentId: string): boolean => { + if (closedCategories.has(makeNavCategoryId(spaceId, parentId))) { + return true; + } + + const parentParentIds = roomToParents.get(parentId); + if (!parentParentIds || parentParentIds.size === 0) { + return false; + } + parentParentIds.forEach((id) => getInClosedCategories(spaceId, id)); + + return false; + }, + [closedCategories, roomToParents] + ); + + // There are a lot better ways to do this + const roomToChildren = useMemo(() => { + const map = new Map>(); + roomToParents.forEach((parentSet, childId) => { + parentSet.forEach((parentId) => { + if (!map.has(parentId)) map.set(parentId, new Set()); + /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ + map.get(parentId)!.add(childId); + }); + }); + return map; + }, [roomToParents]); + + /** + * Recursively checks if the given room or any of its descendants should be visible. + * + * @param roomId - The room ID to check. + * @returns True if the room or any descendant should be visible. + */ + const getContainsShowRoom = useCallback( + (roomId: string): boolean => { + if (roomToUnread.has(roomId) || roomId === selectedRoomId) { + return true; + } + + const childIds = roomToChildren.get(roomId); + console.log('CHILDREN'); + console.log(childIds?.forEach((childId) => getRoom(childId)?.name)); + if (!childIds || childIds.size === 0) { + return false; + } + + // CHILD CATEGORY SHOULD COLLAPSE IF PARENT IS COLLAPSED (but retain their set state when expanded?) + // WHY ARE CHILDREN DISPLAYED? + let visible = false; + childIds.forEach((id) => { + if (getContainsShowRoom(id)) { + visible = true; + } + }); + + return visible; + }, + [roomToUnread, selectedRoomId, roomToChildren] + ); + const hierarchy = useSpaceJoinedHierarchy( space.roomId, getRoom, useCallback( (parentId, roomId) => { - if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) { - // REWORK HOW THIS WORKS? - return false; // This does not account for sub-subspaces, best way to do? - first fix useSpaceHie... + if (!getInClosedCategories(space.roomId, parentId)) { + return false; } - const showRoom = roomToUnread.has(roomId) || roomId === selectedRoomId; - if (showRoom) return false; + if (getContainsShowRoom(roomId)) return false; return true; }, - [space.roomId, closedCategories, roomToUnread, selectedRoomId] + [getContainsShowRoom, getInClosedCategories, space.roomId] ), useCallback( - (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)), - [closedCategories, space.roomId] + //IS CATEGORY CLOSED - SHOULD BE CLOSED IF PARENT IS, add new param?? HOW IS IT HANDLED + (sId) => getInClosedCategories(space.roomId, sId), + [getInClosedCategories, space.roomId] ) ); From a9fa1aada800800d170ea1f0654a217bb6079a74 Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Sat, 28 Jun 2025 14:14:44 +0200 Subject: [PATCH 04/34] remove getInCollapedCategories --- src/app/pages/client/space/Space.tsx | 38 +++++----------------------- 1 file changed, 6 insertions(+), 32 deletions(-) diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 076484ca3..1999120cc 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -313,30 +313,6 @@ export function Space() { [mx, allJoinedRooms] ); - /** - * Recursively checks if a given parentId (and its ancestors) is in a closed category. - * - * @param spaceId - The root space ID to check against. - * @param parentId - The parent room or space ID to start the check from. - * @returns True if parentId or any ancestor is in a closed category. - */ - const getInClosedCategories = useCallback( - (spaceId: string, parentId: string): boolean => { - if (closedCategories.has(makeNavCategoryId(spaceId, parentId))) { - return true; - } - - const parentParentIds = roomToParents.get(parentId); - if (!parentParentIds || parentParentIds.size === 0) { - return false; - } - parentParentIds.forEach((id) => getInClosedCategories(spaceId, id)); - - return false; - }, - [closedCategories, roomToParents] - ); - // There are a lot better ways to do this const roomToChildren = useMemo(() => { const map = new Map>(); @@ -363,14 +339,11 @@ export function Space() { } const childIds = roomToChildren.get(roomId); - console.log('CHILDREN'); - console.log(childIds?.forEach((childId) => getRoom(childId)?.name)); if (!childIds || childIds.size === 0) { return false; } // CHILD CATEGORY SHOULD COLLAPSE IF PARENT IS COLLAPSED (but retain their set state when expanded?) - // WHY ARE CHILDREN DISPLAYED? let visible = false; childIds.forEach((id) => { if (getContainsShowRoom(id)) { @@ -388,18 +361,19 @@ export function Space() { getRoom, useCallback( (parentId, roomId) => { - if (!getInClosedCategories(space.roomId, parentId)) { + // closedCategories.has(makeNavCategoryId(spaceId, parentId)) + // NOT SURE THIS IS NEEDED - children of hidden spaces are not displayed + if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) { return false; } if (getContainsShowRoom(roomId)) return false; return true; }, - [getContainsShowRoom, getInClosedCategories, space.roomId] + [closedCategories, getContainsShowRoom, space.roomId] ), useCallback( - //IS CATEGORY CLOSED - SHOULD BE CLOSED IF PARENT IS, add new param?? HOW IS IT HANDLED - (sId) => getInClosedCategories(space.roomId, sId), - [getInClosedCategories, space.roomId] + (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)), + [closedCategories, space.roomId] ) ); From 6ff0260e8d67f1d85d79ff0528c41f213442c916 Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Sat, 28 Jun 2025 23:59:00 +0200 Subject: [PATCH 05/34] fix collapse behaviour --- src/app/pages/client/space/Space.tsx | 86 ++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 12 deletions(-) diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 1999120cc..a84c0e305 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -313,7 +313,37 @@ export function Space() { [mx, allJoinedRooms] ); - // There are a lot better ways to do this + /** + * Recursively checks if a given parentId (and its ancestors) is in a closed category. + * + * @param spaceId - The root space ID. + * @param parentId - The parent space ID to start the check from. + * @returns True if parentId or any ancestor is in a closed category. + */ + const getInClosedCategories = useCallback( + (spaceId: string, parentId: string): boolean => { + if (closedCategories.has(makeNavCategoryId(spaceId, parentId))) { + return true; + } + + const parentParentIds = roomToParents.get(parentId); + if (!parentParentIds || parentParentIds.size === 0) { + return false; + } + + let closed = false; + parentParentIds.forEach((id) => { + if (getInClosedCategories(spaceId, id)) { + closed = true; + } + }); + + return closed; + }, + [closedCategories, roomToParents] + ); + + // There are better ways to do this const roomToChildren = useMemo(() => { const map = new Map>(); roomToParents.forEach((parentSet, childId) => { @@ -343,7 +373,6 @@ export function Space() { return false; } - // CHILD CATEGORY SHOULD COLLAPSE IF PARENT IS COLLAPSED (but retain their set state when expanded?) let visible = false; childIds.forEach((id) => { if (getContainsShowRoom(id)) { @@ -356,24 +385,46 @@ export function Space() { [roomToUnread, selectedRoomId, roomToChildren] ); + /** + * Determines whether all parent categories are collapsed. + * + * @param spaceId - The root space ID. + * @param roomId - The room ID to start the check from. + * @returns True if every parent category is collapsed; false otherwise. + */ + + const getAllAncestorsCollapsed = (spaceId: string, roomId: string): boolean => { + const parentIds = roomToParents.get(roomId); + + if (!parentIds || parentIds.size === 0) { + return false; + } + + let allCollapsed = true; + parentIds.forEach((id) => { + if (!getInClosedCategories(spaceId, id)) { + allCollapsed = false; + } + }); + return allCollapsed; + }; + const hierarchy = useSpaceJoinedHierarchy( space.roomId, getRoom, useCallback( (parentId, roomId) => { - // closedCategories.has(makeNavCategoryId(spaceId, parentId)) - // NOT SURE THIS IS NEEDED - children of hidden spaces are not displayed - if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) { + if (!getInClosedCategories(space.roomId, parentId)) { return false; } if (getContainsShowRoom(roomId)) return false; return true; }, - [closedCategories, getContainsShowRoom, space.roomId] + [getContainsShowRoom, getInClosedCategories, space.roomId] ), useCallback( - (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)), - [closedCategories, space.roomId] + (sId) => getInClosedCategories(space.roomId, sId), + [getInClosedCategories, space.roomId] ) ); @@ -384,9 +435,18 @@ export function Space() { overscan: 10, }); - const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => - closedCategories.has(categoryId) - ); + const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => { + const collapsed = closedCategories.has(categoryId); + const [spaceId, roomId] = categoryId.split('|').slice(-2); + + // Only prevent collapsing if all parents are collapsed + const toggleable = !getAllAncestorsCollapsed(spaceId, roomId); + + if (toggleable) { + return collapsed; + } + return !collapsed; + }); const getToLink = (roomId: string) => getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); @@ -451,7 +511,8 @@ export function Space() { if (room.isSpaceRoom()) { const categoryId = makeNavCategoryId(space.roomId, roomId); - const closed = closedCategories.has(categoryId); + const closed = getInClosedCategories(space.roomId, roomId); + const toggleable = !getAllAncestorsCollapsed(space.roomId, roomId); const paddingTop = getCategoryPadding(depth); @@ -473,6 +534,7 @@ export function Space() { onClick={handleCategoryClick} closed={closed} aria-expanded={!closed} + aria-disabled={!toggleable} > {roomId === space.roomId ? 'Rooms' : room?.name} From a55065b1de1733fb42a29905f005dda33e911c77 Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Sun, 29 Jun 2025 13:58:05 +0200 Subject: [PATCH 06/34] bugfix --- src/app/hooks/useSpaceHierarchy.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index 81fa81725..54478505d 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -62,12 +62,12 @@ const getHierarchySpaces = ( spaceItems.push(spaceItem); if (!space) return; - const childEvents = getStateEvents(space, StateEvent.SpaceChild); - childEvents + const childEvents = getStateEvents(space, StateEvent.SpaceChild) .filter((childEvent) => { if (!isValidChild(childEvent)) return false; const childId = childEvent.getStateKey(); if (!childId || !isRoomId(childId)) return false; + if (excludeRoom(spaceItem.roomId, childId)) return false; // because we can not find if a childId is space without joining // or requesting room summary, we will look it into spaceRooms local @@ -80,7 +80,6 @@ const getHierarchySpaces = ( childEvents.forEach((childEvent) => { const childId = childEvent.getStateKey(); if (!childId || !isRoomId(childId)) return; - if (excludeRoom(spaceItem.roomId, childId)) return; const childItem: HierarchyItemSpace = { roomId: childId, From 64fc3066a1f09892349722850f26a6ddf2746cbb Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Sun, 29 Jun 2025 20:01:25 +0200 Subject: [PATCH 07/34] improve lobby --- src/app/features/lobby/Lobby.tsx | 46 ++++++++++++++++++++++--- src/app/features/lobby/SpaceItem.tsx | 50 ++++++++++++++++++++++------ src/app/pages/client/space/Space.tsx | 12 +++---- 3 files changed, 87 insertions(+), 21 deletions(-) diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index 069e925eb..6851873ff 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -162,6 +162,7 @@ export function Lobby() { const screenSize = useScreenSizeContext(); const [onTop, setOnTop] = useState(true); const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom()); + const roomToParents = useAtomValue(roomToParentsAtom); const [sidebarItems] = useSidebarItems( useOrphanSpaces(mx, allRoomsAtom, useAtomValue(roomToParentsAtom)) ); @@ -193,6 +194,36 @@ export function Lobby() { [mx] ); + /** + * Recursively checks if a given parentId (or all its ancestors) is in a closed category. + * + * @param spaceId - The root space ID. + * @param parentId - The parent space ID to start the check from. + * @returns True if parentId or all ancestors is in a closed category. + */ + const getInClosedCategories = useCallback( + (spaceId: string, parentId: string): boolean => { + if (closedCategories.has(makeLobbyCategoryId(spaceId, parentId))) { + return true; + } + + const parentParentIds = roomToParents.get(parentId); + if (!parentParentIds || parentParentIds.size === 0) { + return false; + } + + let anyOpen = false; + parentParentIds.forEach((id) => { + if (!getInClosedCategories(spaceId, id)) { + anyOpen = true; + } + }); + + return !anyOpen; + }, + [closedCategories, roomToParents] + ); + const [draggingItem, setDraggingItem] = useState(); const hierarchy = useSpaceHierarchy( space.roomId, @@ -200,9 +231,9 @@ export function Lobby() { getRoom, useCallback( (childId) => - closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) || + getInClosedCategories(space.roomId, childId) || (draggingItem ? 'space' in draggingItem : false), - [closedCategories, space.roomId, draggingItem] + [draggingItem, getInClosedCategories, space.roomId] ) ); @@ -476,14 +507,20 @@ export function Lobby() { const item = hierarchy[vItem.index]; if (!item) return null; const nextSpaceId = hierarchy[vItem.index + 1]?.space.roomId; - const categoryId = makeLobbyCategoryId(space.roomId, item.space.roomId); + const inClosedCategory = getInClosedCategories( + space.roomId, + item.space.roomId + ); + + const paddingLeft = `calc((${item.space.depth} - 1) * ${config.space.S200})`; return ( } > - } - onClick={handleAddSpace} - aria-pressed={!!cords} - > - Add Space - + {item.parentId === undefined ? ( + } + onClick={handleAddSpace} + aria-pressed={!!cords} + > + Add Space + + ) : ( + + Add Space + + } + > + {(triggerRef) => ( + + + + )} + + )} ); } @@ -473,7 +503,7 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>( {canEditChild && ( - {item.parentId === undefined && } + )} diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index a84c0e305..bb79dcac7 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -314,11 +314,11 @@ export function Space() { ); /** - * Recursively checks if a given parentId (and its ancestors) is in a closed category. + * Recursively checks if a given parentId (or all its ancestors) is in a closed category. * * @param spaceId - The root space ID. * @param parentId - The parent space ID to start the check from. - * @returns True if parentId or any ancestor is in a closed category. + * @returns True if parentId or all ancestors is in a closed category. */ const getInClosedCategories = useCallback( (spaceId: string, parentId: string): boolean => { @@ -331,14 +331,14 @@ export function Space() { return false; } - let closed = false; + let anyOpen = false; parentParentIds.forEach((id) => { - if (getInClosedCategories(spaceId, id)) { - closed = true; + if (!getInClosedCategories(spaceId, id)) { + anyOpen = true; } }); - return closed; + return !anyOpen; }, [closedCategories, roomToParents] ); From 56dfc6a497574b51ff259942cc9d7f4be8d12226 Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Mon, 30 Jun 2025 14:17:32 +0200 Subject: [PATCH 08/34] Undo breaking change --- src/app/features/lobby/Lobby.tsx | 53 ++++++++++++++++++++++++---- src/app/pages/client/space/Space.tsx | 20 +++++++---- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index 6851873ff..709334a5b 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -199,10 +199,18 @@ export function Lobby() { * * @param spaceId - The root space ID. * @param parentId - The parent space ID to start the check from. + * @param previousId - The last ID checked, only used to ignore root collapse state. * @returns True if parentId or all ancestors is in a closed category. */ const getInClosedCategories = useCallback( - (spaceId: string, parentId: string): boolean => { + (spaceId: string, parentId: string, previousId?: string): boolean => { + // Ignore root space being collapsed if in a subspace, + // this is due to many spaces dumping all rooms in the top-level space. + if (parentId === spaceId) { + if (previousId) { + if (getRoom(previousId)?.isSpaceRoom() || spaceRooms.has(previousId)) return false; + } + } if (closedCategories.has(makeLobbyCategoryId(spaceId, parentId))) { return true; } @@ -214,16 +222,40 @@ export function Lobby() { let anyOpen = false; parentParentIds.forEach((id) => { - if (!getInClosedCategories(spaceId, id)) { + if (!getInClosedCategories(spaceId, id, parentId)) { anyOpen = true; } }); return !anyOpen; }, - [closedCategories, roomToParents] + [closedCategories, getRoom, roomToParents, spaceRooms] ); + /** + * Determines whether all parent categories are collapsed. + * + * @param spaceId - The root space ID. + * @param roomId - The room ID to start the check from. + * @returns True if every parent category is collapsed; false otherwise. + */ + + const getAllAncestorsCollapsed = (spaceId: string, roomId: string): boolean => { + const parentIds = roomToParents.get(roomId); + + if (!parentIds || parentIds.size === 0) { + return false; + } + + let allCollapsed = true; + parentIds.forEach((id) => { + if (!getInClosedCategories(spaceId, id, roomId)) { + allCollapsed = false; + } + }); + return allCollapsed; + }; + const [draggingItem, setDraggingItem] = useState(); const hierarchy = useSpaceHierarchy( space.roomId, @@ -443,9 +475,18 @@ export function Lobby() { [setSpaceRooms] ); - const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => - closedCategories.has(categoryId) - ); + const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => { + const collapsed = closedCategories.has(categoryId); + const [spaceId, roomId] = categoryId.split('|').slice(-2); + + // Only prevent collapsing if all parents are collapsed + const toggleable = !getAllAncestorsCollapsed(spaceId, roomId); + + if (toggleable) { + return collapsed; + } + return !collapsed; + }); const handleOpenRoom: MouseEventHandler = (evt) => { const rId = evt.currentTarget.getAttribute('data-room-id'); diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index bb79dcac7..c99ed4bc5 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -321,7 +321,15 @@ export function Space() { * @returns True if parentId or all ancestors is in a closed category. */ const getInClosedCategories = useCallback( - (spaceId: string, parentId: string): boolean => { + (spaceId: string, parentId: string, previousId?: string): boolean => { + // Ignore root space being collapsed if in a subspace, + // this is due to many spaces dumping all rooms in the top-level space. + if (parentId === spaceId) { + if (previousId) { + if (getRoom(previousId)?.isSpaceRoom()) return false; + } + } + if (closedCategories.has(makeNavCategoryId(spaceId, parentId))) { return true; } @@ -333,14 +341,14 @@ export function Space() { let anyOpen = false; parentParentIds.forEach((id) => { - if (!getInClosedCategories(spaceId, id)) { + if (!getInClosedCategories(spaceId, id, parentId)) { anyOpen = true; } }); return !anyOpen; }, - [closedCategories, roomToParents] + [closedCategories, getRoom, roomToParents] ); // There are better ways to do this @@ -402,7 +410,7 @@ export function Space() { let allCollapsed = true; parentIds.forEach((id) => { - if (!getInClosedCategories(spaceId, id)) { + if (!getInClosedCategories(spaceId, id, roomId)) { allCollapsed = false; } }); @@ -414,7 +422,7 @@ export function Space() { getRoom, useCallback( (parentId, roomId) => { - if (!getInClosedCategories(space.roomId, parentId)) { + if (!getInClosedCategories(space.roomId, parentId, roomId)) { return false; } if (getContainsShowRoom(roomId)) return false; @@ -423,7 +431,7 @@ export function Space() { [getContainsShowRoom, getInClosedCategories, space.roomId] ), useCallback( - (sId) => getInClosedCategories(space.roomId, sId), + (sId) => getInClosedCategories(space.roomId, sId, sId), [getInClosedCategories, space.roomId] ) ); From e417b18835311a828a4a360853f707d55ec90903 Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Mon, 30 Jun 2025 16:31:40 +0200 Subject: [PATCH 09/34] minor changes --- src/app/features/lobby/Lobby.tsx | 1 - src/app/pages/client/space/Space.tsx | 10 +++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index 709334a5b..ede695b4c 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -239,7 +239,6 @@ export function Lobby() { * @param roomId - The room ID to start the check from. * @returns True if every parent category is collapsed; false otherwise. */ - const getAllAncestorsCollapsed = (spaceId: string, roomId: string): boolean => { const parentIds = roomToParents.get(roomId); diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index c99ed4bc5..cb94b8afa 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -318,6 +318,7 @@ export function Space() { * * @param spaceId - The root space ID. * @param parentId - The parent space ID to start the check from. + * @param previousId - The last ID checked, only used to ignore root collapse state. * @returns True if parentId or all ancestors is in a closed category. */ const getInClosedCategories = useCallback( @@ -354,13 +355,14 @@ export function Space() { // There are better ways to do this const roomToChildren = useMemo(() => { const map = new Map>(); + roomToParents.forEach((parentSet, childId) => { parentSet.forEach((parentId) => { if (!map.has(parentId)) map.set(parentId, new Set()); - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - map.get(parentId)!.add(childId); + map.get(parentId)?.add(childId); }); }); + return map; }, [roomToParents]); @@ -400,10 +402,8 @@ export function Space() { * @param roomId - The room ID to start the check from. * @returns True if every parent category is collapsed; false otherwise. */ - const getAllAncestorsCollapsed = (spaceId: string, roomId: string): boolean => { const parentIds = roomToParents.get(roomId); - if (!parentIds || parentIds.size === 0) { return false; } @@ -431,7 +431,7 @@ export function Space() { [getContainsShowRoom, getInClosedCategories, space.roomId] ), useCallback( - (sId) => getInClosedCategories(space.roomId, sId, sId), + (sId) => getInClosedCategories(space.roomId, sId), [getInClosedCategories, space.roomId] ) ); From 8580c2d3049cd8e7da500c1117c7fa6fdea1f4b1 Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Mon, 30 Jun 2025 16:39:01 +0200 Subject: [PATCH 10/34] Revert "Fix room header members icon not filled when enabled" This reverts commit c0799142d6f667c782c0551e3625e6eca1512ae9. --- src/app/features/room/RoomViewHeader.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index edc3170e1..352ae4b58 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -34,7 +34,7 @@ import { RoomTopicViewer } from '../../components/room-topic-viewer'; import { StateEvent } from '../../../types/matrix/room'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoom } from '../../hooks/useRoom'; -import { useSetting } from '../../state/hooks/settings'; +import { useSetSetting, useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils'; @@ -230,7 +230,7 @@ export function RoomViewHeader() { ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; - const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); + const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); const handleSearchClick = () => { const searchParams: _SearchPathSearchParams = { @@ -410,7 +410,7 @@ export function RoomViewHeader() { > {(triggerRef) => ( setPeopleDrawer((drawer) => !drawer)}> - + )} From 9b5ce37743b6195f1eec5fb8826cf7da710ac41d Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Thu, 3 Jul 2025 22:51:26 +0200 Subject: [PATCH 11/34] bugfix and polishing --- src/app/features/lobby/Lobby.tsx | 13 +++++++++---- src/app/pages/client/space/Space.tsx | 20 ++++---------------- src/app/state/closedLobbyCategories.ts | 2 ++ src/app/state/closedNavCategories.ts | 2 ++ src/app/state/room/roomToChildren.ts | 16 ++++++++++++++++ 5 files changed, 33 insertions(+), 20 deletions(-) create mode 100644 src/app/state/room/roomToChildren.ts diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index ede695b4c..5dd54fa02 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -32,7 +32,7 @@ import { useRoomsPowerLevels, } from '../../hooks/usePowerLevels'; import { mDirectAtom } from '../../state/mDirectList'; -import { makeLobbyCategoryId } from '../../state/closedLobbyCategories'; +import { makeLobbyCategoryId, getLobbyCategoryIdParts } from '../../state/closedLobbyCategories'; import { useCategoryHandler } from '../../hooks/useCategoryHandler'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { allRoomsAtom } from '../../state/room-list/roomList'; @@ -74,6 +74,11 @@ const useCanDropLobbyItem = ( const containerSpaceId = space.roomId; + // only allow to be dropped in parent space + if (item.parentId !== container.item.roomId && item.parentId !== container.item.parentId) { + return false; + } + if ( getRoom(containerSpaceId) === undefined || !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {}) @@ -368,7 +373,7 @@ export function Lobby() { // remove from current space if (item.parentId !== containerParentId) { - mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId); + await mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId); } if ( @@ -388,7 +393,7 @@ export function Lobby() { joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? []; allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId }); - mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, { + await mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, { ...joinRuleContent, allow, }); @@ -476,7 +481,7 @@ export function Lobby() { const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => { const collapsed = closedCategories.has(categoryId); - const [spaceId, roomId] = categoryId.split('|').slice(-2); + const [spaceId, roomId] = getLobbyCategoryIdParts(categoryId); // Only prevent collapsing if all parents are collapsed const toggleable = !getAllAncestorsCollapsed(spaceId, roomId); diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index cb94b8afa..015e014e6 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -45,7 +45,7 @@ import { import { useSpace } from '../../../hooks/useSpace'; import { VirtualTile } from '../../../components/virtualizer'; import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; -import { makeNavCategoryId } from '../../../state/closedNavCategories'; +import { makeNavCategoryId, getNavCategoryIdParts } from '../../../state/closedNavCategories'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; @@ -57,6 +57,7 @@ import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels import { openInviteUser } from '../../../../client/action/navigation'; import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList'; import { roomToParentsAtom } from '../../../state/room/roomToParents'; +import { roomToChildrenAtom } from '../../../state/room/roomToChildren'; import { markAsRead } from '../../../../client/action/notifications'; import { useRoomsUnread } from '../../../state/hooks/unread'; import { UseStateProvider } from '../../../components/UseStateProvider'; @@ -293,6 +294,7 @@ export function Space() { const mDirects = useAtomValue(mDirectAtom); const roomToUnread = useAtomValue(roomToUnreadAtom); const roomToParents = useAtomValue(roomToParentsAtom); + const roomToChildren = useAtomValue(roomToChildrenAtom); const allRooms = useAtomValue(allRoomsAtom); const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); const notificationPreferences = useRoomsNotificationPreferencesContext(); @@ -352,20 +354,6 @@ export function Space() { [closedCategories, getRoom, roomToParents] ); - // There are better ways to do this - const roomToChildren = useMemo(() => { - const map = new Map>(); - - roomToParents.forEach((parentSet, childId) => { - parentSet.forEach((parentId) => { - if (!map.has(parentId)) map.set(parentId, new Set()); - map.get(parentId)?.add(childId); - }); - }); - - return map; - }, [roomToParents]); - /** * Recursively checks if the given room or any of its descendants should be visible. * @@ -445,7 +433,7 @@ export function Space() { const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => { const collapsed = closedCategories.has(categoryId); - const [spaceId, roomId] = categoryId.split('|').slice(-2); + const [spaceId, roomId] = getNavCategoryIdParts(categoryId); // Only prevent collapsing if all parents are collapsed const toggleable = !getAllAncestorsCollapsed(spaceId, roomId); diff --git a/src/app/state/closedLobbyCategories.ts b/src/app/state/closedLobbyCategories.ts index 40ecd1632..22bf2d76d 100644 --- a/src/app/state/closedLobbyCategories.ts +++ b/src/app/state/closedLobbyCategories.ts @@ -66,3 +66,5 @@ export const makeClosedLobbyCategoriesAtom = (userId: string): ClosedLobbyCatego }; export const makeLobbyCategoryId = (...args: string[]): string => args.join('|'); + +export const getLobbyCategoryIdParts = (categoryId: string): string[] => categoryId.split('|'); diff --git a/src/app/state/closedNavCategories.ts b/src/app/state/closedNavCategories.ts index ea61cb2e9..f2e39a278 100644 --- a/src/app/state/closedNavCategories.ts +++ b/src/app/state/closedNavCategories.ts @@ -66,3 +66,5 @@ export const makeClosedNavCategoriesAtom = (userId: string): ClosedNavCategories }; export const makeNavCategoryId = (...args: string[]): string => args.join('|'); + +export const getNavCategoryIdParts = (categoryId: string): string[] => categoryId.split('|'); diff --git a/src/app/state/room/roomToChildren.ts b/src/app/state/room/roomToChildren.ts new file mode 100644 index 000000000..ae0f4f24f --- /dev/null +++ b/src/app/state/room/roomToChildren.ts @@ -0,0 +1,16 @@ +import { atom } from 'jotai'; +import { roomToParentsAtom } from './roomToParents'; + +export const roomToChildrenAtom = atom((get) => { + const roomToParents = get(roomToParentsAtom); + const map = new Map>(); + + roomToParents.forEach((parentSet, childId) => { + parentSet.forEach((parentId) => { + if (!map.has(parentId)) map.set(parentId, new Set()); + map.get(parentId)?.add(childId); + }); + }); + + return map; +}); From 18082ff400b833b2b30a342580e9c3c35e296403 Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Fri, 25 Jul 2025 16:38:44 +0200 Subject: [PATCH 12/34] show space header if subspaces contain rooms --- src/app/hooks/useSpaceHierarchy.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index 54478505d..0baba2827 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -198,6 +198,29 @@ const getSpaceJoinedHierarchy = ( new Set() ); + /** + * Recursively checks if the given space or any of its descendants contain non-space rooms. + * + * @param roomId - The space ID to check. + * @returns True if the space or any descendant contains non-space rooms. + */ + const containsRoom = (spaceId: string) => { + const space = getRoom(spaceId); + if (!space) return false; + + const childEvents = getStateEvents(space, StateEvent.SpaceChild).filter(isValidChild); + + return childEvents.some((childEvent): boolean => { + const childId = childEvent.getStateKey(); + if (!childId || !isRoomId(childId)) return false; + const room = getRoom(childId); + if (!room) return false; + + if (!room.isSpaceRoom()) return true; + return containsRoom(childId); + }); + }; + const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => { const space = getRoom(spaceItem.roomId); if (!space) { @@ -213,7 +236,7 @@ const getSpaceJoinedHierarchy = ( return true; }); - if (joinedRoomEvents.length === 0) return []; + if (!containsRoom(spaceItem.roomId)) return []; const childItems: HierarchyItemRoom[] = []; joinedRoomEvents.forEach((childEvent) => { From b4d7f520c5abb7c35390cc76da5d6a188de1415c Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Fri, 25 Jul 2025 16:51:56 +0200 Subject: [PATCH 13/34] fix docstring --- src/app/hooks/useSpaceHierarchy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index 0baba2827..bec7a0350 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -201,7 +201,7 @@ const getSpaceJoinedHierarchy = ( /** * Recursively checks if the given space or any of its descendants contain non-space rooms. * - * @param roomId - The space ID to check. + * @param spaceId - The space ID to check. * @returns True if the space or any descendant contains non-space rooms. */ const containsRoom = (spaceId: string) => { From df8ba32725a3d03508558eb2ce43bd8dce31b89d Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Sun, 27 Jul 2025 16:33:49 +0200 Subject: [PATCH 14/34] improve function naming --- src/app/hooks/useSpaceHierarchy.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index bec7a0350..62cca99b7 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -204,7 +204,7 @@ const getSpaceJoinedHierarchy = ( * @param spaceId - The space ID to check. * @returns True if the space or any descendant contains non-space rooms. */ - const containsRoom = (spaceId: string) => { + const getContainsRoom = (spaceId: string) => { const space = getRoom(spaceId); if (!space) return false; @@ -217,7 +217,7 @@ const getSpaceJoinedHierarchy = ( if (!room) return false; if (!room.isSpaceRoom()) return true; - return containsRoom(childId); + return getContainsRoom(childId); }); }; @@ -236,7 +236,7 @@ const getSpaceJoinedHierarchy = ( return true; }); - if (!containsRoom(spaceItem.roomId)) return []; + if (!getContainsRoom(spaceItem.roomId)) return []; const childItems: HierarchyItemRoom[] = []; joinedRoomEvents.forEach((childEvent) => { From 4947efe2744bf224e89cea644fd9df58ea421687 Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Mon, 28 Jul 2025 16:42:50 +0200 Subject: [PATCH 15/34] improve performance --- src/app/features/lobby/Lobby.tsx | 22 +++++++++++++++++--- src/app/hooks/useSpaceHierarchy.ts | 3 ++- src/app/pages/client/space/Space.tsx | 31 ++++++++++++++++++++++++++-- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index 5dd54fa02..ef6ea8ef8 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, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config } from 'folds'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useAtom, useAtomValue } from 'jotai'; @@ -199,6 +199,11 @@ export function Lobby() { [mx] ); + const closedCategoriesCache = useRef(new Map()); + useEffect(() => { + closedCategoriesCache.current.clear(); + }, [closedCategories, roomToParents, getRoom]); + /** * Recursively checks if a given parentId (or all its ancestors) is in a closed category. * @@ -209,19 +214,29 @@ export function Lobby() { */ const getInClosedCategories = useCallback( (spaceId: string, parentId: string, previousId?: string): boolean => { + const categoryId = makeLobbyCategoryId(spaceId, parentId); + if (closedCategoriesCache.current.has(categoryId)) { + return closedCategoriesCache.current.get(categoryId); + } + // Ignore root space being collapsed if in a subspace, // this is due to many spaces dumping all rooms in the top-level space. if (parentId === spaceId) { if (previousId) { - if (getRoom(previousId)?.isSpaceRoom() || spaceRooms.has(previousId)) return false; + if (getRoom(previousId)?.isSpaceRoom() || spaceRooms.has(previousId)) { + closedCategoriesCache.current.set(categoryId, false); + return false; + } } } - if (closedCategories.has(makeLobbyCategoryId(spaceId, parentId))) { + if (closedCategories.has(categoryId)) { + closedCategoriesCache.current.set(categoryId, true); return true; } const parentParentIds = roomToParents.get(parentId); if (!parentParentIds || parentParentIds.size === 0) { + closedCategoriesCache.current.set(categoryId, false); return false; } @@ -232,6 +247,7 @@ export function Lobby() { } }); + closedCategoriesCache.current.set(categoryId, !anyOpen); return !anyOpen; }, [closedCategories, getRoom, roomToParents, spaceRooms] diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index 62cca99b7..52dd12b75 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -208,9 +208,10 @@ const getSpaceJoinedHierarchy = ( const space = getRoom(spaceId); if (!space) return false; - const childEvents = getStateEvents(space, StateEvent.SpaceChild).filter(isValidChild); + const childEvents = getStateEvents(space, StateEvent.SpaceChild); return childEvents.some((childEvent): boolean => { + if (!isValidChild(childEvent)) return false; const childId = childEvent.getStateKey(); if (!childId || !isRoomId(childId)) return false; const room = getRoom(childId); diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 5ad9cf68d..6e8c51b87 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -2,6 +2,7 @@ import React, { MouseEventHandler, forwardRef, useCallback, + useEffect, useMemo, useRef, useState, @@ -315,6 +316,13 @@ export function Space() { [mx, allJoinedRooms] ); + const closedCategoriesCache = useRef(new Map()); + const ancestorsCollapsedCache = useRef(new Map()); + useEffect(() => { + closedCategoriesCache.current.clear(); + ancestorsCollapsedCache.current.clear(); + }, [closedCategories, roomToParents, getRoom]); + /** * Recursively checks if a given parentId (or all its ancestors) is in a closed category. * @@ -325,20 +333,30 @@ export function Space() { */ const getInClosedCategories = useCallback( (spaceId: string, parentId: string, previousId?: string): boolean => { + const categoryId = makeNavCategoryId(spaceId, parentId); + if (closedCategoriesCache.current.has(categoryId)) { + return closedCategoriesCache.current.get(categoryId); + } + // Ignore root space being collapsed if in a subspace, // this is due to many spaces dumping all rooms in the top-level space. if (parentId === spaceId) { if (previousId) { - if (getRoom(previousId)?.isSpaceRoom()) return false; + if (getRoom(previousId)?.isSpaceRoom()) { + closedCategoriesCache.current.set(categoryId, false); + return false; + } } } - if (closedCategories.has(makeNavCategoryId(spaceId, parentId))) { + if (closedCategories.has(categoryId)) { + closedCategoriesCache.current.set(categoryId, true); return true; } const parentParentIds = roomToParents.get(parentId); if (!parentParentIds || parentParentIds.size === 0) { + closedCategoriesCache.current.set(categoryId, false); return false; } @@ -349,6 +367,7 @@ export function Space() { } }); + closedCategoriesCache.current.set(categoryId, !anyOpen); return !anyOpen; }, [closedCategories, getRoom, roomToParents] @@ -391,8 +410,14 @@ export function Space() { * @returns True if every parent category is collapsed; false otherwise. */ const getAllAncestorsCollapsed = (spaceId: string, roomId: string): boolean => { + const categoryId = makeNavCategoryId(spaceId, roomId); + if (ancestorsCollapsedCache.current.has(categoryId)) { + return ancestorsCollapsedCache.current.get(categoryId); + } + const parentIds = roomToParents.get(roomId); if (!parentIds || parentIds.size === 0) { + ancestorsCollapsedCache.current.set(categoryId, false); return false; } @@ -402,6 +427,8 @@ export function Space() { allCollapsed = false; } }); + + ancestorsCollapsedCache.current.set(categoryId, allCollapsed); return allCollapsed; }; From 24b3b9cf52814d72b790703f30a9a5ae12cdbc61 Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Wed, 17 Sep 2025 00:06:36 +0200 Subject: [PATCH 16/34] clean up conflicts --- src/app/features/lobby/Lobby.tsx | 10 ---------- src/app/features/lobby/SpaceItem.tsx | 7 +++---- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index 3a3cd0b6a..2589f21a2 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -194,16 +194,6 @@ export function Lobby() { const getRoom = useGetRoom(allJoinedRooms); - const canEditSpaceChild = useCallback( - (powerLevels: IPowerLevels) => - powerLevelAPI.canSendStateEvent( - powerLevels, - StateEvent.SpaceChild, - powerLevelAPI.getPowerLevel(powerLevels, mx.getUserId() ?? undefined) - ), - [mx] - ); - const closedCategoriesCache = useRef(new Map()); useEffect(() => { closedCategoriesCache.current.clear(); diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index 8177c4afb..77b349bdc 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -362,7 +362,6 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) { } > - {item.parentId === undefined ? ( Add Space - {addExisting && ( - setAddExisting(false)} /> - )} ) : ( )} + {addExisting && ( + setAddExisting(false)} /> + )} ); } From d7f85dec27a6114b9a7a5a6b5c45350e9c06bccd Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Thu, 12 Feb 2026 19:50:52 +0100 Subject: [PATCH 17/34] Documentation, fix recursion error --- src/app/features/lobby/Lobby.tsx | 5 +++-- src/app/hooks/useSpaceHierarchy.ts | 9 +++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index 2589f21a2..20850f75c 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -235,13 +235,14 @@ export function Lobby() { return false; } + // As a subspace can be in multiple spaces, + // only return true if all parent spaces are closed. let anyOpen = false; parentParentIds.forEach((id) => { if (!getInClosedCategories(spaceId, id, parentId)) { anyOpen = true; } }); - closedCategoriesCache.current.set(categoryId, !anyOpen); return !anyOpen; }, @@ -491,7 +492,7 @@ export function Lobby() { const collapsed = closedCategories.has(categoryId); const [spaceId, roomId] = getLobbyCategoryIdParts(categoryId); - // Only prevent collapsing if all parents are collapsed + // Prevent collapsing if all parents are collapsed const toggleable = !getAllAncestorsCollapsed(spaceId, roomId); if (toggleable) { diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index 52dd12b75..aae06d94d 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -204,10 +204,15 @@ const getSpaceJoinedHierarchy = ( * @param spaceId - The space ID to check. * @returns True if the space or any descendant contains non-space rooms. */ - const getContainsRoom = (spaceId: string) => { + const getContainsRoom = (spaceId: string, visited: Set = new Set()) => { + // Prevent infinite recursion + if (visited.has(spaceId)) return false; + const space = getRoom(spaceId); if (!space) return false; + visited.add(spaceId); + const childEvents = getStateEvents(space, StateEvent.SpaceChild); return childEvents.some((childEvent): boolean => { @@ -218,7 +223,7 @@ const getSpaceJoinedHierarchy = ( if (!room) return false; if (!room.isSpaceRoom()) return true; - return getContainsRoom(childId); + return getContainsRoom(childId, visited); }); }; From dce97cff536d98d2941ac00e176ce063b36b94ac Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Thu, 12 Feb 2026 20:34:44 +0100 Subject: [PATCH 18/34] minor formatting --- src/app/features/lobby/Lobby.tsx | 1 + src/app/pages/client/space/Space.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index 20850f75c..23d41204c 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -224,6 +224,7 @@ export function Lobby() { } } } + if (closedCategories.has(categoryId)) { closedCategoriesCache.current.set(categoryId, true); return true; diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index a78e67aed..81b4a8817 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -452,13 +452,14 @@ export function Space() { return false; } + // As a subspace can be in multiple spaces, + // only return true if all parent spaces are closed. let anyOpen = false; parentParentIds.forEach((id) => { if (!getInClosedCategories(spaceId, id, parentId)) { anyOpen = true; } }); - closedCategoriesCache.current.set(categoryId, !anyOpen); return !anyOpen; }, From 056adb88b89a82871379506e570cc90040415aab Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Fri, 13 Feb 2026 02:00:17 +0100 Subject: [PATCH 19/34] Use spaceRooms in Space, bugfixing, fix Lobby recursion --- src/app/features/add-existing/AddExisting.tsx | 35 +++++++++++++++++-- src/app/features/lobby/Lobby.tsx | 19 +++++----- src/app/hooks/useSpaceHierarchy.ts | 3 +- src/app/pages/client/space/Space.tsx | 23 ++++++------ 4 files changed, 53 insertions(+), 27 deletions(-) diff --git a/src/app/features/add-existing/AddExisting.tsx b/src/app/features/add-existing/AddExisting.tsx index cbae018f2..a934e7908 100644 --- a/src/app/features/add-existing/AddExisting.tsx +++ b/src/app/features/add-existing/AddExisting.tsx @@ -87,13 +87,44 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM const allRoomsSet = useAllJoinedRoomsSet(); const getRoom = useGetRoom(allRoomsSet); + /** + * Recursively checks if a given sourceId room is an ancestor to the targetId space. + * + * @param sourceId - The room to check. + * @param targetId - The space ID to check against. + * @returns True if rId is an ancestor of targetId. + */ + const isAncestor = useCallback( + (sourceId: string, targetId: string, visited: Set = new Set()): boolean => { + // Prevent infinite recursion + if (visited.has(targetId)) return false; + visited.add(targetId); + + const parentIds = roomIdToParents.get(targetId); + if (!parentIds) return false; + + if (parentIds.has(sourceId)) { + return true; + } + + let hasAncestor = false; + parentIds.forEach((id) => { + if (isAncestor(sourceId, id, visited)) { + hasAncestor = true; + } + }); + return hasAncestor; + }, + [roomIdToParents] + ); + const allItems: string[] = useMemo(() => { const rIds = space ? [...spaces] : [...rooms, ...directs]; return rIds - .filter((rId) => rId !== parentId && !roomIdToParents.get(rId)?.has(parentId)) + .filter((rId) => rId !== parentId && !isAncestor(rId, parentId)) .sort(factoryRoomIdByAtoZ(mx)); - }, [spaces, rooms, directs, space, parentId, roomIdToParents, mx]); + }, [space, spaces, rooms, directs, mx, parentId, isAncestor]); const getRoomNameStr: SearchItemStrGetter = useCallback( (rId) => getRoom(rId)?.name ?? rId, diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index 23d41204c..6c4bda637 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -209,22 +209,19 @@ export function Lobby() { */ const getInClosedCategories = useCallback( (spaceId: string, parentId: string, previousId?: string): boolean => { - const categoryId = makeLobbyCategoryId(spaceId, parentId); - if (closedCategoriesCache.current.has(categoryId)) { - return closedCategoriesCache.current.get(categoryId); - } - // Ignore root space being collapsed if in a subspace, // this is due to many spaces dumping all rooms in the top-level space. - if (parentId === spaceId) { - if (previousId) { - if (getRoom(previousId)?.isSpaceRoom() || spaceRooms.has(previousId)) { - closedCategoriesCache.current.set(categoryId, false); - return false; - } + if (parentId === spaceId && previousId) { + if (spaceRooms.has(previousId) || getRoom(previousId)?.isSpaceRoom()) { + return false; } } + const categoryId = makeLobbyCategoryId(spaceId, parentId); + if (closedCategoriesCache.current.has(categoryId)) { + return closedCategoriesCache.current.get(categoryId); + } + if (closedCategories.has(categoryId)) { closedCategoriesCache.current.set(categoryId, true); return true; diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index aae06d94d..2956af880 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -207,12 +207,11 @@ const getSpaceJoinedHierarchy = ( const getContainsRoom = (spaceId: string, visited: Set = new Set()) => { // Prevent infinite recursion if (visited.has(spaceId)) return false; + visited.add(spaceId); const space = getRoom(spaceId); if (!space) return false; - visited.add(spaceId); - const childEvents = getStateEvents(space, StateEvent.SpaceChild); return childEvents.some((childEvent): boolean => { diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 81b4a8817..104457e79 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -48,6 +48,7 @@ import { } from '../../../hooks/router/useSelectedSpace'; import { useSpace } from '../../../hooks/useSpace'; import { VirtualTile } from '../../../components/virtualizer'; +import { spaceRoomsAtom } from '../../../state/spaceRooms'; import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; import { makeNavCategoryId, getNavCategoryIdParts } from '../../../state/closedNavCategories'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; @@ -387,6 +388,7 @@ export function Space() { const roomToParents = useAtomValue(roomToParentsAtom); const roomToChildren = useAtomValue(roomToChildrenAtom); const allRooms = useAtomValue(allRoomsAtom); + const [spaceRooms] = useAtom(spaceRoomsAtom); const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); const notificationPreferences = useRoomsNotificationPreferencesContext(); @@ -425,22 +427,19 @@ export function Space() { */ const getInClosedCategories = useCallback( (spaceId: string, parentId: string, previousId?: string): boolean => { - const categoryId = makeNavCategoryId(spaceId, parentId); - if (closedCategoriesCache.current.has(categoryId)) { - return closedCategoriesCache.current.get(categoryId); - } - // Ignore root space being collapsed if in a subspace, // this is due to many spaces dumping all rooms in the top-level space. - if (parentId === spaceId) { - if (previousId) { - if (getRoom(previousId)?.isSpaceRoom()) { - closedCategoriesCache.current.set(categoryId, false); - return false; - } + if (parentId === spaceId && previousId) { + if (spaceRooms.has(previousId) || getRoom(previousId)?.isSpaceRoom()) { + return false; } } + const categoryId = makeNavCategoryId(spaceId, parentId); + if (closedCategoriesCache.current.has(categoryId)) { + return closedCategoriesCache.current.get(categoryId); + } + if (closedCategories.has(categoryId)) { closedCategoriesCache.current.set(categoryId, true); return true; @@ -463,7 +462,7 @@ export function Space() { closedCategoriesCache.current.set(categoryId, !anyOpen); return !anyOpen; }, - [closedCategories, getRoom, roomToParents] + [closedCategories, getRoom, roomToParents, spaceRooms] ); /** From 1333ee054970ea12f652fee7aff68be2dcd234cf Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Fri, 13 Feb 2026 10:36:30 +0100 Subject: [PATCH 20/34] optimizations --- src/app/features/add-existing/AddExisting.tsx | 8 +---- src/app/features/lobby/Lobby.tsx | 19 +++-------- src/app/pages/client/space/Space.tsx | 32 ++++++------------- 3 files changed, 14 insertions(+), 45 deletions(-) diff --git a/src/app/features/add-existing/AddExisting.tsx b/src/app/features/add-existing/AddExisting.tsx index a934e7908..03bb32a87 100644 --- a/src/app/features/add-existing/AddExisting.tsx +++ b/src/app/features/add-existing/AddExisting.tsx @@ -107,13 +107,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM return true; } - let hasAncestor = false; - parentIds.forEach((id) => { - if (isAncestor(sourceId, id, visited)) { - hasAncestor = true; - } - }); - return hasAncestor; + return Array.from(parentIds).some((id) => isAncestor(sourceId, id, visited)); }, [roomIdToParents] ); diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index 6c4bda637..d51854d4b 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -235,14 +235,9 @@ export function Lobby() { // As a subspace can be in multiple spaces, // only return true if all parent spaces are closed. - let anyOpen = false; - parentParentIds.forEach((id) => { - if (!getInClosedCategories(spaceId, id, parentId)) { - anyOpen = true; - } - }); - closedCategoriesCache.current.set(categoryId, !anyOpen); - return !anyOpen; + return !Array.from(parentParentIds).some( + (id) => !getInClosedCategories(spaceId, id, parentId) + ); }, [closedCategories, getRoom, roomToParents, spaceRooms] ); @@ -261,13 +256,7 @@ export function Lobby() { return false; } - let allCollapsed = true; - parentIds.forEach((id) => { - if (!getInClosedCategories(spaceId, id, roomId)) { - allCollapsed = false; - } - }); - return allCollapsed; + return !Array.from(parentIds).some((id) => !getInClosedCategories(spaceId, id, roomId)); }; const [draggingItem, setDraggingItem] = useState(); diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 104457e79..42565ab14 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -453,14 +453,11 @@ export function Space() { // As a subspace can be in multiple spaces, // only return true if all parent spaces are closed. - let anyOpen = false; - parentParentIds.forEach((id) => { - if (!getInClosedCategories(spaceId, id, parentId)) { - anyOpen = true; - } - }); - closedCategoriesCache.current.set(categoryId, !anyOpen); - return !anyOpen; + const allClosed = !Array.from(parentParentIds).some( + (id) => !getInClosedCategories(spaceId, id, parentId) + ); + closedCategoriesCache.current.set(categoryId, allClosed); + return allClosed; }, [closedCategories, getRoom, roomToParents, spaceRooms] ); @@ -482,14 +479,7 @@ export function Space() { return false; } - let visible = false; - childIds.forEach((id) => { - if (getContainsShowRoom(id)) { - visible = true; - } - }); - - return visible; + return Array.from(childIds).some((id) => getContainsShowRoom(id)); }, [roomToUnread, selectedRoomId, roomToChildren] ); @@ -513,13 +503,9 @@ export function Space() { return false; } - let allCollapsed = true; - parentIds.forEach((id) => { - if (!getInClosedCategories(spaceId, id, roomId)) { - allCollapsed = false; - } - }); - + const allCollapsed = !Array.from(parentIds).some( + (id) => !getInClosedCategories(spaceId, id, roomId) + ); ancestorsCollapsedCache.current.set(categoryId, allCollapsed); return allCollapsed; }; From 694ca2180cc741390c33fa6ec4242ec6d69ce29a Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Fri, 13 Feb 2026 11:43:28 +0100 Subject: [PATCH 21/34] fix cache in Lobby --- src/app/features/lobby/Lobby.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index d51854d4b..782c6cd3f 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -235,9 +235,11 @@ export function Lobby() { // As a subspace can be in multiple spaces, // only return true if all parent spaces are closed. - return !Array.from(parentParentIds).some( + const allClosed = !Array.from(parentParentIds).some( (id) => !getInClosedCategories(spaceId, id, parentId) ); + closedCategoriesCache.current.set(categoryId, allClosed); + return allClosed; }, [closedCategories, getRoom, roomToParents, spaceRooms] ); From 8d93671bfd60042745d5d249c208b07f1485c4f5 Mon Sep 17 00:00:00 2001 From: Gimle Larpes Date: Sat, 14 Feb 2026 17:39:13 +0100 Subject: [PATCH 22/34] support multiple reuse of spaces, fix recursion errors when encountering mangled spaces --- src/app/features/add-existing/AddExisting.tsx | 1 + src/app/features/lobby/Lobby.tsx | 16 ++++++++++-- src/app/hooks/useSpaceHierarchy.ts | 19 +++++++++++--- src/app/pages/client/space/Space.tsx | 25 ++++++++++++++++--- 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/app/features/add-existing/AddExisting.tsx b/src/app/features/add-existing/AddExisting.tsx index 03bb32a87..fe08a2287 100644 --- a/src/app/features/add-existing/AddExisting.tsx +++ b/src/app/features/add-existing/AddExisting.tsx @@ -92,6 +92,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM * * @param sourceId - The room to check. * @param targetId - The space ID to check against. + * @param visited - Set used to prevent recursion errors. * @returns True if rId is an ancestor of targetId. */ const isAncestor = useCallback( diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index 782c6cd3f..a942443ed 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -205,10 +205,16 @@ export function Lobby() { * @param spaceId - The root space ID. * @param parentId - The parent space ID to start the check from. * @param previousId - The last ID checked, only used to ignore root collapse state. + * @param visited - Set used to prevent recursion errors. * @returns True if parentId or all ancestors is in a closed category. */ const getInClosedCategories = useCallback( - (spaceId: string, parentId: string, previousId?: string): boolean => { + ( + spaceId: string, + parentId: string, + previousId?: string, + visited: Set = new Set() + ): boolean => { // Ignore root space being collapsed if in a subspace, // this is due to many spaces dumping all rooms in the top-level space. if (parentId === spaceId && previousId) { @@ -218,6 +224,11 @@ export function Lobby() { } const categoryId = makeLobbyCategoryId(spaceId, parentId); + + // Prevent infinite recursion + if (visited.has(categoryId)) return false; + visited.add(categoryId); + if (closedCategoriesCache.current.has(categoryId)) { return closedCategoriesCache.current.get(categoryId); } @@ -236,8 +247,9 @@ export function Lobby() { // As a subspace can be in multiple spaces, // only return true if all parent spaces are closed. const allClosed = !Array.from(parentParentIds).some( - (id) => !getInClosedCategories(spaceId, id, parentId) + (id) => !getInClosedCategories(spaceId, id, parentId, visited) ); + visited.delete(categoryId); closedCategoriesCache.current.set(categoryId, allClosed); return allClosed; }, diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index 2956af880..8c410169b 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -7,6 +7,7 @@ import { useMatrixClient } from './useMatrixClient'; import { roomToParentsAtom } from '../state/room/roomToParents'; import { MSpaceChildContent, StateEvent } from '../../types/matrix/room'; import { getAllParents, getStateEvents, isValidChild } from '../utils/room'; +import { makeLobbyCategoryId } from '../state/closedLobbyCategories'; import { isRoomId } from '../utils/matrix'; import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort'; import { useStateEventCallback } from './useStateEventCallback'; @@ -56,8 +57,17 @@ const getHierarchySpaces = ( }; const spaceItems: HierarchyItemSpace[] = []; - const findAndCollectHierarchySpaces = (spaceItem: HierarchyItemSpace) => { - if (spaceItems.find((item) => item.roomId === spaceItem.roomId)) return; + const findAndCollectHierarchySpaces = ( + spaceItem: HierarchyItemSpace, + parentSpaceId: string, + visited: Set = new Set() + ) => { + const spaceItemId = makeLobbyCategoryId(parentSpaceId, spaceItem.roomId); + + // Prevent infinite recursion + if (visited.has(spaceItemId)) return; + visited.add(spaceItemId); + const space = getRoom(spaceItem.roomId); spaceItems.push(spaceItem); @@ -89,10 +99,10 @@ const getHierarchySpaces = ( parentId: spaceItem.roomId, depth: spaceItem.depth + 1, }; - findAndCollectHierarchySpaces(childItem); + findAndCollectHierarchySpaces(childItem, spaceItem.roomId, visited); }); }; - findAndCollectHierarchySpaces(rootSpaceItem); + findAndCollectHierarchySpaces(rootSpaceItem, rootSpaceId); return spaceItems; }; @@ -202,6 +212,7 @@ const getSpaceJoinedHierarchy = ( * Recursively checks if the given space or any of its descendants contain non-space rooms. * * @param spaceId - The space ID to check. + * @param visited - Set used to prevent recursion errors. * @returns True if the space or any descendant contains non-space rooms. */ const getContainsRoom = (spaceId: string, visited: Set = new Set()) => { diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 42565ab14..0836fdede 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -423,10 +423,16 @@ export function Space() { * @param spaceId - The root space ID. * @param parentId - The parent space ID to start the check from. * @param previousId - The last ID checked, only used to ignore root collapse state. + * @param visited - Set used to prevent recursion errors. * @returns True if parentId or all ancestors is in a closed category. */ const getInClosedCategories = useCallback( - (spaceId: string, parentId: string, previousId?: string): boolean => { + ( + spaceId: string, + parentId: string, + previousId?: string, + visited: Set = new Set() + ): boolean => { // Ignore root space being collapsed if in a subspace, // this is due to many spaces dumping all rooms in the top-level space. if (parentId === spaceId && previousId) { @@ -436,6 +442,11 @@ export function Space() { } const categoryId = makeNavCategoryId(spaceId, parentId); + + // Prevent infinite recursion + if (visited.has(categoryId)) return false; + visited.add(categoryId); + if (closedCategoriesCache.current.has(categoryId)) { return closedCategoriesCache.current.get(categoryId); } @@ -454,8 +465,9 @@ export function Space() { // As a subspace can be in multiple spaces, // only return true if all parent spaces are closed. const allClosed = !Array.from(parentParentIds).some( - (id) => !getInClosedCategories(spaceId, id, parentId) + (id) => !getInClosedCategories(spaceId, id, parentId, visited) ); + visited.delete(categoryId); closedCategoriesCache.current.set(categoryId, allClosed); return allClosed; }, @@ -466,20 +478,25 @@ export function Space() { * Recursively checks if the given room or any of its descendants should be visible. * * @param roomId - The room ID to check. + * @param visited - Set used to prevent recursion errors. * @returns True if the room or any descendant should be visible. */ const getContainsShowRoom = useCallback( - (roomId: string): boolean => { + (roomId: string, visited: Set = new Set()): boolean => { if (roomToUnread.has(roomId) || roomId === selectedRoomId) { return true; } + // Prevent infinite recursion + if (visited.has(roomId)) return false; + visited.add(roomId); + const childIds = roomToChildren.get(roomId); if (!childIds || childIds.size === 0) { return false; } - return Array.from(childIds).some((id) => getContainsShowRoom(id)); + return Array.from(childIds).some((id) => getContainsShowRoom(id, visited)); }, [roomToUnread, selectedRoomId, roomToChildren] ); From bf36587df3f12a2b0e32bc28dcb6bfc01b05d235 Mon Sep 17 00:00:00 2001 From: Kace Cottam Date: Sat, 14 Mar 2026 16:05:42 -0700 Subject: [PATCH 23/34] Update Space sidebar with depth-based link to space lobby --- src/app/features/space-nav/SpaceNavItem.tsx | 98 +++++++++++++++++++++ src/app/features/space-nav/index.ts | 1 + src/app/hooks/useSpaceHierarchy.ts | 10 +-- src/app/pages/client/space/Space.tsx | 28 +++++- 4 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 src/app/features/space-nav/SpaceNavItem.tsx create mode 100644 src/app/features/space-nav/index.ts diff --git a/src/app/features/space-nav/SpaceNavItem.tsx b/src/app/features/space-nav/SpaceNavItem.tsx new file mode 100644 index 000000000..b812bc09f --- /dev/null +++ b/src/app/features/space-nav/SpaceNavItem.tsx @@ -0,0 +1,98 @@ +import { MouseEventHandler, useState } from 'react'; +import { Room } from '$types/matrix-sdk'; +import { + Box, + Icon, + Icons, + Text, + config, + RectCords, + Avatar, +} from 'folds'; +import { useFocusWithin, useHover } from 'react-aria'; +import { useNavigate } from 'react-router-dom'; +import { NavButton, NavItem, NavItemContent } from '$components/nav'; +import { useRoomName } from '$hooks/useRoomMeta'; + +type SpaceNavItemProps = { + room: Room; + selected: boolean; + linkPath: string; +}; + +export function SpaceNavItem({ + room, + selected, + linkPath, +}: SpaceNavItemProps) { + const [hover, setHover] = useState(false); + const { hoverProps } = useHover({ onHoverChange: setHover }); + const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); + const [menuAnchor, setMenuAnchor] = useState(); + + const matrixRoomName = useRoomName(room); + const roomName = matrixRoomName; + + const navigate = useNavigate(); + + const handleContextMenu: MouseEventHandler = (evt) => { + evt.preventDefault(); + setMenuAnchor({ + x: evt.clientX, + y: evt.clientY, + width: 0, + height: 0, + }); + }; + + const handleNavItemClick: MouseEventHandler = (evt) => { + navigate(linkPath); + }; + + const ariaLabel = [ + roomName, + 'Space' + ] + .flat() + .filter(Boolean) + .join(', '); + + return ( + + + + + + + + + + + {roomName} + + + + + + + + ); +} diff --git a/src/app/features/space-nav/index.ts b/src/app/features/space-nav/index.ts new file mode 100644 index 000000000..507f8fc17 --- /dev/null +++ b/src/app/features/space-nav/index.ts @@ -0,0 +1 @@ +export * from './SpaceNavItem'; diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index 73b6ceb77..90f4e1dda 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -49,7 +49,7 @@ const childEventByOrder: SortFunc = (a, b) => const getHierarchySpaces = ( rootSpaceId: string, getRoom: GetRoomCallback, - excludeRoom: (parentId: string, roomId: string) => boolean, + excludeRoom: (parentId: string, roomId: string, depth: number) => boolean, spaceRooms: Set ): HierarchyItemSpace[] => { const rootSpaceItem: HierarchyItemSpace = { @@ -81,7 +81,7 @@ const getHierarchySpaces = ( if (!isValidChild(childEvent)) return false; const childId = childEvent.getStateKey(); if (!childId || !isRoomId(childId)) return false; - if (excludeRoom(spaceItem.roomId, childId)) return false; + if (excludeRoom(spaceItem.roomId, childId, spaceItem.depth)) return false; // because we can not find if a childId is space without joining // or requesting room summary, we will look it into spaceRooms local @@ -202,7 +202,7 @@ export const useSpaceHierarchy = ( const getSpaceJoinedHierarchy = ( rootSpaceId: string, getRoom: GetRoomCallback, - excludeRoom: (parentId: string, roomId: string) => boolean, + excludeRoom: (parentId: string, roomId: string, depth: number) => boolean, sortRoomItems: (parentId: string, items: HierarchyItem[]) => HierarchyItem[] ): HierarchyItem[] => { const spaceItems: HierarchyItemSpace[] = getHierarchySpaces( @@ -263,7 +263,7 @@ const getSpaceJoinedHierarchy = ( const childId = childEvent.getStateKey(); if (!childId) return; - if (excludeRoom(space.roomId, childId)) return; + if (excludeRoom(space.roomId, childId, spaceItem.depth)) return; const childItem: HierarchyItemRoom = { roomId: childId, @@ -283,7 +283,7 @@ const getSpaceJoinedHierarchy = ( export const useSpaceJoinedHierarchy = ( spaceId: string, getRoom: GetRoomCallback, - excludeRoom: (parentId: string, roomId: string) => boolean, + excludeRoom: (parentId: string, roomId: string, depth: number) => boolean, sortByActivity: (spaceId: string) => boolean ): HierarchyItem[] => { const mx = useMatrixClient(); diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 79755d16f..e9a8f2afb 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -33,6 +33,7 @@ import { useSpace } from '$hooks/useSpace'; import { VirtualTile } from '$components/virtualizer'; import { spaceRoomsAtom } from '$state/spaceRooms'; import { RoomNavCategoryButton, RoomNavItem } from '$features/room-nav'; +import { SpaceNavItem } from '$features/space-nav'; import { makeNavCategoryId, getNavCategoryIdParts } from '$state/closedNavCategories'; import { roomToUnreadAtom } from '$state/room/roomToUnread'; import { useCategoryHandler } from '$hooks/useCategoryHandler'; @@ -519,7 +520,11 @@ export function Space() { space.roomId, getRoom, useCallback( - (parentId, roomId) => { + (parentId, roomId, depth) => { + if (depth >= 4) + { + return true; + } if (!getInClosedCategories(space.roomId, parentId, roomId)) { return false; } @@ -564,7 +569,7 @@ export function Space() { const getCategoryPadding = (depth: number): string | undefined => { if (depth === 0) return undefined; if (depth === 1) return config.space.S400; - return config.space.S200; + return config.space.S0; }; const navigate = useNavigate(); @@ -633,7 +638,24 @@ export function Space() { const { roomId, depth } = hierarchy[vItem.index] ?? {}; const room = mx.getRoom(roomId); if (!room) return null; - + if (depth == 4 && room.isSpaceRoom()) { + return ( + +
+ +
+
+ ) + } + const paddingTop = getCategoryPadding(depth) const paddingLeft = `calc(${depth-1} * ${config.space.S200})` From 8c4f5e512cc981d7f4d6e97b537e0c33eb5fbb7c Mon Sep 17 00:00:00 2001 From: Kace Cottam Date: Sat, 14 Mar 2026 22:49:26 -0700 Subject: [PATCH 24/34] Initial modification of Space navbar to have svg thread lines --- .../room-nav/RoomNavCategoryButton.tsx | 4 +- src/app/pages/client/space/Space.tsx | 95 +++++++++++++++++-- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/src/app/features/room-nav/RoomNavCategoryButton.tsx b/src/app/features/room-nav/RoomNavCategoryButton.tsx index 7adc6dcb9..f6f2c8f94 100644 --- a/src/app/features/room-nav/RoomNavCategoryButton.tsx +++ b/src/app/features/room-nav/RoomNavCategoryButton.tsx @@ -7,8 +7,8 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>( { - if (depth >= 4) + if (depth >= DEPTH_LIMIT) { + // we will exclude items above this depth return true; } if (!getInClosedCategories(space.roomId, parentId, roomId)) { @@ -554,6 +557,74 @@ export function Space() { overscan: 10, }); + const virtualizedItems = virtualizer.getVirtualItems(); + + var connectorStack: { end: number, paddingLeft: number}[] = []; + const connectorSVG: any[] = []; + const DEPTH_START = 2; + const PADDING_LEFT_DEPTH_OFFSET = 15.75; + const PADDING_LEFT_DEPTH_OFFSET_START = -15.75; + virtualizedItems.forEach((vItem) => { + const { roomId, depth } = hierarchy[vItem.index] ?? {}; + const room = getRoom(roomId); + const renderDepth = room?.isSpaceRoom() ? depth : depth + 1; + if (renderDepth < DEPTH_START) { return; } // for the root item, we are not doing anything with it. + if (renderDepth == DEPTH_START - 1 && !room?.isSpaceRoom() && connectorStack.length == 0) { + // for nearly root level text/call rooms, we will not be drawing any arcs. + return; + } + + if (renderDepth == DEPTH_START) { + // for the sub-root items, we will not draw any arcs from root to it. + // however, we should capture the end point and paddingLeft to draw starter arcs for next depths.. + connectorStack = [{ end: vItem.end, paddingLeft: PADDING_LEFT_DEPTH_OFFSET * DEPTH_START + PADDING_LEFT_DEPTH_OFFSET_START }]; + return; + } + // adjust the stack to be at the correct depth, which is the parent of the current. + while (connectorStack.length + DEPTH_START > renderDepth) { + connectorStack.pop(); + } + if (connectorStack.length == 0) { + // Fixes crash in case the top level virtual item is unrendered. + connectorStack = [{ end: 0, paddingLeft: Math.round((renderDepth) * PADDING_LEFT_DEPTH_OFFSET) }] + } + + const RADIUS = 5; + + const lastConnector = connectorStack[connectorStack.length - 1]; + + // aX: numeric x where the vertical connector starts + // For depth 2+, you probably want something like: + const aX = lastConnector.paddingLeft; + + // aY: end of parent (already numeric) + const aY = lastConnector.end; + + // bY: center of current item + const bX = Math.round((renderDepth - 0.5) * PADDING_LEFT_DEPTH_OFFSET + PADDING_LEFT_DEPTH_OFFSET_START); + const bY = vItem.end - vItem.size / 2; + + const pathString = ( + `M ${aX} ${aY} ` + + `L ${aX} ${bY - RADIUS} ` + + `A ${RADIUS} ${RADIUS} 0 0 0 ${aX + RADIUS} ${bY} ` + + `L ${bX} ${bY}` + ); + + connectorSVG.push( + + ) + + // add this item to the connector stack, in case the next item's depth is higher. + connectorStack.push({ end: vItem.end, paddingLeft: Math.round((renderDepth) * PADDING_LEFT_DEPTH_OFFSET) + PADDING_LEFT_DEPTH_OFFSET_START }) + }); + const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => { const collapsed = closedCategories.has(categoryId); const [spaceId, roomId] = getNavCategoryIdParts(categoryId); @@ -638,18 +709,19 @@ export function Space() { position: 'relative', }} > - {virtualizer.getVirtualItems().map((vItem) => { + {virtualizedItems.map((vItem) => { const { roomId, depth } = hierarchy[vItem.index] ?? {}; const room = mx.getRoom(roomId); + const renderDepth = room?.isSpaceRoom() ? depth - 2 : depth - 1; if (!room) return null; - if (depth == 4 && room.isSpaceRoom()) { + if (depth == DEPTH_LIMIT && room.isSpaceRoom()) { return ( -
+
); })} + + {connectorSVG} + From 515b378a20167f7e8b7f8a521ffa13e32b216c19 Mon Sep 17 00:00:00 2001 From: Kace Cottam Date: Sun, 15 Mar 2026 00:28:02 -0700 Subject: [PATCH 25/34] Added setting to control depth limit and cleaned up the code --- .../room-nav/RoomNavCategoryButton.tsx | 2 +- .../features/settings/cosmetics/Themes.tsx | 52 +++++ src/app/pages/client/space/Space.tsx | 179 ++++++++++-------- src/app/state/settings.ts | 2 + 4 files changed, 150 insertions(+), 85 deletions(-) diff --git a/src/app/features/room-nav/RoomNavCategoryButton.tsx b/src/app/features/room-nav/RoomNavCategoryButton.tsx index f6f2c8f94..3df48aa7d 100644 --- a/src/app/features/room-nav/RoomNavCategoryButton.tsx +++ b/src/app/features/room-nav/RoomNavCategoryButton.tsx @@ -18,7 +18,7 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>( {...props} ref={ref} > - + {children} diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index a57af34a0..57ae92811 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -343,6 +343,50 @@ function ThemeSettings() { ); } + +function SubnestedSpaceLinkDepthInput() { + const [subspaceHierarchyLimit, setSubspaceHierarchyLimit] = useSetting(settingsAtom, 'subspaceHierarchyLimit'); + const [inputValue, setInputValue] = useState(subspaceHierarchyLimit.toString()); + + const handleChange: ChangeEventHandler = (evt) => { + const val = evt.target.value; + setInputValue(val); + + const parsed = parseInt(val, 10); + if (!Number.isNaN(parsed) && parsed >= 2 && parsed <= 10) { + setSubspaceHierarchyLimit(parsed); + } + }; + + const handleKeyDown: KeyboardEventHandler = (evt) => { + if (isKeyHotkey('escape', evt)) { + evt.stopPropagation(); + setInputValue(subspaceHierarchyLimit.toString()); + (evt.target as HTMLInputElement).blur(); + } + + if (isKeyHotkey('enter', evt)) { + (evt.target as HTMLInputElement).blur(); + } + }; + + return ( + + ); +} + function PageZoomInput() { const [pageZoom, setPageZoom] = useSetting(settingsAtom, 'pageZoom'); const [currentZoom, setCurrentZoom] = useState(`${pageZoom}`); @@ -407,6 +451,14 @@ export function Appearance() { } /> + + + } + /> + ); diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 5d0d3b1c2..a254ac871 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -1,4 +1,4 @@ -import { MouseEventHandler, ReactSVGElement, forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { MouseEventHandler, ReactElement, ReactSVGElement, forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAtom, useAtomValue } from 'jotai'; import { Avatar, @@ -18,7 +18,7 @@ import { config, toRem, } from 'folds'; -import { useVirtualizer } from '@tanstack/react-virtual'; +import { useVirtualizer, VirtualItem } from '@tanstack/react-virtual'; import FocusTrap from 'focus-trap-react'; import { useNavigate } from 'react-router-dom'; import { JoinRule, Room, RoomJoinRulesEventContent } from '$types/matrix-sdk'; @@ -520,14 +520,101 @@ export function Space() { return allCollapsed; }; - const DEPTH_LIMIT = 3; + /** + * Determines the depth limit for the joined space hierarchy and the SpaceNavItems to start appearing + */ + const [subspaceHierarchyLimit] = useSetting(settingsAtom, 'subspaceHierarchyLimit'); + /** + * Creates an SVG used for connecting spaces to their subrooms. + * @param virtualizedItems - The virtualized item list that will be used to render elements in the nav + * @returns React SVG Element that can be overlayed on top of the nav category for rooms. + */ + const getConnectorSVG = (virtualizedItems: VirtualItem[]): ReactElement => { + const DEPTH_START = 2; + const PADDING_LEFT_DEPTH_OFFSET = 15.75; + const PADDING_LEFT_DEPTH_OFFSET_START = -15.75; + const RADIUS = 5; + + var connectorStack: { aX: number, aY: number}[] = []; + // Holder for the paths + const pathHolder: ReactElement[] = []; + virtualizedItems.forEach((vItem) => { + const { roomId, depth } = hierarchy[vItem.index] ?? {}; + const room = getRoom(roomId); + // We will render spaces at a level above their normal depth, since we want their children to be "under" them + const renderDepth = room?.isSpaceRoom() ? depth : depth + 1; + // for the root items, we are not doing anything with it. + if (renderDepth < DEPTH_START) { return; } + // for nearly root level text/call rooms, we will not be drawing any arcs. + if (renderDepth == DEPTH_START - 1 && !room?.isSpaceRoom() && connectorStack.length == 0) { return; } + + // for the sub-root items, we will not draw any arcs from root to it. + // however, we should capture the aX and aY to draw starter arcs for next depths. + if (renderDepth == DEPTH_START) { + connectorStack = [{ aX: PADDING_LEFT_DEPTH_OFFSET * DEPTH_START + PADDING_LEFT_DEPTH_OFFSET_START, aY: vItem.end }]; + return; + } + // adjust the stack to be at the correct depth, which is the "parent" of the current item. + while (connectorStack.length + DEPTH_START > renderDepth && connectorStack.length != 0) { + connectorStack.pop(); + } + + // Fixes crash in case the top level virtual item is unrendered. + if (connectorStack.length == 0) { + connectorStack = [{ aX: Math.round((renderDepth) * PADDING_LEFT_DEPTH_OFFSET), aY: 0 }] + } + + const lastConnector = connectorStack[connectorStack.length - 1]; + + // aX: numeric x where the vertical connector starts + // aY: end of parent (already numeric) + const { aX, aY } = lastConnector; + + + // bX: point where the vertical connector ends + const bX = Math.round((renderDepth - 0.5) * PADDING_LEFT_DEPTH_OFFSET + PADDING_LEFT_DEPTH_OFFSET_START); + // bY: center of current item + const bY = vItem.end - vItem.size / 2; + + const pathString = ( + `M ${aX} ${aY} ` + + `L ${aX} ${bY - RADIUS} ` + + `A ${RADIUS} ${RADIUS} 0 0 0 ${aX + RADIUS} ${bY} ` + + `L ${bX} ${bY}` + ); + + pathHolder.push( + + ) + + // add this item to the connector stack, in case the next item's depth is higher. + connectorStack.push({ aX: Math.round((renderDepth) * PADDING_LEFT_DEPTH_OFFSET) + PADDING_LEFT_DEPTH_OFFSET_START, aY: vItem.end }) + }); + return ( + + {pathHolder} + + ); + }; const hierarchy = useSpaceJoinedHierarchy( space.roomId, getRoom, useCallback( (parentId, roomId, depth) => { - if (depth >= DEPTH_LIMIT) + if (depth >= subspaceHierarchyLimit) { // we will exclude items above this depth return true; @@ -542,7 +629,7 @@ export function Space() { hasUnread || roomId === selectedRoomId || callEmbed?.roomId === roomId; return containsShowRoom || !showRoomAnyway; }, - [getContainsShowRoom, getInClosedCategories, space.roomId, callEmbed] + [getContainsShowRoom, getInClosedCategories, space.roomId, callEmbed, subspaceHierarchyLimit] ), useCallback( (sId) => getInClosedCategories(space.roomId, sId), @@ -559,72 +646,6 @@ export function Space() { const virtualizedItems = virtualizer.getVirtualItems(); - var connectorStack: { end: number, paddingLeft: number}[] = []; - const connectorSVG: any[] = []; - const DEPTH_START = 2; - const PADDING_LEFT_DEPTH_OFFSET = 15.75; - const PADDING_LEFT_DEPTH_OFFSET_START = -15.75; - virtualizedItems.forEach((vItem) => { - const { roomId, depth } = hierarchy[vItem.index] ?? {}; - const room = getRoom(roomId); - const renderDepth = room?.isSpaceRoom() ? depth : depth + 1; - if (renderDepth < DEPTH_START) { return; } // for the root item, we are not doing anything with it. - if (renderDepth == DEPTH_START - 1 && !room?.isSpaceRoom() && connectorStack.length == 0) { - // for nearly root level text/call rooms, we will not be drawing any arcs. - return; - } - - if (renderDepth == DEPTH_START) { - // for the sub-root items, we will not draw any arcs from root to it. - // however, we should capture the end point and paddingLeft to draw starter arcs for next depths.. - connectorStack = [{ end: vItem.end, paddingLeft: PADDING_LEFT_DEPTH_OFFSET * DEPTH_START + PADDING_LEFT_DEPTH_OFFSET_START }]; - return; - } - // adjust the stack to be at the correct depth, which is the parent of the current. - while (connectorStack.length + DEPTH_START > renderDepth) { - connectorStack.pop(); - } - if (connectorStack.length == 0) { - // Fixes crash in case the top level virtual item is unrendered. - connectorStack = [{ end: 0, paddingLeft: Math.round((renderDepth) * PADDING_LEFT_DEPTH_OFFSET) }] - } - - const RADIUS = 5; - - const lastConnector = connectorStack[connectorStack.length - 1]; - - // aX: numeric x where the vertical connector starts - // For depth 2+, you probably want something like: - const aX = lastConnector.paddingLeft; - - // aY: end of parent (already numeric) - const aY = lastConnector.end; - - // bY: center of current item - const bX = Math.round((renderDepth - 0.5) * PADDING_LEFT_DEPTH_OFFSET + PADDING_LEFT_DEPTH_OFFSET_START); - const bY = vItem.end - vItem.size / 2; - - const pathString = ( - `M ${aX} ${aY} ` + - `L ${aX} ${bY - RADIUS} ` + - `A ${RADIUS} ${RADIUS} 0 0 0 ${aX + RADIUS} ${bY} ` + - `L ${bX} ${bY}` - ); - - connectorSVG.push( - - ) - - // add this item to the connector stack, in case the next item's depth is higher. - connectorStack.push({ end: vItem.end, paddingLeft: Math.round((renderDepth) * PADDING_LEFT_DEPTH_OFFSET) + PADDING_LEFT_DEPTH_OFFSET_START }) - }); - const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => { const collapsed = closedCategories.has(categoryId); const [spaceId, roomId] = getNavCategoryIdParts(categoryId); @@ -714,7 +735,7 @@ export function Space() { const room = mx.getRoom(roomId); const renderDepth = room?.isSpaceRoom() ? depth - 2 : depth - 1; if (!room) return null; - if (depth == DEPTH_LIMIT && room.isSpaceRoom()) { + if (depth == subspaceHierarchyLimit && room.isSpaceRoom()) { return ( ); })} - - {connectorSVG} - + {getConnectorSVG(virtualizedItems)} diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index a5e373a1e..14abbbc2b 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -95,6 +95,7 @@ export interface Settings { autoplayStickers: boolean; autoplayEmojis: boolean; saveStickerEmojiBandwidth: boolean; + subspaceHierarchyLimit: number; // furry stuff renderAnimals: boolean; @@ -173,6 +174,7 @@ const defaultSettings: Settings = { autoplayStickers: true, autoplayEmojis: true, saveStickerEmojiBandwidth: false, + subspaceHierarchyLimit: 3, // furry stuff renderAnimals: true, From 557f3fc7cca89b9ec3cdc82db770e739b015b866 Mon Sep 17 00:00:00 2001 From: Kace Cottam Date: Tue, 17 Mar 2026 12:34:11 -0700 Subject: [PATCH 26/34] Add "Open Lobby" for subspaces in lobby view --- src/app/features/lobby/HierarchyItemMenu.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index bbaf3885f..78fcdcb54 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -30,6 +30,9 @@ import { IPowerLevels } from '$hooks/usePowerLevels'; import { getRoomCreatorsForRoomId } from '$hooks/useRoomCreators'; import { getRoomPermissionsAPI } from '$hooks/useRoomPermissions'; import { InviteUserPrompt } from '$components/invite-user-prompt'; +import { getCanonicalAliasOrRoomId } from '$utils/matrix'; +import { useNavigate } from 'react-router-dom'; +import { getSpaceLobbyPath } from '$pages/pathUtils'; type HierarchyItemWithParent = HierarchyItem & { parentId: string; @@ -227,6 +230,7 @@ export function HierarchyItemMenu({ }; const handleRequestClose = useCallback(() => setMenuAnchor(undefined), []); + const navigate = useNavigate(); if (!joined && !canEditChild) { return null; @@ -278,6 +282,16 @@ export function HierarchyItemMenu({ )} + { + navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, item.roomId))) + }}> + + Open Lobby + + Date: Tue, 17 Mar 2026 12:39:13 -0700 Subject: [PATCH 27/34] Run lint fix and fmt --- src/app/features/lobby/HierarchyItemMenu.tsx | 13 +- src/app/features/lobby/Lobby.tsx | 2 +- .../features/settings/cosmetics/Themes.tsx | 10 +- src/app/features/space-nav/SpaceNavItem.tsx | 45 ++--- src/app/hooks/useSpaceHierarchy.ts | 5 - src/app/pages/client/space/Space.tsx | 159 ++++++++++-------- 6 files changed, 116 insertions(+), 118 deletions(-) diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index 78fcdcb54..1377f33ff 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -282,15 +282,16 @@ export function HierarchyItemMenu({ )} - { - navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, item.roomId))) - }}> - - Open Lobby - + navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, item.roomId))); + }} + > + + Open Lobby + = (evt) => { @@ -453,8 +455,8 @@ export function Appearance() { - } /> diff --git a/src/app/features/space-nav/SpaceNavItem.tsx b/src/app/features/space-nav/SpaceNavItem.tsx index b812bc09f..e2123a734 100644 --- a/src/app/features/space-nav/SpaceNavItem.tsx +++ b/src/app/features/space-nav/SpaceNavItem.tsx @@ -1,14 +1,6 @@ import { MouseEventHandler, useState } from 'react'; import { Room } from '$types/matrix-sdk'; -import { - Box, - Icon, - Icons, - Text, - config, - RectCords, - Avatar, -} from 'folds'; +import { Box, Icon, Icons, Text, config, RectCords, Avatar } from 'folds'; import { useFocusWithin, useHover } from 'react-aria'; import { useNavigate } from 'react-router-dom'; import { NavButton, NavItem, NavItemContent } from '$components/nav'; @@ -20,13 +12,7 @@ type SpaceNavItemProps = { linkPath: string; }; -export function SpaceNavItem({ - room, - selected, - linkPath, -}: SpaceNavItemProps) { - const [hover, setHover] = useState(false); - const { hoverProps } = useHover({ onHoverChange: setHover }); +export function SpaceNavItem({ room, selected, linkPath }: SpaceNavItemProps) { const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const [menuAnchor, setMenuAnchor] = useState(); @@ -45,17 +31,11 @@ export function SpaceNavItem({ }); }; - const handleNavItemClick: MouseEventHandler = (evt) => { + const handleNavItemClick: MouseEventHandler = () => { navigate(linkPath); }; - const ariaLabel = [ - roomName, - 'Space' - ] - .flat() - .filter(Boolean) - .join(', '); + const ariaLabel = [roomName, 'Space'].flat().filter(Boolean).join(', '); return ( @@ -66,26 +46,21 @@ export function SpaceNavItem({ aria-selected={selected} data-hover={!!menuAnchor} onContextMenu={handleContextMenu} - {...hoverProps} {...focusWithinProps} > - - + {roomName} diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index 90f4e1dda..3b6fd5aa5 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -8,12 +8,7 @@ import { getAllParents, getStateEvents, isValidChild } from '$utils/room'; import { isRoomId } from '$utils/matrix'; import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '$utils/sort'; import { useMatrixClient } from './useMatrixClient'; -import { roomToParentsAtom } from '../state/room/roomToParents'; -import { MSpaceChildContent, StateEvent } from '../../types/matrix/room'; -import { getAllParents, getStateEvents, isValidChild } from '../utils/room'; import { makeLobbyCategoryId } from '../state/closedLobbyCategories'; -import { isRoomId } from '../utils/matrix'; -import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort'; import { useStateEventCallback } from './useStateEventCallback'; import { ErrorCode } from '../cs-errorcode'; diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index a254ac871..13a6b2b61 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -1,4 +1,13 @@ -import { MouseEventHandler, ReactElement, ReactSVGElement, forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + MouseEventHandler, + ReactElement, + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useAtom, useAtomValue } from 'jotai'; import { Avatar, @@ -39,7 +48,7 @@ import { roomToUnreadAtom } from '$state/room/roomToUnread'; import { useCategoryHandler } from '$hooks/useCategoryHandler'; import { useNavToActivePathMapper } from '$hooks/useNavToActivePathMapper'; import { useRoomName } from '$hooks/useRoomMeta'; -import { useSpaceJoinedHierarchy } from '$hooks/useSpaceHierarchy'; +import { HierarchyItem, useSpaceJoinedHierarchy } from '$hooks/useSpaceHierarchy'; import { allRoomsAtom } from '$state/room-list/roomList'; import { PageNav, PageNavContent, PageNavHeader } from '$components/page'; import { usePowerLevels } from '$hooks/usePowerLevels'; @@ -529,81 +538,98 @@ export function Space() { * @param virtualizedItems - The virtualized item list that will be used to render elements in the nav * @returns React SVG Element that can be overlayed on top of the nav category for rooms. */ - const getConnectorSVG = (virtualizedItems: VirtualItem[]): ReactElement => { + const getConnectorSVG = ( + hierarchy: HierarchyItem[], + virtualizedItems: VirtualItem[] + ): ReactElement => { const DEPTH_START = 2; const PADDING_LEFT_DEPTH_OFFSET = 15.75; const PADDING_LEFT_DEPTH_OFFSET_START = -15.75; const RADIUS = 5; - var connectorStack: { aX: number, aY: number}[] = []; + let connectorStack: { aX: number; aY: number }[] = []; // Holder for the paths const pathHolder: ReactElement[] = []; virtualizedItems.forEach((vItem) => { - const { roomId, depth } = hierarchy[vItem.index] ?? {}; - const room = getRoom(roomId); - // We will render spaces at a level above their normal depth, since we want their children to be "under" them - const renderDepth = room?.isSpaceRoom() ? depth : depth + 1; - // for the root items, we are not doing anything with it. - if (renderDepth < DEPTH_START) { return; } - // for nearly root level text/call rooms, we will not be drawing any arcs. - if (renderDepth == DEPTH_START - 1 && !room?.isSpaceRoom() && connectorStack.length == 0) { return; } - - // for the sub-root items, we will not draw any arcs from root to it. - // however, we should capture the aX and aY to draw starter arcs for next depths. - if (renderDepth == DEPTH_START) { - connectorStack = [{ aX: PADDING_LEFT_DEPTH_OFFSET * DEPTH_START + PADDING_LEFT_DEPTH_OFFSET_START, aY: vItem.end }]; - return; - } - // adjust the stack to be at the correct depth, which is the "parent" of the current item. - while (connectorStack.length + DEPTH_START > renderDepth && connectorStack.length != 0) { - connectorStack.pop(); - } - - // Fixes crash in case the top level virtual item is unrendered. - if (connectorStack.length == 0) { - connectorStack = [{ aX: Math.round((renderDepth) * PADDING_LEFT_DEPTH_OFFSET), aY: 0 }] - } - - const lastConnector = connectorStack[connectorStack.length - 1]; + const { roomId, depth } = hierarchy[vItem.index] ?? {}; + const room = getRoom(roomId); + // We will render spaces at a level above their normal depth, since we want their children to be "under" them + const renderDepth = room?.isSpaceRoom() ? depth : depth + 1; + // for the root items, we are not doing anything with it. + if (renderDepth < DEPTH_START) { + return; + } + // for nearly root level text/call rooms, we will not be drawing any arcs. + if (renderDepth == DEPTH_START - 1 && !room?.isSpaceRoom() && connectorStack.length == 0) { + return; + } - // aX: numeric x where the vertical connector starts - // aY: end of parent (already numeric) - const { aX, aY } = lastConnector; + // for the sub-root items, we will not draw any arcs from root to it. + // however, we should capture the aX and aY to draw starter arcs for next depths. + if (renderDepth == DEPTH_START) { + connectorStack = [ + { + aX: PADDING_LEFT_DEPTH_OFFSET * DEPTH_START + PADDING_LEFT_DEPTH_OFFSET_START, + aY: vItem.end, + }, + ]; + return; + } + // adjust the stack to be at the correct depth, which is the "parent" of the current item. + while (connectorStack.length + DEPTH_START > renderDepth && connectorStack.length != 0) { + connectorStack.pop(); + } + // Fixes crash in case the top level virtual item is unrendered. + if (connectorStack.length == 0) { + connectorStack = [{ aX: Math.round(renderDepth * PADDING_LEFT_DEPTH_OFFSET), aY: 0 }]; + } - // bX: point where the vertical connector ends - const bX = Math.round((renderDepth - 0.5) * PADDING_LEFT_DEPTH_OFFSET + PADDING_LEFT_DEPTH_OFFSET_START); - // bY: center of current item - const bY = vItem.end - vItem.size / 2; + const lastConnector = connectorStack[connectorStack.length - 1]; - const pathString = ( - `M ${aX} ${aY} ` + - `L ${aX} ${bY - RADIUS} ` + - `A ${RADIUS} ${RADIUS} 0 0 0 ${aX + RADIUS} ${bY} ` + - `L ${bX} ${bY}` - ); + // aX: numeric x where the vertical connector starts + // aY: end of parent (already numeric) + const { aX, aY } = lastConnector; - pathHolder.push( - - ) + // bX: point where the vertical connector ends + const bX = Math.round( + (renderDepth - 0.5) * PADDING_LEFT_DEPTH_OFFSET + PADDING_LEFT_DEPTH_OFFSET_START + ); + // bY: center of current item + const bY = vItem.end - vItem.size / 2; + + const pathString = + `M ${aX} ${aY} ` + + `L ${aX} ${bY - RADIUS} ` + + `A ${RADIUS} ${RADIUS} 0 0 0 ${aX + RADIUS} ${bY} ` + + `L ${bX} ${bY}`; + + pathHolder.push( + + ); - // add this item to the connector stack, in case the next item's depth is higher. - connectorStack.push({ aX: Math.round((renderDepth) * PADDING_LEFT_DEPTH_OFFSET) + PADDING_LEFT_DEPTH_OFFSET_START, aY: vItem.end }) + // add this item to the connector stack, in case the next item's depth is higher. + connectorStack.push({ + aX: Math.round(renderDepth * PADDING_LEFT_DEPTH_OFFSET) + PADDING_LEFT_DEPTH_OFFSET_START, + aY: vItem.end, }); + }); return ( - + {pathHolder} ); @@ -614,8 +640,7 @@ export function Space() { getRoom, useCallback( (parentId, roomId, depth) => { - if (depth >= subspaceHierarchyLimit) - { + if (depth >= subspaceHierarchyLimit) { // we will exclude items above this depth return true; } @@ -750,11 +775,11 @@ export function Space() { />
- ) + ); } - - const paddingTop = getCategoryPadding(depth) - const paddingLeft = `calc(${renderDepth} * ${config.space.S400})` + + const paddingTop = getCategoryPadding(depth); + const paddingLeft = `calc(${renderDepth} * ${config.space.S400})`; if (room.isSpaceRoom()) { const categoryId = makeNavCategoryId(space.roomId, roomId); From e904a543db4ebbc23c0e51bb4afee37db52dbac8 Mon Sep 17 00:00:00 2001 From: Kace Cottam Date: Tue, 17 Mar 2026 12:54:41 -0700 Subject: [PATCH 28/34] resolve issues after linting --- src/app/features/space-nav/SpaceNavItem.tsx | 2 -- src/app/pages/client/space/Space.tsx | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/features/space-nav/SpaceNavItem.tsx b/src/app/features/space-nav/SpaceNavItem.tsx index e2123a734..ad7fe1010 100644 --- a/src/app/features/space-nav/SpaceNavItem.tsx +++ b/src/app/features/space-nav/SpaceNavItem.tsx @@ -13,7 +13,6 @@ type SpaceNavItemProps = { }; export function SpaceNavItem({ room, selected, linkPath }: SpaceNavItemProps) { - const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const [menuAnchor, setMenuAnchor] = useState(); const matrixRoomName = useRoomName(room); @@ -46,7 +45,6 @@ export function SpaceNavItem({ room, selected, linkPath }: SpaceNavItemProps) { aria-selected={selected} data-hover={!!menuAnchor} onContextMenu={handleContextMenu} - {...focusWithinProps} > diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 13a6b2b61..297863262 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -828,7 +828,7 @@ export function Space() { ); })} - {getConnectorSVG(virtualizedItems)} + {getConnectorSVG(hierarchy, virtualizedItems)} From e69fc1250ffdeab1fb6e44474b59005bbc10823d Mon Sep 17 00:00:00 2001 From: Kace Cottam Date: Tue, 17 Mar 2026 13:04:09 -0700 Subject: [PATCH 29/34] another lint check --- src/app/features/space-nav/SpaceNavItem.tsx | 1 - src/app/pages/client/space/Space.tsx | 20 ++++++++++++++------ src/app/utils/colorMXID.ts | 2 +- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/app/features/space-nav/SpaceNavItem.tsx b/src/app/features/space-nav/SpaceNavItem.tsx index ad7fe1010..f319a9da6 100644 --- a/src/app/features/space-nav/SpaceNavItem.tsx +++ b/src/app/features/space-nav/SpaceNavItem.tsx @@ -1,7 +1,6 @@ import { MouseEventHandler, useState } from 'react'; import { Room } from '$types/matrix-sdk'; import { Box, Icon, Icons, Text, config, RectCords, Avatar } from 'folds'; -import { useFocusWithin, useHover } from 'react-aria'; import { useNavigate } from 'react-router-dom'; import { NavButton, NavItem, NavItemContent } from '$components/nav'; import { useRoomName } from '$hooks/useRoomMeta'; diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 297863262..4eb7b117c 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -560,13 +560,13 @@ export function Space() { return; } // for nearly root level text/call rooms, we will not be drawing any arcs. - if (renderDepth == DEPTH_START - 1 && !room?.isSpaceRoom() && connectorStack.length == 0) { + if (renderDepth === DEPTH_START - 1 && !room?.isSpaceRoom() && connectorStack.length === 0) { return; } // for the sub-root items, we will not draw any arcs from root to it. // however, we should capture the aX and aY to draw starter arcs for next depths. - if (renderDepth == DEPTH_START) { + if (renderDepth === DEPTH_START) { connectorStack = [ { aX: PADDING_LEFT_DEPTH_OFFSET * DEPTH_START + PADDING_LEFT_DEPTH_OFFSET_START, @@ -576,12 +576,12 @@ export function Space() { return; } // adjust the stack to be at the correct depth, which is the "parent" of the current item. - while (connectorStack.length + DEPTH_START > renderDepth && connectorStack.length != 0) { + while (connectorStack.length + DEPTH_START > renderDepth && connectorStack.length !== 0) { connectorStack.pop(); } // Fixes crash in case the top level virtual item is unrendered. - if (connectorStack.length == 0) { + if (connectorStack.length === 0) { connectorStack = [{ aX: Math.round(renderDepth * PADDING_LEFT_DEPTH_OFFSET), aY: 0 }]; } @@ -654,7 +654,15 @@ export function Space() { hasUnread || roomId === selectedRoomId || callEmbed?.roomId === roomId; return containsShowRoom || !showRoomAnyway; }, - [getContainsShowRoom, getInClosedCategories, space.roomId, callEmbed, subspaceHierarchyLimit] + [ + getContainsShowRoom, + getInClosedCategories, + space.roomId, + callEmbed, + subspaceHierarchyLimit, + roomToUnread, + selectedRoomId, + ] ), useCallback( (sId) => getInClosedCategories(space.roomId, sId), @@ -760,7 +768,7 @@ export function Space() { const room = mx.getRoom(roomId); const renderDepth = room?.isSpaceRoom() ? depth - 2 : depth - 1; if (!room) return null; - if (depth == subspaceHierarchyLimit && room.isSpaceRoom()) { + if (depth === subspaceHierarchyLimit && room.isSpaceRoom()) { return ( Date: Wed, 18 Mar 2026 10:51:06 -0700 Subject: [PATCH 30/34] Improve nested displays and clean up UI for small displays --- src/app/features/lobby/DnD.css.ts | 2 +- src/app/features/lobby/Lobby.tsx | 85 +++++++++++++++++-- src/app/features/lobby/RoomItem.css.ts | 5 +- ...ceHierarchy.tsx => SpaceHierarchyItem.tsx} | 17 ++-- src/app/features/lobby/SpaceItem.tsx | 43 ++++++++-- 5 files changed, 124 insertions(+), 28 deletions(-) rename src/app/features/lobby/{SpaceHierarchy.tsx => SpaceHierarchyItem.tsx} (94%) diff --git a/src/app/features/lobby/DnD.css.ts b/src/app/features/lobby/DnD.css.ts index 347382568..2b7e5e363 100644 --- a/src/app/features/lobby/DnD.css.ts +++ b/src/app/features/lobby/DnD.css.ts @@ -11,7 +11,7 @@ export const ItemDraggableTarget = style([ top: 0, zIndex: 1, cursor: 'grab', - borderRadius: config.radii.R400, + borderRadius: 0, opacity: config.opacity.P300, ':active': { diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index bbca2c1dd..dffb73044 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -1,6 +1,6 @@ -import { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config } from 'folds'; -import { useVirtualizer } from '@tanstack/react-virtual'; +import { MouseEventHandler, ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, color, config } from 'folds'; +import { useVirtualizer, VirtualItem } from '@tanstack/react-virtual'; import { useAtom, useAtomValue } from 'jotai'; import { useNavigate } from 'react-router-dom'; import { @@ -13,7 +13,7 @@ import { import { produce } from 'immer'; import { useSpace } from '$hooks/useSpace'; import { Page, PageContent, PageContentCenter, PageHeroSection } from '$components/page'; -import { HierarchyItem, HierarchyItemSpace, useSpaceHierarchy } from '$hooks/useSpaceHierarchy'; +import { HierarchyItem, HierarchyItemSpace, SpaceHierarchy, useSpaceHierarchy } from '$hooks/useSpaceHierarchy'; import { VirtualTile } from '$components/virtualizer'; import { spaceRoomsAtom } from '$state/spaceRooms'; import { useSetting } from '$state/hooks/settings'; @@ -52,7 +52,7 @@ import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { getRoomPermissionsAPI } from '$hooks/useRoomPermissions'; import { getRoomCreatorsForRoomId } from '$hooks/useRoomCreators'; import { MembersDrawer } from '$features/room/MembersDrawer'; -import { SpaceHierarchy } from './SpaceHierarchy'; +import { SpaceHierarchyItem } from './SpaceHierarchyItem'; import { CanDropCallback, useDnDMonitor } from './DnD'; import { LobbyHero } from './LobbyHero'; import { LobbyHeader } from './LobbyHeader'; @@ -520,6 +520,74 @@ export function Lobby() { [mx, sidebarItems, sidebarSpaces] ); + const getPaddingTop = (hierarchy: SpaceHierarchy[], vItem: VirtualItem) => { + if (vItem.index === 0) return 0; + const prevDepth = hierarchy[vItem.index - 1]?.space.depth ?? 0; + const depth = hierarchy[vItem.index].space.depth; + if (depth !== 1 && depth >= prevDepth) return config.space.S200; + return config.space.S500; + }; + + const getConnectorSVG = useCallback((hierarchy: SpaceHierarchy[], virtualizedItems: VirtualItem[]): ReactElement => { + const PADDING_LEFT_DEPTH_OFFSET = 15.75; + const PADDING_LEFT_DEPTH_OFFSET_START = -15; + + var aY: number = 0; + // Holder for the paths + const pathHolder: ReactElement[] = []; + virtualizedItems.forEach((vItem) => { + const { depth } = hierarchy[vItem.index].space ?? {}; + + // We will render spaces at a level above their normal depth, since we want their children to be "under" them + // for the root items, we are not doing anything with it. + if (depth < 1) { + return; + } + // for the sub-root items, we will not draw any arcs from root to it. + // however, we should capture the aX and aY to draw starter arcs for next depths. + if (depth === 1) { + aY = vItem.end; + return; + } + + var pathStrings: string[] = []; + + for (var iDepth = 0; iDepth < depth; iDepth++) { + var X = iDepth * PADDING_LEFT_DEPTH_OFFSET + PADDING_LEFT_DEPTH_OFFSET_START; + + var bY = vItem.end; + + pathStrings.push(`M ${X} ${aY} L ${X} ${bY}`); + } + + pathHolder.push( + + ); + + aY = vItem.end; + }); + + return ( + + {pathHolder} + + ); + }, [hierarchy, vItems]); + return ( @@ -567,19 +635,19 @@ export function Lobby() { item.space.roomId ); - const paddingLeft = `calc((${item.space.depth} - 1) * ${config.space.S200})`; + const paddingLeft = `calc((${item.space.depth} - 1) * ${config.space.S400})`; return ( - ); })} + {getConnectorSVG(hierarchy, vItems)}
{reordering && ( void; onOpenRoom: MouseEventHandler; }; -export const SpaceHierarchy = forwardRef( +export const SpaceHierarchyItem = forwardRef( ( { summary, @@ -109,7 +109,7 @@ export const SpaceHierarchy = forwardRef( } return ( - + ( data-dragging={draggingSpace} /> {childItems && childItems.length > 0 ? ( - + {childItems.map((roomItem, index) => { const roomSummary = rooms.get(roomItem.roomId); @@ -204,22 +204,19 @@ export const SpaceHierarchy = forwardRef( ) : ( childItems && ( - + - - No Rooms - - This space does not contains rooms yet. + This space does not contain any rooms. diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index a6b3cd22b..9ddee7380 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -306,15 +306,42 @@ function AddRoomButton({ item }: { item: HierarchyItem }) { } > - } - onClick={handleAddRoom} - aria-pressed={!!cords} + {item.parentId === undefined ? ( + } + onClick={handleAddRoom} + aria-pressed={!!cords} + > + Add Room + + ) : ( + + Add Room + + } > - Add Room - + {(triggerRef) => ( + + + + )} +
+ )} {addExisting && ( setAddExisting(false)} /> )} From f4d454cd2bcee538e7a1c58fdd8eb31fbfc401de Mon Sep 17 00:00:00 2001 From: Kace Cottam Date: Thu, 19 Mar 2026 10:21:27 -0700 Subject: [PATCH 31/34] Ran formatting and lint checks. Temporarily removed add space and room buttons from children items in lobby view. Added exclusion callback to getSpaceHierarchy --- src/app/features/lobby/Lobby.tsx | 207 +++++---- src/app/features/lobby/SpaceHierarchyItem.tsx | 26 +- .../features/lobby/SpaceHierarchyNavItem.tsx | 105 +++++ src/app/features/lobby/SpaceItem.tsx | 421 ++++++++---------- src/app/features/lobby/SpaceNavItem.css.ts | 32 ++ src/app/features/lobby/SpaceNavItem.tsx | 173 +++++++ src/app/hooks/useSpaceHierarchy.ts | 16 +- 7 files changed, 657 insertions(+), 323 deletions(-) create mode 100644 src/app/features/lobby/SpaceHierarchyNavItem.tsx create mode 100644 src/app/features/lobby/SpaceNavItem.css.ts create mode 100644 src/app/features/lobby/SpaceNavItem.tsx diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index dffb73044..c980db462 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -1,5 +1,25 @@ -import { MouseEventHandler, ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, color, config } from 'folds'; +import { + MouseEventHandler, + ReactElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + Box, + Chip, + Icon, + IconButton, + Icons, + Line, + Scroll, + Spinner, + Text, + color, + config, +} from 'folds'; import { useVirtualizer, VirtualItem } from '@tanstack/react-virtual'; import { useAtom, useAtomValue } from 'jotai'; import { useNavigate } from 'react-router-dom'; @@ -13,7 +33,7 @@ import { import { produce } from 'immer'; import { useSpace } from '$hooks/useSpace'; import { Page, PageContent, PageContentCenter, PageHeroSection } from '$components/page'; -import { HierarchyItem, HierarchyItemSpace, SpaceHierarchy, useSpaceHierarchy } from '$hooks/useSpaceHierarchy'; +import { HierarchyItem, HierarchyItemSpace, useSpaceHierarchy } from '$hooks/useSpaceHierarchy'; import { VirtualTile } from '$components/virtualizer'; import { spaceRoomsAtom } from '$state/spaceRooms'; import { useSetting } from '$state/hooks/settings'; @@ -56,6 +76,7 @@ import { SpaceHierarchyItem } from './SpaceHierarchyItem'; import { CanDropCallback, useDnDMonitor } from './DnD'; import { LobbyHero } from './LobbyHero'; import { LobbyHeader } from './LobbyHeader'; +import { SpaceHierarchyNavItem } from './SpaceHierarchyNavItem'; const useCanDropLobbyItem = ( space: Room, @@ -273,11 +294,16 @@ export function Lobby() { return !Array.from(parentIds).some((id) => !getInClosedCategories(spaceId, id, roomId)); }; + const [subspaceHierarchyLimit] = useSetting(settingsAtom, 'subspaceHierarchyLimit'); const [draggingItem, setDraggingItem] = useState(); const hierarchy = useSpaceHierarchy( space.roomId, spaceRooms, getRoom, + useCallback( + (_childId, _spaceId, depth) => depth >= subspaceHierarchyLimit, + [subspaceHierarchyLimit] + ), useCallback( (childId) => getInClosedCategories(space.roomId, childId) || @@ -520,73 +546,76 @@ export function Lobby() { [mx, sidebarItems, sidebarSpaces] ); - const getPaddingTop = (hierarchy: SpaceHierarchy[], vItem: VirtualItem) => { + const getPaddingTop = (vItem: VirtualItem) => { if (vItem.index === 0) return 0; const prevDepth = hierarchy[vItem.index - 1]?.space.depth ?? 0; - const depth = hierarchy[vItem.index].space.depth; + const { depth } = hierarchy[vItem.index].space; if (depth !== 1 && depth >= prevDepth) return config.space.S200; return config.space.S500; }; - const getConnectorSVG = useCallback((hierarchy: SpaceHierarchy[], virtualizedItems: VirtualItem[]): ReactElement => { - const PADDING_LEFT_DEPTH_OFFSET = 15.75; - const PADDING_LEFT_DEPTH_OFFSET_START = -15; + const getConnectorSVG = useCallback( + (virtualizedItems: VirtualItem[]): ReactElement => { + const PADDING_LEFT_DEPTH_OFFSET = 15.75; + const PADDING_LEFT_DEPTH_OFFSET_START = -15; - var aY: number = 0; - // Holder for the paths - const pathHolder: ReactElement[] = []; - virtualizedItems.forEach((vItem) => { - const { depth } = hierarchy[vItem.index].space ?? {}; + let aY = 0; + // Holder for the paths + const pathHolder: ReactElement[] = []; + virtualizedItems.forEach((vItem) => { + const { depth } = hierarchy[vItem.index].space ?? {}; - // We will render spaces at a level above their normal depth, since we want their children to be "under" them - // for the root items, we are not doing anything with it. - if (depth < 1) { - return; - } - // for the sub-root items, we will not draw any arcs from root to it. - // however, we should capture the aX and aY to draw starter arcs for next depths. - if (depth === 1) { - aY = vItem.end; - return; - } + // We will render spaces at a level above their normal depth, since we want their children to be "under" them + // for the root items, we are not doing anything with it. + if (depth < 1) { + return; + } + // for the sub-root items, we will not draw any arcs from root to it. + // however, we should capture the aX and aY to draw starter arcs for next depths. + if (depth === 1) { + aY = vItem.end; + return; + } - var pathStrings: string[] = []; + const pathStrings: string[] = []; - for (var iDepth = 0; iDepth < depth; iDepth++) { - var X = iDepth * PADDING_LEFT_DEPTH_OFFSET + PADDING_LEFT_DEPTH_OFFSET_START; + for (let iDepth = 0; iDepth < depth; iDepth += 1) { + const X = iDepth * PADDING_LEFT_DEPTH_OFFSET + PADDING_LEFT_DEPTH_OFFSET_START; - var bY = vItem.end; + const bY = vItem.end; - pathStrings.push(`M ${X} ${aY} L ${X} ${bY}`); - } + pathStrings.push(`M ${X} ${aY} L ${X} ${bY}`); + } - pathHolder.push( - - ); + pathHolder.push( + + ); - aY = vItem.end; - }); + aY = vItem.end; + }); - return ( - - {pathHolder} - - ); - }, [hierarchy, vItems]); + return ( + + {pathHolder} + + ); + }, + [hierarchy] + ); return ( @@ -641,39 +670,57 @@ export function Lobby() { - + {item.space.depth !== subspaceHierarchyLimit ? ( + + ) : ( + + )} ); })} - {getConnectorSVG(hierarchy, vItems)} + {getConnectorSVG(vItems)}
{reordering && ( !subspaces.has(i.roomId)); - if (!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())) { - // 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; - }); - } + const [childItems, setChildItems] = useState([] as HierarchyItemRoom[] | undefined); + useEffect(() => { + let childItemsMut = roomItems?.filter((i) => !subspaces.has(i.roomId)); + if (!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())) { + // hide unknown rooms for normal user + childItemsMut = 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; + }); + } + setChildItems(childItemsMut); + }, [childItems, spacePermissions, mx, roomItems, subspaces, fetching, error, rooms]); return ( @@ -204,7 +208,7 @@ export const SpaceHierarchyItem = forwardRef ) : ( childItems && ( - + ; + roomsPowerLevels: Map; + categoryId: string; + draggingItem?: HierarchyItem; + onDragging: (item?: HierarchyItem) => void; + canDrop: CanDropCallback; + disabledReorder?: boolean; + nextSpaceId?: string; + pinned: boolean; + togglePinToSidebar: (roomId: string) => void; + getRoom: (roomId: string) => Room | undefined; +}; +export const SpaceHierarchyNavItem = forwardRef( + ( + { + summary, + spaceItem, + allJoinedRooms, + roomsPowerLevels, + categoryId, + draggingItem, + onDragging, + canDrop, + disabledReorder, + nextSpaceId, + pinned, + togglePinToSidebar, + getRoom, + }, + ref + ) => { + const mx = useMatrixClient(); + + const spacePowerLevels = roomsPowerLevels.get(spaceItem.roomId); + + const draggingSpace = + draggingItem?.roomId === spaceItem.roomId && draggingItem.parentId === spaceItem.parentId; + + const { parentId } = spaceItem; + const parentPowerLevels = parentId ? roomsPowerLevels.get(parentId) : undefined; + const parentCreators = parentId ? getRoomCreatorsForRoomId(mx, parentId) : undefined; + const parentPermissions = + parentCreators && + parentPowerLevels && + getRoomPermissionsAPI(parentCreators, parentPowerLevels); + + return ( + + + ) + } + getRoom={getRoom} + after={ + + } + onDragging={onDragging} + data-dragging={draggingSpace} + /> + + ); + } +); diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index 9ddee7380..586f9dccc 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -1,25 +1,5 @@ -import { MouseEventHandler, ReactNode, useCallback, useRef, useState } from 'react'; -import { - Box, - Avatar, - Text, - Chip, - Icon, - Icons, - as, - Badge, - toRem, - Spinner, - PopOut, - Menu, - MenuItem, - RectCords, - config, - IconButton, - TooltipProvider, - Tooltip, -} from 'folds'; -import FocusTrap from 'focus-trap-react'; +import { MouseEventHandler, ReactNode, useCallback, useRef } from 'react'; +import { Box, Avatar, Text, Chip, Icon, Icons, as, Badge, toRem, Spinner } from 'folds'; import classNames from 'classnames'; import { MatrixError, Room, IHierarchyRoom } from '$types/matrix-sdk'; import { HierarchyItem } from '$hooks/useSpaceHierarchy'; @@ -29,14 +9,8 @@ import { nameInitials } from '$utils/common'; import { LocalRoomSummaryLoader } from '$components/RoomSummaryLoader'; import { getRoomAvatarUrl } from '$utils/room'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; -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 { CreateRoomType } from '$components/create-room/types'; -import { AddExistingModal } from '$features/add-existing'; -import { BetaNoticeBadge } from '$components/BetaNoticeBadge'; import { useDraggableItem } from './DnD'; import * as styleCss from './style.css'; import * as css from './SpaceItem.css'; @@ -60,7 +34,7 @@ type InaccessibleSpaceProfileProps = { roomId: string; suggested?: boolean; }; -function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfileProps) { +export function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfileProps) { return ( (); - const openCreateRoomModal = useOpenCreateRoomModal(); - const [addExisting, setAddExisting] = useState(false); +// Keeping this dead code here until I figure out how to add it into the HierarchyItemMenu... +// function AddRoomButton({ item }: { item: HierarchyItem }) { +// const [cords, setCords] = useState(); +// const openCreateRoomModal = useOpenCreateRoomModal(); +// const [addExisting, setAddExisting] = useState(false); - const handleAddRoom: MouseEventHandler = (evt) => { - setCords(evt.currentTarget.getBoundingClientRect()); - }; +// const handleAddRoom: MouseEventHandler = (evt) => { +// setCords(evt.currentTarget.getBoundingClientRect()); +// }; - const handleCreateRoom = (type?: CreateRoomType) => { - openCreateRoomModal(item.roomId, type); - setCords(undefined); - }; +// const handleCreateRoom = (type?: CreateRoomType) => { +// openCreateRoomModal(item.roomId, type); +// setCords(undefined); +// }; - const handleAddExisting = () => { - setAddExisting(true); - setCords(undefined); - }; +// const handleAddExisting = () => { +// setAddExisting(true); +// setCords(undefined); +// }; - return ( - setCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - - handleCreateRoom(CreateRoomType.TextRoom)} - > - Chat Room - - handleCreateRoom(CreateRoomType.VoiceRoom)} - after={} - > - Voice Room - - - Existing Room - - - - } - > - {item.parentId === undefined ? ( - } - onClick={handleAddRoom} - aria-pressed={!!cords} - > - Add Room - - ) : ( - - Add Room - - } - > - {(triggerRef) => ( - - - - )} - - )} - {addExisting && ( - setAddExisting(false)} /> - )} - - ); -} +// return ( +// setCords(undefined), +// clickOutsideDeactivates: true, +// isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', +// isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', +// escapeDeactivates: stopPropagation, +// }} +// > +// +// handleCreateRoom(CreateRoomType.TextRoom)} +// > +// Chat Room +// +// handleCreateRoom(CreateRoomType.VoiceRoom)} +// after={} +// > +// Voice Room +// +// +// Existing Room +// +// +// +// } +// > +// {item.parentId === undefined ? ( +// } +// onClick={handleAddRoom} +// aria-pressed={!!cords} +// > +// Add Room +// +// ) : ( +// +// Add Room +// +// } +// > +// {(triggerRef) => ( +// +// +// +// )} +// +// )} +// {addExisting && ( +// setAddExisting(false)} /> +// )} +// +// ); +// } -function AddSpaceButton({ item }: { item: HierarchyItem }) { - const [cords, setCords] = useState(); - const openCreateSpaceModal = useOpenCreateSpaceModal(); - const [addExisting, setAddExisting] = useState(false); +// function AddSpaceButton({ item }: { item: HierarchyItem }) { +// const [cords, setCords] = useState(); +// const openCreateSpaceModal = useOpenCreateSpaceModal(); +// const [addExisting, setAddExisting] = useState(false); - const handleAddSpace: MouseEventHandler = (evt) => { - setCords(evt.currentTarget.getBoundingClientRect()); - }; +// const handleAddSpace: MouseEventHandler = (evt) => { +// setCords(evt.currentTarget.getBoundingClientRect()); +// }; - const handleCreateSpace = () => { - openCreateSpaceModal(item.roomId as any); - setCords(undefined); - }; +// const handleCreateSpace = () => { +// openCreateSpaceModal(item.roomId as any); +// setCords(undefined); +// }; - const handleAddExisting = () => { - setAddExisting(true); - setCords(undefined); - }; - return ( - setCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - - - New Space - - - Existing Space - - - - } - > - {item.parentId === undefined ? ( - } - onClick={handleAddSpace} - aria-pressed={!!cords} - > - Add Space - - ) : ( - - Add Space - - } - > - {(triggerRef) => ( - - - - )} - - )} - {addExisting && ( - setAddExisting(false)} /> - )} - - ); -} +// const handleAddExisting = () => { +// setAddExisting(true); +// setCords(undefined); +// }; +// return ( +// setCords(undefined), +// clickOutsideDeactivates: true, +// isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', +// isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', +// escapeDeactivates: stopPropagation, +// }} +// > +// +// +// New Space +// +// +// Existing Space +// +// +// +// } +// > +// {item.parentId === undefined ? ( +// } +// onClick={handleAddSpace} +// aria-pressed={!!cords} +// > +// Add Space +// +// ) : ( +// +// Add Space +// +// } +// > +// {(triggerRef) => ( +// +// +// +// )} +// +// )} +// {addExisting && ( +// setAddExisting(false)} /> +// )} +// +// ); +// } type SpaceItemCardProps = { summary: IHierarchyRoom | undefined; @@ -556,12 +531,6 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>( )} - {space && canEditChild && ( - - - - - )} {options} {after} diff --git a/src/app/features/lobby/SpaceNavItem.css.ts b/src/app/features/lobby/SpaceNavItem.css.ts new file mode 100644 index 000000000..c9d1b2c7a --- /dev/null +++ b/src/app/features/lobby/SpaceNavItem.css.ts @@ -0,0 +1,32 @@ +import { style } from '@vanilla-extract/css'; +import { config, toRem } from 'folds'; +import { recipe } from '@vanilla-extract/recipes'; + +export const SpaceItemCard = recipe({ + base: { + paddingBottom: config.space.S100, + borderBottom: `${config.borderWidth.B300} solid transparent`, + position: 'relative', + selectors: { + '&[data-dragging=true]': { + opacity: config.opacity.Disabled, + }, + }, + }, +}); +export const HeaderChip = style({ + paddingLeft: config.space.S200, + selectors: { + [`&[data-ui-before="true"]`]: { + paddingLeft: config.space.S100, + }, + }, +}); +export const HeaderChipPlaceholder = style([ + { + borderRadius: config.radii.R400, + paddingLeft: config.space.S100, + paddingRight: config.space.S300, + height: toRem(32), + }, +]); diff --git a/src/app/features/lobby/SpaceNavItem.tsx b/src/app/features/lobby/SpaceNavItem.tsx new file mode 100644 index 000000000..c07ce6cbc --- /dev/null +++ b/src/app/features/lobby/SpaceNavItem.tsx @@ -0,0 +1,173 @@ +import { ReactNode, useRef } from 'react'; +import { Avatar, Badge, Box, Chip, Icon, Icons, as, Text } from 'folds'; +import classNames from 'classnames'; +import { IHierarchyRoom, MatrixClient, Room } from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { getCanonicalAliasOrRoomId, mxcUrlToHttp } from '$utils/matrix'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { HierarchyItem } from '$hooks/useSpaceHierarchy'; +import { LocalRoomSummaryLoader } from '$components/RoomSummaryLoader'; +import { getRoomAvatarUrl } from '$utils/room'; +import { RoomAvatar } from '$components/room-avatar'; +import { nameInitials } from '$utils/common'; +import { useNavigate } from 'react-router-dom'; +import { getSpaceLobbyPath } from '$pages/pathUtils'; +import { InaccessibleSpaceProfile, UnjoinedSpaceProfile } from './SpaceItem'; +import * as css from './SpaceNavItem.css'; +import { useDraggableItem } from './DnD'; + +type SpaceProfileProps = { + roomId: string; + name: string; + avatarUrl?: string; + suggested?: boolean; + categoryId: string; + mx: MatrixClient; +}; +function SpaceNavProfile({ + roomId, + name, + avatarUrl, + suggested, + categoryId, + mx, +}: SpaceProfileProps) { + const navigate = useNavigate(); + return ( + navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, roomId)))} + before={ + + ( + + {nameInitials(name)} + + )} + /> + + } + after={} + > + + + {name} + + {suggested && ( + + Suggested + + )} + + + ); +} + +type SpaceNavItemCardProps = { + summary: IHierarchyRoom | undefined; + item: HierarchyItem; + joined?: boolean; + categoryId: string; + options?: ReactNode; + before?: ReactNode; + after?: ReactNode; + canReorder: boolean; + getRoom: (roomId: string) => Room | undefined; + onDragging: (item?: HierarchyItem) => void; +}; +export const SpaceNavItemCard = as<'div', SpaceNavItemCardProps>( + ( + { + className, + summary, + joined, + categoryId, + item, + options, + before, + after, + canReorder, + onDragging, + getRoom, + ...props + }, + ref + ) => { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const { roomId, content } = item; + // const spaceNav = getRoom(roomId); + const targetRef = useRef(null); + useDraggableItem(item, targetRef, onDragging); + const space = getRoom(item.roomId); + + return ( + + {before} + + + {space ? ( + + {(localSummary) => ( + + )} + + ) : ( + <> + {!summary && ( + + )} + {summary && ( + + )} + + )} + + + {options} + {after} + + ); + } +); diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index 3b6fd5aa5..b5c088750 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -114,12 +114,13 @@ const getSpaceHierarchy = ( rootSpaceId: string, spaceRooms: Set, getRoom: (roomId: string) => Room | undefined, + excludeRoom: (parentId: string, roomId: string, depth: number) => boolean, closedCategory: (spaceId: string) => boolean ): SpaceHierarchy[] => { const spaceItems: HierarchyItemSpace[] = getHierarchySpaces( rootSpaceId, getRoom, - () => false, + excludeRoom, spaceRooms ); @@ -161,19 +162,20 @@ export const useSpaceHierarchy = ( spaceId: string, spaceRooms: Set, getRoom: (roomId: string) => Room | undefined, + excludeRoom: (parentId: string, roomId: string, depth: number) => boolean, closedCategory: (spaceId: string) => boolean ): SpaceHierarchy[] => { const mx = useMatrixClient(); const roomToParents = useAtomValue(roomToParentsAtom); const [hierarchyAtom] = useState(() => - atom(getSpaceHierarchy(spaceId, spaceRooms, getRoom, closedCategory)) + atom(getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory)) ); const [hierarchy, setHierarchy] = useAtom(hierarchyAtom); useEffect(() => { - setHierarchy(getSpaceHierarchy(spaceId, spaceRooms, getRoom, closedCategory)); - }, [mx, spaceId, spaceRooms, setHierarchy, getRoom, closedCategory]); + setHierarchy(getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory)); + }, [mx, spaceId, spaceRooms, setHierarchy, getRoom, closedCategory, excludeRoom]); useStateEventCallback( mx, @@ -184,10 +186,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, excludeRoom, closedCategory) + ); } }, - [spaceId, roomToParents, setHierarchy, spaceRooms, getRoom, closedCategory] + [spaceId, roomToParents, setHierarchy, spaceRooms, getRoom, closedCategory, excludeRoom] ) ); From 0dd44ba53adb557451c7b784ba622dcc9ed2a659 Mon Sep 17 00:00:00 2001 From: Kace Cottam Date: Thu, 19 Mar 2026 10:31:04 -0700 Subject: [PATCH 32/34] Add back the button, but removed from subspaces --- src/app/features/lobby/SpaceItem.tsx | 371 +++++++++++++-------------- 1 file changed, 176 insertions(+), 195 deletions(-) diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index 586f9dccc..662611efe 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -1,5 +1,24 @@ -import { MouseEventHandler, ReactNode, useCallback, useRef } from 'react'; -import { Box, Avatar, Text, Chip, Icon, Icons, as, Badge, toRem, Spinner } from 'folds'; +import { MouseEventHandler, ReactNode, useCallback, useRef, useState } from 'react'; +import { + Box, + Avatar, + Text, + Chip, + Icon, + Icons, + as, + Badge, + toRem, + Spinner, + PopOut, + config, + IconButton, + Menu, + MenuItem, + RectCords, + Tooltip, + TooltipProvider, +} from 'folds'; import classNames from 'classnames'; import { MatrixError, Room, IHierarchyRoom } from '$types/matrix-sdk'; import { HierarchyItem } from '$hooks/useSpaceHierarchy'; @@ -11,9 +30,16 @@ import { getRoomAvatarUrl } from '$utils/room'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { mxcUrlToHttp } from '$utils/matrix'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; -import { useDraggableItem } from './DnD'; -import * as styleCss from './style.css'; +import { BetaNoticeBadge } from '$components/BetaNoticeBadge'; +import { CreateRoomType } from '$components/create-room'; +import { AddExistingModal } from '$features/add-existing'; +import { useOpenCreateRoomModal } from '$state/hooks/createRoomModal'; +import { useOpenCreateSpaceModal } from '$state/hooks/createSpaceModal'; +import { stopPropagation } from '$utils/keyboard'; +import FocusTrap from 'focus-trap-react'; import * as css from './SpaceItem.css'; +import * as styleCss from './style.css'; +import { useDraggableItem } from './DnD'; function SpaceProfileLoading() { return ( @@ -218,205 +244,154 @@ function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileP ); } -// Keeping this dead code here until I figure out how to add it into the HierarchyItemMenu... -// function AddRoomButton({ item }: { item: HierarchyItem }) { -// const [cords, setCords] = useState(); -// const openCreateRoomModal = useOpenCreateRoomModal(); -// const [addExisting, setAddExisting] = useState(false); +function AddRoomButton({ item }: { item: HierarchyItem }) { + const [cords, setCords] = useState(); + const openCreateRoomModal = useOpenCreateRoomModal(); + const [addExisting, setAddExisting] = useState(false); -// const handleAddRoom: MouseEventHandler = (evt) => { -// setCords(evt.currentTarget.getBoundingClientRect()); -// }; + const handleAddRoom: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; -// const handleCreateRoom = (type?: CreateRoomType) => { -// openCreateRoomModal(item.roomId, type); -// setCords(undefined); -// }; + const handleCreateRoom = (type?: CreateRoomType) => { + openCreateRoomModal(item.roomId, type); + setCords(undefined); + }; -// const handleAddExisting = () => { -// setAddExisting(true); -// setCords(undefined); -// }; + const handleAddExisting = () => { + setAddExisting(true); + setCords(undefined); + }; -// return ( -// setCords(undefined), -// clickOutsideDeactivates: true, -// isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', -// isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', -// escapeDeactivates: stopPropagation, -// }} -// > -// -// handleCreateRoom(CreateRoomType.TextRoom)} -// > -// Chat Room -// -// handleCreateRoom(CreateRoomType.VoiceRoom)} -// after={} -// > -// Voice Room -// -// -// Existing Room -// -// -// -// } -// > -// {item.parentId === undefined ? ( -// } -// onClick={handleAddRoom} -// aria-pressed={!!cords} -// > -// Add Room -// -// ) : ( -// -// Add Room -// -// } -// > -// {(triggerRef) => ( -// -// -// -// )} -// -// )} -// {addExisting && ( -// setAddExisting(false)} /> -// )} -// -// ); -// } + return ( + setCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + handleCreateRoom(CreateRoomType.TextRoom)} + > + Chat Room + + handleCreateRoom(CreateRoomType.VoiceRoom)} + after={} + > + Voice Room + + + Existing Room + + + + } + > + {item.parentId === undefined && ( + } + onClick={handleAddRoom} + aria-pressed={!!cords} + > + Add Room + + )} + {addExisting && ( + setAddExisting(false)} /> + )} + + ); +} -// function AddSpaceButton({ item }: { item: HierarchyItem }) { -// const [cords, setCords] = useState(); -// const openCreateSpaceModal = useOpenCreateSpaceModal(); -// const [addExisting, setAddExisting] = useState(false); +function AddSpaceButton({ item }: { item: HierarchyItem }) { + const [cords, setCords] = useState(); + const openCreateSpaceModal = useOpenCreateSpaceModal(); + const [addExisting, setAddExisting] = useState(false); -// const handleAddSpace: MouseEventHandler = (evt) => { -// setCords(evt.currentTarget.getBoundingClientRect()); -// }; + const handleAddSpace: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; -// const handleCreateSpace = () => { -// openCreateSpaceModal(item.roomId as any); -// setCords(undefined); -// }; + const handleCreateSpace = () => { + openCreateSpaceModal(item.roomId as any); + setCords(undefined); + }; -// const handleAddExisting = () => { -// setAddExisting(true); -// setCords(undefined); -// }; -// return ( -// setCords(undefined), -// clickOutsideDeactivates: true, -// isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', -// isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', -// escapeDeactivates: stopPropagation, -// }} -// > -// -// -// New Space -// -// -// Existing Space -// -// -// -// } -// > -// {item.parentId === undefined ? ( -// } -// onClick={handleAddSpace} -// aria-pressed={!!cords} -// > -// Add Space -// -// ) : ( -// -// Add Space -// -// } -// > -// {(triggerRef) => ( -// -// -// -// )} -// -// )} -// {addExisting && ( -// setAddExisting(false)} /> -// )} -// -// ); -// } + const handleAddExisting = () => { + setAddExisting(true); + setCords(undefined); + }; + return ( + setCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + New Space + + + Existing Space + + + + } + > + {item.parentId === undefined && ( + } + onClick={handleAddSpace} + aria-pressed={!!cords} + > + Add Space + + )} + {addExisting && ( + setAddExisting(false)} /> + )} + + ); +} type SpaceItemCardProps = { summary: IHierarchyRoom | undefined; @@ -531,6 +506,12 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>( )} + {space && canEditChild && ( + + + + + )} {options} {after} From 7e22cac29b2ef7e9ed4eeff099c93447f6d46ba3 Mon Sep 17 00:00:00 2001 From: Kace Cottam Date: Thu, 19 Mar 2026 10:38:29 -0700 Subject: [PATCH 33/34] Add changeset --- .changeset/implements_improved_subspace_rendering.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/implements_improved_subspace_rendering.md diff --git a/.changeset/implements_improved_subspace_rendering.md b/.changeset/implements_improved_subspace_rendering.md new file mode 100644 index 000000000..d82dab39e --- /dev/null +++ b/.changeset/implements_improved_subspace_rendering.md @@ -0,0 +1,8 @@ +--- +default: minor +--- + +# Implemented improved subspace rendering +Navbar and Lobby view now represent the hierarchy of subspaces via guide lines. +Depth of subspace limit is customizable with a setting. +Also changes the navbar collapse/extpanded arrows to be on the right of the categories. From a7fb89c61377c2ff817deca6b654a4c1271edbfc Mon Sep 17 00:00:00 2001 From: Kace Cottam Date: Thu, 19 Mar 2026 11:00:08 -0700 Subject: [PATCH 34/34] Ran pnpm lint:fix and fmt --- .changeset/implements_improved_subspace_rendering.md | 1 + src/app/features/lobby/SpaceItem.tsx | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.changeset/implements_improved_subspace_rendering.md b/.changeset/implements_improved_subspace_rendering.md index d82dab39e..b45aa6aff 100644 --- a/.changeset/implements_improved_subspace_rendering.md +++ b/.changeset/implements_improved_subspace_rendering.md @@ -3,6 +3,7 @@ default: minor --- # Implemented improved subspace rendering + Navbar and Lobby view now represent the hierarchy of subspaces via guide lines. Depth of subspace limit is customizable with a setting. Also changes the navbar collapse/extpanded arrows to be on the right of the categories. diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index 662611efe..3a95049d7 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -12,12 +12,9 @@ import { Spinner, PopOut, config, - IconButton, Menu, MenuItem, RectCords, - Tooltip, - TooltipProvider, } from 'folds'; import classNames from 'classnames'; import { MatrixError, Room, IHierarchyRoom } from '$types/matrix-sdk';