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

@@ -43,6 +43,8 @@ import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout';
import { ForumSpaceLayout } from '../features/forum/ForumSpaceLayout';
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
import { Notifications, Inbox, Invites } from './client/inbox';
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
@@ -236,15 +238,9 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
path={SPACE_PATH}
element={
<RouteSpaceProvider>
<PageRoot
nav={
<MobileFriendlyPageNav path={SPACE_PATH}>
<Space />
</MobileFriendlyPageNav>
}
>
<AnimatedOutlet />
</PageRoot>
<ForumSpaceLayout>
<ForumAwareSpaceLayout />
</ForumSpaceLayout>
</RouteSpaceProvider>
}
>

View File

@@ -5,7 +5,7 @@ import { mDirectAtom } from '../../../state/mDirectList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useOrphanRooms } from '../../../state/hooks/roomList';
import { StateEvent } from '../../../../types/matrix/room';
import { RoomType, StateEvent } from '../../../../types/matrix/room';
export const useHomeRooms = () => {
const mx = useMatrixClient();
@@ -15,6 +15,25 @@ export const useHomeRooms = () => {
// Filter out rooms that are sub-rooms of ANY room (not just orphan rooms)
const rooms = useMemo(() => {
const isSpaceLikeRoom = (roomId: string): boolean => {
const room = mx.getRoom(roomId);
if (!room) return false;
if (typeof room.isSpaceRoom === 'function' && room.isSpaceRoom()) {
return true;
}
const roomKindEvent = room.currentState.getStateEvents(StateEvent.PaarrotRoomKind, '');
const roomKind = roomKindEvent?.getContent<{ kind?: string }>()?.kind;
if (roomKind === 'forum_space') {
return true;
}
const createEvent = room.currentState.getStateEvents(StateEvent.RoomCreate, '');
const roomType = createEvent?.getContent<{ type?: string }>()?.type;
return roomType === RoomType.Space || roomType === RoomType.Forum;
};
// Build a set of all sub-room IDs from ALL joined rooms
const allSubRoomIds = new Set<string>();
mx.getRooms().forEach((room) => {
@@ -24,7 +43,7 @@ export const useHomeRooms = () => {
});
// Return only rooms that are not sub-rooms of another room
return orphanRooms.filter((roomId) => !allSubRoomIds.has(roomId));
return orphanRooms.filter((roomId) => !allSubRoomIds.has(roomId) && !isSpaceLikeRoom(roomId));
}, [mx, orphanRooms]);
return rooms;

View File

@@ -6,7 +6,7 @@ import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useSpace } from '../../../hooks/useSpace';
import { getAllParents, getSpaceChildren } from '../../../utils/room';
import { getAllParents, getSpaceChildren, isSpace } from '../../../utils/room';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
@@ -38,7 +38,7 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
);
}
if (developerTools && room.isSpaceRoom() && room.roomId === space.roomId) {
if (developerTools && isSpace(room) && room.roomId === space.roomId) {
// allow to view space timeline
return (
<RoomProvider key={room.roomId} value={room}>

View File

@@ -85,165 +85,8 @@ 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<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 markRoomsAsRead = useMarkRoomsAsRead(mx);
const [invitePrompt, setInvitePrompt] = useState(false);
const allChild = useSpaceChildren(
allRoomsAtom,
room.roomId,
useRecursiveChildScopeFactory(mx, roomToParents)
);
const unread = useRoomsUnread(allChild, roomToUnreadAtom);
const handleMarkAsRead = () => {
markRoomsAsRead(allChild, 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 (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{invitePrompt && room && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
<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"
aria-pressed={invitePrompt}
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>
);
});
import { isSpace, shouldShowForumLobby } from '../../../utils/room';
import { SpaceOptionsMenu } from '../../../features/space/SpaceOptionsMenu';
function SpaceHeader() {
const space = useSpace();
@@ -298,7 +141,7 @@ function SpaceHeader() {
escapeDeactivates: stopPropagation,
}}
>
<SpaceMenu room={space} requestClose={() => setMenuAnchor(undefined)} />
<SpaceOptionsMenu room={space} requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
@@ -455,7 +298,7 @@ export function Space() {
const addSubRooms = (parentRoomId: string, baseDepth: number) => {
const room = mx.getRoom(parentRoomId);
if (!room || room.isSpaceRoom()) return;
if (!room || isSpace(room)) return;
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? [];
@@ -484,7 +327,7 @@ export function Space() {
// Add sub-rooms after each non-space room
const room = mx.getRoom(entry.roomId);
if (room && !room.isSpaceRoom()) {
if (room && !isSpace(room)) {
addSubRooms(entry.roomId, 0);
}
});
@@ -506,6 +349,10 @@ export function Space() {
const getToLink = (roomId: string) =>
getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId));
if (shouldShowForumLobby(space)) {
return null;
}
return (
<PageNav>
<SpaceHeader />
@@ -616,7 +463,7 @@ export function Space() {
const room = mx.getRoom(roomId);
if (!room) return null;
if (room.isSpaceRoom()) {
if (isSpace(room)) {
const categoryId = makeNavCategoryId(space.roomId, roomId);
return (