Files
cinny/src/app/features/lobby/SpaceHierarchy.tsx

323 lines
12 KiB
TypeScript

import React, { forwardRef, MouseEventHandler, useEffect, useMemo } from 'react';
import { MatrixError, Room } from 'matrix-js-sdk';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { Box, config, Text } from 'folds';
import {
HierarchyItem,
HierarchyItemRoom,
HierarchyItemSpace,
isHierarchySpaceRoom,
useFetchSpaceHierarchyLevel,
} from '../../hooks/useSpaceHierarchy';
import { IPowerLevels } from '../../hooks/usePowerLevels';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { SpaceItemCard } from './SpaceItem';
import { AfterItemDropTarget, CanDropCallback } from './DnD';
import { HierarchyItemMenu } from './HierarchyItemMenu';
import { RoomItemCard } from './RoomItem';
import { 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;
hierarchySummaries: Map<string, IHierarchyRoom>;
spaceItem: HierarchyItemSpace;
roomItems?: HierarchyItemRoom[];
allJoinedRooms: Set<string>;
mDirects: Set<string>;
roomsPowerLevels: Map<string, IPowerLevels>;
categoryId: string;
closed: boolean;
handleClose: MouseEventHandler<HTMLButtonElement>;
draggingItem?: HierarchyItem;
onDragging: (item?: HierarchyItem) => void;
canDrop: CanDropCallback;
disabledReorder?: boolean;
nextSpaceId?: string;
getRoom: (roomId: string) => Room | undefined;
pinned: boolean;
togglePinToSidebar: (roomId: string) => void;
onSpacesFound: (spaceItems: IHierarchyRoom[]) => void;
onOpenRoom?: MouseEventHandler<HTMLButtonElement>;
};
export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
(
{
summary,
hierarchySummaries,
spaceItem,
roomItems,
allJoinedRooms,
mDirects,
roomsPowerLevels,
categoryId,
closed,
handleClose,
draggingItem,
onDragging,
canDrop,
disabledReorder,
nextSpaceId,
getRoom,
pinned,
togglePinToSidebar,
onOpenRoom,
onSpacesFound,
},
ref
) => {
const mx = useMatrixClient();
const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(spaceItem.roomId, true);
const subspaces = useMemo(() => {
const s: Map<string, IHierarchyRoom> = new Map();
rooms.forEach((r) => {
if (isHierarchySpaceRoom(r)) {
s.set(r.room_id, r);
}
});
return s;
}, [rooms]);
const spacePowerLevels = roomsPowerLevels.get(spaceItem.roomId);
const spaceCreators = getRoomCreatorsForRoomId(mx, spaceItem.roomId);
const spacePermissions =
spacePowerLevels && getRoomPermissionsAPI(spaceCreators, spacePowerLevels);
const draggingSpace =
draggingItem?.roomId === spaceItem.roomId && draggingItem.parentId === spaceItem.parentId;
const { parentId } = spaceItem;
const parentPowerLevels = parentId ? roomsPowerLevels.get(parentId) : undefined;
const parentCreators = parentId ? getRoomCreatorsForRoomId(mx, parentId) : undefined;
const parentPermissions =
parentCreators &&
parentPowerLevels &&
getRoomPermissionsAPI(parentCreators, parentPowerLevels);
useEffect(() => {
onSpacesFound(Array.from(rooms.values()));
}, [rooms, onSpacesFound]);
// 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));
// Only hide when we know the user cannot edit children. If power levels are
// still loading (spacePermissions undefined), keep inaccessible rows visible
// so admins can clean them up.
const canEditChild = spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
if (spacePermissions && !canEditChild) {
// hide unknown rooms for normal user
childItems = childItems?.filter((i) => {
const forbidden = error instanceof MatrixError ? error.errcode === 'M_FORBIDDEN' : false;
const inaccessibleRoom =
!rooms.get(i.roomId) &&
!hierarchySummaries.has(i.roomId) &&
!fetching &&
(error ? forbidden : true);
return !inaccessibleRoom;
});
}
return (
<Box direction="Column" gap="100" ref={ref}>
<SpaceItemCard
summary={rooms.get(spaceItem.roomId) ?? summary}
loading={fetching}
item={spaceItem}
joined={allJoinedRooms.has(spaceItem.roomId)}
categoryId={categoryId}
closed={closed}
handleClose={handleClose}
getRoom={getRoom}
canEditChild={!!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())}
canReorder={
parentPowerLevels && !disabledReorder && parentPermissions
? parentPermissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
: false
}
options={
parentId &&
parentPowerLevels && (
<HierarchyItemMenu
item={{ ...spaceItem, parentId }}
powerLevels={spacePowerLevels}
joined={allJoinedRooms.has(spaceItem.roomId)}
canEditChild={
!!parentPermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
}
pinned={pinned}
onTogglePin={togglePinToSidebar}
/>
)
}
after={
<AfterItemDropTarget
item={spaceItem}
nextRoomId={closed ? nextSpaceId : childItems?.[0]?.roomId}
afterSpace
canDrop={canDrop}
/>
}
onDragging={onDragging}
data-dragging={draggingSpace}
/>
{childItems && childItems.length > 0 ? (
<Box direction="Column" gap="100">
{childItems.map((roomItem, index) => {
const roomSummary = rooms.get(roomItem.roomId) ?? hierarchySummaries.get(roomItem.roomId);
const roomPowerLevels = roomsPowerLevels.get(roomItem.roomId) ?? {};
const lastItem = index === childItems.length;
const nextRoomId = lastItem ? nextSpaceId : childItems[index + 1]?.roomId;
const roomDragging =
draggingItem?.roomId === roomItem.roomId &&
draggingItem.parentId === roomItem.parentId;
// Get sub-rooms for this room
const subRoomIds = subRoomsMap.get(roomItem.roomId) ?? [];
return (
<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) ?? hierarchySummaries.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>
) : (
childItems && (
<SequenceCard variant="SurfaceVariant" gap="300" alignItems="Center">
<Box
grow="Yes"
style={{
padding: config.space.S700,
}}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="100"
>
<Text size="H5" align="Center">
No Rooms
</Text>
<Text align="Center" size="T300" priority="300">
This space does not contains rooms yet.
</Text>
</Box>
</SequenceCard>
)
)}
</Box>
);
}
);