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

@@ -60,6 +60,7 @@ import { CallType } from '../call/types';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { StateEvent } from '../../../types/matrix/room';
import { mDirectAtom } from '../../state/mDirectList';
import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal';
type RoomNavItemMenuProps = {
room: Room;
@@ -78,7 +79,9 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const canAddSubRoom = permissions.stateEvent(StateEvent.PaarrotSubRooms, mx.getSafeUserId());
const openRoomSettings = useOpenRoomSettings();
const openCreateSubRoom = useOpenCreateSubRoomModal();
const space = useSpaceOptionally();
const [invitePrompt, setInvitePrompt] = useState(false);
@@ -104,6 +107,11 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
requestClose();
};
const handleAddSubRoom = () => {
openCreateSubRoom(room.roomId);
requestClose();
};
const handleJoinVoice = async () => {
try {
// If already in a call, leave it first
@@ -211,6 +219,17 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
Room Settings
</Text>
</MenuItem>
<MenuItem
onClick={handleAddSubRoom}
size="300"
after={<Icon size="100" src={Icons.Plus} />}
radii="300"
disabled={!canAddSubRoom}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Add Sub-Room
</Text>
</MenuItem>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>

View File

@@ -0,0 +1,99 @@
import React, { useCallback } from 'react';
import {
Avatar,
Box,
Chip,
Icon,
Icons,
Spinner,
Text,
Tooltip,
TooltipProvider,
color,
config,
toRem,
} from 'folds';
import { MatrixError, Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
/**
* Extract server name from a room ID (!abc:server.com => server.com)
*/
function getServerFromRoomId(roomId: string): string {
const parts = roomId.split(':');
return parts.length > 1 ? parts.slice(1).join(':') : '';
}
type UnjoinedSubRoomItemProps = {
roomId: string;
depth: number;
};
/**
* Displays an unjoined sub-room with a Join button.
* Used when a parent room has sub-rooms that the user hasn't joined yet.
*/
export function UnjoinedSubRoomItem({ roomId, depth }: UnjoinedSubRoomItemProps) {
const mx = useMatrixClient();
const viaServers = [getServerFromRoomId(roomId)].filter(Boolean);
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
useCallback(() => mx.joinRoom(roomId, { viaServers }), [mx, roomId, viaServers])
);
const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
const paddingLeft = depth > 0 ? `${depth * 24}px` : undefined;
return (
<Box
style={{ paddingLeft, padding: `${config.space.S100} ${config.space.S200}` }}
alignItems="Center"
gap="200"
>
<Avatar size="200" radii="400">
<Icon src={Icons.Hash} size="100" />
</Avatar>
<Box grow="Yes" alignItems="Center" gap="100">
<Text size="T300" truncate style={{ opacity: 0.7 }}>
{roomId}
</Text>
</Box>
<Box shrink="No" gap="100" alignItems="Center">
{joinState.status === AsyncStatus.Error && (
<TooltipProvider
tooltip={
<Tooltip variant="Critical" style={{ maxWidth: toRem(200) }}>
<Text style={{ wordBreak: 'break-word' }} size="T300">
{joinState.error.data?.error || joinState.error.message}
</Text>
</Tooltip>
}
>
{(triggerRef) => (
<Icon
ref={triggerRef}
style={{ color: color.Critical.Main, cursor: 'pointer' }}
src={Icons.Warning}
size="200"
/>
)}
</TooltipProvider>
)}
<Chip
variant="Secondary"
fill="Soft"
size="400"
radii="Pill"
before={
canJoin ? <Icon src={Icons.Plus} size="50" /> : <Spinner variant="Secondary" size="50" />
}
onClick={join}
disabled={!canJoin}
>
<Text size="B300">Join</Text>
</Chip>
</Box>
</Box>
);
}

View File

@@ -1,3 +1,4 @@
export * from './RoomNavItem';
export * from './RoomNavCategoryButton';
export * from './CallParticipantsIndicator';
export * from './UnjoinedSubRoomItem';