feat: Implement sub-room functionality

- Added support for sub-rooms in the SpaceHierarchy component, allowing rooms to have nested child rooms.
- Introduced UnjoinedSubRoomItem component to display unjoined sub-rooms with a join button.
- Created SubRooms settings page for managing sub-rooms, including adding and removing sub-rooms.
- Updated RoomNavItem to include options for creating sub-rooms.
- Enhanced room fetching logic to filter out sub-rooms from the main room list.
- Added hooks for managing sub-rooms state and permissions.
- Updated relevant state management to handle sub-room creation and association with parent rooms.
This commit is contained in:
2026-03-15 16:25:53 +11:00
parent a0c0068997
commit c7bd376292
21 changed files with 917 additions and 111 deletions

View File

@@ -26,7 +26,7 @@ import {
toRem,
} from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import { JoinRule, Room } from 'matrix-js-sdk';
import { JoinRule, Room, Thread, ThreadEvent } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import FocusTrap from 'focus-trap-react';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -47,7 +47,7 @@ import {
} from '../../../hooks/router/useSelectedSpace';
import { useSpace } from '../../../hooks/useSpace';
import { VirtualTile } from '../../../components/virtualizer';
import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
import { RoomNavCategoryButton, RoomNavItem, UnjoinedSubRoomItem } from '../../../features/room-nav';
import { makeNavCategoryId } from '../../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
@@ -425,8 +425,72 @@ export function Space() {
)
);
// Build a global set of all sub-room IDs to filter from hierarchy
// This prevents sub-rooms from appearing both as top-level entries AND nested under parents
const globalSubRoomIds = useMemo(() => {
const subRoomIds = new Set<string>();
mx.getRooms().forEach((room) => {
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
const content = subRoomsEvent?.getContent<{ children: string[] }>();
content?.children?.forEach((childId: string) => subRoomIds.add(childId));
});
return subRoomIds;
}, [mx, allJoinedRooms, hierarchy]);
// Filter hierarchy to exclude sub-rooms (they'll be shown nested under their parent)
const filteredHierarchy = useMemo(() => {
return hierarchy.filter((entry) => !globalSubRoomIds.has(entry.roomId));
}, [hierarchy, globalSubRoomIds]);
// Flatten hierarchy to include sub-rooms
type HierarchyItem =
| { type: 'hierarchy'; roomId: string }
| { type: 'subroom'; roomId: string; room: Room; depth: number }
| { type: 'unjoined-subroom'; roomId: string; depth: number };
const flattenedHierarchy = useMemo(() => {
const items: HierarchyItem[] = [];
const processedSubRooms = new Set<string>();
const addSubRooms = (parentRoomId: string, baseDepth: number) => {
const room = mx.getRoom(parentRoomId);
if (!room || room.isSpaceRoom()) return;
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? [];
subRooms.forEach(subRoomId => {
if (processedSubRooms.has(subRoomId)) return;
processedSubRooms.add(subRoomId);
const subRoom = mx.getRoom(subRoomId);
if (subRoom) {
// Joined sub-room
items.push({ type: 'subroom', roomId: subRoomId, room: subRoom, depth: baseDepth + 1 });
// Recursively add sub-rooms of sub-rooms
addSubRooms(subRoomId, baseDepth + 1);
} else {
// Unjoined sub-room - show with join button
items.push({ type: 'unjoined-subroom', roomId: subRoomId, depth: baseDepth + 1 });
}
});
};
filteredHierarchy.forEach((entry) => {
items.push({ type: 'hierarchy', roomId: entry.roomId });
// Add sub-rooms after each non-space room
const room = mx.getRoom(entry.roomId);
if (room && !room.isSpaceRoom()) {
addSubRooms(entry.roomId, 0);
}
});
return items;
}, [mx, filteredHierarchy]);
const virtualizer = useVirtualizer({
count: hierarchy.length,
count: flattenedHierarchy.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 0,
overscan: 10,
@@ -491,7 +555,40 @@ export function Space() {
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const { roomId } = hierarchy[vItem.index] ?? {};
const item = flattenedHierarchy[vItem.index];
if (!item) return null;
// Unjoined sub-room item
if (item.type === 'unjoined-subroom') {
return (
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
<UnjoinedSubRoomItem roomId={item.roomId} depth={item.depth} />
</VirtualTile>
);
}
// Sub-room item (nested under parent)
if (item.type === 'subroom') {
const paddingLeft = `${item.depth * 24}px`;
return (
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
<div style={{ paddingLeft }}>
<RoomNavItem
room={item.room}
selected={selectedRoomId === item.roomId}
showAvatar={mDirects.has(item.roomId)}
direct={mDirects.has(item.roomId)}
linkPath={getToLink(item.roomId)}
notificationMode={getRoomNotificationMode(notificationPreferences, item.room.roomId)}
/>
</div>
</VirtualTile>
);
}
// Hierarchy item (room or space)
const { roomId } = item;
const room = mx.getRoom(roomId);
if (!room) return null;