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:
@@ -22,8 +22,23 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
const fileContent = await downloadMedia(src, mx.getAccessToken());
|
try {
|
||||||
FileSaver.saveAs(fileContent, alt);
|
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 (
|
return (
|
||||||
|
|||||||
@@ -36,27 +36,34 @@ import { validBlurHash } from '../../../utils/blurHash';
|
|||||||
* Fetches media with authentication headers and returns a blob URL.
|
* Fetches media with authentication headers and returns a blob URL.
|
||||||
* This is needed because service workers don't work reliably in Tauri/WebKit.
|
* This is needed because service workers don't work reliably in Tauri/WebKit.
|
||||||
* Falls back to unauthenticated request if authenticated request fails with 401.
|
* 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 (
|
const fetchAuthenticatedMedia = async (
|
||||||
url: string,
|
url: string,
|
||||||
accessToken: string | null
|
accessToken: string | null
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
let response = await fetch(url, {
|
try {
|
||||||
method: 'GET',
|
let response = await fetch(url, {
|
||||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
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) {
|
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
||||||
console.warn('[ImageContent] Auth failed (401), attempting unauthenticated fallback for:', url);
|
if (!response.ok && response.status === 401 && accessToken) {
|
||||||
response = await fetch(url, { method: 'GET' });
|
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,27 +9,34 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
|||||||
/**
|
/**
|
||||||
* Fetches media with authentication headers and returns a blob URL.
|
* Fetches media with authentication headers and returns a blob URL.
|
||||||
* Falls back to unauthenticated request if authenticated request fails with 401.
|
* 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 (
|
const fetchAuthenticatedMedia = async (
|
||||||
url: string,
|
url: string,
|
||||||
accessToken: string | null
|
accessToken: string | null
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
let response = await fetch(url, {
|
try {
|
||||||
method: 'GET',
|
let response = await fetch(url, {
|
||||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
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) {
|
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
||||||
console.warn('[ThumbnailContent] Auth failed (401), attempting unauthenticated fallback for:', url);
|
if (!response.ok && response.status === 401 && accessToken) {
|
||||||
response = await fetch(url, { method: 'GET' });
|
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 = {
|
export type ThumbnailContentProps = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
config,
|
config,
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
|
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
|
||||||
import { SpaceProvider } from '../../hooks/useSpace';
|
import { SpaceProvider } from '../../hooks/useSpace';
|
||||||
import { CreateRoomForm } from './CreateRoom';
|
import { CreateRoomForm } from './CreateRoom';
|
||||||
@@ -23,17 +24,96 @@ import {
|
|||||||
} from '../../state/hooks/createRoomModal';
|
} from '../../state/hooks/createRoomModal';
|
||||||
import { CreateRoomModalState } from '../../state/createRoomModal';
|
import { CreateRoomModalState } from '../../state/createRoomModal';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
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 = {
|
type CreateRoomModalProps = {
|
||||||
state: CreateRoomModalState;
|
state: CreateRoomModalState;
|
||||||
};
|
};
|
||||||
function CreateRoomModal({ state }: CreateRoomModalProps) {
|
function CreateRoomModal({ state }: CreateRoomModalProps) {
|
||||||
const { spaceId } = state;
|
const { spaceId, parentSubRoomId } = state;
|
||||||
|
const mx = useMatrixClient();
|
||||||
const closeDialog = useCloseCreateRoomModal();
|
const closeDialog = useCloseCreateRoomModal();
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
|
||||||
const allJoinedRooms = useAllJoinedRoomsSet();
|
const allJoinedRooms = useAllJoinedRoomsSet();
|
||||||
const getRoom = useGetRoom(allJoinedRooms);
|
const getRoom = useGetRoom(allJoinedRooms);
|
||||||
const space = spaceId ? getRoom(spaceId) : undefined;
|
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 (
|
return (
|
||||||
<SpaceProvider value={space ?? null}>
|
<SpaceProvider value={space ?? null}>
|
||||||
@@ -57,7 +137,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4">New Room</Text>
|
<Text size="H4">{modalTitle}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton size="300" radii="300" onClick={closeDialog}>
|
<IconButton size="300" radii="300" onClick={closeDialog}>
|
||||||
@@ -74,7 +154,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
|
|||||||
direction="Column"
|
direction="Column"
|
||||||
gap="500"
|
gap="500"
|
||||||
>
|
>
|
||||||
<CreateRoomForm space={space} onCreate={closeDialog} />
|
<CreateRoomForm space={space} onCreate={handleCreate} />
|
||||||
</Box>
|
</Box>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { RoomType, StateEvent } from '../../../types/matrix/room';
|
|||||||
import { SequenceCard } from '../../components/sequence-card';
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
|
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
|
||||||
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
|
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
|
||||||
|
import { PaarrotSubRoomsContent } from '../../hooks/useRoomSubRooms';
|
||||||
|
|
||||||
type SpaceHierarchyProps = {
|
type SpaceHierarchyProps = {
|
||||||
summary: IHierarchyRoom | undefined;
|
summary: IHierarchyRoom | undefined;
|
||||||
@@ -99,7 +100,36 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
|||||||
onSpacesFound(Array.from(subspaces.values()));
|
onSpacesFound(Array.from(subspaces.values()));
|
||||||
}, [subspaces, onSpacesFound]);
|
}, [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())) {
|
if (!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())) {
|
||||||
// hide unknown rooms for normal user
|
// hide unknown rooms for normal user
|
||||||
childItems = childItems?.filter((i) => {
|
childItems = childItems?.filter((i) => {
|
||||||
@@ -166,40 +196,89 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
|||||||
draggingItem?.roomId === roomItem.roomId &&
|
draggingItem?.roomId === roomItem.roomId &&
|
||||||
draggingItem.parentId === roomItem.parentId;
|
draggingItem.parentId === roomItem.parentId;
|
||||||
|
|
||||||
|
// Get sub-rooms for this room
|
||||||
|
const subRoomIds = subRoomsMap.get(roomItem.roomId) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RoomItemCard
|
<React.Fragment key={roomItem.roomId}>
|
||||||
key={roomItem.roomId}
|
<RoomItemCard
|
||||||
item={roomItem}
|
item={roomItem}
|
||||||
loading={fetching}
|
loading={fetching}
|
||||||
error={error}
|
error={error}
|
||||||
summary={roomSummary}
|
summary={roomSummary}
|
||||||
dm={mDirects.has(roomItem.roomId)}
|
dm={mDirects.has(roomItem.roomId)}
|
||||||
onOpen={onOpenRoom}
|
onOpen={onOpenRoom}
|
||||||
getRoom={getRoom}
|
getRoom={getRoom}
|
||||||
canReorder={
|
canReorder={
|
||||||
!!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) &&
|
!!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) &&
|
||||||
!disabledReorder
|
!disabledReorder
|
||||||
}
|
}
|
||||||
options={
|
options={
|
||||||
<HierarchyItemMenu
|
<HierarchyItemMenu
|
||||||
item={roomItem}
|
item={roomItem}
|
||||||
powerLevels={roomPowerLevels}
|
powerLevels={roomPowerLevels}
|
||||||
joined={allJoinedRooms.has(roomItem.roomId)}
|
joined={allJoinedRooms.has(roomItem.roomId)}
|
||||||
canEditChild={
|
canEditChild={
|
||||||
!!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
|
!!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
after={
|
after={
|
||||||
<AfterItemDropTarget
|
<AfterItemDropTarget
|
||||||
item={roomItem}
|
item={roomItem}
|
||||||
nextRoomId={nextRoomId}
|
nextRoomId={nextRoomId}
|
||||||
canDrop={canDrop}
|
canDrop={canDrop}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
data-dragging={roomDragging}
|
data-dragging={roomDragging}
|
||||||
onDragging={onDragging}
|
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>
|
</Box>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { CallType } from '../call/types';
|
|||||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
|
import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal';
|
||||||
|
|
||||||
type RoomNavItemMenuProps = {
|
type RoomNavItemMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -78,7 +79,9 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
|
|
||||||
const permissions = useRoomPermissions(creators, powerLevels);
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
||||||
|
const canAddSubRoom = permissions.stateEvent(StateEvent.PaarrotSubRooms, mx.getSafeUserId());
|
||||||
const openRoomSettings = useOpenRoomSettings();
|
const openRoomSettings = useOpenRoomSettings();
|
||||||
|
const openCreateSubRoom = useOpenCreateSubRoomModal();
|
||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
|
|
||||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||||
@@ -104,6 +107,11 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
requestClose();
|
requestClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddSubRoom = () => {
|
||||||
|
openCreateSubRoom(room.roomId);
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
const handleJoinVoice = async () => {
|
const handleJoinVoice = async () => {
|
||||||
try {
|
try {
|
||||||
// If already in a call, leave it first
|
// If already in a call, leave it first
|
||||||
@@ -211,6 +219,17 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
Room Settings
|
Room Settings
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</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>
|
</Box>
|
||||||
<Line variant="Surface" size="300" />
|
<Line variant="Surface" size="300" />
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
|||||||
99
src/app/features/room-nav/UnjoinedSubRoomItem.tsx
Normal file
99
src/app/features/room-nav/UnjoinedSubRoomItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './RoomNavItem';
|
export * from './RoomNavItem';
|
||||||
export * from './RoomNavCategoryButton';
|
export * from './RoomNavCategoryButton';
|
||||||
export * from './CallParticipantsIndicator';
|
export * from './CallParticipantsIndicator';
|
||||||
|
export * from './UnjoinedSubRoomItem';
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Permissions } from './permissions';
|
|||||||
import { RoomSettingsPage } from '../../state/roomSettings';
|
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
import { DeveloperTools } from '../common-settings/developer-tools';
|
import { DeveloperTools } from '../common-settings/developer-tools';
|
||||||
|
import { SubRooms } from './sub-rooms';
|
||||||
|
|
||||||
type RoomSettingsMenuItem = {
|
type RoomSettingsMenuItem = {
|
||||||
page: RoomSettingsPage;
|
page: RoomSettingsPage;
|
||||||
@@ -42,6 +43,11 @@ const useRoomSettingsMenuItems = (): RoomSettingsMenuItem[] =>
|
|||||||
name: 'Permissions',
|
name: 'Permissions',
|
||||||
icon: Icons.Lock,
|
icon: Icons.Lock,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
page: RoomSettingsPage.SubRoomsPage,
|
||||||
|
name: 'Sub-Rooms',
|
||||||
|
icon: Icons.Hash,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
page: RoomSettingsPage.EmojisStickersPage,
|
page: RoomSettingsPage.EmojisStickersPage,
|
||||||
name: 'Emojis & Stickers',
|
name: 'Emojis & Stickers',
|
||||||
@@ -161,6 +167,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
|||||||
{activePage === RoomSettingsPage.PermissionsPage && (
|
{activePage === RoomSettingsPage.PermissionsPage && (
|
||||||
<Permissions requestClose={handlePageRequestClose} />
|
<Permissions requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
|
{activePage === RoomSettingsPage.SubRoomsPage && (
|
||||||
|
<SubRooms />
|
||||||
|
)}
|
||||||
{activePage === RoomSettingsPage.EmojisStickersPage && (
|
{activePage === RoomSettingsPage.EmojisStickersPage && (
|
||||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
<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';
|
||||||
@@ -494,9 +494,16 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
if (!httpUrl) throw new Error('Could not resolve uploaded avatar URL');
|
if (!httpUrl) throw new Error('Could not resolve uploaded avatar URL');
|
||||||
|
|
||||||
const accessToken = mx.getAccessToken();
|
const accessToken = mx.getAccessToken();
|
||||||
const response = await fetch(httpUrl, {
|
let response = await fetch(httpUrl, {
|
||||||
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
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');
|
if (!response.ok) throw new Error('Failed to fetch uploaded avatar');
|
||||||
const avatarData = await response.arrayBuffer();
|
const avatarData = await response.arrayBuffer();
|
||||||
|
|
||||||
|
|||||||
29
src/app/hooks/useRoomSubRooms.ts
Normal file
29
src/app/hooks/useRoomSubRooms.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useStateEvent } from './useStateEvent';
|
||||||
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content structure for Paarrot sub-rooms
|
||||||
|
*/
|
||||||
|
export interface PaarrotSubRoomsContent {
|
||||||
|
children: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get the sub-rooms (child rooms) for a given room.
|
||||||
|
* Sub-rooms are stored in the room's state as im.paarrot.sub_rooms
|
||||||
|
*
|
||||||
|
* @param room - The parent room
|
||||||
|
* @returns Array of child room IDs
|
||||||
|
*/
|
||||||
|
export const useRoomSubRooms = (room: Room): string[] => {
|
||||||
|
const subRoomsEvent = useStateEvent(room, StateEvent.PaarrotSubRooms);
|
||||||
|
|
||||||
|
const subRooms = useMemo(() => {
|
||||||
|
const content = subRoomsEvent?.getContent<PaarrotSubRoomsContent>();
|
||||||
|
return content?.children ?? [];
|
||||||
|
}, [subRoomsEvent]);
|
||||||
|
|
||||||
|
return subRooms;
|
||||||
|
};
|
||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
import { useHomeRooms } from './useHomeRooms';
|
import { useHomeRooms } from './useHomeRooms';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { VirtualTile } from '../../../components/virtualizer';
|
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 { makeNavCategoryId } from '../../../state/closedNavCategories';
|
||||||
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
||||||
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
|
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
|
||||||
@@ -67,7 +67,7 @@ import { UseStateProvider } from '../../../components/UseStateProvider';
|
|||||||
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
|
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
|
||||||
import { ManageRoomsPrompt } from '../../../components/manage-rooms-prompt';
|
import { ManageRoomsPrompt } from '../../../components/manage-rooms-prompt';
|
||||||
import { _RoomSearchParams } from '../../paths';
|
import { _RoomSearchParams } from '../../paths';
|
||||||
import { HomeThreadsCategory } from './HomeThreadsCategory';
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
|
||||||
type HomeMenuProps = {
|
type HomeMenuProps = {
|
||||||
requestClose: () => void;
|
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');
|
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -257,14 +261,65 @@ export function Home() {
|
|||||||
? factoryRoomIdByActivity(mx)
|
? factoryRoomIdByActivity(mx)
|
||||||
: factoryRoomIdByAtoZ(mx)
|
: factoryRoomIdByAtoZ(mx)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
|
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
|
||||||
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
|
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]);
|
}, [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<string>();
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
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({
|
const virtualizer = useVirtualizer({
|
||||||
count: sortedRooms.length,
|
count: listItems.length,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
estimateSize: () => 38,
|
estimateSize: () => 38,
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
@@ -323,13 +378,12 @@ export function Home() {
|
|||||||
onCancel={() => setOpen(false)}
|
onCancel={() => setOpen(false)}
|
||||||
onOpen={(roomIdOrAlias, viaServers, eventId) => {
|
onOpen={(roomIdOrAlias, viaServers, eventId) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
const path = getHomeRoomPath(roomIdOrAlias, eventId);
|
const searchParams: _RoomSearchParams = { viaServers, eventId };
|
||||||
navigate(
|
navigate(
|
||||||
viaServers
|
withSearchParam(
|
||||||
? withSearchParam<_RoomSearchParams>(path, {
|
getHomeRoomPath(roomIdOrAlias),
|
||||||
viaServers: encodeSearchParamValueArray(viaServers),
|
searchParams
|
||||||
})
|
)
|
||||||
: path
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -371,10 +425,23 @@ export function Home() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{virtualizer.getVirtualItems().map((vItem) => {
|
{virtualizer.getVirtualItems().map((vItem) => {
|
||||||
const roomId = sortedRooms[vItem.index];
|
const item = listItems[vItem.index];
|
||||||
const room = mx.getRoom(roomId);
|
if (!item) return null;
|
||||||
if (!room) return null;
|
|
||||||
const selected = selectedRoomId === roomId;
|
if (item.type === 'unjoined-subroom') {
|
||||||
|
return (
|
||||||
|
<VirtualTile
|
||||||
|
virtualItem={vItem}
|
||||||
|
key={vItem.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
>
|
||||||
|
<UnjoinedSubRoomItem roomId={item.roomId} depth={item.depth} />
|
||||||
|
</VirtualTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = selectedRoomId === item.roomId;
|
||||||
|
const paddingLeft = item.depth > 0 ? `${item.depth * 24}px` : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VirtualTile
|
<VirtualTile
|
||||||
@@ -382,21 +449,22 @@ export function Home() {
|
|||||||
key={vItem.index}
|
key={vItem.index}
|
||||||
ref={virtualizer.measureElement}
|
ref={virtualizer.measureElement}
|
||||||
>
|
>
|
||||||
<RoomNavItem
|
<div style={{ paddingLeft }}>
|
||||||
room={room}
|
<RoomNavItem
|
||||||
selected={selected}
|
room={item.room}
|
||||||
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
selected={selected}
|
||||||
notificationMode={getRoomNotificationMode(
|
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, item.roomId))}
|
||||||
notificationPreferences,
|
notificationMode={getRoomNotificationMode(
|
||||||
room.roomId
|
notificationPreferences,
|
||||||
)}
|
item.room.roomId
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</VirtualTile>
|
</VirtualTile>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
<HomeThreadsCategory rooms={rooms} />
|
|
||||||
</Box>
|
</Box>
|
||||||
</PageNavContent>
|
</PageNavContent>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { mDirectAtom } from '../../../state/mDirectList';
|
import { mDirectAtom } from '../../../state/mDirectList';
|
||||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||||
import { useOrphanRooms } from '../../../state/hooks/roomList';
|
import { useOrphanRooms } from '../../../state/hooks/roomList';
|
||||||
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
|
||||||
export const useHomeRooms = () => {
|
export const useHomeRooms = () => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
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<string>();
|
||||||
|
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;
|
return rooms;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
toRem,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
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 { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
} from '../../../hooks/router/useSelectedSpace';
|
} from '../../../hooks/router/useSelectedSpace';
|
||||||
import { useSpace } from '../../../hooks/useSpace';
|
import { useSpace } from '../../../hooks/useSpace';
|
||||||
import { VirtualTile } from '../../../components/virtualizer';
|
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 { makeNavCategoryId } from '../../../state/closedNavCategories';
|
||||||
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
||||||
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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({
|
const virtualizer = useVirtualizer({
|
||||||
count: hierarchy.length,
|
count: flattenedHierarchy.length,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
estimateSize: () => 0,
|
estimateSize: () => 0,
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
@@ -491,7 +555,40 @@ export function Space() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{virtualizer.getVirtualItems().map((vItem) => {
|
{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 (
|
||||||
|
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
|
||||||
|
<UnjoinedSubRoomItem roomId={item.roomId} depth={item.depth} />
|
||||||
|
</VirtualTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-room item (nested under parent)
|
||||||
|
if (item.type === 'subroom') {
|
||||||
|
const paddingLeft = `${item.depth * 24}px`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
|
||||||
|
<div style={{ paddingLeft }}>
|
||||||
|
<RoomNavItem
|
||||||
|
room={item.room}
|
||||||
|
selected={selectedRoomId === item.roomId}
|
||||||
|
showAvatar={mDirects.has(item.roomId)}
|
||||||
|
direct={mDirects.has(item.roomId)}
|
||||||
|
linkPath={getToLink(item.roomId)}
|
||||||
|
notificationMode={getRoomNotificationMode(notificationPreferences, item.room.roomId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</VirtualTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hierarchy item (room or space)
|
||||||
|
const { roomId } = item;
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { atom } from 'jotai';
|
|||||||
|
|
||||||
export type CreateRoomModalState = {
|
export type CreateRoomModalState = {
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
|
/** When set, the created room will be added as a sub-room to this room */
|
||||||
|
parentSubRoomId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createRoomModalAtom = atom<CreateRoomModalState | undefined>(undefined);
|
export const createRoomModalAtom = atom<CreateRoomModalState | undefined>(undefined);
|
||||||
|
|||||||
@@ -32,3 +32,17 @@ export const useOpenCreateRoomModal = (): OpenCallback => {
|
|||||||
|
|
||||||
return open;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export enum RoomSettingsPage {
|
|||||||
GeneralPage,
|
GeneralPage,
|
||||||
MembersPage,
|
MembersPage,
|
||||||
PermissionsPage,
|
PermissionsPage,
|
||||||
|
SubRoomsPage,
|
||||||
EmojisStickersPage,
|
EmojisStickersPage,
|
||||||
DeveloperToolsPage,
|
DeveloperToolsPage,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ export const isAuthenticatedMediaUrl = (url: string): boolean =>
|
|||||||
* Downloads media with optional authentication.
|
* Downloads media with optional authentication.
|
||||||
* For authenticated media URLs, the access token is required.
|
* For authenticated media URLs, the access token is required.
|
||||||
* Falls back to unauthenticated request if authenticated request fails with 401.
|
* 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<Blob> => {
|
export const downloadMedia = async (src: string, accessToken?: string | null): Promise<Blob> => {
|
||||||
const needsAuth = isAuthenticatedMediaUrl(src);
|
const needsAuth = isAuthenticatedMediaUrl(src);
|
||||||
@@ -316,19 +317,26 @@ export const downloadMedia = async (src: string, accessToken?: string | null): P
|
|||||||
headers.Authorization = `Bearer ${accessToken}`;
|
headers.Authorization = `Bearer ${accessToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let res = await fetch(src, { method: 'GET', headers });
|
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) {
|
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
||||||
console.warn('[downloadMedia] Auth failed (401), attempting unauthenticated fallback');
|
if (!res.ok && res.status === 401 && needsAuth && accessToken) {
|
||||||
res = await fetch(src, { method: 'GET' });
|
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 (
|
export const downloadEncryptedMedia = async (
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export enum StateEvent {
|
|||||||
/** Room-specific LiveKit service URL (admin-controlled) */
|
/** Room-specific LiveKit service URL (admin-controlled) */
|
||||||
RoomLivekitConfig = 'im.paarrot.room.livekit_config',
|
RoomLivekitConfig = 'im.paarrot.room.livekit_config',
|
||||||
|
|
||||||
|
/** Sub-rooms: child rooms nested under this room */
|
||||||
|
PaarrotSubRooms = 'im.paarrot.sub_rooms',
|
||||||
|
|
||||||
/** Matrix RTC call membership (MSC3401) */
|
/** Matrix RTC call membership (MSC3401) */
|
||||||
CallMember = 'org.matrix.msc3401.call.member',
|
CallMember = 'org.matrix.msc3401.call.member',
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user