Fix unknown rooms in space lobby ()

* 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;
}) {
const handleSettings = () => {
if (item.space) {
if ('space' in item) {
openSpaceSettings(item.roomId);
} else {
toggleRoomSettings(item.roomId);
@ -271,7 +271,7 @@ export function HierarchyItemMenu({
</Text>
</MenuItem>
{promptLeave &&
(item.space ? (
('space' in item ? (
<LeaveSpacePrompt
roomId={item.roomId}
onDone={handleRequestClose}

View file

@ -5,9 +5,15 @@ import { useAtom, useAtomValue } from 'jotai';
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';
@ -25,18 +31,15 @@ import {
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';
@ -49,6 +52,7 @@ import { useOrphanSpaces } from '../../state/hooks/roomList';
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();
@ -81,6 +85,8 @@ export function Lobby() {
return new Set(sideSpaces);
}, [sidebarItems]);
const [spacesItems, setSpacesItem] = useState<Map<string, IHierarchyRoom>>(() => new Map());
useElementSizeObserver(
useCallback(() => heroSectionRef.current, []),
useCallback((w, height) => setHeroSectionHeight(height), [])
@ -107,19 +113,20 @@ export function Lobby() {
);
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,
@ -129,8 +136,17 @@ export function Lobby() {
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]
)
);
@ -142,8 +158,8 @@ export function Lobby() {
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 (
@ -156,9 +172,8 @@ export function Lobby() {
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;
@ -192,22 +207,22 @@ export function Lobby() {
);
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;
}
@ -217,21 +232,21 @@ export function Lobby() {
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(
@ -240,13 +255,12 @@ export function Lobby() {
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 (
@ -265,28 +279,29 @@ export function Lobby() {
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;
}
@ -296,18 +311,18 @@ export function Lobby() {
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(
@ -318,7 +333,7 @@ export function Lobby() {
if (!canDrop(item, container)) {
return;
}
if (item.space) {
if ('space' in item) {
reorderSpace(item, container.item);
} else {
reorderRoom(item, container.item);
@ -328,8 +343,16 @@ export function Lobby() {
)
);
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]
);
@ -394,121 +417,44 @@ export function Lobby() {
<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 nextSpaceId = hierarchy[vItem.index + 1]?.space.roomId;
const nextRoomId: string | undefined =
flattenHierarchy[vItem.index + 1]?.roomId;
const categoryId = makeLobbyCategoryId(space.roomId, item.space.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 (
<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)}
/>
<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)
}
after={
<AfterItemDropTarget
item={item}
nextRoomId={nextRoomId}
canDrop={canDrop}
/>
}
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>
);

View file

@ -1,4 +1,4 @@
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useRef } from 'react';
import React, { MouseEventHandler, ReactNode, useCallback, useRef } from 'react';
import {
Avatar,
Badge,
@ -20,23 +20,20 @@ import {
} 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';
@ -125,13 +122,11 @@ function RoomProfileLoading() {
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>
@ -142,7 +137,7 @@ function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorPro
renderFallback={() => (
<RoomIcon
size="300"
joinRule={privateRoom ? JoinRule.Invite : JoinRule.Restricted}
joinRule={inaccessibleRoom ? JoinRule.Invite : JoinRule.Restricted}
filled
/>
)}
@ -162,25 +157,18 @@ function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorPro
)}
</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>
);
}
@ -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 = {
item: HierarchyItem;
onSpaceFound: (roomId: string) => void;
loading: boolean;
error: Error | null;
summary: IHierarchyRoom | undefined;
dm?: boolean;
firstChild?: boolean;
lastChild?: boolean;
@ -320,10 +296,10 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
(
{
item,
onSpaceFound,
loading,
error,
summary,
dm,
firstChild,
lastChild,
onOpen,
options,
before,
@ -348,8 +324,6 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
return (
<SequenceCard
className={css.RoomItemCard}
firstChild={firstChild}
lastChild={lastChild}
variant="SurfaceVariant"
gap="300"
alignItems="Center"
@ -367,7 +341,9 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
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}
@ -395,46 +371,46 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
)}
</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}

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 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';
@ -53,18 +50,11 @@ function SpaceProfileLoading() {
);
}
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"
@ -75,11 +65,9 @@ function UnknownPrivateSpaceProfile({
<Avatar size="200" radii="300">
<RoomAvatar
roomId={roomId}
src={avatarUrl}
alt={name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(name)}
U
</Text>
)}
/>
@ -88,11 +76,11 @@ function UnknownPrivateSpaceProfile({
>
<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>
@ -104,20 +92,20 @@ function UnknownPrivateSpaceProfile({
);
}
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, []>(
@ -376,6 +364,8 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) {
}
type SpaceItemCardProps = {
summary: IHierarchyRoom | undefined;
loading?: boolean;
item: HierarchyItem;
joined?: boolean;
categoryId: string;
@ -393,6 +383,8 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
(
{
className,
summary,
loading,
joined,
closed,
categoryId,
@ -451,37 +443,31 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
}
</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 && (

View file

@ -1,6 +1,8 @@
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';
@ -8,22 +10,24 @@ import { getAllParents, getStateEvents, isValidChild } from '../utils/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 HierarchyItem =
| {
roomId: string;
content: MSpaceChildContent;
ts: number;
space: true;
parentId?: string;
}
| {
roomId: string;
content: MSpaceChildContent;
ts: number;
space?: false;
parentId: string;
};
export type HierarchyItemSpace = {
roomId: string;
content: MSpaceChildContent;
ts: number;
space: true;
parentId?: string;
};
export type HierarchyItemRoom = {
roomId: string;
content: MSpaceChildContent;
ts: number;
parentId: string;
};
export type HierarchyItem = HierarchyItemSpace | HierarchyItemRoom;
type GetRoomCallback = (roomId: string) => Room | undefined;
@ -35,16 +39,16 @@ const getHierarchySpaces = (
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);
@ -61,7 +65,7 @@ const getHierarchySpaces = (
// 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(),
@ -85,28 +89,34 @@ const getHierarchySpaces = (
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(),
@ -114,7 +124,11 @@ const getSpaceHierarchy = (
};
childItems.push(childItem);
});
return [spaceItem, ...childItems.sort(hierarchyItemTs).sort(hierarchyItemByOrder)];
return {
space: spaceItem,
rooms: childItems.sort(hierarchyItemTs).sort(hierarchyItemByOrder),
};
});
return hierarchy;
@ -125,7 +139,7 @@ export const useSpaceHierarchy = (
spaceRooms: Set<string>,
getRoom: (roomId: string) => Room | undefined,
closedCategory: (spaceId: string) => boolean
): HierarchyItem[] => {
): SpaceHierarchy[] => {
const mx = useMatrixClient();
const roomToParents = useAtomValue(roomToParentsAtom);
@ -163,7 +177,7 @@ const getSpaceJoinedHierarchy = (
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);
@ -182,14 +196,14 @@ const getSpaceJoinedHierarchy = (
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(),
@ -251,3 +265,85 @@ export const useSpaceJoinedHierarchy = (
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: '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));
})
);
}
}
);