import React, { MouseEventHandler, forwardRef, useCallback, useMemo, useRef, useState, } from 'react'; import { useAtom, useAtomValue } from 'jotai'; import { Avatar, Box, Button, IconButton, Line, Menu, MenuItem, PopOut, RectCords, Spinner, Text, color, config, toRem } from 'folds'; import { Icon, Icons } from '../../../components/icons'; import { useVirtualizer } from '@tanstack/react-virtual'; import { JoinRule, Room, Thread, ThreadEvent } from 'matrix-js-sdk'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import FocusTrap from 'focus-trap-react'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { mDirectAtom } from '../../../state/mDirectList'; import { NavCategory, NavCategoryHeader, NavItem, NavItemContent, NavLink, } from '../../../components/nav'; import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils'; import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useSpaceLobbySelected, useSpaceSearchSelected, } from '../../../hooks/router/useSelectedSpace'; import { useSpace } from '../../../hooks/useSpace'; import { VirtualTile } from '../../../components/virtualizer'; import { RoomNavCategoryButton, RoomNavItem, UnjoinedSubRoomItem } from '../../../features/room-nav'; import { makeNavCategoryId } from '../../../state/closedNavCategories'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; import { useRoomName } from '../../../hooks/useRoomMeta'; import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy'; import { allRoomsAtom } from '../../../state/room-list/roomList'; import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList'; import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead'; import { useRoomsUnread } from '../../../state/hooks/unread'; import { UseStateProvider } from '../../../components/UseStateProvider'; import { LeaveSpacePrompt } from '../../../components/leave-space-prompt'; import { copyToClipboard } from '../../../utils/dom'; import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; import { useStateEvent } from '../../../hooks/useStateEvent'; import { Membership, StateEvent } from '../../../../types/matrix/room'; import { stopPropagation } from '../../../utils/keyboard'; import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot'; import { getMatrixToRoom } from '../../../plugins/matrix-to'; import { getViaServers } from '../../../plugins/via-servers'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { getRoomNotificationMode, useRoomsNotificationPreferencesContext, } from '../../../hooks/useRoomsNotificationPreferences'; import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; import { ContainerColor } from '../../../styles/ContainerColor.css'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { BreakWord } from '../../../styles/Text.css'; import { InviteUserPrompt } from '../../../components/invite-user-prompt'; import { isSpace, shouldShowForumLobby } from '../../../utils/room'; import { SpaceOptionsMenu } from '../../../features/space/SpaceOptionsMenu'; import { ForumFeedSidebar } from '../../../features/forum/ForumFeedSidebar'; import * as forumBoardCss from '../../../features/forum/ForumBoardView.css'; function SpaceHeader() { const space = useSpace(); const spaceName = useRoomName(space); const [menuAnchor, setMenuAnchor] = useState(); const joinRules = useStateEvent( space, StateEvent.RoomJoinRules )?.getContent(); const handleOpenMenu: MouseEventHandler = (evt) => { const cords = evt.currentTarget.getBoundingClientRect(); setMenuAnchor((currentState) => { if (currentState) return undefined; return cords; }); }; return ( <> {spaceName} {joinRules?.join_rule !== JoinRule.Public && } {menuAnchor && ( setMenuAnchor(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', escapeDeactivates: stopPropagation, }} > setMenuAnchor(undefined)} /> } /> )} ); } type SpaceTombstoneProps = { roomId: string; replacementRoomId: string }; export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) { const mx = useMatrixClient(); const { navigateSpace } = useRoomNavigate(); const [joinState, handleJoin] = useAsyncCallback( useCallback(() => { const currentRoom = mx.getRoom(roomId); const via = currentRoom ? getViaServers(currentRoom) : []; return mx.joinRoom(replacementRoomId, { viaServers: via, }); }, [mx, roomId, replacementRoomId]) ); const replacementRoom = mx.getRoom(replacementRoomId); const handleOpen = () => { if (replacementRoom) navigateSpace(replacementRoom.roomId); if (joinState.status === AsyncStatus.Success) navigateSpace(joinState.data.roomId); }; return ( Space Upgraded This space has been replaced and is no longer active. {joinState.status === AsyncStatus.Error && ( {(joinState.error as any)?.message ?? 'Failed to join replacement space!'} )} {replacementRoom?.getMyMembership() === Membership.Join || joinState.status === AsyncStatus.Success ? ( ) : ( )} ); } export function Space() { const mx = useMatrixClient(); const space = useSpace(); useNavToActivePathMapper(space.roomId); const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId); const scrollRef = useRef(null); const mDirects = useAtomValue(mDirectAtom); const roomToUnread = useAtomValue(roomToUnreadAtom); const allRooms = useAtomValue(allRoomsAtom); const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); const notificationPreferences = useRoomsNotificationPreferencesContext(); const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone); const selectedRoomId = useSelectedRoom(); const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias); const searchSelected = useSpaceSearchSelected(spaceIdOrAlias); const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); const getRoom = useCallback( (rId: string) => { if (allJoinedRooms.has(rId)) { return mx.getRoom(rId) ?? undefined; } return undefined; }, [mx, allJoinedRooms] ); const hierarchy = useSpaceJoinedHierarchy( space.roomId, getRoom, useCallback( (parentId, roomId) => { if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) { return false; } const showRoom = roomToUnread.has(roomId) || roomId === selectedRoomId; if (showRoom) return false; return true; }, [space.roomId, closedCategories, roomToUnread, selectedRoomId] ), useCallback( (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)), [closedCategories, space.roomId] ) ); // Build a global set of all sub-room IDs to filter from hierarchy // This prevents sub-rooms from appearing both as top-level entries AND nested under parents const globalSubRoomIds = useMemo(() => { const subRoomIds = new Set(); mx.getRooms().forEach((room) => { const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, ''); const content = subRoomsEvent?.getContent<{ children: string[] }>(); content?.children?.forEach((childId: string) => subRoomIds.add(childId)); }); return subRoomIds; }, [mx, allJoinedRooms, hierarchy]); // Filter hierarchy to exclude sub-rooms (they'll be shown nested under their parent) const filteredHierarchy = useMemo(() => { return hierarchy.filter((entry) => !globalSubRoomIds.has(entry.roomId)); }, [hierarchy, globalSubRoomIds]); // Flatten hierarchy to include sub-rooms type HierarchyItem = | { type: 'hierarchy'; roomId: string } | { type: 'subroom'; roomId: string; room: Room; depth: number; isLast?: boolean } | { type: 'unjoined-subroom'; roomId: string; depth: number; isLast?: boolean }; const flattenedHierarchy = useMemo(() => { const items: HierarchyItem[] = []; const processedSubRooms = new Set(); const addSubRooms = (parentRoomId: string, baseDepth: number) => { const room = mx.getRoom(parentRoomId); if (!room || isSpace(room)) return; const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, ''); const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? []; // Filter to only joined sub-rooms that haven't been processed const joinedSubRooms = subRooms.filter(subRoomId => !processedSubRooms.has(subRoomId) && mx.getRoom(subRoomId) ); joinedSubRooms.forEach((subRoomId, index) => { processedSubRooms.add(subRoomId); const isLast = index === joinedSubRooms.length - 1; const subRoom = mx.getRoom(subRoomId); if (subRoom) { // Joined sub-room items.push({ type: 'subroom', roomId: subRoomId, room: subRoom, depth: baseDepth + 1, isLast }); // Recursively add sub-rooms of sub-rooms addSubRooms(subRoomId, baseDepth + 1); } }); }; filteredHierarchy.forEach((entry) => { items.push({ type: 'hierarchy', roomId: entry.roomId }); // Add sub-rooms after each non-space room const room = mx.getRoom(entry.roomId); if (room && !isSpace(room)) { addSubRooms(entry.roomId, 0); } }); return items; }, [mx, filteredHierarchy]); const virtualizer = useVirtualizer({ count: flattenedHierarchy.length, getScrollElement: () => scrollRef.current, estimateSize: () => 0, overscan: 10, }); const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => closedCategories.has(categoryId) ); const getToLink = (roomId: string) => getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); if (shouldShowForumLobby(space)) { return ( ); } return ( {tombstoneEvent && ( )} Lobby Message Search {virtualizer.getVirtualItems().map((vItem) => { const item = flattenedHierarchy[vItem.index]; if (!item) return null; // Unjoined sub-room item if (item.type === 'unjoined-subroom') { return ( ); } // Sub-room item (nested under parent) if (item.type === 'subroom') { const paddingLeft = '30px'; let treeIcon = ''; if (item.depth > 0) { treeIcon = item.isLast ? '╰' : '├'; } return (
{treeIcon && ( {treeIcon} )}
0} showAvatar={mDirects.has(item.roomId)} direct={mDirects.has(item.roomId)} linkPath={getToLink(item.roomId)} notificationMode={getRoomNotificationMode(notificationPreferences, item.room.roomId)} />
); } // Hierarchy item (room or space) const { roomId } = item; const room = mx.getRoom(roomId); if (!room) return null; if (isSpace(room)) { const categoryId = makeNavCategoryId(space.roomId, roomId); return (
{roomId === space.roomId ? 'Rooms' : room?.name}
); } return ( ); })}
); }