disabled?: boolean;
}) {
const handleSettings = () => {
- if (item.space) {
+ if ('space' in item) {
openSpaceSettings(item.roomId);
} else {
toggleRoomSettings(item.roomId);
</Text>
</MenuItem>
{promptLeave &&
- (item.space ? (
+ ('space' in item ? (
<LeaveSpacePrompt
roomId={item.roomId}
onDone={handleRequestClose}
import { useNavigate } from 'react-router-dom';
import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
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 { 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 { spaceRoomsAtom } from '../../state/spaceRooms';
import { MembersDrawer } from '../room/MembersDrawer';
usePowerLevels,
useRoomsPowerLevels,
} from '../../hooks/usePowerLevels';
-import { RoomItemCard } from './RoomItem';
import { mDirectAtom } from '../../state/mDirectList';
-import { SpaceItemCard } from './SpaceItem';
import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
import { useCategoryHandler } from '../../hooks/useCategoryHandler';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { getSpaceRoomPath } from '../../pages/pathUtils';
-import { HierarchyItemMenu } from './HierarchyItemMenu';
import { StateEvent } from '../../../types/matrix/room';
-import { AfterItemDropTarget, CanDropCallback, useDnDMonitor } from './DnD';
+import { CanDropCallback, useDnDMonitor } from './DnD';
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
import { getStateEvent } from '../../utils/room';
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { useRoomMembers } from '../../hooks/useRoomMembers';
+import { SpaceHierarchy } from './SpaceHierarchy';
export function Lobby() {
const navigate = useNavigate();
return new Set(sideSpaces);
}, [sidebarItems]);
+ const [spacesItems, setSpacesItem] = useState<Map<string, IHierarchyRoom>>(() => new Map());
+
useElementSizeObserver(
useCallback(() => heroSectionRef.current, []),
useCallback((w, height) => setHeroSectionHeight(height), [])
);
const [draggingItem, setDraggingItem] = useState<HierarchyItem>();
- const flattenHierarchy = useSpaceHierarchy(
+ const hierarchy = useSpaceHierarchy(
space.roomId,
spaceRooms,
getRoom,
useCallback(
(childId) =>
- closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) || !!draggingItem?.space,
+ closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) ||
+ (draggingItem ? 'space' in draggingItem : false),
[closedCategories, space.roomId, draggingItem]
)
);
const virtualizer = useVirtualizer({
- count: flattenHierarchy.length,
+ count: hierarchy.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 1,
overscan: 2,
const roomsPowerLevels = useRoomsPowerLevels(
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]
)
);
return false;
}
- if (item.space) {
- if (!container.item.space) return false;
+ if ('space' in item) {
+ if (!('space' in container.item)) return false;
const containerSpaceId = space.roomId;
if (
return true;
}
- const containerSpaceId = container.item.space
- ? container.item.roomId
- : container.item.parentId;
+ const containerSpaceId =
+ 'space' in container.item ? container.item.roomId : container.item.parentId;
const dropOutsideSpace = item.parentId !== containerSpaceId;
);
const reorderSpace = useCallback(
- (item: HierarchyItem, containerItem: HierarchyItem) => {
+ (item: HierarchyItemSpace, containerItem: HierarchyItem) => {
if (!item.parentId) return;
- const childItems = flattenHierarchy
- .filter((i) => i.parentId && i.space)
+ const itemSpaces: HierarchyItemSpace[] = hierarchy
+ .map((i) => i.space)
.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;
- childItems.splice(insertIndex, 0, {
+ itemSpaces.splice(insertIndex, 0, {
...item,
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)) {
return i.content.order;
}
const newOrders = orderKeys(lex, currentOrders);
newOrders?.forEach((orderKey, index) => {
- const itm = childItems[index];
+ const itm = itemSpaces[index];
if (!itm || !itm.parentId) return;
const parentPL = roomsPowerLevels.get(itm.parentId);
const canEdit = parentPL && canEditSpaceChild(parentPL);
if (canEdit && orderKey !== currentOrders[index]) {
mx.sendStateEvent(
itm.parentId,
- StateEvent.SpaceChild,
+ StateEvent.SpaceChild as any,
{ ...itm.content, order: orderKey },
itm.roomId
);
}
});
},
- [mx, flattenHierarchy, lex, roomsPowerLevels, canEditSpaceChild]
+ [mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
);
const reorderRoom = useCallback(
if (!item.parentId) {
return;
}
- const containerParentId: string = containerItem.space
- ? containerItem.roomId
- : containerItem.parentId;
+ const containerParentId: string =
+ 'space' in containerItem ? containerItem.roomId : containerItem.parentId;
const itemContent = item.content;
if (item.parentId !== containerParentId) {
- mx.sendStateEvent(item.parentId, StateEvent.SpaceChild, {}, item.roomId);
+ mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
}
if (
const allow =
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? [];
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
- mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules, {
+ mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, {
...joinRuleContent,
allow,
});
}
}
- const childItems = flattenHierarchy
- .filter((i) => i.parentId === containerParentId && !i.space)
- .filter((i) => i.roomId !== item.roomId);
+ const itemSpaces = Array.from(
+ hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? []
+ );
- const beforeItem: HierarchyItem | undefined = containerItem.space ? undefined : containerItem;
- const beforeIndex = childItems.findIndex((i) => i.roomId === beforeItem?.roomId);
+ const beforeItem: HierarchyItem | undefined =
+ 'space' in containerItem ? undefined : containerItem;
+ const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId);
const insertIndex = beforeIndex + 1;
- childItems.splice(insertIndex, 0, {
+ itemSpaces.splice(insertIndex, 0, {
...item,
parentId: containerParentId,
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)) {
return i.content.order;
}
const newOrders = orderKeys(lex, currentOrders);
newOrders?.forEach((orderKey, index) => {
- const itm = childItems[index];
+ const itm = itemSpaces[index];
if (itm && orderKey !== currentOrders[index]) {
mx.sendStateEvent(
containerParentId,
- StateEvent.SpaceChild,
+ StateEvent.SpaceChild as any,
{ ...itm.content, order: orderKey },
itm.roomId
);
}
});
},
- [mx, flattenHierarchy, lex]
+ [mx, hierarchy, lex]
);
useDnDMonitor(
if (!canDrop(item, container)) {
return;
}
- if (item.space) {
+ if ('space' in item) {
reorderSpace(item, container.item);
} else {
reorderRoom(item, container.item);
)
);
- const addSpaceRoom = useCallback(
- (roomId: string) => setSpaceRooms({ type: 'PUT', roomId }),
+ const handleSpacesFound = useCallback(
+ (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]
);
<LobbyHero />
</PageHeroSection>
{vItems.map((vItem) => {
- const item = flattenHierarchy[vItem.index];
+ const item = hierarchy[vItem.index];
if (!item) return null;
- const itemPowerLevel = roomsPowerLevels.get(item.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 =
- 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];
+ const nextSpaceId = hierarchy[vItem.index + 1]?.space.roomId;
+
+ const categoryId = makeLobbyCategoryId(space.roomId, item.space.roomId);
+
return (
<VirtualTile
virtualItem={vItem}
- style={{ paddingTop: config.space.S100 }}
+ style={{
+ paddingTop: vItem.index === 0 ? 0 : config.space.S500,
+ }}
ref={virtualizer.measureElement}
key={vItem.index}
>
- <RoomItemCard
- item={item}
- onSpaceFound={addSpaceRoom}
- dm={mDirects.has(item.roomId)}
- firstChild={!prevItem || prevItem.space === true}
- lastChild={!nextItem || nextItem.space === true}
- onOpen={handleOpenRoom}
- getRoom={getRoom}
- canReorder={canEditSpaceChild(parentPowerLevels)}
- options={
- <HierarchyItemMenu
- item={item}
- canInvite={canInvite}
- joined={isJoined}
- canEditChild={canEditSpaceChild(parentPowerLevels)}
- />
- }
- after={
- <AfterItemDropTarget
- item={item}
- nextRoomId={nextRoomId}
- canDrop={canDrop}
- />
+ <SpaceHierarchy
+ spaceItem={item.space}
+ summary={spacesItems.get(item.space.roomId)}
+ roomItems={item.rooms}
+ allJoinedRooms={allJoinedRooms}
+ mDirects={mDirects}
+ roomsPowerLevels={roomsPowerLevels}
+ canEditSpaceChild={canEditSpaceChild}
+ categoryId={categoryId}
+ closed={
+ closedCategories.has(categoryId) ||
+ (draggingItem ? 'space' in draggingItem : false)
}
- data-dragging={dragging}
+ handleClose={handleCategoryClick}
+ draggingItem={draggingItem}
onDragging={setDraggingItem}
+ canDrop={canDrop}
+ nextSpaceId={nextSpaceId}
+ getRoom={getRoom}
+ pinned={sidebarSpaces.has(item.space.roomId)}
+ togglePinToSidebar={togglePinToSidebar}
+ onSpacesFound={handleSpacesFound}
+ onOpenRoom={handleOpenRoom}
/>
</VirtualTile>
);
-import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useRef } from 'react';
+import React, { MouseEventHandler, ReactNode, useCallback, useRef } from 'react';
import {
Avatar,
Badge,
} from 'folds';
import FocusTrap from 'focus-trap-react';
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 { SequenceCard } from '../../components/sequence-card';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { millify } from '../../plugins/millify';
-import {
- HierarchyRoomSummaryLoader,
- LocalRoomSummaryLoader,
-} from '../../components/RoomSummaryLoader';
+import { LocalRoomSummaryLoader } from '../../components/RoomSummaryLoader';
import { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer';
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 styleCss from './style.css';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
-import { ErrorCode } from '../../cs-errorcode';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
import { ItemDraggableTarget, useDraggableItem } from './DnD';
import { mxcUrlToHttp } from '../../utils/matrix';
type RoomProfileErrorProps = {
roomId: string;
- error: Error;
+ inaccessibleRoom: boolean;
suggested?: boolean;
via?: string[];
};
-function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorProps) {
- const privateRoom = error.name === ErrorCode.M_FORBIDDEN;
-
+function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProfileErrorProps) {
return (
<Box grow="Yes" gap="300">
<Avatar>
renderFallback={() => (
<RoomIcon
size="300"
- joinRule={privateRoom ? JoinRule.Invite : JoinRule.Restricted}
+ joinRule={inaccessibleRoom ? JoinRule.Invite : JoinRule.Restricted}
filled
/>
)}
)}
</Box>
<Box gap="200" alignItems="Center">
- {privateRoom && (
- <>
- <Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
- <Text size="L400">Private Room</Text>
- </Badge>
- <Line
- variant="SurfaceVariant"
- style={{ height: toRem(12) }}
- direction="Vertical"
- size="400"
- />
- </>
+ {inaccessibleRoom ? (
+ <Badge variant="Secondary" fill="Soft" radii="300" size="500">
+ <Text size="L400">Inaccessible</Text>
+ </Badge>
+ ) : (
+ <Text size="T200" truncate>
+ {roomId}
+ </Text>
)}
- <Text size="T200" truncate>
- {roomId}
- </Text>
</Box>
</Box>
- {!privateRoom && <RoomJoinButton roomId={roomId} via={via} />}
+ {!inaccessibleRoom && <RoomJoinButton roomId={roomId} via={via} />}
</Box>
);
}
);
}
-function CallbackOnFoundSpace({
- roomId,
- onSpaceFound,
-}: {
- roomId: string;
- onSpaceFound: (roomId: string) => void;
-}) {
- useEffect(() => {
- onSpaceFound(roomId);
- }, [roomId, onSpaceFound]);
-
- return null;
-}
-
type RoomItemCardProps = {
item: HierarchyItem;
- onSpaceFound: (roomId: string) => void;
+ loading: boolean;
+ error: Error | null;
+ summary: IHierarchyRoom | undefined;
dm?: boolean;
firstChild?: boolean;
lastChild?: boolean;
(
{
item,
- onSpaceFound,
+ loading,
+ error,
+ summary,
dm,
- firstChild,
- lastChild,
onOpen,
options,
before,
return (
<SequenceCard
className={css.RoomItemCard}
- firstChild={firstChild}
- lastChild={lastChild}
variant="SurfaceVariant"
gap="300"
alignItems="Center"
name={localSummary.name}
topic={localSummary.topic}
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}
suggested={content.suggested}
)}
</LocalRoomSummaryLoader>
) : (
- <HierarchyRoomSummaryLoader roomId={roomId}>
- {(summaryState) => (
- <>
- {summaryState.status === AsyncStatus.Loading && <RoomProfileLoading />}
- {summaryState.status === AsyncStatus.Error && (
- <RoomProfileError
- roomId={roomId}
- error={summaryState.error}
- suggested={content.suggested}
- via={content.via}
- />
- )}
- {summaryState.status === AsyncStatus.Success && (
- <>
- {summaryState.data.room_type === RoomType.Space && (
- <CallbackOnFoundSpace
- roomId={summaryState.data.room_id}
- onSpaceFound={onSpaceFound}
- />
- )}
- <RoomProfile
+ <>
+ {!summary &&
+ (error ? (
+ <RoomProfileError
+ roomId={roomId}
+ inaccessibleRoom={false}
+ suggested={content.suggested}
+ via={content.via}
+ />
+ ) : (
+ <>
+ {loading && <RoomProfileLoading />}
+ {!loading && (
+ <RoomProfileError
roomId={roomId}
- name={summaryState.data.name || summaryState.data.canonical_alias || roomId}
- 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}
+ inaccessibleRoom
suggested={content.suggested}
- joinRule={summaryState.data.join_rule}
- options={<RoomJoinButton roomId={roomId} via={content.via} />}
+ 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>
{options}
--- /dev/null
+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>
+ );
+ }
+);
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { MatrixError, Room } from 'matrix-js-sdk';
+import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomAvatar } from '../../components/room-avatar';
import { nameInitials } from '../../utils/common';
-import {
- HierarchyRoomSummaryLoader,
- LocalRoomSummaryLoader,
-} from '../../components/RoomSummaryLoader';
+import { LocalRoomSummaryLoader } from '../../components/RoomSummaryLoader';
import { getRoomAvatarUrl } from '../../utils/room';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import * as css from './SpaceItem.css';
import * as styleCss from './style.css';
-import { ErrorCode } from '../../cs-errorcode';
import { useDraggableItem } from './DnD';
import { openCreateRoom, openSpaceAddExisting } from '../../../client/action/navigation';
import { stopPropagation } from '../../utils/keyboard';
);
}
-type UnknownPrivateSpaceProfileProps = {
+type InaccessibleSpaceProfileProps = {
roomId: string;
- name?: string;
- avatarUrl?: string;
suggested?: boolean;
};
-function UnknownPrivateSpaceProfile({
- roomId,
- name,
- avatarUrl,
- suggested,
-}: UnknownPrivateSpaceProfileProps) {
+function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfileProps) {
return (
<Chip
as="span"
<Avatar size="200" radii="300">
<RoomAvatar
roomId={roomId}
- src={avatarUrl}
- alt={name}
renderFallback={() => (
<Text as="span" size="H6">
- {nameInitials(name)}
+ U
</Text>
)}
/>
>
<Box alignItems="Center" gap="200">
<Text size="H4" truncate>
- {name || 'Unknown'}
+ Unknown
</Text>
<Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
- <Text size="L400">Private Space</Text>
+ <Text size="L400">Inaccessible</Text>
</Badge>
{suggested && (
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
);
}
-type UnknownSpaceProfileProps = {
+type UnjoinedSpaceProfileProps = {
roomId: string;
via?: string[];
name?: string;
avatarUrl?: string;
suggested?: boolean;
};
-function UnknownSpaceProfile({
+function UnjoinedSpaceProfile({
roomId,
via,
name,
avatarUrl,
suggested,
-}: UnknownSpaceProfileProps) {
+}: UnjoinedSpaceProfileProps) {
const mx = useMatrixClient();
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
}
type SpaceItemCardProps = {
+ summary: IHierarchyRoom | undefined;
+ loading?: boolean;
item: HierarchyItem;
joined?: boolean;
categoryId: string;
(
{
className,
+ summary,
+ loading,
joined,
closed,
categoryId,
}
</LocalRoomSummaryLoader>
) : (
- <HierarchyRoomSummaryLoader roomId={roomId}>
- {(summaryState) => (
- <>
- {summaryState.status === AsyncStatus.Loading && <SpaceProfileLoading />}
- {summaryState.status === AsyncStatus.Error &&
- (summaryState.error.name === ErrorCode.M_FORBIDDEN ? (
- <UnknownPrivateSpaceProfile roomId={roomId} suggested={content.suggested} />
- ) : (
- <UnknownSpaceProfile
- roomId={roomId}
- via={item.content.via}
- suggested={content.suggested}
- />
- ))}
- {summaryState.status === AsyncStatus.Success && (
- <UnknownSpaceProfile
- roomId={roomId}
- via={item.content.via}
- name={summaryState.data.name || summaryState.data.canonical_alias || roomId}
- avatarUrl={
- summaryState.data?.avatar_url
- ? mxcUrlToHttp(mx, summaryState.data.avatar_url, useAuthentication, 96, 96, 'crop') ??
- undefined
- : undefined
- }
- suggested={content.suggested}
- />
- )}
- </>
+ <>
+ {!summary &&
+ (loading ? (
+ <SpaceProfileLoading />
+ ) : (
+ <InaccessibleSpaceProfile
+ roomId={item.roomId}
+ suggested={item.content.suggested}
+ />
+ ))}
+ {summary && (
+ <UnjoinedSpaceProfile
+ roomId={roomId}
+ via={item.content.via}
+ name={summary.name || summary.canonical_alias || roomId}
+ avatarUrl={
+ summary?.avatar_url
+ ? mxcUrlToHttp(mx, summary.avatar_url, useAuthentication, 96, 96, 'crop') ??
+ undefined
+ : undefined
+ }
+ suggested={content.suggested}
+ />
)}
- </HierarchyRoomSummaryLoader>
+ </>
)}
</Box>
{canEditChild && (
import { atom, useAtom, useAtomValue } from 'jotai';
-import { useCallback, useEffect, useState } from 'react';
-import { Room } from 'matrix-js-sdk';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+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 { roomToParentsAtom } from '../state/room/roomToParents';
import { MSpaceChildContent, StateEvent } from '../../types/matrix/room';
import { isRoomId } from '../utils/matrix';
import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort';
import { useStateEventCallback } from './useStateEventCallback';
+import { ErrorCode } from '../cs-errorcode';
+
+export type HierarchyItemSpace = {
+ roomId: string;
+ content: MSpaceChildContent;
+ ts: number;
+ space: true;
+ parentId?: string;
+};
-export type HierarchyItem =
- | {
- roomId: string;
- content: MSpaceChildContent;
- ts: number;
- space: true;
- parentId?: string;
- }
- | {
- roomId: string;
- content: MSpaceChildContent;
- ts: number;
- space?: false;
- parentId: string;
- };
+export type HierarchyItemRoom = {
+ roomId: string;
+ content: MSpaceChildContent;
+ ts: number;
+ parentId: string;
+};
+
+export type HierarchyItem = HierarchyItemSpace | HierarchyItemRoom;
type GetRoomCallback = (roomId: string) => Room | undefined;
rootSpaceId: string,
getRoom: GetRoomCallback,
spaceRooms: Set<string>
-): HierarchyItem[] => {
- const rootSpaceItem: HierarchyItem = {
+): HierarchyItemSpace[] => {
+ const rootSpaceItem: HierarchyItemSpace = {
roomId: rootSpaceId,
content: { via: [] },
ts: 0,
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;
const space = getRoom(spaceItem.roomId);
spaceItems.push(spaceItem);
// or requesting room summary, we will look it into spaceRooms local
// cache which we maintain as we load summary in UI.
if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) {
- const childItem: HierarchyItem = {
+ const childItem: HierarchyItemSpace = {
roomId: childId,
content: childEvent.getContent<MSpaceChildContent>(),
ts: childEvent.getTs(),
return spaceItems;
};
+export type SpaceHierarchy = {
+ space: HierarchyItemSpace;
+ rooms?: HierarchyItemRoom[];
+};
const getSpaceHierarchy = (
rootSpaceId: string,
spaceRooms: Set<string>,
getRoom: (roomId: string) => Room | undefined,
closedCategory: (spaceId: string) => boolean
-): HierarchyItem[] => {
- const spaceItems: HierarchyItem[] = getHierarchySpaces(rootSpaceId, getRoom, spaceRooms);
+): SpaceHierarchy[] => {
+ const spaceItems: HierarchyItemSpace[] = getHierarchySpaces(rootSpaceId, getRoom, spaceRooms);
- const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => {
+ const hierarchy: SpaceHierarchy[] = spaceItems.map((spaceItem) => {
const space = getRoom(spaceItem.roomId);
if (!space || closedCategory(spaceItem.roomId)) {
- return [spaceItem];
+ return {
+ space: spaceItem,
+ };
}
const childEvents = getStateEvents(space, StateEvent.SpaceChild);
- const childItems: HierarchyItem[] = [];
+ const childItems: HierarchyItemRoom[] = [];
childEvents.forEach((childEvent) => {
if (!isValidChild(childEvent)) return;
const childId = childEvent.getStateKey();
if (!childId || !isRoomId(childId)) return;
if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) return;
- const childItem: HierarchyItem = {
+ const childItem: HierarchyItemRoom = {
roomId: childId,
content: childEvent.getContent<MSpaceChildContent>(),
ts: childEvent.getTs(),
};
childItems.push(childItem);
});
- return [spaceItem, ...childItems.sort(hierarchyItemTs).sort(hierarchyItemByOrder)];
+
+ return {
+ space: spaceItem,
+ rooms: childItems.sort(hierarchyItemTs).sort(hierarchyItemByOrder),
+ };
});
return hierarchy;
spaceRooms: Set<string>,
getRoom: (roomId: string) => Room | undefined,
closedCategory: (spaceId: string) => boolean
-): HierarchyItem[] => {
+): SpaceHierarchy[] => {
const mx = useMatrixClient();
const roomToParents = useAtomValue(roomToParentsAtom);
excludeRoom: (parentId: string, roomId: string) => boolean,
sortRoomItems: (parentId: string, items: 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 space = getRoom(spaceItem.roomId);
if (joinedRoomEvents.length === 0) return [];
- const childItems: HierarchyItem[] = [];
+ const childItems: HierarchyItemRoom[] = [];
joinedRoomEvents.forEach((childEvent) => {
const childId = childEvent.getStateKey();
if (!childId) return;
if (excludeRoom(space.roomId, childId)) return;
- const childItem: HierarchyItem = {
+ const childItem: HierarchyItemRoom = {
roomId: childId,
content: childEvent.getContent<MSpaceChildContent>(),
ts: childEvent.getTs(),
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,
+ };
+};
type SpaceRoomsAction =
| {
type: 'PUT';
- roomId: string;
+ roomIds: string[];
}
| {
type: 'DELETE';
- roomId: string;
+ roomIds: string[];
};
export const spaceRoomsAtom = atom<Set<string>, [SpaceRoomsAction], undefined>(
(get) => get(baseSpaceRoomsAtom),
(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(
baseSpaceRoomsAtom,
- produce(get(baseSpaceRoomsAtom), (draft) => {
- draft.delete(action.roomId);
+ produce(current, (draft) => {
+ roomIds.forEach((roomId) => draft.delete(roomId));
})
);
return;
}
- if (action.type === 'PUT') {
- set(
- baseSpaceRoomsAtom,
- produce(get(baseSpaceRoomsAtom), (draft) => {
- draft.add(action.roomId);
- })
- );
+ if (type === 'PUT') {
+ const newEntries = roomIds.filter((roomId) => !current.has(roomId));
+ if (newEntries.length > 0)
+ set(
+ baseSpaceRoomsAtom,
+ produce(current, (draft) => {
+ newEntries.forEach((roomId) => draft.add(roomId));
+ })
+ );
}
}
);