Files
cinny/src/app/features/room-settings/sub-rooms/SubRooms.tsx
Max Litruv Boonzaayer c7bd376292 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.
2026-03-15 16:25:53 +11:00

244 lines
8.3 KiB
TypeScript

import React, { useState, useCallback } from 'react';
import {
Box,
Button,
Text,
Icon,
Icons,
config,
IconButton,
Chip,
Line,
} from 'folds';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom';
import { useRoomSubRooms, PaarrotSubRoomsContent } from '../../../hooks/useRoomSubRooms';
import { useAllJoinedRoomsSet, useGetRoom } from '../../../hooks/useGetRoom';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SettingTile } from '../../../components/setting-tile';
import { StateEvent } from '../../../../types/matrix/room';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { isSpace } from '../../../utils/room';
function SubRoomItem({ roomId, onRemove, canEdit }: { roomId: string; onRemove: (roomId: string) => void; canEdit: boolean }) {
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
const room = getRoom(roomId);
if (!room) {
return (
<Chip variant="Secondary" radii="400">
<Text size="T300">Unknown Room ({roomId})</Text>
{canEdit && (
<IconButton
variant="SurfaceVariant"
size="300"
radii="Pill"
onClick={() => onRemove(roomId)}
>
<Icon size="50" src={Icons.Cross} />
</IconButton>
)}
</Chip>
);
}
return (
<Chip variant="Secondary" radii="400">
<Text size="T300">{room.name}</Text>
{canEdit && (
<IconButton
variant="SurfaceVariant"
size="300"
radii="Pill"
onClick={() => onRemove(roomId)}
>
<Icon size="50" src={Icons.Cross} />
</IconButton>
)}
</Chip>
);
}
export function SubRooms() {
const mx = useMatrixClient();
const room = useRoom();
const subRooms = useRoomSubRooms(room);
const [addRoomId, setAddRoomId] = useState('');
const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canEdit = permissions.stateEvent(StateEvent.PaarrotSubRooms, mx.getSafeUserId());
const [updateState, updateSubRooms] = useAsyncCallback(
useCallback(
async (newSubRooms: string[]) => {
const content: PaarrotSubRoomsContent = {
children: newSubRooms,
};
// Custom state event type - need to cast
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await mx.sendStateEvent(room.roomId, StateEvent.PaarrotSubRooms as any, content, '');
},
[mx, room]
)
);
const handleRemoveSubRoom = useCallback(
(roomId: string) => {
const newSubRooms = subRooms.filter(id => id !== roomId);
updateSubRooms(newSubRooms);
},
[subRooms, updateSubRooms]
);
const handleAddRoom = useCallback(async () => {
const trimmedId = addRoomId.trim();
if (!trimmedId) return;
// Try to resolve room - if it's already joined, use the actual room ID
const targetRoom = mx.getRoom(trimmedId);
const roomIdToAdd = targetRoom ? targetRoom.roomId : trimmedId;
// Allow adding any room ID (even if not joined)
if (!subRooms.includes(roomIdToAdd)) {
const newSubRooms = [...subRooms, roomIdToAdd];
await updateSubRooms(newSubRooms);
// Also add sub-room to this room's parent space(s) for joining/permissions
// The UI will hide it from the top-level display and only show it nested
const parentSpaces = roomToParents.get(room.roomId);
if (parentSpaces) {
const spaceIds = Array.from(parentSpaces);
await Promise.all(
spaceIds.map(async (parentSpaceId) => {
const parentSpaceRoom = mx.getRoom(parentSpaceId);
// Only add to actual spaces (not other sub-room parents)
if (parentSpaceRoom && isSpace(parentSpaceRoom)) {
try {
const spaceServerName = parentSpaceId.split(':')[1] || '';
const spaceViaServers = spaceServerName ? [spaceServerName] : [];
// Add as m.space.child to the space
await mx.sendStateEvent(
parentSpaceId,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
StateEvent.SpaceChild as any,
{ via: spaceViaServers },
roomIdToAdd
);
} catch (spaceError) {
// eslint-disable-next-line no-console
console.warn(`Failed to add sub-room to space ${parentSpaceId}:`, spaceError);
}
}
})
);
}
setAddRoomId('');
}
}, [mx, addRoomId, subRooms, updateSubRooms, room.roomId, roomToParents]);
return (
<Box direction="Column" gap="400">
<SettingTile>
<Box direction="Column" gap="200">
<Text size="H5" priority="400">
Sub-Rooms
</Text>
<Text size="T300" priority="300">
Sub-rooms are nested rooms that appear indented under this room in the room list.
They work like sub-spaces but for regular rooms.
</Text>
</Box>
</SettingTile>
<Line variant="Surface" size="300" />
<SettingTile>
<Box direction="Column" gap="300">
<Box justifyContent="SpaceBetween" alignItems="Center">
<Text size="T400" priority="400">
Current Sub-Rooms
</Text>
</Box>
<Box gap="200" alignItems="Center">
<input
type="text"
placeholder="Enter room ID (!abc:server.com) or alias (#room:server.com)"
value={addRoomId}
onChange={(e) => setAddRoomId(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddRoom();
}
}}
style={{
flexGrow: 1,
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R400,
border: '1px solid var(--border-surface)',
backgroundColor: 'var(--background-surface)',
color: 'var(--text-primary)',
fontSize: config.fontSize.T300,
}}
/>
<Button
variant="Primary"
size="300"
radii="Pill"
onClick={handleAddRoom}
disabled={!canEdit || updateState.status === AsyncStatus.Loading || !addRoomId.trim()}
before={<Icon size="100" src={Icons.Plus} />}
>
<Text size="B300">Add</Text>
</Button>
</Box>
{!canEdit && (
<Text size="T300" priority="300" style={{ fontStyle: 'italic' }}>
You need power level 50 or higher to manage sub-rooms.
</Text>
)}
{subRooms.length === 0 ? (
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="200"
style={{ padding: `${config.space.S400} 0` }}
>
<Icon size="400" src={Icons.Hash} style={{ opacity: config.opacity.P300 }} />
<Text size="T300" priority="300" align="Center">
No sub-rooms added yet.
<br />
Add a room to nest it under this room.
</Text>
</Box>
) : (
<Box wrap="Wrap" gap="200">
{subRooms.map((roomId) => (
<SubRoomItem key={roomId} roomId={roomId} onRemove={handleRemoveSubRoom} canEdit={canEdit} />
))}
</Box>
)}
{updateState.status === AsyncStatus.Error && (
<Text size="T300" priority="400" style={{ color: 'var(--color-critical)' }}>
Failed to update sub-rooms: {String((updateState.error as Error)?.message || updateState.error)}
</Text>
)}
</Box>
</SettingTile>
</Box>
);
}