502 lines
19 KiB
TypeScript
502 lines
19 KiB
TypeScript
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<RectCords>();
|
|
|
|
const joinRules = useStateEvent(
|
|
space,
|
|
StateEvent.RoomJoinRules
|
|
)?.getContent<RoomJoinRulesEventContent>();
|
|
|
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
|
const cords = evt.currentTarget.getBoundingClientRect();
|
|
setMenuAnchor((currentState) => {
|
|
if (currentState) return undefined;
|
|
return cords;
|
|
});
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<PageNavHeader>
|
|
<Box alignItems="Center" grow="Yes" gap="300">
|
|
<Box grow="Yes" alignItems="Center" gap="100">
|
|
<Text size="H4" truncate>
|
|
{spaceName}
|
|
</Text>
|
|
{joinRules?.join_rule !== JoinRule.Public && <Icon src={Icons.Lock} size="50" />}
|
|
</Box>
|
|
<Box shrink="No">
|
|
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
|
|
<Icon src={Icons.VerticalDots} size="200" />
|
|
</IconButton>
|
|
</Box>
|
|
</Box>
|
|
</PageNavHeader>
|
|
{menuAnchor && (
|
|
<PopOut
|
|
anchor={menuAnchor}
|
|
position="Bottom"
|
|
align="End"
|
|
offset={6}
|
|
content={
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
returnFocusOnDeactivate: false,
|
|
onDeactivate: () => setMenuAnchor(undefined),
|
|
clickOutsideDeactivates: true,
|
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<SpaceOptionsMenu room={space} requestClose={() => setMenuAnchor(undefined)} />
|
|
</FocusTrap>
|
|
}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Box
|
|
style={{
|
|
padding: config.space.S200,
|
|
borderRadius: config.radii.R400,
|
|
borderWidth: config.borderWidth.B300,
|
|
}}
|
|
className={ContainerColor({ variant: 'Surface' })}
|
|
direction="Column"
|
|
gap="300"
|
|
>
|
|
<Box direction="Column" grow="Yes" gap="100">
|
|
<Text size="L400">Space Upgraded</Text>
|
|
<Text size="T200">This space has been replaced and is no longer active.</Text>
|
|
{joinState.status === AsyncStatus.Error && (
|
|
<Text className={BreakWord} style={{ color: color.Critical.Main }} size="T200">
|
|
{(joinState.error as any)?.message ?? 'Failed to join replacement space!'}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
<Box direction="Column" shrink="No">
|
|
{replacementRoom?.getMyMembership() === Membership.Join ||
|
|
joinState.status === AsyncStatus.Success ? (
|
|
<Button onClick={handleOpen} size="300" variant="Success" fill="Solid" radii="300">
|
|
<Text size="B300">Open New Space</Text>
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
onClick={handleJoin}
|
|
size="300"
|
|
variant="Primary"
|
|
fill="Solid"
|
|
radii="300"
|
|
before={
|
|
joinState.status === AsyncStatus.Loading && (
|
|
<Spinner size="100" variant="Primary" fill="Solid" />
|
|
)
|
|
}
|
|
disabled={joinState.status === AsyncStatus.Loading}
|
|
>
|
|
<Text size="B300">Join New Space</Text>
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export function Space() {
|
|
const mx = useMatrixClient();
|
|
const space = useSpace();
|
|
useNavToActivePathMapper(space.roomId);
|
|
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
|
|
const scrollRef = useRef<HTMLDivElement>(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<string>();
|
|
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<string>();
|
|
|
|
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 (
|
|
<PageNav className={forumBoardCss.ForumPageNav}>
|
|
<SpaceHeader />
|
|
<Box grow="Yes" direction="Column" style={{ minHeight: 0, overflow: 'hidden' }}>
|
|
<ForumFeedSidebar />
|
|
</Box>
|
|
</PageNav>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PageNav>
|
|
<SpaceHeader />
|
|
<PageNavContent scrollRef={scrollRef}>
|
|
<Box direction="Column" gap="300">
|
|
{tombstoneEvent && (
|
|
<SpaceTombstone
|
|
roomId={space.roomId}
|
|
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
|
/>
|
|
)}
|
|
<NavCategory>
|
|
<NavItem variant="Background" radii="400" aria-selected={lobbySelected}>
|
|
<NavLink to={getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, space.roomId))}>
|
|
<NavItemContent>
|
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
|
<Avatar size="200" radii="400">
|
|
<Icon src={Icons.Flag} size="100" filled={lobbySelected} />
|
|
</Avatar>
|
|
<Box as="span" grow="Yes">
|
|
<Text as="span" size="Inherit" truncate>
|
|
Lobby
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
</NavItemContent>
|
|
</NavLink>
|
|
</NavItem>
|
|
<NavItem variant="Background" radii="400" aria-selected={searchSelected}>
|
|
<NavLink to={getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))}>
|
|
<NavItemContent>
|
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
|
<Avatar size="200" radii="400">
|
|
<Icon src={Icons.Search} size="100" filled={searchSelected} />
|
|
</Avatar>
|
|
<Box as="span" grow="Yes">
|
|
<Text as="span" size="Inherit" truncate>
|
|
Message Search
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
</NavItemContent>
|
|
</NavLink>
|
|
</NavItem>
|
|
<PluginNavSlot location="channel-list" />
|
|
</NavCategory>
|
|
<NavCategory
|
|
style={{
|
|
height: virtualizer.getTotalSize(),
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{virtualizer.getVirtualItems().map((vItem) => {
|
|
const item = flattenedHierarchy[vItem.index];
|
|
if (!item) return null;
|
|
|
|
// Unjoined sub-room item
|
|
if (item.type === 'unjoined-subroom') {
|
|
return (
|
|
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
|
|
<UnjoinedSubRoomItem roomId={item.roomId} depth={item.depth} isLast={item.isLast} />
|
|
</VirtualTile>
|
|
);
|
|
}
|
|
|
|
// Sub-room item (nested under parent)
|
|
if (item.type === 'subroom') {
|
|
const paddingLeft = '30px';
|
|
|
|
let treeIcon = '';
|
|
if (item.depth > 0) {
|
|
treeIcon = item.isLast ? '╰' : '├';
|
|
}
|
|
|
|
return (
|
|
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
|
|
<div style={{ paddingLeft, display: 'flex', alignItems: 'center', minHeight: '1.5rem' }}>
|
|
{treeIcon && (
|
|
<span style={{
|
|
paddingRight: '2px',
|
|
opacity: 0.5,
|
|
fontFamily: 'monospace',
|
|
fontSize: '14px',
|
|
lineHeight: '1',
|
|
userSelect: 'none'
|
|
}}>
|
|
{treeIcon}
|
|
</span>
|
|
)}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<RoomNavItem
|
|
room={item.room}
|
|
selected={selectedRoomId === item.roomId}
|
|
hideIcon={item.depth > 0}
|
|
showAvatar={mDirects.has(item.roomId)}
|
|
direct={mDirects.has(item.roomId)}
|
|
linkPath={getToLink(item.roomId)}
|
|
notificationMode={getRoomNotificationMode(notificationPreferences, item.room.roomId)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</VirtualTile>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<VirtualTile
|
|
virtualItem={vItem}
|
|
key={vItem.index}
|
|
ref={virtualizer.measureElement}
|
|
>
|
|
<div style={{ paddingTop: vItem.index === 0 ? undefined : config.space.S400 }}>
|
|
<NavCategoryHeader>
|
|
<RoomNavCategoryButton
|
|
data-category-id={categoryId}
|
|
onClick={handleCategoryClick}
|
|
closed={closedCategories.has(categoryId)}
|
|
>
|
|
{roomId === space.roomId ? 'Rooms' : room?.name}
|
|
</RoomNavCategoryButton>
|
|
</NavCategoryHeader>
|
|
</div>
|
|
</VirtualTile>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
|
|
<RoomNavItem
|
|
room={room}
|
|
selected={selectedRoomId === roomId}
|
|
showAvatar={mDirects.has(roomId)}
|
|
direct={mDirects.has(roomId)}
|
|
linkPath={getToLink(roomId)}
|
|
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
|
|
/>
|
|
</VirtualTile>
|
|
);
|
|
})}
|
|
</NavCategory>
|
|
</Box>
|
|
</PageNavContent>
|
|
</PageNav>
|
|
);
|
|
}
|