fix: rework compact nav and centralized back routing

Use master-detail layout on skinny windows so server and channel lists share one screen, with back returning to the space list instead of redirect loops. Add useBackRoute for title-bar and page-header back buttons with view transitions.
This commit is contained in:
2026-07-09 04:22:07 +10:00
parent b4258278d8
commit 9c7f71896c
11 changed files with 247 additions and 116 deletions

View File

@@ -0,0 +1,151 @@
import { useCallback, useMemo } from 'react';
import { Location, matchPath, useLocation, useParams } from 'react-router-dom';
import type { MatrixClient } from 'matrix-js-sdk';
import {
decodeRouteParam,
getDirectPath,
getExplorePath,
getHomePath,
getInboxPath,
getSpacePath,
} from '../pages/pathUtils';
import {
DIRECT_PATH,
EXPLORE_PATH,
HOME_PATH,
INBOX_PATH,
SPACE_FEED_PATH,
SPACE_LOBBY_PATH,
SPACE_PATH,
SPACE_ROOM_PATH,
SPACE_SEARCH_PATH,
} from '../pages/paths';
import { ScreenSize } from './useScreenSize';
import { useNavigateWithTransition } from './useViewTransitions';
import { useMatrixClientOptionally } from './useMatrixClient';
import { useScreenSizeContext } from './useScreenSize';
import { getCanonicalAliasOrRoomId, getCanonicalAliasRoomId, isRoomAlias } from '../utils/matrix';
type BackRouteContext = {
location: Location;
mx: MatrixClient;
selectedSpaceId?: string;
};
const spaceListPath = (mx: MatrixClient, spaceId: string, spaceIdOrAlias: string): string =>
getSpacePath(getCanonicalAliasOrRoomId(mx, spaceId) ?? spaceIdOrAlias);
export const getBackRouteTarget = ({
location,
mx,
selectedSpaceId,
}: BackRouteContext): string | null => {
const { pathname } = location;
const homeMatch = matchPath({ path: HOME_PATH, caseSensitive: true, end: false }, pathname);
if (homeMatch) {
const atRoot = matchPath({ path: HOME_PATH, caseSensitive: true, end: true }, pathname);
if (!atRoot) {
return getHomePath();
}
return null;
}
const directMatch = matchPath({ path: DIRECT_PATH, caseSensitive: true, end: false }, pathname);
if (directMatch) {
const atRoot = matchPath({ path: DIRECT_PATH, caseSensitive: true, end: true }, pathname);
if (!atRoot) {
return getDirectPath();
}
return null;
}
const spaceMatch = matchPath({ path: SPACE_PATH, caseSensitive: true, end: false }, pathname);
if (spaceMatch?.params.spaceIdOrAlias) {
const spaceIdOrAlias = decodeRouteParam(spaceMatch.params.spaceIdOrAlias);
if (!spaceIdOrAlias) return null;
const spaceId = selectedSpaceId ?? spaceIdOrAlias;
const listPath = spaceListPath(mx, spaceId, spaceIdOrAlias);
const inRoom = matchPath({ path: SPACE_ROOM_PATH, caseSensitive: true, end: false }, pathname);
if (inRoom) {
return listPath;
}
const atLobby = matchPath({ path: SPACE_LOBBY_PATH, caseSensitive: true, end: false }, pathname);
if (atLobby) {
return listPath;
}
const atFeed = matchPath({ path: SPACE_FEED_PATH, caseSensitive: true, end: false }, pathname);
if (atFeed) {
return listPath;
}
const atSearch = matchPath({ path: SPACE_SEARCH_PATH, caseSensitive: true, end: false }, pathname);
if (atSearch) {
return listPath;
}
return null;
}
const exploreMatch = matchPath({ path: EXPLORE_PATH, caseSensitive: true, end: false }, pathname);
if (exploreMatch) {
const atRoot = matchPath({ path: EXPLORE_PATH, caseSensitive: true, end: true }, pathname);
if (!atRoot) {
return getExplorePath();
}
return null;
}
const inboxMatch = matchPath({ path: INBOX_PATH, caseSensitive: true, end: false }, pathname);
if (inboxMatch) {
const atRoot = matchPath({ path: INBOX_PATH, caseSensitive: true, end: true }, pathname);
if (!atRoot) {
return getInboxPath();
}
return null;
}
return null;
};
export const showBackInPageHeader = (screenSize: ScreenSize): boolean =>
screenSize !== ScreenSize.Desktop;
export const showBackInTitleBar = (screenSize: ScreenSize, canGoBack: boolean): boolean =>
screenSize === ScreenSize.Desktop && canGoBack;
export const useBackRoute = (mxOverride?: MatrixClient | null) => {
const navigate = useNavigateWithTransition();
const location = useLocation();
const mxFromContext = useMatrixClientOptionally();
const mx = mxOverride ?? mxFromContext;
const screenSize = useScreenSizeContext();
const { spaceIdOrAlias: rawSpaceIdOrAlias } = useParams();
const spaceIdOrAlias = decodeRouteParam(rawSpaceIdOrAlias);
const selectedSpaceId =
mx && spaceIdOrAlias && isRoomAlias(spaceIdOrAlias)
? getCanonicalAliasRoomId(mx, spaceIdOrAlias)
: spaceIdOrAlias;
const target = useMemo(() => {
if (!mx) return null;
return getBackRouteTarget({ location, mx, selectedSpaceId });
}, [location, mx, selectedSpaceId]);
const goBack = useCallback(() => {
if (target) {
navigate(target);
}
}, [navigate, target]);
return {
canGoBack: target !== null,
goBack,
showInPageHeader: showBackInPageHeader(screenSize) && target !== null,
showInTitleBar: showBackInTitleBar(screenSize, target !== null),
};
};

View File

@@ -0,0 +1,26 @@
import { useMatch } from 'react-router-dom';
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from '../pages/paths';
import { ScreenSize, useScreenSizeContext } from './useScreenSize';
/** Skinny window layout (≤750px): master-detail instead of 3-column desktop. */
export const useCompactNav = (): boolean => {
const screenSize = useScreenSizeContext();
return screenSize === ScreenSize.Mobile;
};
/** Routes where sidebar + channel list are shown together (no room/content pane). */
export const useIsCompactListRoute = (): boolean => {
const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true });
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true });
const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true });
const exploreMatch = useMatch({ path: EXPLORE_PATH, caseSensitive: true, end: true });
const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true });
return !!(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch);
};
export const useShowCompactMasterView = (): boolean => {
const compact = useCompactNav();
const isListRoute = useIsCompactListRoute();
return compact && isListRoute;
};

View File

@@ -10,3 +10,7 @@ export function useMatrixClient(): MatrixClient {
if (!mx) throw new Error('MatrixClient not initialized!');
return mx;
}
export function useMatrixClientOptionally(): MatrixClient | null {
return useContext(MatrixClientContext);
}