feat: Integrate forum layout support across various components and enhance room handling logic

This commit is contained in:
2026-07-06 01:06:20 +10:00
parent d1626e3585
commit c57797ffe4
62 changed files with 6876 additions and 385 deletions

View File

@@ -40,7 +40,8 @@ import { getSpaceRoomPath } from '../../pages/pathUtils';
import { StateEvent } from '../../../types/matrix/room';
import { CanDropCallback, useDnDMonitor } from './DnD';
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
import { getStateEvent } from '../../utils/room';
import { getStateEvent, shouldShowForumLobby } from '../../utils/room';
import { ForumSpaceShell } from '../forum/ForumSpaceShell';
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
import {
makeCinnySpacesContent,
@@ -149,7 +150,19 @@ const useCanDropLobbyItem = (
return canDrop;
};
function ForumSpaceLobby() {
return <ForumSpaceShell />;
}
export function Lobby() {
const space = useSpace();
if (shouldShowForumLobby(space)) {
return <ForumSpaceLobby />;
}
return <SpaceCardLobby />;
}
function SpaceCardLobby() {
const navigate = useNavigate();
const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom);

View File

@@ -1,4 +1,4 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react';
import React, { MouseEventHandler, forwardRef, useEffect, useMemo, useRef, useState } from 'react';
import {
Avatar,
Box,
@@ -38,6 +38,12 @@ import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { AddExistingModal } from '../add-existing';
import { SpeechBubble } from '../../components/speech-bubble/SpeechBubble';
import { SpaceOptionsMenu, type OpenAddExistingOptions } from '../space/SpaceOptionsMenu';
import type { HierarchyItemSpace } from '../../hooks/useSpaceHierarchy';
import { StateEvent } from '../../../types/matrix/room';
import { useForumBoardContextOptionally } from '../forum/ForumBoardContext';
type LobbyMenuProps = {
powerLevels: IPowerLevels;
@@ -137,14 +143,19 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
type LobbyHeaderProps = {
showProfile?: boolean;
/** Space name on the left + add room/space + ⋮ menu (forum toolbar). */
showSpaceToolbar?: boolean;
powerLevels: IPowerLevels;
};
export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
export function LobbyHeader({ showProfile, showSpaceToolbar, powerLevels }: LobbyHeaderProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const space = useSpace();
const creators = useRoomCreators(space);
const spacePermissions = useRoomPermissions(creators, powerLevels);
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [addExisting, setAddExisting] = useState<OpenAddExistingOptions | null>(null);
const screenSize = useScreenSizeContext();
const name = useRoomName(space);
@@ -153,10 +164,192 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const forumBoard = useForumBoardContextOptionally();
const menuWrapperRef = useRef<HTMLDivElement | null>(null);
const menuTriggerElRef = useRef<HTMLButtonElement | null>(null);
const addParent = useMemo(() => {
const rootId = space.roomId;
if (!forumBoard) {
return { roomId: rootId, showAddSpace: true };
}
if (forumBoard.query.category) {
const section = forumBoard.sections.find((s) => s.title === forumBoard.query.category);
if (section?.spaceId) {
return { roomId: section.spaceId, showAddSpace: false };
}
}
return { roomId: rootId, showAddSpace: true };
}, [forumBoard, space.roomId]);
const addParentItem: HierarchyItemSpace = useMemo(
() => ({
roomId: addParent.roomId,
content: { via: [] },
ts: 0,
space: true,
parentId: addParent.roomId === space.roomId ? undefined : space.roomId,
}),
[addParent.roomId, space.roomId]
);
const canEditChildren = Boolean(
spacePermissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
);
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
menuTriggerElRef.current = evt.currentTarget;
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
useEffect(() => {
if (!showSpaceToolbar || !menuAnchor || addExisting) return;
let outsideTimer: number | undefined;
const clearOutsideTimer = () => {
if (outsideTimer) {
window.clearTimeout(outsideTimer);
outsideTimer = undefined;
}
};
const closeMenu = () => {
clearOutsideTimer();
setMenuAnchor(undefined);
};
const isInside = (node: Node | null) => {
if (!node) return false;
return Boolean(
menuWrapperRef.current?.contains(node) || menuTriggerElRef.current?.contains(node)
);
};
const handlePointerDown = (evt: MouseEvent | TouchEvent) => {
const node = evt.target as Node | null;
if (!isInside(node)) {
closeMenu();
}
};
const handlePointerMove = (evt: PointerEvent | MouseEvent) => {
const node = evt.target as Node | null;
const inside = isInside(node);
if (inside) {
clearOutsideTimer();
return;
}
if (!outsideTimer) {
outsideTimer = window.setTimeout(closeMenu, 3000);
}
};
document.addEventListener('mousedown', handlePointerDown, true);
document.addEventListener('touchstart', handlePointerDown, true);
document.addEventListener('pointermove', handlePointerMove, true);
document.addEventListener('mousemove', handlePointerMove, true);
return () => {
clearOutsideTimer();
document.removeEventListener('mousedown', handlePointerDown, true);
document.removeEventListener('touchstart', handlePointerDown, true);
document.removeEventListener('pointermove', handlePointerMove, true);
document.removeEventListener('mousemove', handlePointerMove, true);
};
}, [addExisting, menuAnchor, showSpaceToolbar]);
if (showSpaceToolbar) {
return (
<>
<PageHeader outlined>
<Box grow="Yes" alignItems="Center" gap="300">
<Avatar size="300" shrink="No">
<RoomAvatar
roomId={space.roomId}
src={avatarUrl}
alt={name}
renderFallback={() => <Text size="H4">{nameInitials(name)}</Text>}
/>
</Avatar>
<Text size="H3" truncate style={{ minWidth: 0 }}>
{name}
</Text>
<Box grow="Yes" />
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>More Options</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
onClick={handleOpenMenu}
ref={triggerRef}
aria-pressed={!!menuAnchor}
variant="SurfaceVariant"
fill="None"
size="300"
radii="300"
>
<Icon size="400" src={Icons.VerticalDots} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
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,
}}
>
<div ref={menuWrapperRef}>
<SpeechBubble variant="menu" tail="top" tailX="calc(100% - 22px)">
<SpaceOptionsMenu
room={space}
requestClose={() => setMenuAnchor(undefined)}
onOpenAddExisting={(options) => {
setAddExisting(options);
setMenuAnchor(undefined);
}}
forumAdd={
forumBoard && canEditChildren
? {
item: addParentItem,
showAddSpace: addParent.showAddSpace,
}
: undefined
}
/>
</SpeechBubble>
</div>
</FocusTrap>
}
/>
</Box>
</PageHeader>
{addExisting && (
<AddExistingModal
parentId={addExisting.parentId}
space={addExisting.space}
requestClose={() => setAddExisting(null)}
/>
)}
</>
);
}
return (
<PageHeader className={showProfile ? undefined : css.Header} balance>
<Box grow="Yes" alignItems="Center" gap="200">

View File

@@ -0,0 +1,171 @@
import React, { MouseEventHandler, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { Box, Chip, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config } from 'folds';
import type { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
import { AddExistingModal } from '../add-existing';
import { stopPropagation } from '../../utils/keyboard';
function AddRoomButton({ item, roomLabel }: { item: HierarchyItem; roomLabel?: string }) {
const [cords, setCords] = useState<RectCords>();
const openCreateRoomModal = useOpenCreateRoomModal();
const [addExisting, setAddExisting] = useState(false);
const handleAddRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateRoom = () => {
openCreateRoomModal(item.roomId);
setCords(undefined);
};
const handleAddExisting = () => {
setAddExisting(true);
setCords(undefined);
};
return (
<>
<PopOut
anchor={cords}
position="Bottom"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
radii="300"
variant="Primary"
fill="None"
onClick={handleCreateRoom}
>
<Text size="T300">New Room</Text>
</MenuItem>
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
<Text size="T300">Existing Room</Text>
</MenuItem>
</Menu>
</FocusTrap>
}
>
<Chip
variant="Primary"
radii="Pill"
before={<Icon src={Icons.Plus} size="50" />}
onClick={handleAddRoom}
aria-pressed={!!cords}
>
<Text size="B300">{roomLabel ?? 'Add Room'}</Text>
</Chip>
</PopOut>
{addExisting && (
<AddExistingModal parentId={item.roomId} requestClose={() => setAddExisting(false)} />
)}
</>
);
}
function AddSpaceButton({ item, spaceLabel }: { item: HierarchyItem; spaceLabel?: string }) {
const [cords, setCords] = useState<RectCords>();
const openCreateSpaceModal = useOpenCreateSpaceModal();
const [addExisting, setAddExisting] = useState(false);
const handleAddSpace: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateSpace = () => {
openCreateSpaceModal(item.roomId as string);
setCords(undefined);
};
const handleAddExisting = () => {
setAddExisting(true);
setCords(undefined);
};
return (
<>
<PopOut
anchor={cords}
position="Bottom"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
radii="300"
variant="Primary"
fill="None"
onClick={handleCreateSpace}
>
<Text size="T300">New Space</Text>
</MenuItem>
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
<Text size="T300">Existing Space</Text>
</MenuItem>
</Menu>
</FocusTrap>
}
>
<Chip
variant="SurfaceVariant"
radii="Pill"
before={<Icon src={Icons.Plus} size="50" />}
onClick={handleAddSpace}
aria-pressed={!!cords}
>
<Text size="B300">{spaceLabel ?? 'Add Space'}</Text>
</Chip>
</PopOut>
{addExisting && (
<AddExistingModal space parentId={item.roomId} requestClose={() => setAddExisting(false)} />
)}
</>
);
}
type SpaceAddControlsProps = {
item: HierarchyItem;
/** Show “Add Space” (root / top-level space only). */
showAddSpace?: boolean;
roomLabel?: string;
spaceLabel?: string;
};
/** Add room / add sub-space chips from the space lobby. */
export function SpaceAddControls({
item,
showAddSpace = false,
roomLabel,
spaceLabel,
}: SpaceAddControlsProps) {
return (
<Box direction="Row" alignItems="Center" gap="200">
<AddRoomButton item={item} roomLabel={roomLabel} />
{showAddSpace && <AddSpaceButton item={item} spaceLabel={spaceLabel} />}
</Box>
);
}

View File

@@ -73,7 +73,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
const subspaces = useMemo(() => {
const s: Map<string, IHierarchyRoom> = new Map();
rooms.forEach((r) => {
if (r.room_type === RoomType.Space) {
if (r.room_type === RoomType.Space || r.room_type === RoomType.Forum) {
s.set(r.room_id, r);
}
});

View File

@@ -33,9 +33,7 @@ import { useDraggableItem } from './DnD';
import { stopPropagation } from '../../utils/keyboard';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
import { AddExistingModal } from '../add-existing';
import { SpaceAddControls } from './SpaceAddControls';
function SpaceProfileLoading() {
return (
@@ -240,141 +238,6 @@ function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileP
);
}
function AddRoomButton({ item }: { item: HierarchyItem }) {
const [cords, setCords] = useState<RectCords>();
const openCreateRoomModal = useOpenCreateRoomModal();
const [addExisting, setAddExisting] = useState(false);
const handleAddRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateRoom = () => {
openCreateRoomModal(item.roomId);
setCords(undefined);
};
const handleAddExisting = () => {
setAddExisting(true);
setCords(undefined);
};
return (
<PopOut
anchor={cords}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
radii="300"
variant="Primary"
fill="None"
onClick={handleCreateRoom}
>
<Text size="T300">New Room</Text>
</MenuItem>
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
<Text size="T300">Existing Room</Text>
</MenuItem>
</Menu>
</FocusTrap>
}
>
<Chip
variant="Primary"
radii="Pill"
before={<Icon src={Icons.Plus} size="50" />}
onClick={handleAddRoom}
aria-pressed={!!cords}
>
<Text size="B300">Add Room</Text>
</Chip>
{addExisting && (
<AddExistingModal parentId={item.roomId} requestClose={() => setAddExisting(false)} />
)}
</PopOut>
);
}
function AddSpaceButton({ item }: { item: HierarchyItem }) {
const [cords, setCords] = useState<RectCords>();
const openCreateSpaceModal = useOpenCreateSpaceModal();
const [addExisting, setAddExisting] = useState(false);
const handleAddSpace: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateSpace = () => {
openCreateSpaceModal(item.roomId as any);
setCords(undefined);
};
const handleAddExisting = () => {
setAddExisting(true);
setCords(undefined);
};
return (
<PopOut
anchor={cords}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
radii="300"
variant="Primary"
fill="None"
onClick={handleCreateSpace}
>
<Text size="T300">New Space</Text>
</MenuItem>
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
<Text size="T300">Existing Space</Text>
</MenuItem>
</Menu>
</FocusTrap>
}
>
<Chip
variant="SurfaceVariant"
radii="Pill"
before={<Icon src={Icons.Plus} size="50" />}
onClick={handleAddSpace}
aria-pressed={!!cords}
>
<Text size="B300">Add Space</Text>
</Chip>
{addExisting && (
<AddExistingModal space parentId={item.roomId} requestClose={() => setAddExisting(false)} />
)}
</PopOut>
);
}
type SpaceItemCardProps = {
summary: IHierarchyRoom | undefined;
loading?: boolean;
@@ -484,8 +347,7 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
</Box>
{space && canEditChild && (
<Box shrink="No" alignItems="Inherit" gap="200">
<AddRoomButton item={item} />
{item.parentId === undefined && <AddSpaceButton item={item} />}
<SpaceAddControls item={item} showAddSpace={item.parentId === undefined} />
</Box>
)}
</Box>