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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { useOrphanRooms } from '../../../state/hooks/roomList';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
|
||||
export const useHomeRooms = () => {
|
||||
const mx = useMatrixClient();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const rooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
|
||||
const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
|
||||
|
||||
// Filter out rooms that are sub-rooms of ANY room (not just orphan rooms)
|
||||
const rooms = useMemo(() => {
|
||||
// Build a set of all sub-room IDs from ALL joined rooms
|
||||
const allSubRoomIds = new Set<string>();
|
||||
mx.getRooms().forEach((room) => {
|
||||
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
|
||||
const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? [];
|
||||
subRooms.forEach((id) => allSubRoomIds.add(id));
|
||||
});
|
||||
|
||||
// Return only rooms that are not sub-rooms of another room
|
||||
return orphanRooms.filter((roomId) => !allSubRoomIds.has(roomId));
|
||||
}, [mx, orphanRooms]);
|
||||
|
||||
return rooms;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user