chat/src/app/pages/client/space/Space.tsx
Ajay Bura fa575fc09d Support room version 12 (#2399)
* WIP - support room version 12

* add room creators hook

* revert changes from powerlevels

* improve use room creators hook

* add hook to get dm users

* add options to add creators in create room/space

* add member item component in member drawer

* remove unused import

* extract member drawer header component

* get room creators as set only if room version support them

* add room permissions hook

* support room v12 creators power

* make predecessor event id optional

* add info about founders in permissions

* allow to create infinite powers to room creators

* allow everyone with permission to create infinite power

* handle additional creators in room upgrade

* add option to follow space tombstone
2025-08-21 21:24:05 +02:00

528 lines
18 KiB
TypeScript

import React, {
MouseEventHandler,
forwardRef,
useCallback,
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 } 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 { openInviteUser } from '../../../../client/action/navigation';
import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { markAsRead } from '../../../../client/action/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';
type SpaceMenuProps = {
room: Room;
requestClose: () => void;
};
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ 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 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 = () => {
openInviteUser(room.roomId);
requestClose();
};
const handleRoomSettings = () => {
openSpaceSettings(room.roomId);
requestClose();
};
const handleOpenTimeline = () => {
navigateRoom(room.roomId);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleInvite}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
disabled={!canInvite}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Invite
</Text>
</MenuItem>
<MenuItem
onClick={handleCopyLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Copy Link
</Text>
</MenuItem>
<MenuItem
onClick={handleRoomSettings}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Space Settings
</Text>
</MenuItem>
{developerTools && (
<MenuItem
onClick={handleOpenTimeline}
size="300"
after={<Icon size="100" src={Icons.Terminal} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Event Timeline
</Text>
</MenuItem>
)}
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Leave Space
</Text>
</MenuItem>
{promptLeave && (
<LeaveSpacePrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
);
});
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>
<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,
}}
>
<SpaceMenu room={space} requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
)}
</>
);
}
type SpaceTombstoneProps = { roomId: string; replacementRoomId: string };
export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) {
const mx = useMatrixClient();
const { navigateRoom } = 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) navigateRoom(replacementRoom.roomId);
if (joinState.status === AsyncStatus.Success) navigateRoom(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]
)
);
const virtualizer = useVirtualizer({
count: hierarchy.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));
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>
</NavCategory>
<NavCategory
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const { roomId } = hierarchy[vItem.index] ?? {};
const room = mx.getRoom(roomId);
if (!room) return null;
if (room.isSpaceRoom()) {
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>
);
}