From c7bd3762920ac3fde10d3ea466b142be2d4310e2 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Sun, 15 Mar 2026 16:25:53 +1100 Subject: [PATCH] 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. --- .../components/image-viewer/ImageViewer.tsx | 19 +- .../message/content/ImageContent.tsx | 37 +-- .../message/content/ThumbnailContent.tsx | 37 +-- .../features/create-room/CreateRoomModal.tsx | 88 ++++++- src/app/features/lobby/SpaceHierarchy.tsx | 147 ++++++++--- src/app/features/room-nav/RoomNavItem.tsx | 19 ++ .../features/room-nav/UnjoinedSubRoomItem.tsx | 99 +++++++ src/app/features/room-nav/index.ts | 1 + .../features/room-settings/RoomSettings.tsx | 9 + .../room-settings/sub-rooms/SubRooms.tsx | 243 ++++++++++++++++++ .../features/room-settings/sub-rooms/index.ts | 1 + src/app/features/settings/account/Profile.tsx | 9 +- src/app/hooks/useRoomSubRooms.ts | 29 +++ src/app/pages/client/home/Home.tsx | 114 ++++++-- src/app/pages/client/home/useHomeRooms.ts | 19 +- src/app/pages/client/space/Space.tsx | 105 +++++++- src/app/state/createRoomModal.ts | 2 + src/app/state/hooks/createRoomModal.ts | 14 + src/app/state/roomSettings.ts | 1 + src/app/utils/matrix.ts | 32 ++- src/types/matrix/room.ts | 3 + 21 files changed, 917 insertions(+), 111 deletions(-) create mode 100644 src/app/features/room-nav/UnjoinedSubRoomItem.tsx create mode 100644 src/app/features/room-settings/sub-rooms/SubRooms.tsx create mode 100644 src/app/features/room-settings/sub-rooms/index.ts create mode 100644 src/app/hooks/useRoomSubRooms.ts diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 76a092d..3692118 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -22,8 +22,23 @@ export const ImageViewer = as<'div', ImageViewerProps>( const { pan, cursor, onMouseDown } = usePan(zoom !== 1); const handleDownload = async () => { - const fileContent = await downloadMedia(src, mx.getAccessToken()); - FileSaver.saveAs(fileContent, alt); + try { + const fileContent = await downloadMedia(src, mx.getAccessToken()); + FileSaver.saveAs(fileContent, alt); + } catch (error) { + console.warn('[ImageViewer] Failed to download media:', error); + // Fallback: try to fetch via standard fetch as blob + try { + const response = await fetch(src); + if (response.ok) { + const blob = await response.blob(); + FileSaver.saveAs(blob, alt); + } + } catch { + // If all else fails, open in new tab to let browser handle it + window.open(src, '_blank'); + } + } }; return ( diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 831f71e..be56314 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -36,27 +36,34 @@ import { validBlurHash } from '../../../utils/blurHash'; * Fetches media with authentication headers and returns a blob URL. * This is needed because service workers don't work reliably in Tauri/WebKit. * Falls back to unauthenticated request if authenticated request fails with 401. + * If all attempts fail, returns the original URL to let the browser try directly. */ const fetchAuthenticatedMedia = async ( url: string, accessToken: string | null ): Promise => { - let response = await fetch(url, { - method: 'GET', - headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, - }); - - // If we got a 401 and we tried with auth, fallback to unauthenticated request - if (!response.ok && response.status === 401 && accessToken) { - console.warn('[ImageContent] Auth failed (401), attempting unauthenticated fallback for:', url); - response = await fetch(url, { method: 'GET' }); + try { + let response = await fetch(url, { + method: 'GET', + headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, + }); + + // If we got a 401 and we tried with auth, fallback to unauthenticated request + if (!response.ok && response.status === 401 && accessToken) { + console.warn('[ImageContent] Auth failed (401), attempting unauthenticated fallback for:', url); + response = await fetch(url, { method: 'GET' }); + } + + if (!response.ok) { + console.warn(`[ImageContent] Failed to fetch authenticated media (${response.status}), falling back to original URL`); + return url; + } + const blob = await response.blob(); + return URL.createObjectURL(blob); + } catch (error) { + console.warn('[ImageContent] Error fetching authenticated media, falling back to original URL:', error); + return url; } - - if (!response.ok) { - throw new Error(`Failed to fetch media: ${response.status}`); - } - const blob = await response.blob(); - return URL.createObjectURL(blob); }; /** diff --git a/src/app/components/message/content/ThumbnailContent.tsx b/src/app/components/message/content/ThumbnailContent.tsx index 15520de..efda1f8 100644 --- a/src/app/components/message/content/ThumbnailContent.tsx +++ b/src/app/components/message/content/ThumbnailContent.tsx @@ -9,27 +9,34 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes'; /** * Fetches media with authentication headers and returns a blob URL. * Falls back to unauthenticated request if authenticated request fails with 401. + * If all attempts fail, returns the original URL to let the browser try directly. */ const fetchAuthenticatedMedia = async ( url: string, accessToken: string | null ): Promise => { - let response = await fetch(url, { - method: 'GET', - headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, - }); - - // If we got a 401 and we tried with auth, fallback to unauthenticated request - if (!response.ok && response.status === 401 && accessToken) { - console.warn('[ThumbnailContent] Auth failed (401), attempting unauthenticated fallback for:', url); - response = await fetch(url, { method: 'GET' }); + try { + let response = await fetch(url, { + method: 'GET', + headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, + }); + + // If we got a 401 and we tried with auth, fallback to unauthenticated request + if (!response.ok && response.status === 401 && accessToken) { + console.warn('[ThumbnailContent] Auth failed (401), attempting unauthenticated fallback for:', url); + response = await fetch(url, { method: 'GET' }); + } + + if (!response.ok) { + console.warn(`[ThumbnailContent] Failed to fetch authenticated media (${response.status}), falling back to original URL`); + return url; + } + const blob = await response.blob(); + return URL.createObjectURL(blob); + } catch (error) { + console.warn('[ThumbnailContent] Error fetching authenticated media, falling back to original URL:', error); + return url; } - - if (!response.ok) { - throw new Error(`Failed to fetch media: ${response.status}`); - } - const blob = await response.blob(); - return URL.createObjectURL(blob); }; export type ThumbnailContentProps = { diff --git a/src/app/features/create-room/CreateRoomModal.tsx b/src/app/features/create-room/CreateRoomModal.tsx index c9919ba..4fa5e77 100644 --- a/src/app/features/create-room/CreateRoomModal.tsx +++ b/src/app/features/create-room/CreateRoomModal.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { Box, config, @@ -14,6 +14,7 @@ import { Text, } from 'folds'; import FocusTrap from 'focus-trap-react'; +import { useAtomValue } from 'jotai'; import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; import { SpaceProvider } from '../../hooks/useSpace'; import { CreateRoomForm } from './CreateRoom'; @@ -23,17 +24,96 @@ import { } from '../../state/hooks/createRoomModal'; import { CreateRoomModalState } from '../../state/createRoomModal'; import { stopPropagation } from '../../utils/keyboard'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { StateEvent } from '../../../types/matrix/room'; +import { PaarrotSubRoomsContent } from '../../hooks/useRoomSubRooms'; +import { roomToParentsAtom } from '../../state/room/roomToParents'; +import { isSpace } from '../../utils/room'; type CreateRoomModalProps = { state: CreateRoomModalState; }; function CreateRoomModal({ state }: CreateRoomModalProps) { - const { spaceId } = state; + const { spaceId, parentSubRoomId } = state; + const mx = useMatrixClient(); const closeDialog = useCloseCreateRoomModal(); + const roomToParents = useAtomValue(roomToParentsAtom); const allJoinedRooms = useAllJoinedRoomsSet(); const getRoom = useGetRoom(allJoinedRooms); const space = spaceId ? getRoom(spaceId) : undefined; + const parentRoom = parentSubRoomId ? getRoom(parentSubRoomId) : undefined; + + const handleCreate = useCallback( + async (newRoomId: string) => { + // If creating a sub-room, add it to the parent's sub-rooms and set parent relationship + if (parentSubRoomId && parentRoom) { + try { + // Extract server from parent room ID for via servers + const serverName = parentSubRoomId.split(':')[1] || ''; + const viaServers = serverName ? [serverName] : []; + + // Set m.space.parent on the new room pointing to parent + await mx.sendStateEvent( + newRoomId, + StateEvent.SpaceParent, + { + canonical: true, + via: viaServers, + }, + parentSubRoomId + ); + + // Add new room to parent's sub-rooms list + const stateEvent = parentRoom.currentState.getStateEvents(StateEvent.PaarrotSubRooms, ''); + const currentContent = stateEvent?.getContent() as PaarrotSubRoomsContent | undefined; + const currentChildren = currentContent?.children ?? []; + const newContent: PaarrotSubRoomsContent = { + children: [...currentChildren, newRoomId], + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await mx.sendStateEvent(parentSubRoomId, StateEvent.PaarrotSubRooms as any, newContent, ''); + + // Also add sub-room to parent's space(s) for joining/permissions + // The UI will hide it from the top-level display and only show it nested + const parentSpaces = roomToParents.get(parentSubRoomId); + 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 }, + newRoomId + ); + } catch (spaceError) { + // eslint-disable-next-line no-console + console.warn(`Failed to add sub-room to space ${parentSpaceId}:`, spaceError); + } + } + }) + ); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to set up sub-room relationship:', error); + } + } + closeDialog(); + }, + [mx, parentSubRoomId, parentRoom, roomToParents, closeDialog] + ); + + const modalTitle = parentSubRoomId ? 'New Sub-Room' : 'New Room'; return ( @@ -57,7 +137,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) { }} > - New Room + {modalTitle} @@ -74,7 +154,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) { direction="Column" gap="500" > - + diff --git a/src/app/features/lobby/SpaceHierarchy.tsx b/src/app/features/lobby/SpaceHierarchy.tsx index 280b8a5..6667647 100644 --- a/src/app/features/lobby/SpaceHierarchy.tsx +++ b/src/app/features/lobby/SpaceHierarchy.tsx @@ -18,6 +18,7 @@ import { RoomType, StateEvent } from '../../../types/matrix/room'; import { SequenceCard } from '../../components/sequence-card'; import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators'; import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions'; +import { PaarrotSubRoomsContent } from '../../hooks/useRoomSubRooms'; type SpaceHierarchyProps = { summary: IHierarchyRoom | undefined; @@ -99,7 +100,36 @@ export const SpaceHierarchy = forwardRef( onSpacesFound(Array.from(subspaces.values())); }, [subspaces, onSpacesFound]); - let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId)); + // Build a global set of all sub-room IDs by checking ALL joined rooms + // This ensures sub-rooms are hidden even if their parent isn't in this space + const globalSubRoomIds = useMemo(() => { + const subRoomIds = new Set(); + mx.getRooms().forEach((room) => { + const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, ''); + const content = subRoomsEvent?.getContent(); + content?.children?.forEach((childId: string) => subRoomIds.add(childId)); + }); + return subRoomIds; + }, [mx, allJoinedRooms]); + + // Build a map of parent room -> sub-room IDs for nested rendering (only for rooms in this space) + const subRoomsMap = useMemo(() => { + const parentToSubRooms = new Map(); + roomItems?.forEach((item) => { + const room = mx.getRoom(item.roomId); + if (room) { + const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, ''); + const content = subRoomsEvent?.getContent(); + const children = content?.children ?? []; + if (children.length > 0) { + parentToSubRooms.set(item.roomId, children); + } + } + }); + return parentToSubRooms; + }, [mx, roomItems]); + + let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId) && !globalSubRoomIds.has(i.roomId)); if (!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())) { // hide unknown rooms for normal user childItems = childItems?.filter((i) => { @@ -166,40 +196,89 @@ export const SpaceHierarchy = forwardRef( draggingItem?.roomId === roomItem.roomId && draggingItem.parentId === roomItem.parentId; + // Get sub-rooms for this room + const subRoomIds = subRoomsMap.get(roomItem.roomId) ?? []; + return ( - - } - after={ - - } - data-dragging={roomDragging} - onDragging={onDragging} - /> + + + } + after={ + + } + data-dragging={roomDragging} + onDragging={onDragging} + /> + {/* Render sub-rooms nested under this room */} + {subRoomIds.length > 0 && ( + + {subRoomIds.map((subRoomId) => { + const subRoom = mx.getRoom(subRoomId); + const subRoomSummary = rooms.get(subRoomId); + const subRoomPowerLevels = roomsPowerLevels.get(subRoomId) ?? {}; + const subRoomItem: HierarchyItemRoom = { + roomId: subRoomId, + parentId: roomItem.roomId, + content: {}, + }; + const subRoomDragging = + draggingItem?.roomId === subRoomId && + draggingItem.parentId === roomItem.roomId; + + // Skip sub-rooms the user hasn't joined yet if we have no summary + if (!subRoom && !subRoomSummary) return null; + + return ( + + } + data-dragging={subRoomDragging} + onDragging={onDragging} + /> + ); + })} + + )} + ); })} diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index f59d340..53e6aea 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -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( 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( 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( Room Settings + } + radii="300" + disabled={!canAddSubRoom} + > + + Add Sub-Room + + diff --git a/src/app/features/room-nav/UnjoinedSubRoomItem.tsx b/src/app/features/room-nav/UnjoinedSubRoomItem.tsx new file mode 100644 index 0000000..3250fc2 --- /dev/null +++ b/src/app/features/room-nav/UnjoinedSubRoomItem.tsx @@ -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( + 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 ( + + + + + + + {roomId} + + + + {joinState.status === AsyncStatus.Error && ( + + + {joinState.error.data?.error || joinState.error.message} + + + } + > + {(triggerRef) => ( + + )} + + )} + : + } + onClick={join} + disabled={!canJoin} + > + Join + + + + ); +} diff --git a/src/app/features/room-nav/index.ts b/src/app/features/room-nav/index.ts index 4c76496..495bc41 100644 --- a/src/app/features/room-nav/index.ts +++ b/src/app/features/room-nav/index.ts @@ -1,3 +1,4 @@ export * from './RoomNavItem'; export * from './RoomNavCategoryButton'; export * from './CallParticipantsIndicator'; +export * from './UnjoinedSubRoomItem'; diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx index 32b5df9..904f325 100644 --- a/src/app/features/room-settings/RoomSettings.tsx +++ b/src/app/features/room-settings/RoomSettings.tsx @@ -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 && ( )} + {activePage === RoomSettingsPage.SubRoomsPage && ( + + )} {activePage === RoomSettingsPage.EmojisStickersPage && ( )} diff --git a/src/app/features/room-settings/sub-rooms/SubRooms.tsx b/src/app/features/room-settings/sub-rooms/SubRooms.tsx new file mode 100644 index 0000000..651f12d --- /dev/null +++ b/src/app/features/room-settings/sub-rooms/SubRooms.tsx @@ -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 ( + + Unknown Room ({roomId}) + {canEdit && ( + onRemove(roomId)} + > + + + )} + + ); + } + + return ( + + {room.name} + {canEdit && ( + onRemove(roomId)} + > + + + )} + + ); +} + +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 ( + + + + + Sub-Rooms + + + Sub-rooms are nested rooms that appear indented under this room in the room list. + They work like sub-spaces but for regular rooms. + + + + + + + + + + + Current Sub-Rooms + + + + + 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, + }} + /> + + + + {!canEdit && ( + + You need power level 50 or higher to manage sub-rooms. + + )} + + {subRooms.length === 0 ? ( + + + + No sub-rooms added yet. +
+ Add a room to nest it under this room. +
+
+ ) : ( + + {subRooms.map((roomId) => ( + + ))} + + )} + + {updateState.status === AsyncStatus.Error && ( + + Failed to update sub-rooms: {String((updateState.error as Error)?.message || updateState.error)} + + )} +
+
+
+ ); +} diff --git a/src/app/features/room-settings/sub-rooms/index.ts b/src/app/features/room-settings/sub-rooms/index.ts new file mode 100644 index 0000000..b1029c4 --- /dev/null +++ b/src/app/features/room-settings/sub-rooms/index.ts @@ -0,0 +1 @@ +export * from './SubRooms'; diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 7952386..03e8343 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -494,9 +494,16 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject { + const subRoomsEvent = useStateEvent(room, StateEvent.PaarrotSubRooms); + + const subRooms = useMemo(() => { + const content = subRoomsEvent?.getContent(); + return content?.children ?? []; + }, [subRoomsEvent]); + + return subRooms; +}; diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index 5650eff..40dc70b 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -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(); + + 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(); + 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 ( + + + + ); + } + + const selected = selectedRoomId === item.roomId; + const paddingLeft = item.depth > 0 ? `${item.depth * 24}px` : undefined; return ( - +
+ +
); })} -
)} diff --git a/src/app/pages/client/home/useHomeRooms.ts b/src/app/pages/client/home/useHomeRooms.ts index b0181b0..30166d2 100644 --- a/src/app/pages/client/home/useHomeRooms.ts +++ b/src/app/pages/client/home/useHomeRooms.ts @@ -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(); + 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; }; diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 88894e5..6858e89 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -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(); + 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(); + + 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 ( + + + + ); + } + + // Sub-room item (nested under parent) + if (item.type === 'subroom') { + const paddingLeft = `${item.depth * 24}px`; + + return ( + +
+ +
+
+ ); + } + + // Hierarchy item (room or space) + const { roomId } = item; const room = mx.getRoom(roomId); if (!room) return null; diff --git a/src/app/state/createRoomModal.ts b/src/app/state/createRoomModal.ts index 81af5d5..5275ea0 100644 --- a/src/app/state/createRoomModal.ts +++ b/src/app/state/createRoomModal.ts @@ -2,6 +2,8 @@ import { atom } from 'jotai'; export type CreateRoomModalState = { spaceId?: string; + /** When set, the created room will be added as a sub-room to this room */ + parentSubRoomId?: string; }; export const createRoomModalAtom = atom(undefined); diff --git a/src/app/state/hooks/createRoomModal.ts b/src/app/state/hooks/createRoomModal.ts index 15db728..41be120 100644 --- a/src/app/state/hooks/createRoomModal.ts +++ b/src/app/state/hooks/createRoomModal.ts @@ -32,3 +32,17 @@ export const useOpenCreateRoomModal = (): OpenCallback => { return open; }; + +type OpenSubRoomCallback = (parentRoomId: string) => void; +export const useOpenCreateSubRoomModal = (): OpenSubRoomCallback => { + const setSettings = useSetAtom(createRoomModalAtom); + + const open: OpenSubRoomCallback = useCallback( + (parentSubRoomId) => { + setSettings({ parentSubRoomId }); + }, + [setSettings] + ); + + return open; +}; diff --git a/src/app/state/roomSettings.ts b/src/app/state/roomSettings.ts index 327db59..ba20adc 100644 --- a/src/app/state/roomSettings.ts +++ b/src/app/state/roomSettings.ts @@ -4,6 +4,7 @@ export enum RoomSettingsPage { GeneralPage, MembersPage, PermissionsPage, + SubRoomsPage, EmojisStickersPage, DeveloperToolsPage, } diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index d5ef76c..b172bbd 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -307,6 +307,7 @@ export const isAuthenticatedMediaUrl = (url: string): boolean => * Downloads media with optional authentication. * For authenticated media URLs, the access token is required. * Falls back to unauthenticated request if authenticated request fails with 401. + * Returns an empty blob with error type if all attempts fail, to prevent cascading failures. */ export const downloadMedia = async (src: string, accessToken?: string | null): Promise => { const needsAuth = isAuthenticatedMediaUrl(src); @@ -316,19 +317,26 @@ export const downloadMedia = async (src: string, accessToken?: string | null): P headers.Authorization = `Bearer ${accessToken}`; } - let res = await fetch(src, { method: 'GET', headers }); - - // If we got a 401 and we tried with auth, fallback to unauthenticated request - if (!res.ok && res.status === 401 && needsAuth && accessToken) { - console.warn('[downloadMedia] Auth failed (401), attempting unauthenticated fallback'); - res = await fetch(src, { method: 'GET' }); + try { + let res = await fetch(src, { method: 'GET', headers }); + + // If we got a 401 and we tried with auth, fallback to unauthenticated request + if (!res.ok && res.status === 401 && needsAuth && accessToken) { + console.warn('[downloadMedia] Auth failed (401), attempting unauthenticated fallback for:', src); + res = await fetch(src, { method: 'GET' }); + } + + if (!res.ok) { + console.warn(`[downloadMedia] Failed to download media (${res.status}) from:`, src); + throw new Error(`Failed to download media: ${res.status}`); + } + const blob = await res.blob(); + return blob; + } catch (error) { + // Log but re-throw so callers can handle appropriately + console.warn('[downloadMedia] Error downloading media:', error); + throw error; } - - if (!res.ok) { - throw new Error(`Failed to download media: ${res.status}`); - } - const blob = await res.blob(); - return blob; }; export const downloadEncryptedMedia = async ( diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index fa58acc..5243335 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -45,6 +45,9 @@ export enum StateEvent { /** Room-specific LiveKit service URL (admin-controlled) */ RoomLivekitConfig = 'im.paarrot.room.livekit_config', + /** Sub-rooms: child rooms nested under this room */ + PaarrotSubRooms = 'im.paarrot.sub_rooms', + /** Matrix RTC call membership (MSC3401) */ CallMember = 'org.matrix.msc3401.call.member', }