diff --git a/src/app/features/add-existing/AddExisting.tsx b/src/app/features/add-existing/AddExisting.tsx index 80ace407b..50cfc6717 100644 --- a/src/app/features/add-existing/AddExisting.tsx +++ b/src/app/features/add-existing/AddExisting.tsx @@ -83,13 +83,39 @@ 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. + * @param visited - Set used to prevent recursion errors. + * @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; + } + + return Array.from(parentIds).some((id) => isAncestor(sourceId, id, visited)); + }, + [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/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/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index bbaf3885f..1377f33ff 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,17 @@ export function HierarchyItemMenu({ )} + { + navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, item.roomId))); + }} + > + + Open Lobby + + { + closedCategoriesCache.current.clear(); + }, [closedCategories, roomToParents, getRoom]); + + /** + * 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. + * @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, + 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) { + if (spaceRooms.has(previousId) || getRoom(previousId)?.isSpaceRoom()) { + return false; + } + } + + 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); + } + + 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; + } + + // 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, visited) + ); + visited.delete(categoryId); + closedCategoriesCache.current.set(categoryId, allClosed); + return allClosed; + }, + [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; + } + + return !Array.from(parentIds).some((id) => !getInClosedCategories(spaceId, id, roomId)); + }; + const [draggingItem, setDraggingItem] = useState(); const hierarchy = useSpaceHierarchy( space.roomId, @@ -195,9 +280,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] ) ); @@ -298,7 +383,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 ( @@ -317,7 +402,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, }); @@ -403,9 +488,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] = getLobbyCategoryIdParts(categoryId); + + // 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'); @@ -426,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 ( @@ -467,19 +629,25 @@ 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.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 c8ec05f22..9ddee7380 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -15,6 +15,9 @@ import { MenuItem, RectCords, config, + IconButton, + TooltipProvider, + Tooltip, } from 'folds'; import FocusTrap from 'focus-trap-react'; import classNames from 'classnames'; @@ -303,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)} /> )} @@ -370,15 +400,42 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) { } > - } - onClick={handleAddSpace} - aria-pressed={!!cords} - > - Add Space - + {item.parentId === undefined ? ( + } + onClick={handleAddSpace} + aria-pressed={!!cords} + > + Add Space + + ) : ( + + Add Space + + } + > + {(triggerRef) => ( + + + + )} + + )} {addExisting && ( setAddExisting(false)} /> )} @@ -502,7 +559,7 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>( {space && canEditChild && ( - {item.parentId === undefined && } + )} diff --git a/src/app/features/room-nav/RoomNavCategoryButton.tsx b/src/app/features/room-nav/RoomNavCategoryButton.tsx index 7adc6dcb9..3df48aa7d 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 }>( ( {...props} ref={ref} > - + {children} diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index 8d0f75e7e..a9d8e020c 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -343,6 +343,52 @@ 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 +453,14 @@ export function Appearance() { } /> + + + } + /> + ); diff --git a/src/app/features/space-nav/SpaceNavItem.tsx b/src/app/features/space-nav/SpaceNavItem.tsx new file mode 100644 index 000000000..f319a9da6 --- /dev/null +++ b/src/app/features/space-nav/SpaceNavItem.tsx @@ -0,0 +1,70 @@ +import { MouseEventHandler, useState } from 'react'; +import { Room } from '$types/matrix-sdk'; +import { Box, Icon, Icons, Text, config, RectCords, Avatar } from 'folds'; +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 [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 = () => { + 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 af8c80196..3b6fd5aa5 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, IHierarchyRoom } from '$types/matrix-sdk'; +import { MatrixError, MatrixEvent, Room, IHierarchyRoom } from '$types/matrix-sdk'; import { QueryFunction, useInfiniteQuery } from '@tanstack/react-query'; import { MSpaceChildContent, StateEvent } from '$types/matrix/room'; import { roomToParentsAtom } from '$state/room/roomToParents'; @@ -8,6 +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 { makeLobbyCategoryId } from '../state/closedLobbyCategories'; import { useStateEventCallback } from './useStateEventCallback'; import { ErrorCode } from '../cs-errorcode'; @@ -17,6 +18,7 @@ export type HierarchyItemSpace = { ts: number; space: true; parentId?: string; + depth: number; }; export type HierarchyItemRoom = { @@ -24,6 +26,7 @@ export type HierarchyItemRoom = { content: MSpaceChildContent; ts: number; parentId: string; + depth: number; }; export type HierarchyItem = HierarchyItemSpace | HierarchyItemRoom; @@ -34,9 +37,14 @@ 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, + excludeRoom: (parentId: string, roomId: string, depth: number) => boolean, spaceRooms: Set ): HierarchyItemSpace[] => { const rootSpaceItem: HierarchyItemSpace = { @@ -44,46 +52,56 @@ const getHierarchySpaces = ( content: { via: [] }, ts: 0, space: true, + depth: 0, }; - let spaceItems: HierarchyItemSpace[] = []; + const spaceItems: HierarchyItemSpace[] = []; + + 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 findAndCollectHierarchySpaces = (spaceItem: HierarchyItemSpace) => { - if (spaceItems.find((item) => item.roomId === spaceItem.roomId)) return; const space = getRoom(spaceItem.roomId); spaceItems.push(spaceItem); if (!space) return; - const childEvents = getStateEvents(space, StateEvent.SpaceChild); + 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, 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 + // 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, spaceItem.roomId, visited); }); }; - findAndCollectHierarchySpaces(rootSpaceItem); - - spaceItems = [ - rootSpaceItem, - ...spaceItems - .filter((item) => item.roomId !== rootSpaceId) - .sort(hierarchyItemTs) - .sort(hierarchyItemByOrder), - ]; + findAndCollectHierarchySpaces(rootSpaceItem, rootSpaceId); return spaceItems; }; @@ -98,7 +116,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); @@ -120,6 +143,7 @@ const getSpaceHierarchy = ( content: childEvent.getContent(), ts: childEvent.getTs(), parentId: spaceItem.roomId, + depth: spaceItem.depth, }; childItems.push(childItem); }); @@ -173,10 +197,44 @@ 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(rootSpaceId, getRoom, new Set()); + const spaceItems: HierarchyItemSpace[] = getHierarchySpaces( + rootSpaceId, + getRoom, + excludeRoom, + new Set() + ); + + /** + * 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()) => { + // Prevent infinite recursion + if (visited.has(spaceId)) return false; + visited.add(spaceId); + + const space = getRoom(spaceId); + if (!space) return false; + + 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); + if (!room) return false; + + if (!room.isSpaceRoom()) return true; + return getContainsRoom(childId, visited); + }); + }; const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => { const space = getRoom(spaceItem.roomId); @@ -193,20 +251,21 @@ const getSpaceJoinedHierarchy = ( return true; }); - if (joinedRoomEvents.length === 0) return []; + if (!getContainsRoom(spaceItem.roomId)) return []; const childItems: HierarchyItemRoom[] = []; joinedRoomEvents.forEach((childEvent) => { 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, content: childEvent.getContent(), ts: childEvent.getTs(), parentId: spaceItem.roomId, + depth: spaceItem.depth, }; childItems.push(childItem); }); @@ -219,7 +278,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 aae2f2d7b..4eb7b117c 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -1,4 +1,13 @@ -import { MouseEventHandler, forwardRef, useCallback, useMemo, useRef, useState } from 'react'; +import { + MouseEventHandler, + ReactElement, + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useAtom, useAtomValue } from 'jotai'; import { Avatar, @@ -18,7 +27,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'; @@ -31,18 +40,21 @@ import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { useSpaceLobbySelected, useSpaceSearchSelected } 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 } from '$state/closedNavCategories'; +import { SpaceNavItem } from '$features/space-nav'; +import { makeNavCategoryId, getNavCategoryIdParts } from '$state/closedNavCategories'; 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'; import { useRecursiveChildScopeFactory, useSpaceChildren } from '$state/hooks/roomList'; import { roomToParentsAtom } from '$state/room/roomToParents'; +import { roomToChildrenAtom } from '$state/room/roomToChildren'; import { markAsRead } from '$utils/notifications'; import { useRoomsUnread } from '$state/hooks/unread'; import { UseStateProvider } from '$components/UseStateProvider'; @@ -375,7 +387,10 @@ export function Space() { const scrollRef = useRef(null); const mDirects = useAtomValue(mDirectAtom); const roomToUnread = useAtomValue(roomToUnreadAtom); + 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(); @@ -397,25 +412,261 @@ 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. + * + * @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, + 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) { + if (spaceRooms.has(previousId) || getRoom(previousId)?.isSpaceRoom()) { + return false; + } + } + + 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); + } + + 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; + } + + // 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, visited) + ); + visited.delete(categoryId); + closedCategoriesCache.current.set(categoryId, allClosed); + return allClosed; + }, + [closedCategories, getRoom, roomToParents, spaceRooms] + ); + + /** + * 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, 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, visited)); + }, + [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 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; + } + + const allCollapsed = !Array.from(parentIds).some( + (id) => !getInClosedCategories(spaceId, id, roomId) + ); + ancestorsCollapsedCache.current.set(categoryId, allCollapsed); + return allCollapsed; + }; + + /** + * 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 = ( + 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; + + 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]; + + // 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) => { - if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) { + (parentId, roomId, depth) => { + if (depth >= subspaceHierarchyLimit) { + // we will exclude items above this depth + return true; + } + if (!getInClosedCategories(space.roomId, parentId, roomId)) { return false; } const unread = roomToUnread.get(roomId); + const containsShowRoom = getContainsShowRoom(roomId); const hasUnread = !!unread && (unread.total > 0 || unread.highlight > 0); const showRoomAnyway = hasUnread || roomId === selectedRoomId || callEmbed?.roomId === roomId; - return !showRoomAnyway; + return containsShowRoom || !showRoomAnyway; }, - [space.roomId, closedCategories, roomToUnread, selectedRoomId, callEmbed] + [ + getContainsShowRoom, + getInClosedCategories, + space.roomId, + callEmbed, + subspaceHierarchyLimit, + roomToUnread, + selectedRoomId, + ] ), useCallback( - (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)), - [closedCategories, space.roomId] + (sId) => getInClosedCategories(space.roomId, sId), + [getInClosedCategories, space.roomId] ) ); @@ -426,13 +677,30 @@ export function Space() { overscan: 10, }); - const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => - closedCategories.has(categoryId) - ); + const virtualizedItems = virtualizer.getVirtualItems(); + + const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => { + const collapsed = closedCategories.has(categoryId); + const [spaceId, roomId] = getNavCategoryIdParts(categoryId); + + // 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)); + const getCategoryPadding = (depth: number): string | undefined => { + if (depth === 0) return undefined; + if (depth === 1) return config.space.S400; + return config.space.S0; + }; + const navigate = useNavigate(); const lastRoomId = useAtomValue(lastVisitedRoomIdAtom); @@ -495,13 +763,35 @@ export function Space() { position: 'relative', }} > - {virtualizer.getVirtualItems().map((vItem) => { - const { roomId } = hierarchy[vItem.index] ?? {}; + {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 === subspaceHierarchyLimit && room.isSpaceRoom()) { + return ( + +
+ +
+
+ ); + } + + const paddingTop = getCategoryPadding(depth); + const paddingLeft = `calc(${renderDepth} * ${config.space.S400})`; if (room.isSpaceRoom()) { const categoryId = makeNavCategoryId(space.roomId, roomId); + const closedViaCategory = getInClosedCategories(space.roomId, roomId); return ( -
+
{roomId === space.roomId ? 'Rooms' : room?.name} @@ -532,20 +820,23 @@ export function Space() { key={vItem.index} ref={virtualizer.measureElement} > - +
+ +
); })} + {getConnectorSVG(hierarchy, virtualizedItems)} diff --git a/src/app/state/closedLobbyCategories.ts b/src/app/state/closedLobbyCategories.ts index 9d4d5d175..3c5c99e16 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 d21187f9a..8c2348902 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; +}); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index e9331c62f..fdaf72400 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; alwaysShowCallButton: boolean; faviconForMentionsOnly: boolean; @@ -176,6 +177,7 @@ const defaultSettings: Settings = { autoplayStickers: true, autoplayEmojis: true, saveStickerEmojiBandwidth: false, + subspaceHierarchyLimit: 3, alwaysShowCallButton: false, faviconForMentionsOnly: false, diff --git a/src/app/utils/colorMXID.ts b/src/app/utils/colorMXID.ts index c94a19d48..3c512d99f 100644 --- a/src/app/utils/colorMXID.ts +++ b/src/app/utils/colorMXID.ts @@ -9,7 +9,7 @@ function hashCode(str?: string): number { const chr = str.codePointAt(i) ?? 0; // eslint-disable-next-line no-bitwise hash = (hash << 5) - hash + chr; - // eslint-disable-next-line no-bitwise + hash = Math.trunc(hash); } return Math.abs(hash);