1
Fork 0
mirror of https://github.com/RGBCube/cinny synced 2025-08-01 09:27:46 +00:00

Fix unknown rooms in space lobby (#2224)

* add hook to fetch one level of space hierarchy

* add enable param to level hierarchy hook

* improve HierarchyItem types

* fix type errors in lobby

* load space hierarachy per level

* fix menu item visibility

* fix unknown spaces over federation

* show inaccessible rooms only to admins

* fix unknown room renders loading content twice

* fix unknown room visible to normal user if space all room are unknown

* show no rooms card if space does not have any room
This commit is contained in:
Ajay Bura 2025-02-22 19:24:33 +11:00 committed by GitHub
parent f121cc0a24
commit 7c6ab366af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 564 additions and 330 deletions

View file

@ -155,7 +155,7 @@ function SettingsMenuItem({
disabled?: boolean; disabled?: boolean;
}) { }) {
const handleSettings = () => { const handleSettings = () => {
if (item.space) { if ('space' in item) {
openSpaceSettings(item.roomId); openSpaceSettings(item.roomId);
} else { } else {
toggleRoomSettings(item.roomId); toggleRoomSettings(item.roomId);
@ -271,7 +271,7 @@ export function HierarchyItemMenu({
</Text> </Text>
</MenuItem> </MenuItem>
{promptLeave && {promptLeave &&
(item.space ? ( ('space' in item ? (
<LeaveSpacePrompt <LeaveSpacePrompt
roomId={item.roomId} roomId={item.roomId}
onDone={handleRequestClose} onDone={handleRequestClose}

View file

@ -5,9 +5,15 @@ import { useAtom, useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk'; import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import produce from 'immer';
import { useSpace } from '../../hooks/useSpace'; import { useSpace } from '../../hooks/useSpace';
import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page'; import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page';
import { HierarchyItem, useSpaceHierarchy } from '../../hooks/useSpaceHierarchy'; import {
HierarchyItem,
HierarchyItemSpace,
useSpaceHierarchy,
} from '../../hooks/useSpaceHierarchy';
import { VirtualTile } from '../../components/virtualizer'; import { VirtualTile } from '../../components/virtualizer';
import { spaceRoomsAtom } from '../../state/spaceRooms'; import { spaceRoomsAtom } from '../../state/spaceRooms';
import { MembersDrawer } from '../room/MembersDrawer'; import { MembersDrawer } from '../room/MembersDrawer';
@ -25,18 +31,15 @@ import {
usePowerLevels, usePowerLevels,
useRoomsPowerLevels, useRoomsPowerLevels,
} from '../../hooks/usePowerLevels'; } from '../../hooks/usePowerLevels';
import { RoomItemCard } from './RoomItem';
import { mDirectAtom } from '../../state/mDirectList'; import { mDirectAtom } from '../../state/mDirectList';
import { SpaceItemCard } from './SpaceItem';
import { makeLobbyCategoryId } from '../../state/closedLobbyCategories'; import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
import { useCategoryHandler } from '../../hooks/useCategoryHandler'; import { useCategoryHandler } from '../../hooks/useCategoryHandler';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList'; import { allRoomsAtom } from '../../state/room-list/roomList';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { getSpaceRoomPath } from '../../pages/pathUtils'; import { getSpaceRoomPath } from '../../pages/pathUtils';
import { HierarchyItemMenu } from './HierarchyItemMenu';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { AfterItemDropTarget, CanDropCallback, useDnDMonitor } from './DnD'; import { CanDropCallback, useDnDMonitor } from './DnD';
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable'; import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
import { getStateEvent } from '../../utils/room'; import { getStateEvent } from '../../utils/room';
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories'; import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
@ -49,6 +52,7 @@ import { useOrphanSpaces } from '../../state/hooks/roomList';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
import { AccountDataEvent } from '../../../types/matrix/accountData'; import { AccountDataEvent } from '../../../types/matrix/accountData';
import { useRoomMembers } from '../../hooks/useRoomMembers'; import { useRoomMembers } from '../../hooks/useRoomMembers';
import { SpaceHierarchy } from './SpaceHierarchy';
export function Lobby() { export function Lobby() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -81,6 +85,8 @@ export function Lobby() {
return new Set(sideSpaces); return new Set(sideSpaces);
}, [sidebarItems]); }, [sidebarItems]);
const [spacesItems, setSpacesItem] = useState<Map<string, IHierarchyRoom>>(() => new Map());
useElementSizeObserver( useElementSizeObserver(
useCallback(() => heroSectionRef.current, []), useCallback(() => heroSectionRef.current, []),
useCallback((w, height) => setHeroSectionHeight(height), []) useCallback((w, height) => setHeroSectionHeight(height), [])
@ -107,19 +113,20 @@ export function Lobby() {
); );
const [draggingItem, setDraggingItem] = useState<HierarchyItem>(); const [draggingItem, setDraggingItem] = useState<HierarchyItem>();
const flattenHierarchy = useSpaceHierarchy( const hierarchy = useSpaceHierarchy(
space.roomId, space.roomId,
spaceRooms, spaceRooms,
getRoom, getRoom,
useCallback( useCallback(
(childId) => (childId) =>
closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) || !!draggingItem?.space, closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) ||
(draggingItem ? 'space' in draggingItem : false),
[closedCategories, space.roomId, draggingItem] [closedCategories, space.roomId, draggingItem]
) )
); );
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: flattenHierarchy.length, count: hierarchy.length,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
estimateSize: () => 1, estimateSize: () => 1,
overscan: 2, overscan: 2,
@ -129,8 +136,17 @@ export function Lobby() {
const roomsPowerLevels = useRoomsPowerLevels( const roomsPowerLevels = useRoomsPowerLevels(
useMemo( useMemo(
() => flattenHierarchy.map((i) => mx.getRoom(i.roomId)).filter((r) => !!r) as Room[], () =>
[mx, flattenHierarchy] hierarchy
.flatMap((i) => {
const childRooms = Array.isArray(i.rooms)
? i.rooms.map((r) => mx.getRoom(r.roomId))
: [];
return [mx.getRoom(i.space.roomId), ...childRooms];
})
.filter((r) => !!r) as Room[],
[mx, hierarchy]
) )
); );
@ -142,8 +158,8 @@ export function Lobby() {
return false; return false;
} }
if (item.space) { if ('space' in item) {
if (!container.item.space) return false; if (!('space' in container.item)) return false;
const containerSpaceId = space.roomId; const containerSpaceId = space.roomId;
if ( if (
@ -156,9 +172,8 @@ export function Lobby() {
return true; return true;
} }
const containerSpaceId = container.item.space const containerSpaceId =
? container.item.roomId 'space' in container.item ? container.item.roomId : container.item.parentId;
: container.item.parentId;
const dropOutsideSpace = item.parentId !== containerSpaceId; const dropOutsideSpace = item.parentId !== containerSpaceId;
@ -192,22 +207,22 @@ export function Lobby() {
); );
const reorderSpace = useCallback( const reorderSpace = useCallback(
(item: HierarchyItem, containerItem: HierarchyItem) => { (item: HierarchyItemSpace, containerItem: HierarchyItem) => {
if (!item.parentId) return; if (!item.parentId) return;
const childItems = flattenHierarchy const itemSpaces: HierarchyItemSpace[] = hierarchy
.filter((i) => i.parentId && i.space) .map((i) => i.space)
.filter((i) => i.roomId !== item.roomId); .filter((i) => i.roomId !== item.roomId);
const beforeIndex = childItems.findIndex((i) => i.roomId === containerItem.roomId); const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId);
const insertIndex = beforeIndex + 1; const insertIndex = beforeIndex + 1;
childItems.splice(insertIndex, 0, { itemSpaces.splice(insertIndex, 0, {
...item, ...item,
content: { ...item.content, order: undefined }, content: { ...item.content, order: undefined },
}); });
const currentOrders = childItems.map((i) => { const currentOrders = itemSpaces.map((i) => {
if (typeof i.content.order === 'string' && lex.has(i.content.order)) { if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
return i.content.order; return i.content.order;
} }
@ -217,21 +232,21 @@ export function Lobby() {
const newOrders = orderKeys(lex, currentOrders); const newOrders = orderKeys(lex, currentOrders);
newOrders?.forEach((orderKey, index) => { newOrders?.forEach((orderKey, index) => {
const itm = childItems[index]; const itm = itemSpaces[index];
if (!itm || !itm.parentId) return; if (!itm || !itm.parentId) return;
const parentPL = roomsPowerLevels.get(itm.parentId); const parentPL = roomsPowerLevels.get(itm.parentId);
const canEdit = parentPL && canEditSpaceChild(parentPL); const canEdit = parentPL && canEditSpaceChild(parentPL);
if (canEdit && orderKey !== currentOrders[index]) { if (canEdit && orderKey !== currentOrders[index]) {
mx.sendStateEvent( mx.sendStateEvent(
itm.parentId, itm.parentId,
StateEvent.SpaceChild, StateEvent.SpaceChild as any,
{ ...itm.content, order: orderKey }, { ...itm.content, order: orderKey },
itm.roomId itm.roomId
); );
} }
}); });
}, },
[mx, flattenHierarchy, lex, roomsPowerLevels, canEditSpaceChild] [mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
); );
const reorderRoom = useCallback( const reorderRoom = useCallback(
@ -240,13 +255,12 @@ export function Lobby() {
if (!item.parentId) { if (!item.parentId) {
return; return;
} }
const containerParentId: string = containerItem.space const containerParentId: string =
? containerItem.roomId 'space' in containerItem ? containerItem.roomId : containerItem.parentId;
: containerItem.parentId;
const itemContent = item.content; const itemContent = item.content;
if (item.parentId !== containerParentId) { if (item.parentId !== containerParentId) {
mx.sendStateEvent(item.parentId, StateEvent.SpaceChild, {}, item.roomId); mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
} }
if ( if (
@ -265,28 +279,29 @@ export function Lobby() {
const allow = const allow =
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? []; joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? [];
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId }); allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules, { mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, {
...joinRuleContent, ...joinRuleContent,
allow, allow,
}); });
} }
} }
const childItems = flattenHierarchy const itemSpaces = Array.from(
.filter((i) => i.parentId === containerParentId && !i.space) hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? []
.filter((i) => i.roomId !== item.roomId); );
const beforeItem: HierarchyItem | undefined = containerItem.space ? undefined : containerItem; const beforeItem: HierarchyItem | undefined =
const beforeIndex = childItems.findIndex((i) => i.roomId === beforeItem?.roomId); 'space' in containerItem ? undefined : containerItem;
const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId);
const insertIndex = beforeIndex + 1; const insertIndex = beforeIndex + 1;
childItems.splice(insertIndex, 0, { itemSpaces.splice(insertIndex, 0, {
...item, ...item,
parentId: containerParentId, parentId: containerParentId,
content: { ...itemContent, order: undefined }, content: { ...itemContent, order: undefined },
}); });
const currentOrders = childItems.map((i) => { const currentOrders = itemSpaces.map((i) => {
if (typeof i.content.order === 'string' && lex.has(i.content.order)) { if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
return i.content.order; return i.content.order;
} }
@ -296,18 +311,18 @@ export function Lobby() {
const newOrders = orderKeys(lex, currentOrders); const newOrders = orderKeys(lex, currentOrders);
newOrders?.forEach((orderKey, index) => { newOrders?.forEach((orderKey, index) => {
const itm = childItems[index]; const itm = itemSpaces[index];
if (itm && orderKey !== currentOrders[index]) { if (itm && orderKey !== currentOrders[index]) {
mx.sendStateEvent( mx.sendStateEvent(
containerParentId, containerParentId,
StateEvent.SpaceChild, StateEvent.SpaceChild as any,
{ ...itm.content, order: orderKey }, { ...itm.content, order: orderKey },
itm.roomId itm.roomId
); );
} }
}); });
}, },
[mx, flattenHierarchy, lex] [mx, hierarchy, lex]
); );
useDnDMonitor( useDnDMonitor(
@ -318,7 +333,7 @@ export function Lobby() {
if (!canDrop(item, container)) { if (!canDrop(item, container)) {
return; return;
} }
if (item.space) { if ('space' in item) {
reorderSpace(item, container.item); reorderSpace(item, container.item);
} else { } else {
reorderRoom(item, container.item); reorderRoom(item, container.item);
@ -328,8 +343,16 @@ export function Lobby() {
) )
); );
const addSpaceRoom = useCallback( const handleSpacesFound = useCallback(
(roomId: string) => setSpaceRooms({ type: 'PUT', roomId }), (sItems: IHierarchyRoom[]) => {
setSpaceRooms({ type: 'PUT', roomIds: sItems.map((i) => i.room_id) });
setSpacesItem((current) => {
const newItems = produce(current, (draft) => {
sItems.forEach((item) => draft.set(item.room_id, item));
});
return current.size === newItems.size ? current : newItems;
});
},
[setSpaceRooms] [setSpaceRooms]
); );
@ -394,121 +417,44 @@ export function Lobby() {
<LobbyHero /> <LobbyHero />
</PageHeroSection> </PageHeroSection>
{vItems.map((vItem) => { {vItems.map((vItem) => {
const item = flattenHierarchy[vItem.index]; const item = hierarchy[vItem.index];
if (!item) return null; if (!item) return null;
const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {}; const nextSpaceId = hierarchy[vItem.index + 1]?.space.roomId;
const userPLInItem = powerLevelAPI.getPowerLevel(
itemPowerLevel,
mx.getUserId() ?? undefined
);
const canInvite = powerLevelAPI.canDoAction(
itemPowerLevel,
'invite',
userPLInItem
);
const isJoined = allJoinedRooms.has(item.roomId);
const nextRoomId: string | undefined = const categoryId = makeLobbyCategoryId(space.roomId, item.space.roomId);
flattenHierarchy[vItem.index + 1]?.roomId;
const dragging =
draggingItem?.roomId === item.roomId &&
draggingItem.parentId === item.parentId;
if (item.space) {
const categoryId = makeLobbyCategoryId(space.roomId, item.roomId);
const { parentId } = item;
const parentPowerLevels = parentId
? roomsPowerLevels.get(parentId) ?? {}
: undefined;
return (
<VirtualTile
virtualItem={vItem}
style={{
paddingTop: vItem.index === 0 ? 0 : config.space.S500,
}}
ref={virtualizer.measureElement}
key={vItem.index}
>
<SpaceItemCard
item={item}
joined={allJoinedRooms.has(item.roomId)}
categoryId={categoryId}
closed={closedCategories.has(categoryId) || !!draggingItem?.space}
handleClose={handleCategoryClick}
getRoom={getRoom}
canEditChild={canEditSpaceChild(
roomsPowerLevels.get(item.roomId) ?? {}
)}
canReorder={
parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false
}
options={
parentId &&
parentPowerLevels && (
<HierarchyItemMenu
item={{ ...item, parentId }}
canInvite={canInvite}
joined={isJoined}
canEditChild={canEditSpaceChild(parentPowerLevels)}
pinned={sidebarSpaces.has(item.roomId)}
onTogglePin={togglePinToSidebar}
/>
)
}
before={item.parentId ? undefined : undefined}
after={
<AfterItemDropTarget
item={item}
nextRoomId={nextRoomId}
afterSpace
canDrop={canDrop}
/>
}
onDragging={setDraggingItem}
data-dragging={dragging}
/>
</VirtualTile>
);
}
const parentPowerLevels = roomsPowerLevels.get(item.parentId) ?? {};
const prevItem: HierarchyItem | undefined = flattenHierarchy[vItem.index - 1];
const nextItem: HierarchyItem | undefined = flattenHierarchy[vItem.index + 1];
return ( return (
<VirtualTile <VirtualTile
virtualItem={vItem} virtualItem={vItem}
style={{ paddingTop: config.space.S100 }} style={{
paddingTop: vItem.index === 0 ? 0 : config.space.S500,
}}
ref={virtualizer.measureElement} ref={virtualizer.measureElement}
key={vItem.index} key={vItem.index}
> >
<RoomItemCard <SpaceHierarchy
item={item} spaceItem={item.space}
onSpaceFound={addSpaceRoom} summary={spacesItems.get(item.space.roomId)}
dm={mDirects.has(item.roomId)} roomItems={item.rooms}
firstChild={!prevItem || prevItem.space === true} allJoinedRooms={allJoinedRooms}
lastChild={!nextItem || nextItem.space === true} mDirects={mDirects}
onOpen={handleOpenRoom} roomsPowerLevels={roomsPowerLevels}
getRoom={getRoom} canEditSpaceChild={canEditSpaceChild}
canReorder={canEditSpaceChild(parentPowerLevels)} categoryId={categoryId}
options={ closed={
<HierarchyItemMenu closedCategories.has(categoryId) ||
item={item} (draggingItem ? 'space' in draggingItem : false)
canInvite={canInvite}
joined={isJoined}
canEditChild={canEditSpaceChild(parentPowerLevels)}
/>
} }
after={ handleClose={handleCategoryClick}
<AfterItemDropTarget draggingItem={draggingItem}
item={item}
nextRoomId={nextRoomId}
canDrop={canDrop}
/>
}
data-dragging={dragging}
onDragging={setDraggingItem} onDragging={setDraggingItem}
canDrop={canDrop}
nextSpaceId={nextSpaceId}
getRoom={getRoom}
pinned={sidebarSpaces.has(item.space.roomId)}
togglePinToSidebar={togglePinToSidebar}
onSpacesFound={handleSpacesFound}
onOpenRoom={handleOpenRoom}
/> />
</VirtualTile> </VirtualTile>
); );

View file

@ -1,4 +1,4 @@
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useRef } from 'react'; import React, { MouseEventHandler, ReactNode, useCallback, useRef } from 'react';
import { import {
Avatar, Avatar,
Badge, Badge,
@ -20,23 +20,20 @@ import {
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { JoinRule, MatrixError, Room } from 'matrix-js-sdk'; import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { SequenceCard } from '../../components/sequence-card'; import { SequenceCard } from '../../components/sequence-card';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { millify } from '../../plugins/millify'; import { millify } from '../../plugins/millify';
import { import { LocalRoomSummaryLoader } from '../../components/RoomSummaryLoader';
HierarchyRoomSummaryLoader,
LocalRoomSummaryLoader,
} from '../../components/RoomSummaryLoader';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer'; import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { onEnterOrSpace, stopPropagation } from '../../utils/keyboard'; import { onEnterOrSpace, stopPropagation } from '../../utils/keyboard';
import { Membership, RoomType } from '../../../types/matrix/room'; import { Membership } from '../../../types/matrix/room';
import * as css from './RoomItem.css'; import * as css from './RoomItem.css';
import * as styleCss from './style.css'; import * as styleCss from './style.css';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { ErrorCode } from '../../cs-errorcode';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
import { ItemDraggableTarget, useDraggableItem } from './DnD'; import { ItemDraggableTarget, useDraggableItem } from './DnD';
import { mxcUrlToHttp } from '../../utils/matrix'; import { mxcUrlToHttp } from '../../utils/matrix';
@ -125,13 +122,11 @@ function RoomProfileLoading() {
type RoomProfileErrorProps = { type RoomProfileErrorProps = {
roomId: string; roomId: string;
error: Error; inaccessibleRoom: boolean;
suggested?: boolean; suggested?: boolean;
via?: string[]; via?: string[];
}; };
function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorProps) { function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProfileErrorProps) {
const privateRoom = error.name === ErrorCode.M_FORBIDDEN;
return ( return (
<Box grow="Yes" gap="300"> <Box grow="Yes" gap="300">
<Avatar> <Avatar>
@ -142,7 +137,7 @@ function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorPro
renderFallback={() => ( renderFallback={() => (
<RoomIcon <RoomIcon
size="300" size="300"
joinRule={privateRoom ? JoinRule.Invite : JoinRule.Restricted} joinRule={inaccessibleRoom ? JoinRule.Invite : JoinRule.Restricted}
filled filled
/> />
)} )}
@ -162,25 +157,18 @@ function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorPro
)} )}
</Box> </Box>
<Box gap="200" alignItems="Center"> <Box gap="200" alignItems="Center">
{privateRoom && ( {inaccessibleRoom ? (
<> <Badge variant="Secondary" fill="Soft" radii="300" size="500">
<Badge variant="Secondary" fill="Soft" radii="Pill" outlined> <Text size="L400">Inaccessible</Text>
<Text size="L400">Private Room</Text> </Badge>
</Badge> ) : (
<Line <Text size="T200" truncate>
variant="SurfaceVariant" {roomId}
style={{ height: toRem(12) }} </Text>
direction="Vertical"
size="400"
/>
</>
)} )}
<Text size="T200" truncate>
{roomId}
</Text>
</Box> </Box>
</Box> </Box>
{!privateRoom && <RoomJoinButton roomId={roomId} via={via} />} {!inaccessibleRoom && <RoomJoinButton roomId={roomId} via={via} />}
</Box> </Box>
); );
} }
@ -288,23 +276,11 @@ function RoomProfile({
); );
} }
function CallbackOnFoundSpace({
roomId,
onSpaceFound,
}: {
roomId: string;
onSpaceFound: (roomId: string) => void;
}) {
useEffect(() => {
onSpaceFound(roomId);
}, [roomId, onSpaceFound]);
return null;
}
type RoomItemCardProps = { type RoomItemCardProps = {
item: HierarchyItem; item: HierarchyItem;
onSpaceFound: (roomId: string) => void; loading: boolean;
error: Error | null;
summary: IHierarchyRoom | undefined;
dm?: boolean; dm?: boolean;
firstChild?: boolean; firstChild?: boolean;
lastChild?: boolean; lastChild?: boolean;
@ -320,10 +296,10 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
( (
{ {
item, item,
onSpaceFound, loading,
error,
summary,
dm, dm,
firstChild,
lastChild,
onOpen, onOpen,
options, options,
before, before,
@ -348,8 +324,6 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
return ( return (
<SequenceCard <SequenceCard
className={css.RoomItemCard} className={css.RoomItemCard}
firstChild={firstChild}
lastChild={lastChild}
variant="SurfaceVariant" variant="SurfaceVariant"
gap="300" gap="300"
alignItems="Center" alignItems="Center"
@ -367,7 +341,9 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
name={localSummary.name} name={localSummary.name}
topic={localSummary.topic} topic={localSummary.topic}
avatarUrl={ avatarUrl={
dm ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication) dm
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
} }
memberCount={localSummary.memberCount} memberCount={localSummary.memberCount}
suggested={content.suggested} suggested={content.suggested}
@ -395,46 +371,46 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
)} )}
</LocalRoomSummaryLoader> </LocalRoomSummaryLoader>
) : ( ) : (
<HierarchyRoomSummaryLoader roomId={roomId}> <>
{(summaryState) => ( {!summary &&
<> (error ? (
{summaryState.status === AsyncStatus.Loading && <RoomProfileLoading />} <RoomProfileError
{summaryState.status === AsyncStatus.Error && ( roomId={roomId}
<RoomProfileError inaccessibleRoom={false}
roomId={roomId} suggested={content.suggested}
error={summaryState.error} via={content.via}
suggested={content.suggested} />
via={content.via} ) : (
/> <>
)} {loading && <RoomProfileLoading />}
{summaryState.status === AsyncStatus.Success && ( {!loading && (
<> <RoomProfileError
{summaryState.data.room_type === RoomType.Space && (
<CallbackOnFoundSpace
roomId={summaryState.data.room_id}
onSpaceFound={onSpaceFound}
/>
)}
<RoomProfile
roomId={roomId} roomId={roomId}
name={summaryState.data.name || summaryState.data.canonical_alias || roomId} inaccessibleRoom
topic={summaryState.data.topic}
avatarUrl={
summaryState.data?.avatar_url
? mxcUrlToHttp(mx, summaryState.data.avatar_url, useAuthentication, 96, 96, 'crop') ??
undefined
: undefined
}
memberCount={summaryState.data.num_joined_members}
suggested={content.suggested} suggested={content.suggested}
joinRule={summaryState.data.join_rule} via={content.via}
options={<RoomJoinButton roomId={roomId} via={content.via} />}
/> />
</> )}
)} </>
</> ))}
{summary && (
<RoomProfile
roomId={roomId}
name={summary.name || summary.canonical_alias || roomId}
topic={summary.topic}
avatarUrl={
summary?.avatar_url
? mxcUrlToHttp(mx, summary.avatar_url, useAuthentication, 96, 96, 'crop') ??
undefined
: undefined
}
memberCount={summary.num_joined_members}
suggested={content.suggested}
joinRule={summary.join_rule}
options={<RoomJoinButton roomId={roomId} via={content.via} />}
/>
)} )}
</HierarchyRoomSummaryLoader> </>
)} )}
</Box> </Box>
{options} {options}

View file

@ -0,0 +1,225 @@
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,
useFetchSpaceHierarchyLevel,
} from '../../hooks/useSpaceHierarchy';
import { IPowerLevels, powerLevelAPI } 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 { RoomType } from '../../../types/matrix/room';
import { SequenceCard } from '../../components/sequence-card';
type SpaceHierarchyProps = {
summary: IHierarchyRoom | undefined;
spaceItem: HierarchyItemSpace;
roomItems?: HierarchyItemRoom[];
allJoinedRooms: Set<string>;
mDirects: Set<string>;
roomsPowerLevels: Map<string, IPowerLevels>;
canEditSpaceChild: (powerLevels: IPowerLevels) => boolean;
categoryId: string;
closed: boolean;
handleClose: MouseEventHandler<HTMLButtonElement>;
draggingItem?: HierarchyItem;
onDragging: (item?: HierarchyItem) => void;
canDrop: CanDropCallback;
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,
spaceItem,
roomItems,
allJoinedRooms,
mDirects,
roomsPowerLevels,
canEditSpaceChild,
categoryId,
closed,
handleClose,
draggingItem,
onDragging,
canDrop,
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 (r.room_type === RoomType.Space) {
s.set(r.room_id, r);
}
});
return s;
}, [rooms]);
const spacePowerLevels = roomsPowerLevels.get(spaceItem.roomId) ?? {};
const userPLInSpace = powerLevelAPI.getPowerLevel(
spacePowerLevels,
mx.getUserId() ?? undefined
);
const canInviteInSpace = powerLevelAPI.canDoAction(spacePowerLevels, 'invite', userPLInSpace);
const draggingSpace =
draggingItem?.roomId === spaceItem.roomId && draggingItem.parentId === spaceItem.parentId;
const { parentId } = spaceItem;
const parentPowerLevels = parentId ? roomsPowerLevels.get(parentId) ?? {} : undefined;
useEffect(() => {
onSpacesFound(Array.from(subspaces.values()));
}, [subspaces, onSpacesFound]);
let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId));
if (!canEditSpaceChild(spacePowerLevels)) {
// 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) && !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={canEditSpaceChild(spacePowerLevels)}
canReorder={parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false}
options={
parentId &&
parentPowerLevels && (
<HierarchyItemMenu
item={{ ...spaceItem, parentId }}
canInvite={canInviteInSpace}
joined={allJoinedRooms.has(spaceItem.roomId)}
canEditChild={canEditSpaceChild(parentPowerLevels)}
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);
const roomPowerLevels = roomsPowerLevels.get(roomItem.roomId) ?? {};
const userPLInRoom = powerLevelAPI.getPowerLevel(
roomPowerLevels,
mx.getUserId() ?? undefined
);
const canInviteInRoom = powerLevelAPI.canDoAction(
roomPowerLevels,
'invite',
userPLInRoom
);
const lastItem = index === childItems.length;
const nextRoomId = lastItem ? nextSpaceId : childItems[index + 1]?.roomId;
const roomDragging =
draggingItem?.roomId === roomItem.roomId &&
draggingItem.parentId === roomItem.parentId;
return (
<RoomItemCard
key={roomItem.roomId}
item={roomItem}
loading={fetching}
error={error}
summary={roomSummary}
dm={mDirects.has(roomItem.roomId)}
onOpen={onOpenRoom}
getRoom={getRoom}
canReorder={canEditSpaceChild(spacePowerLevels)}
options={
<HierarchyItemMenu
item={roomItem}
canInvite={canInviteInRoom}
joined={allJoinedRooms.has(roomItem.roomId)}
canEditChild={canEditSpaceChild(spacePowerLevels)}
/>
}
after={
<AfterItemDropTarget
item={roomItem}
nextRoomId={nextRoomId}
canDrop={canDrop}
/>
}
data-dragging={roomDragging}
onDragging={onDragging}
/>
);
})}
</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>
);
}
);

View file

@ -19,19 +19,16 @@ import {
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import classNames from 'classnames'; import classNames from 'classnames';
import { MatrixError, Room } from 'matrix-js-sdk'; import { MatrixError, Room } from 'matrix-js-sdk';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomAvatar } from '../../components/room-avatar'; import { RoomAvatar } from '../../components/room-avatar';
import { nameInitials } from '../../utils/common'; import { nameInitials } from '../../utils/common';
import { import { LocalRoomSummaryLoader } from '../../components/RoomSummaryLoader';
HierarchyRoomSummaryLoader,
LocalRoomSummaryLoader,
} from '../../components/RoomSummaryLoader';
import { getRoomAvatarUrl } from '../../utils/room'; import { getRoomAvatarUrl } from '../../utils/room';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import * as css from './SpaceItem.css'; import * as css from './SpaceItem.css';
import * as styleCss from './style.css'; import * as styleCss from './style.css';
import { ErrorCode } from '../../cs-errorcode';
import { useDraggableItem } from './DnD'; import { useDraggableItem } from './DnD';
import { openCreateRoom, openSpaceAddExisting } from '../../../client/action/navigation'; import { openCreateRoom, openSpaceAddExisting } from '../../../client/action/navigation';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
@ -53,18 +50,11 @@ function SpaceProfileLoading() {
); );
} }
type UnknownPrivateSpaceProfileProps = { type InaccessibleSpaceProfileProps = {
roomId: string; roomId: string;
name?: string;
avatarUrl?: string;
suggested?: boolean; suggested?: boolean;
}; };
function UnknownPrivateSpaceProfile({ function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfileProps) {
roomId,
name,
avatarUrl,
suggested,
}: UnknownPrivateSpaceProfileProps) {
return ( return (
<Chip <Chip
as="span" as="span"
@ -75,11 +65,9 @@ function UnknownPrivateSpaceProfile({
<Avatar size="200" radii="300"> <Avatar size="200" radii="300">
<RoomAvatar <RoomAvatar
roomId={roomId} roomId={roomId}
src={avatarUrl}
alt={name}
renderFallback={() => ( renderFallback={() => (
<Text as="span" size="H6"> <Text as="span" size="H6">
{nameInitials(name)} U
</Text> </Text>
)} )}
/> />
@ -88,11 +76,11 @@ function UnknownPrivateSpaceProfile({
> >
<Box alignItems="Center" gap="200"> <Box alignItems="Center" gap="200">
<Text size="H4" truncate> <Text size="H4" truncate>
{name || 'Unknown'} Unknown
</Text> </Text>
<Badge variant="Secondary" fill="Soft" radii="Pill" outlined> <Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
<Text size="L400">Private Space</Text> <Text size="L400">Inaccessible</Text>
</Badge> </Badge>
{suggested && ( {suggested && (
<Badge variant="Success" fill="Soft" radii="Pill" outlined> <Badge variant="Success" fill="Soft" radii="Pill" outlined>
@ -104,20 +92,20 @@ function UnknownPrivateSpaceProfile({
); );
} }
type UnknownSpaceProfileProps = { type UnjoinedSpaceProfileProps = {
roomId: string; roomId: string;
via?: string[]; via?: string[];
name?: string; name?: string;
avatarUrl?: string; avatarUrl?: string;
suggested?: boolean; suggested?: boolean;
}; };
function UnknownSpaceProfile({ function UnjoinedSpaceProfile({
roomId, roomId,
via, via,
name, name,
avatarUrl, avatarUrl,
suggested, suggested,
}: UnknownSpaceProfileProps) { }: UnjoinedSpaceProfileProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>( const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
@ -376,6 +364,8 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) {
} }
type SpaceItemCardProps = { type SpaceItemCardProps = {
summary: IHierarchyRoom | undefined;
loading?: boolean;
item: HierarchyItem; item: HierarchyItem;
joined?: boolean; joined?: boolean;
categoryId: string; categoryId: string;
@ -393,6 +383,8 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
( (
{ {
className, className,
summary,
loading,
joined, joined,
closed, closed,
categoryId, categoryId,
@ -451,37 +443,31 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
} }
</LocalRoomSummaryLoader> </LocalRoomSummaryLoader>
) : ( ) : (
<HierarchyRoomSummaryLoader roomId={roomId}> <>
{(summaryState) => ( {!summary &&
<> (loading ? (
{summaryState.status === AsyncStatus.Loading && <SpaceProfileLoading />} <SpaceProfileLoading />
{summaryState.status === AsyncStatus.Error && ) : (
(summaryState.error.name === ErrorCode.M_FORBIDDEN ? ( <InaccessibleSpaceProfile
<UnknownPrivateSpaceProfile roomId={roomId} suggested={content.suggested} /> roomId={item.roomId}
) : ( suggested={item.content.suggested}
<UnknownSpaceProfile />
roomId={roomId} ))}
via={item.content.via} {summary && (
suggested={content.suggested} <UnjoinedSpaceProfile
/> roomId={roomId}
))} via={item.content.via}
{summaryState.status === AsyncStatus.Success && ( name={summary.name || summary.canonical_alias || roomId}
<UnknownSpaceProfile avatarUrl={
roomId={roomId} summary?.avatar_url
via={item.content.via} ? mxcUrlToHttp(mx, summary.avatar_url, useAuthentication, 96, 96, 'crop') ??
name={summaryState.data.name || summaryState.data.canonical_alias || roomId} undefined
avatarUrl={ : undefined
summaryState.data?.avatar_url }
? mxcUrlToHttp(mx, summaryState.data.avatar_url, useAuthentication, 96, 96, 'crop') ?? suggested={content.suggested}
undefined />
: undefined
}
suggested={content.suggested}
/>
)}
</>
)} )}
</HierarchyRoomSummaryLoader> </>
)} )}
</Box> </Box>
{canEditChild && ( {canEditChild && (

View file

@ -1,6 +1,8 @@
import { atom, useAtom, useAtomValue } from 'jotai'; import { atom, useAtom, useAtomValue } from 'jotai';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Room } from 'matrix-js-sdk'; import { MatrixError, Room } from 'matrix-js-sdk';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { QueryFunction, useInfiniteQuery } from '@tanstack/react-query';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { roomToParentsAtom } from '../state/room/roomToParents'; import { roomToParentsAtom } from '../state/room/roomToParents';
import { MSpaceChildContent, StateEvent } from '../../types/matrix/room'; import { MSpaceChildContent, StateEvent } from '../../types/matrix/room';
@ -8,22 +10,24 @@ import { getAllParents, getStateEvents, isValidChild } from '../utils/room';
import { isRoomId } from '../utils/matrix'; import { isRoomId } from '../utils/matrix';
import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort'; import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort';
import { useStateEventCallback } from './useStateEventCallback'; import { useStateEventCallback } from './useStateEventCallback';
import { ErrorCode } from '../cs-errorcode';
export type HierarchyItem = export type HierarchyItemSpace = {
| { roomId: string;
roomId: string; content: MSpaceChildContent;
content: MSpaceChildContent; ts: number;
ts: number; space: true;
space: true; parentId?: string;
parentId?: string; };
}
| { export type HierarchyItemRoom = {
roomId: string; roomId: string;
content: MSpaceChildContent; content: MSpaceChildContent;
ts: number; ts: number;
space?: false; parentId: string;
parentId: string; };
};
export type HierarchyItem = HierarchyItemSpace | HierarchyItemRoom;
type GetRoomCallback = (roomId: string) => Room | undefined; type GetRoomCallback = (roomId: string) => Room | undefined;
@ -35,16 +39,16 @@ const getHierarchySpaces = (
rootSpaceId: string, rootSpaceId: string,
getRoom: GetRoomCallback, getRoom: GetRoomCallback,
spaceRooms: Set<string> spaceRooms: Set<string>
): HierarchyItem[] => { ): HierarchyItemSpace[] => {
const rootSpaceItem: HierarchyItem = { const rootSpaceItem: HierarchyItemSpace = {
roomId: rootSpaceId, roomId: rootSpaceId,
content: { via: [] }, content: { via: [] },
ts: 0, ts: 0,
space: true, space: true,
}; };
let spaceItems: HierarchyItem[] = []; let spaceItems: HierarchyItemSpace[] = [];
const findAndCollectHierarchySpaces = (spaceItem: HierarchyItem) => { const findAndCollectHierarchySpaces = (spaceItem: HierarchyItemSpace) => {
if (spaceItems.find((item) => item.roomId === spaceItem.roomId)) return; if (spaceItems.find((item) => item.roomId === spaceItem.roomId)) return;
const space = getRoom(spaceItem.roomId); const space = getRoom(spaceItem.roomId);
spaceItems.push(spaceItem); spaceItems.push(spaceItem);
@ -61,7 +65,7 @@ const getHierarchySpaces = (
// or requesting room summary, we will look it into spaceRooms local // or requesting room summary, we will look it into spaceRooms local
// cache which we maintain as we load summary in UI. // cache which we maintain as we load summary in UI.
if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) { if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) {
const childItem: HierarchyItem = { const childItem: HierarchyItemSpace = {
roomId: childId, roomId: childId,
content: childEvent.getContent<MSpaceChildContent>(), content: childEvent.getContent<MSpaceChildContent>(),
ts: childEvent.getTs(), ts: childEvent.getTs(),
@ -85,28 +89,34 @@ const getHierarchySpaces = (
return spaceItems; return spaceItems;
}; };
export type SpaceHierarchy = {
space: HierarchyItemSpace;
rooms?: HierarchyItemRoom[];
};
const getSpaceHierarchy = ( const getSpaceHierarchy = (
rootSpaceId: string, rootSpaceId: string,
spaceRooms: Set<string>, spaceRooms: Set<string>,
getRoom: (roomId: string) => Room | undefined, getRoom: (roomId: string) => Room | undefined,
closedCategory: (spaceId: string) => boolean closedCategory: (spaceId: string) => boolean
): HierarchyItem[] => { ): SpaceHierarchy[] => {
const spaceItems: HierarchyItem[] = getHierarchySpaces(rootSpaceId, getRoom, spaceRooms); const spaceItems: HierarchyItemSpace[] = getHierarchySpaces(rootSpaceId, getRoom, spaceRooms);
const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => { const hierarchy: SpaceHierarchy[] = spaceItems.map((spaceItem) => {
const space = getRoom(spaceItem.roomId); const space = getRoom(spaceItem.roomId);
if (!space || closedCategory(spaceItem.roomId)) { if (!space || closedCategory(spaceItem.roomId)) {
return [spaceItem]; return {
space: spaceItem,
};
} }
const childEvents = getStateEvents(space, StateEvent.SpaceChild); const childEvents = getStateEvents(space, StateEvent.SpaceChild);
const childItems: HierarchyItem[] = []; const childItems: HierarchyItemRoom[] = [];
childEvents.forEach((childEvent) => { childEvents.forEach((childEvent) => {
if (!isValidChild(childEvent)) return; if (!isValidChild(childEvent)) return;
const childId = childEvent.getStateKey(); const childId = childEvent.getStateKey();
if (!childId || !isRoomId(childId)) return; if (!childId || !isRoomId(childId)) return;
if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) return; if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) return;
const childItem: HierarchyItem = { const childItem: HierarchyItemRoom = {
roomId: childId, roomId: childId,
content: childEvent.getContent<MSpaceChildContent>(), content: childEvent.getContent<MSpaceChildContent>(),
ts: childEvent.getTs(), ts: childEvent.getTs(),
@ -114,7 +124,11 @@ const getSpaceHierarchy = (
}; };
childItems.push(childItem); childItems.push(childItem);
}); });
return [spaceItem, ...childItems.sort(hierarchyItemTs).sort(hierarchyItemByOrder)];
return {
space: spaceItem,
rooms: childItems.sort(hierarchyItemTs).sort(hierarchyItemByOrder),
};
}); });
return hierarchy; return hierarchy;
@ -125,7 +139,7 @@ export const useSpaceHierarchy = (
spaceRooms: Set<string>, spaceRooms: Set<string>,
getRoom: (roomId: string) => Room | undefined, getRoom: (roomId: string) => Room | undefined,
closedCategory: (spaceId: string) => boolean closedCategory: (spaceId: string) => boolean
): HierarchyItem[] => { ): SpaceHierarchy[] => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
@ -163,7 +177,7 @@ const getSpaceJoinedHierarchy = (
excludeRoom: (parentId: string, roomId: string) => boolean, excludeRoom: (parentId: string, roomId: string) => boolean,
sortRoomItems: (parentId: string, items: HierarchyItem[]) => HierarchyItem[] sortRoomItems: (parentId: string, items: HierarchyItem[]) => HierarchyItem[]
): HierarchyItem[] => { ): HierarchyItem[] => {
const spaceItems: HierarchyItem[] = getHierarchySpaces(rootSpaceId, getRoom, new Set()); const spaceItems: HierarchyItemSpace[] = getHierarchySpaces(rootSpaceId, getRoom, new Set());
const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => { const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => {
const space = getRoom(spaceItem.roomId); const space = getRoom(spaceItem.roomId);
@ -182,14 +196,14 @@ const getSpaceJoinedHierarchy = (
if (joinedRoomEvents.length === 0) return []; if (joinedRoomEvents.length === 0) return [];
const childItems: HierarchyItem[] = []; const childItems: HierarchyItemRoom[] = [];
joinedRoomEvents.forEach((childEvent) => { joinedRoomEvents.forEach((childEvent) => {
const childId = childEvent.getStateKey(); const childId = childEvent.getStateKey();
if (!childId) return; if (!childId) return;
if (excludeRoom(space.roomId, childId)) return; if (excludeRoom(space.roomId, childId)) return;
const childItem: HierarchyItem = { const childItem: HierarchyItemRoom = {
roomId: childId, roomId: childId,
content: childEvent.getContent<MSpaceChildContent>(), content: childEvent.getContent<MSpaceChildContent>(),
ts: childEvent.getTs(), ts: childEvent.getTs(),
@ -251,3 +265,85 @@ export const useSpaceJoinedHierarchy = (
return hierarchy; return hierarchy;
}; };
// we will paginate until 5000 items
const PER_PAGE_COUNT = 100;
const MAX_AUTO_PAGE_COUNT = 50;
export type FetchSpaceHierarchyLevelData = {
fetching: boolean;
error: Error | null;
rooms: Map<string, IHierarchyRoom>;
};
export const useFetchSpaceHierarchyLevel = (
roomId: string,
enable: boolean
): FetchSpaceHierarchyLevelData => {
const mx = useMatrixClient();
const pageNoRef = useRef(0);
const fetchLevel: QueryFunction<
Awaited<ReturnType<typeof mx.getRoomHierarchy>>,
string[],
string | undefined
> = useCallback(
({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, 1, false, pageParam),
[roomId, mx]
);
const queryResponse = useInfiniteQuery({
refetchOnMount: enable,
queryKey: [roomId, 'hierarchy_level'],
initialPageParam: undefined,
queryFn: fetchLevel,
getNextPageParam: (result) => {
if (result.next_batch) return result.next_batch;
return undefined;
},
retry: 5,
retryDelay: (failureCount, error) => {
if (error instanceof MatrixError && error.errcode === ErrorCode.M_LIMIT_EXCEEDED) {
const { retry_after_ms: delay } = error.data;
if (typeof delay === 'number') {
return delay;
}
}
return 500 * failureCount;
},
});
const { data, isLoading, isFetchingNextPage, error, fetchNextPage, hasNextPage } = queryResponse;
useEffect(() => {
if (
hasNextPage &&
pageNoRef.current <= MAX_AUTO_PAGE_COUNT &&
!error &&
data &&
data.pages.length > 0
) {
pageNoRef.current += 1;
fetchNextPage();
}
}, [fetchNextPage, hasNextPage, data, error]);
const rooms: Map<string, IHierarchyRoom> = useMemo(() => {
const roomsMap: Map<string, IHierarchyRoom> = new Map();
if (!data) return roomsMap;
const rms = data.pages.flatMap((result) => result.rooms);
rms.forEach((r) => {
roomsMap.set(r.room_id, r);
});
return roomsMap;
}, [data]);
const fetching = isLoading || isFetchingNextPage;
return {
fetching,
error,
rooms,
};
};

View file

@ -23,32 +23,37 @@ const baseSpaceRoomsAtom = atomWithLocalStorage<Set<string>>(
type SpaceRoomsAction = type SpaceRoomsAction =
| { | {
type: 'PUT'; type: 'PUT';
roomId: string; roomIds: string[];
} }
| { | {
type: 'DELETE'; type: 'DELETE';
roomId: string; roomIds: string[];
}; };
export const spaceRoomsAtom = atom<Set<string>, [SpaceRoomsAction], undefined>( export const spaceRoomsAtom = atom<Set<string>, [SpaceRoomsAction], undefined>(
(get) => get(baseSpaceRoomsAtom), (get) => get(baseSpaceRoomsAtom),
(get, set, action) => { (get, set, action) => {
if (action.type === 'DELETE') { const current = get(baseSpaceRoomsAtom);
const { type, roomIds } = action;
if (type === 'DELETE' && roomIds.find((roomId) => current.has(roomId))) {
set( set(
baseSpaceRoomsAtom, baseSpaceRoomsAtom,
produce(get(baseSpaceRoomsAtom), (draft) => { produce(current, (draft) => {
draft.delete(action.roomId); roomIds.forEach((roomId) => draft.delete(roomId));
}) })
); );
return; return;
} }
if (action.type === 'PUT') { if (type === 'PUT') {
set( const newEntries = roomIds.filter((roomId) => !current.has(roomId));
baseSpaceRoomsAtom, if (newEntries.length > 0)
produce(get(baseSpaceRoomsAtom), (draft) => { set(
draft.add(action.roomId); baseSpaceRoomsAtom,
}) produce(current, (draft) => {
); newEntries.forEach((roomId) => draft.add(roomId));
})
);
} }
} }
); );