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

@@ -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 (
<SpaceProvider value={space ?? null}>
@@ -57,7 +137,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
}}
>
<Box grow="Yes">
<Text size="H4">New Room</Text>
<Text size="H4">{modalTitle}</Text>
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={closeDialog}>
@@ -74,7 +154,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
direction="Column"
gap="500"
>
<CreateRoomForm space={space} onCreate={closeDialog} />
<CreateRoomForm space={space} onCreate={handleCreate} />
</Box>
</Scroll>
</Box>

View File

@@ -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<HTMLDivElement, SpaceHierarchyProps>(
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<string>();
mx.getRooms().forEach((room) => {
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
const content = subRoomsEvent?.getContent<PaarrotSubRoomsContent>();
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<string, string[]>();
roomItems?.forEach((item) => {
const room = mx.getRoom(item.roomId);
if (room) {
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
const content = subRoomsEvent?.getContent<PaarrotSubRoomsContent>();
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<HTMLDivElement, SpaceHierarchyProps>(
draggingItem?.roomId === roomItem.roomId &&
draggingItem.parentId === roomItem.parentId;
// Get sub-rooms for this room
const subRoomIds = subRoomsMap.get(roomItem.roomId) ?? [];
return (
<RoomItemCard
key={roomItem.roomId}
item={roomItem}
loading={fetching}
error={error}
summary={roomSummary}
dm={mDirects.has(roomItem.roomId)}
onOpen={onOpenRoom}
getRoom={getRoom}
canReorder={
!!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) &&
!disabledReorder
}
options={
<HierarchyItemMenu
item={roomItem}
powerLevels={roomPowerLevels}
joined={allJoinedRooms.has(roomItem.roomId)}
canEditChild={
!!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
}
/>
}
after={
<AfterItemDropTarget
item={roomItem}
nextRoomId={nextRoomId}
canDrop={canDrop}
/>
}
data-dragging={roomDragging}
onDragging={onDragging}
/>
<React.Fragment key={roomItem.roomId}>
<RoomItemCard
item={roomItem}
loading={fetching}
error={error}
summary={roomSummary}
dm={mDirects.has(roomItem.roomId)}
onOpen={onOpenRoom}
getRoom={getRoom}
canReorder={
!!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) &&
!disabledReorder
}
options={
<HierarchyItemMenu
item={roomItem}
powerLevels={roomPowerLevels}
joined={allJoinedRooms.has(roomItem.roomId)}
canEditChild={
!!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
}
/>
}
after={
<AfterItemDropTarget
item={roomItem}
nextRoomId={nextRoomId}
canDrop={canDrop}
/>
}
data-dragging={roomDragging}
onDragging={onDragging}
/>
{/* Render sub-rooms nested under this room */}
{subRoomIds.length > 0 && (
<Box direction="Column" gap="100" style={{ marginLeft: config.space.S400 }}>
{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 (
<RoomItemCard
key={subRoomId}
item={subRoomItem}
loading={fetching}
error={error}
summary={subRoomSummary}
dm={mDirects.has(subRoomId)}
onOpen={onOpenRoom}
getRoom={getRoom}
canReorder={false}
options={
<HierarchyItemMenu
item={subRoomItem}
powerLevels={subRoomPowerLevels}
joined={allJoinedRooms.has(subRoomId)}
canEditChild={false}
/>
}
data-dragging={subRoomDragging}
onDragging={onDragging}
/>
);
})}
</Box>
)}
</React.Fragment>
);
})}
</Box>

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';

View File

@@ -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} />
)}

View 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>
);
}

View File

@@ -0,0 +1 @@
export * from './SubRooms';

View File

@@ -494,9 +494,16 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
if (!httpUrl) throw new Error('Could not resolve uploaded avatar URL');
const accessToken = mx.getAccessToken();
const response = await fetch(httpUrl, {
let response = await fetch(httpUrl, {
headers: accessToken && useAuthentication ? { 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 && useAuthentication) {
console.warn('[Profile] Auth failed (401), attempting unauthenticated fallback for avatar fetch');
response = await fetch(httpUrl);
}
if (!response.ok) throw new Error('Failed to fetch uploaded avatar');
const avatarData = await response.arrayBuffer();