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

@@ -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">