feat: Implement forum feed layout and refactor space navigation logic to support new routing structure

This commit is contained in:
2026-07-06 15:35:21 +10:00
parent 09db721b7e
commit 3ee95a295d
17 changed files with 296 additions and 135 deletions

View File

@@ -1,15 +1,31 @@
import React from 'react'; import React, { useMemo } from 'react';
import { PageRoot } from '../../components/page'; import { PageRoot } from '../../components/page';
import { AnimatedOutlet } from '../../components/AnimatedOutlet'; import { AnimatedOutlet } from '../../components/AnimatedOutlet';
import { MobileFriendlyPageNav } from '../../pages/MobileFriendly'; import { MobileFriendlyPageNav } from '../../pages/MobileFriendly';
import { SPACE_PATH } from '../../pages/paths'; import { SPACE_PATH } from '../../pages/paths';
import { Space } from '../../pages/client/space/Space'; import { Space } from '../../pages/client/space/Space';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useSpace } from '../../hooks/useSpace';
import { isForumContainer, shouldShowForumLobby } from '../../utils/room';
import { ForumBoardProvider } from './ForumBoardContext';
/** /**
* Space sidebar + outlet. Forum spaces render ForumFeedSidebar in Space.tsx. * Space sidebar + outlet. Forum spaces render ForumFeedSidebar in Space.tsx.
*/ */
export function ForumAwareSpaceLayout() { export function ForumAwareSpaceLayout() {
return ( const mx = useMatrixClient();
const space = useSpace();
const selectedRoomId = useSelectedRoom();
const scope = useMemo(() => {
const room = selectedRoomId ? mx.getRoom(selectedRoomId) : null;
const topicRoomId =
room && room.roomId !== space.roomId && !isForumContainer(room) ? room.roomId : undefined;
return { forumSpaceId: space.roomId, topicRoomId };
}, [mx, selectedRoomId, space.roomId]);
const layout = (
<PageRoot <PageRoot
nav={ nav={
<MobileFriendlyPageNav path={SPACE_PATH}> <MobileFriendlyPageNav path={SPACE_PATH}>
@@ -20,4 +36,14 @@ export function ForumAwareSpaceLayout() {
<AnimatedOutlet /> <AnimatedOutlet />
</PageRoot> </PageRoot>
); );
if (!shouldShowForumLobby(space)) {
return layout;
}
return (
<ForumBoardProvider forumSpace={space} scope={scope}>
{layout}
</ForumBoardProvider>
);
} }

View File

@@ -0,0 +1,6 @@
import { ForumBoardDetail } from './ForumBoardDetail';
/** Forum post feed in the main pane (sidebar holds filters + post list). */
export function ForumFeedPage() {
return <ForumBoardDetail />;
}

View File

@@ -1,30 +1,8 @@
import React, { ReactNode, useMemo } from 'react'; import React, { ReactNode } from 'react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useSpace } from '../../hooks/useSpace';
import { isForumContainer, shouldShowForumLobby } from '../../utils/room';
import { ForumBoardProvider } from './ForumBoardContext';
/** Shares forum board state between space sidebar and lobby/room outlet. */ /**
* @deprecated Forum board context now lives in ForumAwareSpaceLayout.
*/
export function ForumSpaceLayout({ children }: { children: ReactNode }) { export function ForumSpaceLayout({ children }: { children: ReactNode }) {
const mx = useMatrixClient(); return children;
const space = useSpace();
const selectedRoomId = useSelectedRoom();
const scope = useMemo(() => {
const room = selectedRoomId ? mx.getRoom(selectedRoomId) : null;
const topicRoomId =
room && room.roomId !== space.roomId && !isForumContainer(room) ? room.roomId : undefined;
return { forumSpaceId: space.roomId, topicRoomId };
}, [mx, selectedRoomId, space.roomId]);
if (!shouldShowForumLobby(space)) {
return children;
}
return (
<ForumBoardProvider forumSpace={space} scope={scope}>
{children}
</ForumBoardProvider>
);
} }

View File

@@ -3,6 +3,7 @@ export { ForumBoardDetail } from './ForumBoardDetail';
export { ForumSortIcons } from './forumLucideIcons'; export { ForumSortIcons } from './forumLucideIcons';
export { ForumSpaceShell } from './ForumSpaceShell'; export { ForumSpaceShell } from './ForumSpaceShell';
export { ForumThreadDetail } from './ForumThreadDetail'; export { ForumThreadDetail } from './ForumThreadDetail';
export { ForumFeedPage } from './ForumFeedPage';
export { ForumAwareSpaceLayout } from './ForumAwareSpaceLayout'; export { ForumAwareSpaceLayout } from './ForumAwareSpaceLayout';
export { ForumSpaceLayout } from './ForumSpaceLayout'; export { ForumSpaceLayout } from './ForumSpaceLayout';
export type { ForumBoardScope } from './useForumBoard'; export type { ForumBoardScope } from './useForumBoard';

View File

@@ -41,8 +41,7 @@ import { getSpaceRoomPath } from '../../pages/pathUtils';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { CanDropCallback, useDnDMonitor } from './DnD'; import { CanDropCallback, useDnDMonitor } from './DnD';
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable'; import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
import { getStateEvent, shouldShowForumLobby } from '../../utils/room'; import { getStateEvent } from '../../utils/room';
import { ForumBoardDetail } from '../forum/ForumBoardDetail';
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories'; import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
import { import {
makeCinnySpacesContent, makeCinnySpacesContent,
@@ -151,15 +150,7 @@ const useCanDropLobbyItem = (
return canDrop; return canDrop;
}; };
function ForumSpaceLobby() {
return <ForumBoardDetail />;
}
export function Lobby() { export function Lobby() {
const space = useSpace();
if (shouldShowForumLobby(space)) {
return <ForumSpaceLobby />;
}
return <SpaceCardLobby />; return <SpaceCardLobby />;
} }

View File

@@ -27,8 +27,12 @@ import { AddExistingModal } from '../add-existing';
import { SpeechBubble } from '../../components/speech-bubble/SpeechBubble'; import { SpeechBubble } from '../../components/speech-bubble/SpeechBubble';
import { SpaceOptionsMenu, type OpenAddExistingOptions } from '../space/SpaceOptionsMenu'; import { SpaceOptionsMenu, type OpenAddExistingOptions } from '../space/SpaceOptionsMenu';
import type { HierarchyItemSpace } from '../../hooks/useSpaceHierarchy'; import type { HierarchyItemSpace } from '../../hooks/useSpaceHierarchy';
import { StateEvent } from '../../../types/matrix/room'; import { useNavigate } from 'react-router-dom';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { getSpaceFeedPath } from '../../pages/pathUtils';
import { shouldShowForumLobby } from '../../utils/room';
import { useForumBoardContextOptionally } from '../forum/ForumBoardContext'; import { useForumBoardContextOptionally } from '../forum/ForumBoardContext';
import { StateEvent } from '../../../types/matrix/room';
type LobbyMenuProps = { type LobbyMenuProps = {
powerLevels: IPowerLevels; powerLevels: IPowerLevels;
@@ -37,7 +41,9 @@ type LobbyMenuProps = {
const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>( const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
({ powerLevels, requestClose }, ref) => { ({ powerLevels, requestClose }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const navigate = useNavigate();
const space = useSpace(); const space = useSpace();
const isForum = shouldShowForumLobby(space);
const creators = useRoomCreators(space); const creators = useRoomCreators(space);
const permissions = useRoomPermissions(creators, powerLevels); const permissions = useRoomPermissions(creators, powerLevels);
@@ -55,6 +61,11 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
requestClose(); requestClose();
}; };
const handleOpenFeed = () => {
navigate(getSpaceFeedPath(getCanonicalAliasOrRoomId(mx, space.roomId)));
requestClose();
};
return ( return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}> <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
{invitePrompt && ( {invitePrompt && (
@@ -67,6 +78,18 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
/> />
)} )}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{isForum && (
<MenuItem
onClick={handleOpenFeed}
size="300"
after={<Icon size="100" src={Icons.Thread} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Feed
</Text>
</MenuItem>
)}
<MenuItem <MenuItem
onClick={handleInvite} onClick={handleInvite}
variant="Primary" variant="Primary"

View File

@@ -28,6 +28,10 @@ import { copyToClipboard } from '../../utils/dom';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix'; import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix';
import { getMatrixToRoom } from '../../plugins/matrix-to'; import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getViaServers } from '../../plugins/via-servers'; import { getViaServers } from '../../plugins/via-servers';
import { useNavigate } from 'react-router-dom';
import { getSpaceFeedPath, getSpaceLobbyPath } from '../../pages/pathUtils';
import { shouldShowForumLobby } from '../../utils/room';
import { useSpaceFeedSelected } from '../../hooks/router/useSelectedSpace';
type ForumAddOptions = { type ForumAddOptions = {
item: HierarchyItem; item: HierarchyItem;
@@ -50,6 +54,9 @@ type SpaceOptionsMenuProps = {
export const SpaceOptionsMenu = forwardRef<HTMLDivElement, SpaceOptionsMenuProps>( export const SpaceOptionsMenu = forwardRef<HTMLDivElement, SpaceOptionsMenuProps>(
({ room, requestClose, forumAdd, onOpenAddExisting }, ref) => { ({ room, requestClose, forumAdd, onOpenAddExisting }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const navigate = useNavigate();
const onFeed = useSpaceFeedSelected();
const isForum = shouldShowForumLobby(room);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [developerTools] = useSetting(settingsAtom, 'developerTools'); const [developerTools] = useSetting(settingsAtom, 'developerTools');
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
@@ -101,6 +108,16 @@ export const SpaceOptionsMenu = forwardRef<HTMLDivElement, SpaceOptionsMenuProps
requestClose(); requestClose();
}; };
const handleOpenFeed = () => {
navigate(getSpaceFeedPath(getCanonicalAliasOrRoomId(mx, room.roomId)));
requestClose();
};
const handleOpenManage = () => {
navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, room.roomId)));
requestClose();
};
const handleAddTopic = () => { const handleAddTopic = () => {
if (!forumAdd) return; if (!forumAdd) return;
openCreateRoomModal(forumAdd.item.roomId); openCreateRoomModal(forumAdd.item.roomId);
@@ -200,6 +217,30 @@ export const SpaceOptionsMenu = forwardRef<HTMLDivElement, SpaceOptionsMenuProps
}} }}
/> />
)} )}
{isForum && !onFeed && (
<MenuItem
onClick={handleOpenFeed}
size="300"
after={<Icon size="100" src={Icons.Thread} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Feed
</Text>
</MenuItem>
)}
{isForum && onFeed && (
<MenuItem
onClick={handleOpenManage}
size="300"
after={<Icon size="100" src={Icons.Flag} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Manage
</Text>
</MenuItem>
)}
<MenuItem <MenuItem
onClick={handleMarkAsRead} onClick={handleMarkAsRead}
size="300" size="300"

View File

@@ -1,7 +1,6 @@
import { useMatch, useParams } from 'react-router-dom'; import { useLocation, useParams } from 'react-router-dom';
import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix'; import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix';
import { useMatrixClient } from '../useMatrixClient'; import { useMatrixClient } from '../useMatrixClient';
import { getSpaceLobbyPath, getSpaceSearchPath } from '../../pages/pathUtils';
export const useSelectedSpace = (): string | undefined => { export const useSelectedSpace = (): string | undefined => {
const mx = useMatrixClient(); const mx = useMatrixClient();
@@ -16,22 +15,18 @@ export const useSelectedSpace = (): string | undefined => {
return spaceId; return spaceId;
}; };
export const useSpaceLobbySelected = (spaceIdOrAlias: string): boolean => { /** True when the current URL is the forum feed route. */
const match = useMatch({ export const useSpaceFeedSelected = (): boolean => {
path: getSpaceLobbyPath(spaceIdOrAlias), const { pathname } = useLocation();
caseSensitive: true, return /\/feed\/?$/.test(pathname);
end: false,
});
return !!match;
}; };
export const useSpaceSearchSelected = (spaceIdOrAlias: string): boolean => { export const useSpaceLobbySelected = (_spaceIdOrAlias?: string): boolean => {
const match = useMatch({ const { pathname } = useLocation();
path: getSpaceSearchPath(spaceIdOrAlias), return /\/lobby\/?$/.test(pathname);
caseSensitive: true, };
end: false,
}); export const useSpaceSearchSelected = (_spaceIdOrAlias?: string): boolean => {
const { pathname } = useLocation();
return !!match; return /\/search\/?$/.test(pathname);
}; };

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback } from 'react'; import { useEffect, useCallback } from 'react';
import { MatrixClient, Room, RoomEvent } from 'matrix-js-sdk'; import { MatrixClient, Room, RoomEvent } from 'matrix-js-sdk';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
@@ -10,6 +10,7 @@ import {
RoomNotificationMode, RoomNotificationMode,
} from './useRoomsNotificationPreferences'; } from './useRoomsNotificationPreferences';
import { updateJoiningProgressAtom } from '../state/joiningProgress'; import { updateJoiningProgressAtom } from '../state/joiningProgress';
import { useMatrixClient } from './useMatrixClient';
const PER_PAGE_COUNT = 100; const PER_PAGE_COUNT = 100;
const MAX_PAGES = 50; const MAX_PAGES = 50;
@@ -18,6 +19,8 @@ const JOIN_DELAY_MS = 500;
/** Join rules that allow automatic joining */ /** Join rules that allow automatic joining */
const AUTO_JOINABLE_RULES = ['public', 'restricted', 'knock_restricted']; const AUTO_JOINABLE_RULES = ['public', 'restricted', 'knock_restricted'];
const processingSpaces = new Set<string>();
/** /**
* Extracts the server name from a Matrix room ID * Extracts the server name from a Matrix room ID
* @param roomId - The room ID (e.g., "!abc123:matrix.org") * @param roomId - The room ID (e.g., "!abc123:matrix.org")
@@ -46,12 +49,12 @@ function canAutoJoin(hierarchyRoom: IHierarchyRoom): boolean {
} }
/** /**
* Checks if a hierarchy item is a space * Checks if a hierarchy item is a space (including m.forum category spaces)
* @param hierarchyRoom - The room info from the space hierarchy * @param hierarchyRoom - The room info from the space hierarchy
* @returns Whether the item is a space * @returns Whether the item is a space
*/ */
function isHierarchySpace(hierarchyRoom: IHierarchyRoom): boolean { function isHierarchySpace(hierarchyRoom: IHierarchyRoom): boolean {
return hierarchyRoom.room_type === 'm.space'; return hierarchyRoom.room_type === 'm.space' || hierarchyRoom.room_type === 'm.forum';
} }
/** /**
@@ -177,6 +180,29 @@ async function joinRoomsSequentially(
); );
} }
/**
* Joins all auto-joinable children of a space (and nested spaces/rooms).
*/
export async function autoJoinSpaceChildren(
mx: MatrixClient,
spaceId: string,
onProgress?: ProgressCallback
): Promise<void> {
if (processingSpaces.has(spaceId)) {
return;
}
processingSpaces.add(spaceId);
try {
const hierarchyRooms = await fetchSpaceHierarchy(mx, spaceId);
const childRooms = hierarchyRooms.filter((r) => r.room_id !== spaceId);
await joinRoomsSequentially(mx, childRooms, spaceId, onProgress);
} finally {
processingSpaces.delete(spaceId);
}
}
/** /**
* Hook that automatically joins all accessible rooms when joining a space * Hook that automatically joins all accessible rooms when joining a space
* @param mx - Matrix client instance * @param mx - Matrix client instance
@@ -184,7 +210,6 @@ async function joinRoomsSequentially(
export function useAutoJoinSpaceRooms(mx: MatrixClient): void { export function useAutoJoinSpaceRooms(mx: MatrixClient): void {
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const setJoiningProgress = useSetAtom(updateJoiningProgressAtom); const setJoiningProgress = useSetAtom(updateJoiningProgressAtom);
const processingSpaces = useRef<Set<string>>(new Set());
const handleSpaceJoin = useCallback( const handleSpaceJoin = useCallback(
async (room: Room) => { async (room: Room) => {
@@ -192,7 +217,7 @@ export function useAutoJoinSpaceRooms(mx: MatrixClient): void {
return; return;
} }
// Only process spaces // Only process spaces (includes m.forum containers)
if (!isSpace(room)) { if (!isSpace(room)) {
return; return;
} }
@@ -204,27 +229,11 @@ export function useAutoJoinSpaceRooms(mx: MatrixClient): void {
const spaceId = room.roomId; const spaceId = room.roomId;
// Prevent duplicate processing
if (processingSpaces.current.has(spaceId)) {
return;
}
processingSpaces.current.add(spaceId);
try { try {
// Fetch the space hierarchy await autoJoinSpaceChildren(mx, spaceId, (current, total) => {
const hierarchyRooms = await fetchSpaceHierarchy(mx, spaceId);
// Filter out the space itself
const childRooms = hierarchyRooms.filter((r) => r.room_id !== spaceId);
// Join all accessible rooms with progress tracking
await joinRoomsSequentially(mx, childRooms, spaceId, (current, total) => {
setJoiningProgress({ spaceId, progress: { current, total } }); setJoiningProgress({ spaceId, progress: { current, total } });
}); });
} finally { } finally {
processingSpaces.current.delete(spaceId);
// Clear progress when done
setJoiningProgress({ spaceId, progress: null }); setJoiningProgress({ spaceId, progress: null });
} }
}, },
@@ -245,3 +254,45 @@ export function useAutoJoinSpaceRooms(mx: MatrixClient): void {
}; };
}, [mx, handleSpaceJoin]); }, [mx, handleSpaceJoin]);
} }
/**
* Auto-joins space children when visiting an already-joined space (e.g. forum spaces).
*/
export function useAutoJoinOnSpaceVisit(space: Room | undefined): void {
const mx = useMatrixClient();
const settings = useAtomValue(settingsAtom);
const setJoiningProgress = useSetAtom(updateJoiningProgressAtom);
useEffect(() => {
if (!space || !settings.autoJoinSpaceRooms) {
return;
}
if (!isSpace(space)) {
return;
}
if (space.getMyMembership() !== Membership.Join) {
return;
}
const spaceId = space.roomId;
let cancelled = false;
void (async () => {
try {
await autoJoinSpaceChildren(mx, spaceId, (current, total) => {
if (!cancelled) {
setJoiningProgress({ spaceId, progress: { current, total } });
}
});
} finally {
if (!cancelled) {
setJoiningProgress({ spaceId, progress: null });
}
}
})();
return () => {
cancelled = true;
};
}, [space, settings.autoJoinSpaceRooms, mx, setJoiningProgress]);
}

View File

@@ -23,6 +23,7 @@ import {
_FEATURED_PATH, _FEATURED_PATH,
_INVITES_PATH, _INVITES_PATH,
_JOIN_PATH, _JOIN_PATH,
_FEED_PATH,
_LOBBY_PATH, _LOBBY_PATH,
_NOTIFICATIONS_PATH, _NOTIFICATIONS_PATH,
_ROOM_PATH, _ROOM_PATH,
@@ -37,14 +38,13 @@ import {
getInboxNotificationsPath, getInboxNotificationsPath,
getLoginPath, getLoginPath,
getOriginBaseUrl, getOriginBaseUrl,
getSpaceLobbyPath,
} from './pathUtils'; } from './pathUtils';
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client'; import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch, SpaceIndexRedirect } from './client/space';
import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout'; import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout';
import { ForumSpaceLayout } from '../features/forum/ForumSpaceLayout'; import { ForumFeedPage } from '../features/forum/ForumFeedPage';
import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
import { Notifications, Inbox, Invites } from './client/inbox'; import { Notifications, Inbox, Invites } from './client/inbox';
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
@@ -238,25 +238,12 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
path={SPACE_PATH} path={SPACE_PATH}
element={ element={
<RouteSpaceProvider> <RouteSpaceProvider>
<ForumSpaceLayout> <ForumAwareSpaceLayout />
<ForumAwareSpaceLayout />
</ForumSpaceLayout>
</RouteSpaceProvider> </RouteSpaceProvider>
} }
> >
{mobile ? null : ( <Route index element={<SpaceIndexRedirect />} />
<Route <Route path={_FEED_PATH} element={<ForumFeedPage />} />
index
loader={({ params }) => {
const { spaceIdOrAlias } = params;
if (spaceIdOrAlias) {
return redirect(getSpaceLobbyPath(spaceIdOrAlias));
}
return null;
}}
element={<WelcomePage />}
/>
)}
<Route path={_LOBBY_PATH} element={<Lobby />} /> <Route path={_LOBBY_PATH} element={<Lobby />} />
<Route path={_SEARCH_PATH} element={<SpaceSearch />} /> <Route path={_SEARCH_PATH} element={<SpaceSearch />} />
<Route <Route

View File

@@ -37,7 +37,7 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList'; import { mDirectAtom } from '../../../state/mDirectList';
import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList'; import { allRoomsAtom } from '../../../state/room-list/roomList';
import { getHomePath, getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils'; import { getHomePath, getSpaceDefaultPath, getSpacePath, joinPathComponent } from '../../pathUtils';
import { import {
SidebarAvatar, SidebarAvatar,
SidebarItem, SidebarItem,
@@ -1067,7 +1067,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
return; return;
} }
navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId))); navigate(getSpaceDefaultPath(mx, targetSpaceId));
}; };
const handleFolderToggle: MouseEventHandler<HTMLButtonElement> = (evt) => { const handleFolderToggle: MouseEventHandler<HTMLButtonElement> = (evt) => {
@@ -1206,7 +1206,7 @@ export function HiddenSpacesTabs() {
return; return;
} }
navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId))); navigate(getSpaceDefaultPath(mx, targetSpaceId));
}; };
const handleHomeClick = () => { const handleHomeClick = () => {

View File

@@ -335,7 +335,12 @@ export function Space() {
const getToLink = (roomId: string) => const getToLink = (roomId: string) =>
getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId));
if (shouldShowForumLobby(space)) { const isForum = shouldShowForumLobby(space);
// Forum feed unless user opened manage, search, or a room (not tied to URL alias encoding).
const feedActive = isForum && !lobbySelected && !searchSelected && !selectedRoomId;
const showForumFeedSidebar = feedActive;
if (showForumFeedSidebar) {
return ( return (
<PageNav className={forumBoardCss.ForumPageNav}> <PageNav className={forumBoardCss.ForumPageNav}>
<SpaceHeader /> <SpaceHeader />
@@ -346,6 +351,8 @@ export function Space() {
); );
} }
const spacePath = getCanonicalAliasOrRoomId(mx, space.roomId);
return ( return (
<PageNav> <PageNav>
<SpaceHeader /> <SpaceHeader />
@@ -358,38 +365,42 @@ export function Space() {
/> />
)} )}
<NavCategory> <NavCategory>
<NavItem variant="Background" radii="400" aria-selected={lobbySelected}> {!isForum && (
<NavLink to={getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, space.roomId))}> <>
<NavItemContent> <NavItem variant="Background" radii="400" aria-selected={lobbySelected}>
<Box as="span" grow="Yes" alignItems="Center" gap="200"> <NavLink to={getSpaceLobbyPath(spacePath)}>
<Avatar size="200" radii="400"> <NavItemContent>
<Icon src={Icons.Flag} size="100" filled={lobbySelected} /> <Box as="span" grow="Yes" alignItems="Center" gap="200">
</Avatar> <Avatar size="200" radii="400">
<Box as="span" grow="Yes"> <Icon src={Icons.Flag} size="100" filled={lobbySelected} />
<Text as="span" size="Inherit" truncate> </Avatar>
Lobby <Box as="span" grow="Yes">
</Text> <Text as="span" size="Inherit" truncate>
</Box> Lobby
</Box> </Text>
</NavItemContent> </Box>
</NavLink> </Box>
</NavItem> </NavItemContent>
<NavItem variant="Background" radii="400" aria-selected={searchSelected}> </NavLink>
<NavLink to={getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))}> </NavItem>
<NavItemContent> <NavItem variant="Background" radii="400" aria-selected={searchSelected}>
<Box as="span" grow="Yes" alignItems="Center" gap="200"> <NavLink to={getSpaceSearchPath(spacePath)}>
<Avatar size="200" radii="400"> <NavItemContent>
<Icon src={Icons.Search} size="100" filled={searchSelected} /> <Box as="span" grow="Yes" alignItems="Center" gap="200">
</Avatar> <Avatar size="200" radii="400">
<Box as="span" grow="Yes"> <Icon src={Icons.Search} size="100" filled={searchSelected} />
<Text as="span" size="Inherit" truncate> </Avatar>
Message Search <Box as="span" grow="Yes">
</Text> <Text as="span" size="Inherit" truncate>
</Box> Message Search
</Box> </Text>
</NavItemContent> </Box>
</NavLink> </Box>
</NavItem> </NavItemContent>
</NavLink>
</NavItem>
</>
)}
<PluginNavSlot location="channel-list" /> <PluginNavSlot location="channel-list" />
</NavCategory> </NavCategory>
<NavCategory <NavCategory

