From 9c7f71896c4b827431c83220c80f4f8e8b5a5bde Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Thu, 9 Jul 2026 04:22:07 +1000 Subject: [PATCH] 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. --- src/app/components/BackRouteHandler.tsx | 85 +--------- src/app/components/page/Page.tsx | 4 +- src/app/components/title-bar/TitleBar.css.ts | 5 + src/app/components/title-bar/TitleBar.tsx | 19 ++- src/app/features/lobby/LobbyHeader.tsx | 22 +-- src/app/features/room/RoomViewHeader.tsx | 23 ++- src/app/hooks/useBackRoute.ts | 151 ++++++++++++++++++ src/app/hooks/useCompactNav.ts | 26 +++ src/app/hooks/useMatrixClient.ts | 4 + src/app/pages/MobileFriendly.tsx | 13 +- .../pages/client/space/SpaceIndexRedirect.tsx | 11 +- 11 files changed, 247 insertions(+), 116 deletions(-) create mode 100644 src/app/hooks/useBackRoute.ts create mode 100644 src/app/hooks/useCompactNav.ts diff --git a/src/app/components/BackRouteHandler.tsx b/src/app/components/BackRouteHandler.tsx index a1497b4..91cb6e3 100644 --- a/src/app/components/BackRouteHandler.tsx +++ b/src/app/components/BackRouteHandler.tsx @@ -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); } diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index c8e7f85..29b0e26 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -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 ( @@ -20,7 +22,7 @@ export function PageRoot({ nav, children }: PageRootProps) { {screenSize !== ScreenSize.Mobile && ( )} - {children} + {!showCompactMaster && children} ); } diff --git a/src/app/components/title-bar/TitleBar.css.ts b/src/app/components/title-bar/TitleBar.css.ts index 6a8c770..60af7d4 100644 --- a/src/app/components/title-bar/TitleBar.css.ts +++ b/src/app/components/title-bar/TitleBar.css.ts @@ -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%', diff --git a/src/app/components/title-bar/TitleBar.tsx b/src/app/components/title-bar/TitleBar.tsx index bd7a275..75bab3a 100644 --- a/src/app/components/title-bar/TitleBar.tsx +++ b/src/app/components/title-bar/TitleBar.tsx @@ -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 && ( + e.stopPropagation()} + aria-label="Back" + > + + + )} Paarrot{titleParts.suffix} diff --git a/src/app/features/lobby/LobbyHeader.tsx b/src/app/features/lobby/LobbyHeader.tsx index 6bec403..28d67cf 100644 --- a/src/app/features/lobby/LobbyHeader.tsx +++ b/src/app/features/lobby/LobbyHeader.tsx @@ -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(); const [addExisting, setAddExisting] = useState(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 <> + {showInPageHeader && ( + + + + + + )} - {screenSize === ScreenSize.Mobile ? ( + {showInPageHeader ? ( <> - - {(onBack) => ( - - - - )} - + + + {showProfile && ( diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index f6fa151..2971d09 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -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(); @@ -501,21 +502,17 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) { }; return ( - + - {screenSize === ScreenSize.Mobile && ( - - {(onBack) => ( - - - - - - )} - + {showInPageHeader && ( + + + + + )} - {screenSize !== ScreenSize.Mobile && ( + {!showInPageHeader && ( + 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), + }; +}; diff --git a/src/app/hooks/useCompactNav.ts b/src/app/hooks/useCompactNav.ts new file mode 100644 index 0000000..4e4de4a --- /dev/null +++ b/src/app/hooks/useCompactNav.ts @@ -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; +}; diff --git a/src/app/hooks/useMatrixClient.ts b/src/app/hooks/useMatrixClient.ts index 5ff6d90..ede4069 100644 --- a/src/app/hooks/useMatrixClient.ts +++ b/src/app/hooks/useMatrixClient.ts @@ -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); +} diff --git a/src/app/pages/MobileFriendly.tsx b/src/app/pages/MobileFriendly.tsx index ca947ac..5c4724f 100644 --- a/src/app/pages/MobileFriendly.tsx +++ b/src/app/pages/MobileFriendly.tsx @@ -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; } diff --git a/src/app/pages/client/space/SpaceIndexRedirect.tsx b/src/app/pages/client/space/SpaceIndexRedirect.tsx index bdf2069..9086bc3 100644 --- a/src/app/pages/client/space/SpaceIndexRedirect.tsx +++ b/src/app/pages/client/space/SpaceIndexRedirect.tsx @@ -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) {