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

@@ -1,90 +1,11 @@
import { ReactNode, useCallback } from 'react';
import { matchPath, useLocation, useNavigate } from 'react-router-dom';
import {
decodeRouteParam,
getDirectPath,
getExplorePath,
getHomePath,
getInboxPath,
getSpacePath,
} from '../pages/pathUtils';
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from '../pages/paths';
import { ReactNode } from 'react';
import { useBackRoute } from '../hooks/useBackRoute';
type BackRouteHandlerProps = {
children: (onBack: () => void) => ReactNode;
};
export function BackRouteHandler({ children }: BackRouteHandlerProps) {
const navigate = useNavigate();
const location = useLocation();
const goBack = useCallback(() => {
if (
matchPath(
{
path: HOME_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getHomePath());
return;
}
if (
matchPath(
{
path: DIRECT_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getDirectPath());
return;
}
const spaceMatch = matchPath(
{
path: SPACE_PATH,
caseSensitive: true,
end: false,
},
location.pathname
);
if (spaceMatch?.params.spaceIdOrAlias) {
const spaceIdOrAlias = decodeRouteParam(spaceMatch.params.spaceIdOrAlias);
if (spaceIdOrAlias) {
navigate(getSpacePath(spaceIdOrAlias));
}
return;
}
if (
matchPath(
{
path: EXPLORE_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getExplorePath());
return;
}
if (
matchPath(
{
path: INBOX_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getInboxPath());
}
}, [navigate, location]);
const { goBack } = useBackRoute();
return children(goBack);
}

View File

@@ -5,6 +5,7 @@ import { ContainerColor } from '../../styles/ContainerColor.css';
import * as css from './style.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
type PageRootProps = {
nav: ReactNode;
@@ -13,6 +14,7 @@ type PageRootProps = {
export function PageRoot({ nav, children }: PageRootProps) {
const screenSize = useScreenSizeContext();
const showCompactMaster = useShowCompactMasterView();
return (
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
@@ -20,7 +22,7 @@ export function PageRoot({ nav, children }: PageRootProps) {
{screenSize !== ScreenSize.Mobile && (
<Line variant="Background" size="300" direction="Vertical" />
)}
{children}
{!showCompactMaster && children}
</Box>
);
}

View File

@@ -76,6 +76,11 @@ export const TitleBarDragRegion = style({
color: color.Surface.OnContainer,
});
export const TitleBarBackButton = style({
WebkitAppRegion: 'no-drag',
flexShrink: 0,
});
export const SyncStatusBadge = style({
position: 'absolute',
left: '50%',

View File

@@ -1,5 +1,5 @@
import React, { ReactNode, useMemo, useEffect, useState, useCallback, useRef } from 'react';
import { Box, Text } from 'folds';
import { Box, Text, IconButton } from 'folds';
import { invoke } from '@tauri-apps/api/core';
import { MatrixClient, RoomEvent, ClientEvent, MatrixEvent, Room, IPushRules, SyncState } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
@@ -18,6 +18,8 @@ import { AccountDataEvent } from '../../../types/matrix/accountData';
import { getNotificationMode, NotificationMode } from '../../hooks/useNotificationMode';
import { isRoomId } from '../../utils/matrix';
import { useSyncState } from '../../hooks/useSyncState';
import { useBackRoute } from '../../hooks/useBackRoute';
import { Icon, Icons } from '../icons';
type NewMessageNotification = {
id: string;
@@ -42,6 +44,7 @@ export function TitleBar({ mx, children }: TitleBarProps) {
const navigate = useNavigate();
const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
const { showInTitleBar, goBack } = useBackRoute(mx);
// Sync status state
const [syncStateData, setSyncStateData] = useState<{
@@ -652,6 +655,20 @@ export function TitleBar({ mx, children }: TitleBarProps) {
grow="Yes"
onMouseDown={handleDragStart}
>
{showInTitleBar && (
<IconButton
className={css.TitleBarBackButton}
size="300"
variant="Surface"
fill="None"
radii="300"
onClick={goBack}
onMouseDown={(e) => e.stopPropagation()}
aria-label="Back"
>
<Icon src={Icons.ArrowLeft} size="200" />
</IconButton>
)}
<Text size="T200" truncate style={{ color: 'inherit' }}>
<span style={{ fontWeight: 700 }}>Paarrot</span>{titleParts.suffix}
</Text>

View File

@@ -16,7 +16,7 @@ import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
import { stopPropagation } from '../../utils/keyboard';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { BackRouteHandler } from '../../components/BackRouteHandler';
import { useBackRoute } from '../../hooks/useBackRoute';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
@@ -165,6 +165,7 @@ export function LobbyHeader({ showProfile, showSpaceToolbar, powerLevels }: Lobb
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [addExisting, setAddExisting] = useState<OpenAddExistingOptions | null>(null);
const screenSize = useScreenSizeContext();
const { showInPageHeader, goBack } = useBackRoute();
const name = useRoomName(space);
const avatarMxc = useRoomAvatar(space);
@@ -270,6 +271,13 @@ export function LobbyHeader({ showProfile, showSpaceToolbar, powerLevels }: Lobb
<>
<PageHeader outlined>
<Box grow="Yes" alignItems="Center" gap="300">
{showInPageHeader && (
<Box shrink="No">
<IconButton onClick={goBack} aria-label="Back">
<Icon src={Icons.ArrowLeft} />
</IconButton>
</Box>
)}
<Avatar size="300" shrink="No">
<RoomAvatar
roomId={space.roomId}
@@ -361,16 +369,12 @@ export function LobbyHeader({ showProfile, showSpaceToolbar, powerLevels }: Lobb
return (
<PageHeader className={showProfile ? undefined : css.Header} balance>
<Box grow="Yes" alignItems="Center" gap="200">
{screenSize === ScreenSize.Mobile ? (
{showInPageHeader ? (
<>
<Box shrink="No">
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<IconButton onClick={goBack} aria-label="Back">
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
</Box>
<Box grow="Yes" justifyContent="Center">
{showProfile && (

View File

@@ -36,7 +36,7 @@ import { stopPropagation } from '../../utils/keyboard';
import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getViaServers } from '../../plugins/via-servers';
import { PluginButtonSlot } from '../settings/plugins/PluginButtonSlot';
import { BackRouteHandler } from '../../components/BackRouteHandler';
import { useBackRoute } from '../../hooks/useBackRoute';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
import { RoomPinMenu } from './room-pin-menu';
@@ -463,6 +463,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const screenSize = useScreenSizeContext();
const { showInPageHeader, goBack } = useBackRoute();
const room = useRoom();
const space = useSpaceOptionally();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
@@ -501,21 +502,17 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
};
return (
<PageHeader balance={screenSize === ScreenSize.Mobile}>
<PageHeader balance={showInPageHeader}>
<Box grow="Yes" gap="300">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
{showInPageHeader && (
<Box shrink="No" alignItems="Center">
<IconButton onClick={onBack}>
<IconButton onClick={goBack} aria-label="Back">
<Icon src={Icons.ArrowLeft} />
</IconButton>
</Box>
)}
</BackRouteHandler>
)}
<Box grow="Yes" alignItems="Center" gap="300">
{screenSize !== ScreenSize.Mobile && (
{!showInPageHeader && (
<Avatar size="300">
<RoomAvatar
roomId={room.roomId}

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);
}

View File

@@ -1,23 +1,20 @@
import { ReactNode } from 'react';
import { useMatch } from 'react-router-dom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths';
import { useCompactNav } from '../hooks/useCompactNav';
type MobileFriendlyClientNavProps = {
children: ReactNode;
};
export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) {
const screenSize = useScreenSizeContext();
const compact = useCompactNav();
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 });
if (
screenSize === ScreenSize.Mobile &&
!(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch)
) {
if (compact && !(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch)) {
return null;
}
@@ -29,14 +26,14 @@ type MobileFriendlyPageNavProps = {
children: ReactNode;
};
export function MobileFriendlyPageNav({ path, children }: MobileFriendlyPageNavProps) {
const screenSize = useScreenSizeContext();
const compact = useCompactNav();
const exactPath = useMatch({
path,
caseSensitive: true,
end: true,
});
if (screenSize === ScreenSize.Mobile && !exactPath) {
if (compact && !exactPath) {
return null;
}

View File

@@ -5,17 +5,24 @@ import { useSpace } from '../../../hooks/useSpace';
import { shouldShowForumLobby } from '../../../utils/room';
import { getSpaceDefaultPath } from '../../pathUtils';
import { ForumFeedPage } from '../../../features/forum/ForumFeedPage';
import { useCompactNav } from '../../../hooks/useCompactNav';
/** Sends /:spaceId/ to the forum feed or space lobby. */
/** Sends /:spaceId/ to the forum feed or space lobby (desktop). On compact nav, stay on index for the channel list. */
export function SpaceIndexRedirect() {
const mx = useMatrixClient();
const space = useSpace();
const navigate = useNavigate();
const compact = useCompactNav();
const isForum = shouldShowForumLobby(space);
useEffect(() => {
if (compact) return;
navigate(getSpaceDefaultPath(mx, space.roomId), { replace: true });
}, [mx, navigate, space.roomId]);
}, [compact, mx, navigate, space.roomId]);
if (compact) {
return null;
}
// Avoid a blank main pane while redirecting forum spaces to /feed/.
if (isForum) {