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

@@ -47,7 +47,7 @@ import {
import { useHomeRooms } from './useHomeRooms';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
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';
@@ -67,7 +67,7 @@ import { UseStateProvider } from '../../../components/UseStateProvider';
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
import { ManageRoomsPrompt } from '../../../components/manage-rooms-prompt';
import { _RoomSearchParams } from '../../paths';
import { HomeThreadsCategory } from './HomeThreadsCategory';
import { StateEvent } from '../../../../types/matrix/room';
type HomeMenuProps = {
requestClose: () => void;
@@ -235,6 +235,10 @@ function HomeEmpty() {
);
}
type RoomListItem =
| { type: 'room'; roomId: string; room: Room; depth: number; parentId?: string }
| { type: 'unjoined-subroom'; roomId: string; depth: number; parentId: string };
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
export function Home() {
const mx = useMatrixClient();
@@ -257,14 +261,65 @@ export function Home() {
? factoryRoomIdByActivity(mx)
: factoryRoomIdByAtoZ(mx)
);
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
}
return items;
}, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]);
// Flatten rooms and their sub-rooms into a hierarchical list
const listItems = useMemo(() => {
const items: RoomListItem[] = [];
const processedRooms = new Set<string>();
const addRoomAndSubRooms = (roomId: string, depth: number = 0, parentId?: string) => {
if (processedRooms.has(roomId)) return; // Prevent infinite loops
processedRooms.add(roomId);
const room = mx.getRoom(roomId);
if (room) {
// Joined room - show normally
items.push({ type: 'room', roomId, room, depth, parentId });
// Get sub-rooms from room state
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? [];
// Add sub-rooms recursively
subRooms.forEach(subRoomId => {
addRoomAndSubRooms(subRoomId, depth + 1, roomId);
});
} else if (parentId) {
// Unjoined sub-room - show with join button
items.push({ type: 'unjoined-subroom', roomId, depth, parentId });
}
};
// Build set of all sub-room IDs to identify top-level rooms
const allSubRoomIds = new Set<string>();
sortedRooms.forEach(roomId => {
const room = mx.getRoom(roomId);
if (room) {
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? [];
subRooms.forEach(id => allSubRoomIds.add(id));
}
});
// Only add top-level rooms (rooms that aren't sub-rooms of another)
sortedRooms.forEach(roomId => {
if (!allSubRoomIds.has(roomId)) {
addRoomAndSubRooms(roomId);
}
});
return items;
}, [mx, sortedRooms]);
const virtualizer = useVirtualizer({
count: sortedRooms.length,
count: listItems.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
overscan: 10,
@@ -323,13 +378,12 @@ export function Home() {
onCancel={() => setOpen(false)}
onOpen={(roomIdOrAlias, viaServers, eventId) => {
setOpen(false);
const path = getHomeRoomPath(roomIdOrAlias, eventId);
const searchParams: _RoomSearchParams = { viaServers, eventId };
navigate(
viaServers
? withSearchParam<_RoomSearchParams>(path, {
viaServers: encodeSearchParamValueArray(viaServers),
})
: path
withSearchParam(
getHomeRoomPath(roomIdOrAlias),
searchParams
)
);
}}
/>
@@ -371,10 +425,23 @@ export function Home() {
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = sortedRooms[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
const item = listItems[vItem.index];
if (!item) return null;
if (item.type === 'unjoined-subroom') {
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<UnjoinedSubRoomItem roomId={item.roomId} depth={item.depth} />
</VirtualTile>
);
}
const selected = selectedRoomId === item.roomId;
const paddingLeft = item.depth > 0 ? `${item.depth * 24}px` : undefined;
return (
<VirtualTile
@@ -382,21 +449,22 @@ export function Home() {
key={vItem.index}
ref={virtualizer.measureElement}
>
<RoomNavItem
room={room}
selected={selected}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId
)}
/>
<div style={{ paddingLeft }}>
<RoomNavItem
room={item.room}
selected={selected}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, item.roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
item.room.roomId
)}
/>
</div>
</VirtualTile>
);
})}
</div>
</NavCategory>
<HomeThreadsCategory rooms={rooms} />
</Box>
</PageNavContent>
)}