import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react'; import { Box, Icon, IconButton, Icons, Line, Scroll, config } from 'folds'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useAtom, useAtomValue } from 'jotai'; import { useNavigate } from 'react-router-dom'; import { IJoinRuleEventContent, JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk'; import { useSpace } from '../../hooks/useSpace'; import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page'; import { HierarchyItem, useSpaceHierarchy } from '../../hooks/useSpaceHierarchy'; import { VirtualTile } from '../../components/virtualizer'; import { spaceRoomsAtom } from '../../state/spaceRooms'; import { MembersDrawer } from '../room/MembersDrawer'; import { useSetting } from '../../state/hooks/settings'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { settingsAtom } from '../../state/settings'; import { LobbyHeader } from './LobbyHeader'; import { LobbyHero } from './LobbyHero'; import { ScrollTopContainer } from '../../components/scroll-top-container'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { IPowerLevels, PowerLevelsContextProvider, powerLevelAPI, 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 { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable'; import { getStateEvent } from '../../utils/room'; import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories'; import { makeCinnySpacesContent, sidebarItemWithout, useSidebarItems, } from '../../hooks/useSidebarItems'; import { useOrphanSpaces } from '../../state/hooks/roomList'; import { roomToParentsAtom } from '../../state/room/roomToParents'; import { AccountDataEvent } from '../../../types/matrix/accountData'; export function Lobby() { const navigate = useNavigate(); const mx = useMatrixClient(); const mDirects = useAtomValue(mDirectAtom); const allRooms = useAtomValue(allRoomsAtom); const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); const space = useSpace(); const spacePowerLevels = usePowerLevels(space); const lex = useMemo(() => new ASCIILexicalTable(' '.charCodeAt(0), '~'.charCodeAt(0), 6), []); const scrollRef = useRef(null); const heroSectionRef = useRef(null); const [heroSectionHeight, setHeroSectionHeight] = useState(); const [spaceRooms, setSpaceRooms] = useAtom(spaceRoomsAtom); const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); const screenSize = useScreenSizeContext(); const [onTop, setOnTop] = useState(true); const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom()); const [sidebarItems] = useSidebarItems( useOrphanSpaces(mx, allRoomsAtom, useAtomValue(roomToParentsAtom)) ); const sidebarSpaces = useMemo(() => { const sideSpaces = sidebarItems.flatMap((item) => { if (typeof item === 'string') return item; return item.content; }); return new Set(sideSpaces); }, [sidebarItems]); useElementSizeObserver( useCallback(() => heroSectionRef.current, []), useCallback((w, height) => setHeroSectionHeight(height), []) ); const getRoom = useCallback( (rId: string) => { if (allJoinedRooms.has(rId)) { return mx.getRoom(rId) ?? undefined; } return undefined; }, [mx, allJoinedRooms] ); const canEditSpaceChild = useCallback( (powerLevels: IPowerLevels) => powerLevelAPI.canSendStateEvent( powerLevels, StateEvent.SpaceChild, powerLevelAPI.getPowerLevel(powerLevels, mx.getUserId() ?? undefined) ), [mx] ); const [draggingItem, setDraggingItem] = useState(); const flattenHierarchy = useSpaceHierarchy( space.roomId, spaceRooms, getRoom, useCallback( (childId) => closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) || !!draggingItem?.space, [closedCategories, space.roomId, draggingItem] ) ); const virtualizer = useVirtualizer({ count: flattenHierarchy.length, getScrollElement: () => scrollRef.current, estimateSize: () => 1, overscan: 2, paddingStart: heroSectionHeight ?? 258, }); const vItems = virtualizer.getVirtualItems(); const roomsPowerLevels = useRoomsPowerLevels( useMemo( () => flattenHierarchy.map((i) => mx.getRoom(i.roomId)).filter((r) => !!r) as Room[], [mx, flattenHierarchy] ) ); const canDrop: CanDropCallback = useCallback( (item, container): boolean => { const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted; if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) { // can not drop before or after itself return false; } if (item.space) { if (!container.item.space) return false; const containerSpaceId = space.roomId; if ( getRoom(containerSpaceId) === undefined || !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {}) ) { return false; } return true; } const containerSpaceId = container.item.space ? container.item.roomId : container.item.parentId; const dropOutsideSpace = item.parentId !== containerSpaceId; if (dropOutsideSpace && restrictedItem) { // do not allow restricted room to drop outside // current space if can't change join rule allow const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {}; const userPLInItem = powerLevelAPI.getPowerLevel( itemPowerLevel, mx.getUserId() ?? undefined ); const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent( itemPowerLevel, StateEvent.RoomJoinRules, userPLInItem ); if (!canChangeJoinRuleAllow) { return false; } } if ( getRoom(containerSpaceId) === undefined || !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {}) ) { return false; } return true; }, [getRoom, space.roomId, roomsPowerLevels, canEditSpaceChild, mx] ); const reorderSpace = useCallback( (item: HierarchyItem, containerItem: HierarchyItem) => { if (!item.parentId) return; const childItems = flattenHierarchy .filter((i) => i.parentId && i.space) .filter((i) => i.roomId !== item.roomId); const beforeIndex = childItems.findIndex((i) => i.roomId === containerItem.roomId); const insertIndex = beforeIndex + 1; childItems.splice(insertIndex, 0, { ...item, content: { ...item.content, order: undefined }, }); const currentOrders = childItems.map((i) => { if (typeof i.content.order === 'string' && lex.has(i.content.order)) { return i.content.order; } return undefined; }); const newOrders = orderKeys(lex, currentOrders); newOrders?.forEach((orderKey, index) => { const itm = childItems[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, { ...itm.content, order: orderKey }, itm.roomId ); } }); }, [mx, flattenHierarchy, lex, roomsPowerLevels, canEditSpaceChild] ); const reorderRoom = useCallback( (item: HierarchyItem, containerItem: HierarchyItem): void => { const itemRoom = mx.getRoom(item.roomId); if (!item.parentId) { return; } const containerParentId: string = containerItem.space ? containerItem.roomId : containerItem.parentId; const itemContent = item.content; if (item.parentId !== containerParentId) { mx.sendStateEvent(item.parentId, StateEvent.SpaceChild, {}, item.roomId); } if ( itemRoom && itemRoom.getJoinRule() === JoinRule.Restricted && item.parentId !== containerParentId ) { // change join rule allow parameter when dragging // restricted room from one space to another const joinRuleContent = getStateEvent( itemRoom, StateEvent.RoomJoinRules )?.getContent(); if (joinRuleContent) { 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, { ...joinRuleContent, allow, }); } } const childItems = flattenHierarchy .filter((i) => i.parentId === containerParentId && !i.space) .filter((i) => i.roomId !== item.roomId); const beforeItem: HierarchyItem | undefined = containerItem.space ? undefined : containerItem; const beforeIndex = childItems.findIndex((i) => i.roomId === beforeItem?.roomId); const insertIndex = beforeIndex + 1; childItems.splice(insertIndex, 0, { ...item, parentId: containerParentId, content: { ...itemContent, order: undefined }, }); const currentOrders = childItems.map((i) => { if (typeof i.content.order === 'string' && lex.has(i.content.order)) { return i.content.order; } return undefined; }); const newOrders = orderKeys(lex, currentOrders); newOrders?.forEach((orderKey, index) => { const itm = childItems[index]; if (itm && orderKey !== currentOrders[index]) { mx.sendStateEvent( containerParentId, StateEvent.SpaceChild, { ...itm.content, order: orderKey }, itm.roomId ); } }); }, [mx, flattenHierarchy, lex] ); useDnDMonitor( scrollRef, setDraggingItem, useCallback( (item, container) => { if (!canDrop(item, container)) { return; } if (item.space) { reorderSpace(item, container.item); } else { reorderRoom(item, container.item); } }, [reorderRoom, reorderSpace, canDrop] ) ); const addSpaceRoom = useCallback( (roomId: string) => setSpaceRooms({ type: 'PUT', roomId }), [setSpaceRooms] ); const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => closedCategories.has(categoryId) ); const handleOpenRoom: MouseEventHandler = (evt) => { const rId = evt.currentTarget.getAttribute('data-room-id'); if (!rId) return; const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId); navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId))); }; const togglePinToSidebar = useCallback( (rId: string) => { const newItems = sidebarItemWithout(sidebarItems, rId); if (!sidebarSpaces.has(rId)) { newItems.push(rId); } const newSpacesContent = makeCinnySpacesContent(mx, newItems); mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent); }, [mx, sidebarItems, sidebarSpaces] ); return ( virtualizer.scrollToOffset(0)} variant="SurfaceVariant" radii="Pill" outlined size="300" aria-label="Scroll to Top" >
{vItems.map((vItem) => { const item = flattenHierarchy[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 ( ) } 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 ( } after={ } data-dragging={dragging} onDragging={setDraggingItem} /> ); })}
{screenSize === ScreenSize.Desktop && isDrawer && ( <> )}
); }