View File

@@ -0,0 +1,26 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useSpace } from '../../../hooks/useSpace';
import { shouldShowForumLobby } from '../../../utils/room';
import { getSpaceDefaultPath } from '../../pathUtils';
import { ForumFeedPage } from '../../../features/forum/ForumFeedPage';
/** Sends /:spaceId/ to the forum feed or space lobby. */
export function SpaceIndexRedirect() {
const mx = useMatrixClient();
const space = useSpace();
const navigate = useNavigate();
const isForum = shouldShowForumLobby(space);
useEffect(() => {
navigate(getSpaceDefaultPath(mx, space.roomId), { replace: true });
}, [mx, navigate, space.roomId]);
// Avoid a blank main pane while redirecting forum spaces to /feed/.
if (isForum) {
return <ForumFeedPage />;
}
return null;
}

View File

@@ -1,6 +1,7 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useAutoJoinOnSpaceVisit } from '../../../hooks/useAutoJoinSpaceRooms';
import { useSpaces } from '../../../state/hooks/roomList'; import { useSpaces } from '../../../state/hooks/roomList';
import { allRoomsAtom } from '../../../state/room-list/roomList'; import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace'; import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace';
@@ -21,6 +22,8 @@ export function RouteSpaceProvider({ children }: RouteSpaceProviderProps) {
const selectedSpaceId = useSelectedSpace(); const selectedSpaceId = useSelectedSpace();
const space = mx.getRoom(selectedSpaceId); const space = mx.getRoom(selectedSpaceId);
useAutoJoinOnSpaceVisit(space ?? undefined);
if (!space || !joinedSpaces.includes(space.roomId)) { if (!space || !joinedSpaces.includes(space.roomId)) {
return <JoinBeforeNavigate roomIdOrAlias={spaceIdOrAlias ?? ''} viaServers={viaServers} />; return <JoinBeforeNavigate roomIdOrAlias={spaceIdOrAlias ?? ''} viaServers={viaServers} />;
} }

View File

@@ -1,3 +1,4 @@
export * from './SpaceIndexRedirect';
export * from './SpaceProvider'; export * from './SpaceProvider';
export * from './Space'; export * from './Space';
export * from './Search'; export * from './Search';

View File

@@ -20,6 +20,7 @@ import {
REGISTER_PATH, REGISTER_PATH,
RESET_PASSWORD_PATH, RESET_PASSWORD_PATH,
ROOT_PATH, ROOT_PATH,
SPACE_FEED_PATH,
SPACE_LOBBY_PATH, SPACE_LOBBY_PATH,
SPACE_PATH, SPACE_PATH,
SPACE_ROOM_PATH, SPACE_ROOM_PATH,
@@ -28,6 +29,9 @@ import {
} from './paths'; } from './paths';
import { trimLeadingSlash, trimTrailingSlash } from '../utils/common'; import { trimLeadingSlash, trimTrailingSlash } from '../utils/common';
import { HashRouterConfig } from '../hooks/useClientConfig'; import { HashRouterConfig } from '../hooks/useClientConfig';
import type { MatrixClient } from 'matrix-js-sdk';
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
import { shouldShowForumLobby } from '../utils/room';
export const joinPathComponent = (path: Path): string => path.pathname + path.search + path.hash; export const joinPathComponent = (path: Path): string => path.pathname + path.search + path.hash;
@@ -128,6 +132,21 @@ export const getSpaceLobbyPath = (spaceIdOrAlias: string): string => {
}; };
return generatePath(SPACE_LOBBY_PATH, params); return generatePath(SPACE_LOBBY_PATH, params);
}; };
export const getSpaceFeedPath = (spaceIdOrAlias: string): string => {
const params = {
spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias),
};
return generatePath(SPACE_FEED_PATH, params);
};
/** Forum spaces open the post feed; other spaces open the lobby. */
export const getSpaceDefaultPath = (mx: MatrixClient, spaceId: string): string => {
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, spaceId);
const space = mx.getRoom(spaceId);
if (space && shouldShowForumLobby(space)) {
return getSpaceFeedPath(spaceIdOrAlias);
}
return getSpaceLobbyPath(spaceIdOrAlias);
};
export const getSpaceSearchPath = (spaceIdOrAlias: string): string => { export const getSpaceSearchPath = (spaceIdOrAlias: string): string => {
const params = { const params = {
spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias), spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias),

View File

@@ -22,6 +22,7 @@ export const RESET_PASSWORD_PATH = '/reset-password/:server?/';
export const _CREATE_PATH = 'create/'; export const _CREATE_PATH = 'create/';
export const _JOIN_PATH = 'join/'; export const _JOIN_PATH = 'join/';
export const _LOBBY_PATH = 'lobby/'; export const _LOBBY_PATH = 'lobby/';
export const _FEED_PATH = 'feed/';
/** /**
* array of rooms and senders mxId assigned * array of rooms and senders mxId assigned
* to search param as string should be "," separated * to search param as string should be "," separated
@@ -57,6 +58,7 @@ export const DIRECT_ROOM_PATH = `/direct/${_ROOM_PATH}`;
export const SPACE_PATH = '/:spaceIdOrAlias/'; export const SPACE_PATH = '/:spaceIdOrAlias/';
export const SPACE_LOBBY_PATH = `/:spaceIdOrAlias/${_LOBBY_PATH}`; export const SPACE_LOBBY_PATH = `/:spaceIdOrAlias/${_LOBBY_PATH}`;
export const SPACE_FEED_PATH = `/:spaceIdOrAlias/${_FEED_PATH}`;
export const SPACE_SEARCH_PATH = `/:spaceIdOrAlias/${_SEARCH_PATH}`; export const SPACE_SEARCH_PATH = `/:spaceIdOrAlias/${_SEARCH_PATH}`;
export const SPACE_ROOM_PATH = `/:spaceIdOrAlias/${_ROOM_PATH}`; export const SPACE_ROOM_PATH = `/:spaceIdOrAlias/${_ROOM_PATH}`;