import React, { MouseEventHandler, forwardRef, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { useAtom, useAtomValue } from 'jotai'; import { Avatar, Box, Button, Icon, IconButton, Icons, Line, Menu, MenuItem, PopOut, RectCords, Spinner, Text, color, config, toRem, } from 'folds'; import { useVirtualizer } from '@tanstack/react-virtual'; import { JoinRule, Room } 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 } from '../../../features/room-nav'; import { makeNavCategoryId, getNavCategoryIdParts } 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 { roomToChildrenAtom } from '../../../state/room/roomToChildren'; import { markAsRead } from '../../../utils/notifications'; 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 { 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'; type SpaceMenuProps = { room: Room; requestClose: () => void; }; const SpaceMenu = forwardRef(({ room, requestClose }, ref) => { const mx = useMatrixClient(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [developerTools] = useSetting(settingsAtom, 'developerTools'); const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevels(room); const creators = useRoomCreators(room); const permissions = useRoomPermissions(creators, powerLevels); const canInvite = permissions.action('invite', mx.getSafeUserId()); const openSpaceSettings = useOpenSpaceSettings(); const { navigateRoom } = useRoomNavigate(); const [invitePrompt, setInvitePrompt] = useState(false); const allChild = useSpaceChildren( allRoomsAtom, room.roomId, useRecursiveChildScopeFactory(mx, roomToParents) ); const unread = useRoomsUnread(allChild, roomToUnreadAtom); const handleMarkAsRead = () => { allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); requestClose(); }; const handleCopyLink = () => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); requestClose(); }; const handleInvite = () => { setInvitePrompt(true); }; const handleRoomSettings = () => { openSpaceSettings(room.roomId); requestClose(); }; const handleOpenTimeline = () => { navigateRoom(room.roomId); requestClose(); }; return ( {invitePrompt && room && ( { setInvitePrompt(false); requestClose(); }} /> )} } radii="300" disabled={!unread} > Mark as Read } radii="300" aria-pressed={invitePrompt} disabled={!canInvite} > Invite } radii="300" > Copy Link } radii="300" > Space Settings {developerTools && ( } radii="300" > Event Timeline )} {(promptLeave, setPromptLeave) => ( <> setPromptLeave(true)} variant="Critical" fill="None" size="300" after={} radii="300" aria-pressed={promptLeave} > Leave Space {promptLeave && ( setPromptLeave(false)} /> )} )} ); }); 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 roomToParents = useAtomValue(roomToParentsAtom); const roomToChildren = useAtomValue(roomToChildrenAtom); 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 closedCategoriesCache = useRef(new Map()); const ancestorsCollapsedCache = useRef(new Map()); useEffect(() => { closedCategoriesCache.current.clear(); ancestorsCollapsedCache.current.clear(); }, [closedCategories, roomToParents, getRoom]); /** * Recursively checks if a given parentId (or all its ancestors) is in a closed category. * * @param spaceId - The root space ID. * @param parentId - The parent space ID to start the check from. * @param previousId - The last ID checked, only used to ignore root collapse state. * @returns True if parentId or all ancestors is in a closed category. */ const getInClosedCategories = useCallback( (spaceId: string, parentId: string, previousId?: string): boolean => { const categoryId = makeNavCategoryId(spaceId, parentId); if (closedCategoriesCache.current.has(categoryId)) { return closedCategoriesCache.current.get(categoryId); } // Ignore root space being collapsed if in a subspace, // this is due to many spaces dumping all rooms in the top-level space. if (parentId === spaceId) { if (previousId) { if (getRoom(previousId)?.isSpaceRoom()) { closedCategoriesCache.current.set(categoryId, false); return false; } } } if (closedCategories.has(categoryId)) { closedCategoriesCache.current.set(categoryId, true); return true; } const parentParentIds = roomToParents.get(parentId); if (!parentParentIds || parentParentIds.size === 0) { closedCategoriesCache.current.set(categoryId, false); return false; } let anyOpen = false; parentParentIds.forEach((id) => { if (!getInClosedCategories(spaceId, id, parentId)) { anyOpen = true; } }); closedCategoriesCache.current.set(categoryId, !anyOpen); return !anyOpen; }, [closedCategories, getRoom, roomToParents] ); /** * Recursively checks if the given room or any of its descendants should be visible. * * @param roomId - The room ID to check. * @returns True if the room or any descendant should be visible. */ const getContainsShowRoom = useCallback( (roomId: string): boolean => { if (roomToUnread.has(roomId) || roomId === selectedRoomId) { return true; } const childIds = roomToChildren.get(roomId); if (!childIds || childIds.size === 0) { return false; } let visible = false; childIds.forEach((id) => { if (getContainsShowRoom(id)) { visible = true; } }); return visible; }, [roomToUnread, selectedRoomId, roomToChildren] ); /** * Determines whether all parent categories are collapsed. * * @param spaceId - The root space ID. * @param roomId - The room ID to start the check from. * @returns True if every parent category is collapsed; false otherwise. */ const getAllAncestorsCollapsed = (spaceId: string, roomId: string): boolean => { const categoryId = makeNavCategoryId(spaceId, roomId); if (ancestorsCollapsedCache.current.has(categoryId)) { return ancestorsCollapsedCache.current.get(categoryId); } const parentIds = roomToParents.get(roomId); if (!parentIds || parentIds.size === 0) { ancestorsCollapsedCache.current.set(categoryId, false); return false; } let allCollapsed = true; parentIds.forEach((id) => { if (!getInClosedCategories(spaceId, id, roomId)) { allCollapsed = false; } }); ancestorsCollapsedCache.current.set(categoryId, allCollapsed); return allCollapsed; }; const hierarchy = useSpaceJoinedHierarchy( space.roomId, getRoom, useCallback( (parentId, roomId) => { if (!getInClosedCategories(space.roomId, parentId, roomId)) { return false; } if (getContainsShowRoom(roomId)) return false; return true; }, [getContainsShowRoom, getInClosedCategories, space.roomId] ), useCallback( (sId) => getInClosedCategories(space.roomId, sId), [getInClosedCategories, space.roomId] ) ); const virtualizer = useVirtualizer({ count: hierarchy.length, getScrollElement: () => scrollRef.current, estimateSize: () => 0, overscan: 10, }); const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => { const collapsed = closedCategories.has(categoryId); const [spaceId, roomId] = getNavCategoryIdParts(categoryId); // Only prevent collapsing if all parents are collapsed const toggleable = !getAllAncestorsCollapsed(spaceId, roomId); if (toggleable) { return collapsed; } return !collapsed; }); const getToLink = (roomId: string) => getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); const getCategoryPadding = (depth: number): string | undefined => { if (depth === 0) return undefined; if (depth === 1) return config.space.S400; return config.space.S200; }; return ( {tombstoneEvent && ( )} Lobby Message Search {virtualizer.getVirtualItems().map((vItem) => { const { roomId, depth } = hierarchy[vItem.index] ?? {}; const room = mx.getRoom(roomId); if (!room) return null; const paddingLeft = `calc((${depth} - 1) * ${config.space.S200})`; if (room.isSpaceRoom()) { const categoryId = makeNavCategoryId(space.roomId, roomId); const closed = getInClosedCategories(space.roomId, roomId); const toggleable = !getAllAncestorsCollapsed(space.roomId, roomId); const paddingTop = getCategoryPadding(depth); return (
{roomId === space.roomId ? 'Rooms' : room?.name}
); } return (
); })}
); }