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:
@@ -17,6 +17,7 @@ import { Permissions } from './permissions';
|
||||
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { DeveloperTools } from '../common-settings/developer-tools';
|
||||
import { SubRooms } from './sub-rooms';
|
||||
|
||||
type RoomSettingsMenuItem = {
|
||||
page: RoomSettingsPage;
|
||||
@@ -42,6 +43,11 @@ const useRoomSettingsMenuItems = (): RoomSettingsMenuItem[] =>
|
||||
name: 'Permissions',
|
||||
icon: Icons.Lock,
|
||||
},
|
||||
{
|
||||
page: RoomSettingsPage.SubRoomsPage,
|
||||
name: 'Sub-Rooms',
|
||||
icon: Icons.Hash,
|
||||
},
|
||||
{
|
||||
page: RoomSettingsPage.EmojisStickersPage,
|
||||
name: 'Emojis & Stickers',
|
||||
@@ -161,6 +167,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
||||
{activePage === RoomSettingsPage.PermissionsPage && (
|
||||
<Permissions requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === RoomSettingsPage.SubRoomsPage && (
|
||||
<SubRooms />
|
||||
)}
|
||||
{activePage === RoomSettingsPage.EmojisStickersPage && (
|
||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
|
||||
243
src/app/features/room-settings/sub-rooms/SubRooms.tsx
Normal file
243
src/app/features/room-settings/sub-rooms/SubRooms.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
1
src/app/features/room-settings/sub-rooms/index.ts
Normal file
1
src/app/features/room-settings/sub-rooms/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './SubRooms';
|
||||
Reference in New Issue
Block a user