diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index 30a4f63..d1a7ec6 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -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({ {promptLeave && - (item.space ? ( + ('space' in item ? ( >(() => new Map()); + useElementSizeObserver( useCallback(() => heroSectionRef.current, []), useCallback((w, height) => setHeroSectionHeight(height), []) @@ -107,19 +113,20 @@ export function Lobby() { ); const [draggingItem, setDraggingItem] = useState(); - 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() { {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 ( - - - ) - } - before={item.parentId ? undefined : undefined} - after={ - - } - onDragging={setDraggingItem} - data-dragging={dragging} - /> - - ); - } - - const parentPowerLevels = roomsPowerLevels.get(item.parentId) ?? {}; - const prevItem: HierarchyItem | undefined = flattenHierarchy[vItem.index - 1]; - const nextItem: HierarchyItem | undefined = flattenHierarchy[vItem.index + 1]; return ( - + - } - 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} /> ); diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx index f8db399..994cda0 100644 --- a/src/app/features/lobby/RoomItem.tsx +++ b/src/app/features/lobby/RoomItem.tsx @@ -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 ( @@ -142,7 +137,7 @@ function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorPro renderFallback={() => ( )} @@ -162,25 +157,18 @@ function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorPro )} - {privateRoom && ( - <> - - Private Room - - - + {inaccessibleRoom ? ( + + Inaccessible + + ) : ( + + {roomId} + )} - - {roomId} - - {!privateRoom && } + {!inaccessibleRoom && } ); } @@ -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 ( ( 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>( )} ) : ( - - {(summaryState) => ( - <> - {summaryState.status === AsyncStatus.Loading && } - {summaryState.status === AsyncStatus.Error && ( - - )} - {summaryState.status === AsyncStatus.Success && ( - <> - {summaryState.data.room_type === RoomType.Space && ( - - )} - + {!summary && + (error ? ( + + ) : ( + <> + {loading && } + {!loading && ( + } + via={content.via} /> - - )} - + )} + + ))} + {summary && ( + } + /> )} - + )} {options} diff --git a/src/app/features/lobby/SpaceHierarchy.tsx b/src/app/features/lobby/SpaceHierarchy.tsx new file mode 100644 index 0000000..2c43282 --- /dev/null +++ b/src/app/features/lobby/SpaceHierarchy.tsx @@ -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; + mDirects: Set; + roomsPowerLevels: Map; + canEditSpaceChild: (powerLevels: IPowerLevels) => boolean; + categoryId: string; + closed: boolean; + handleClose: MouseEventHandler; + 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; +}; +export const SpaceHierarchy = forwardRef( + ( + { + 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 = 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 ( + + + ) + } + after={ + + } + onDragging={onDragging} + data-dragging={draggingSpace} + /> + {childItems && childItems.length > 0 ? ( + + {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 ( + + } + after={ + + } + data-dragging={roomDragging} + onDragging={onDragging} + /> + ); + })} + + ) : ( + childItems && ( + + + + No Rooms + + + This space does not contains rooms yet. + + + + ) + )} + + ); + } +); diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index deaf9ba..0a4d9de 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -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 ( ( - {nameInitials(name)} + U )} /> @@ -88,11 +76,11 @@ function UnknownPrivateSpaceProfile({ > - {name || 'Unknown'} + Unknown - Private Space + Inaccessible {suggested && ( @@ -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( @@ -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>( } ) : ( - - {(summaryState) => ( - <> - {summaryState.status === AsyncStatus.Loading && } - {summaryState.status === AsyncStatus.Error && - (summaryState.error.name === ErrorCode.M_FORBIDDEN ? ( - - ) : ( - - ))} - {summaryState.status === AsyncStatus.Success && ( - - )} - + <> + {!summary && + (loading ? ( + + ) : ( + + ))} + {summary && ( + )} - + )} {canEditChild && ( diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index c109cc2..ad34e3f 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -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 -): 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(), ts: childEvent.getTs(), @@ -85,28 +89,34 @@ const getHierarchySpaces = ( return spaceItems; }; +export type SpaceHierarchy = { + space: HierarchyItemSpace; + rooms?: HierarchyItemRoom[]; +}; const getSpaceHierarchy = ( rootSpaceId: string, spaceRooms: Set, 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(), 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, 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(), 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; +}; +export const useFetchSpaceHierarchyLevel = ( + roomId: string, + enable: boolean +): FetchSpaceHierarchyLevelData => { + const mx = useMatrixClient(); + const pageNoRef = useRef(0); + + const fetchLevel: QueryFunction< + Awaited>, + 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 = useMemo(() => { + const roomsMap: Map = 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, + }; +}; diff --git a/src/app/state/spaceRooms.ts b/src/app/state/spaceRooms.ts index 8480498..94abe2b 100644 --- a/src/app/state/spaceRooms.ts +++ b/src/app/state/spaceRooms.ts @@ -23,32 +23,37 @@ const baseSpaceRoomsAtom = atomWithLocalStorage>( type SpaceRoomsAction = | { type: 'PUT'; - roomId: string; + roomIds: string[]; } | { type: 'DELETE'; - roomId: string; + roomIds: string[]; }; export const spaceRoomsAtom = atom, [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)); + }) + ); } } );