From 0b31e6b9305475c71bc1d44e3f3525332dd65726 Mon Sep 17 00:00:00 2001 From: heaven Date: Sun, 10 May 2026 20:13:50 +0300 Subject: [PATCH] feat(channels): route room-in-space navigations through /channels/ with eventId-anchored permalinks and channels-aware cold-push redirect --- src/app/hooks/useRoomNavigate.ts | 20 +++++++++++------ src/app/pages/Router.tsx | 5 +++++ src/app/pages/client/home/RoomProvider.tsx | 26 +++++++++++++++++----- src/app/pages/pathUtils.ts | 16 +++++++++---- src/app/pages/paths.ts | 6 +++++ src/app/utils/routeParent.ts | 14 ++++++++++++ 6 files changed, 71 insertions(+), 16 deletions(-) diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index d37c4ee9..af7b1946 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -2,7 +2,7 @@ import { useCallback, useRef } from 'react'; import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom'; import { useAtomValue } from 'jotai'; import { getCanonicalAliasOrRoomId } from '../utils/matrix'; -import { getDirectRoomPath, getSpacePath, getSpaceRoomPath } from '../pages/pathUtils'; +import { getChannelsRoomPath, getDirectRoomPath, getSpacePath } from '../pages/pathUtils'; import { useMatrixClient } from './useMatrixClient'; import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room'; import { roomToParentsAtom } from '../state/room/roomToParents'; @@ -61,9 +61,18 @@ export const useRoomNavigate = () => { const navigateRoom = useCallback( (roomId: string, eventId?: string, opts?: NavigateOptions) => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); - const openSpaceTimeline = developerTools && spaceSelectedId === roomId; - const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId); + // Developer-tools edge case: clicking the rail-selected space row + // opens the space's own timeline as a pseudo-room. Route through + // /channels/// so the dev view shares the channels + // SpaceRouteRoomProvider — its `developerTools && isSpaceRoom && + // room.roomId === space.roomId` branch already permits this. + if (developerTools && spaceSelectedId === roomId) { + safeNavigate(getChannelsRoomPath(roomIdOrAlias, roomIdOrAlias, eventId), opts); + return; + } + + const orphanParents = getOrphanParents(roomToParents, roomId); if (orphanParents.length > 0) { let parentSpace: string; if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) { @@ -74,10 +83,7 @@ export const useRoomNavigate = () => { const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); - safeNavigate( - getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId), - opts - ); + safeNavigate(getChannelsRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId), opts); return; } diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index ab9c7d1c..e593252d 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -15,6 +15,7 @@ import { AuthLayout, Login, Register, ResetPassword } from './auth'; import { BOTS_PATH, CHANNELS_PATH, + CHANNELS_ROOM_EVENT_PATH, CHANNELS_ROOM_PATH, CHANNELS_SPACE_PATH, CHANNELS_THREAD_PATH, @@ -323,6 +324,10 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) SpaceRouteRoomProvider lives on the parent route and stays mounted across the room↔thread URL flip. */} + {/* Event-anchored URL — search/inbox/mention/push permalinks. + RR6 merges child params into the parent's useParams so + Room.tsx reads `eventId` without re-routing. */} + diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx index c2c7b675..1123f909 100644 --- a/src/app/pages/client/home/RoomProvider.tsx +++ b/src/app/pages/client/home/RoomProvider.tsx @@ -6,9 +6,10 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers'; import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { getDirectRoomPath } from '../../pathUtils'; +import { roomToParentsAtom } from '../../../state/room/roomToParents'; +import { getChannelsRoomPath, getDirectRoomPath } from '../../pathUtils'; import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; -import { isSpace } from '../../../utils/room'; +import { getOrphanParents, guessPerfectParent, isSpace } from '../../../utils/room'; export function HomeRouteRoomProvider({ children: _children }: { children: ReactNode }) { const mx = useMatrixClient(); @@ -23,17 +24,32 @@ export function HomeRouteRoomProvider({ children: _children }: { children: React // explicitly so the redirect re-evaluates after /sync. See plan §6.7 / §6.8 // / round-5 review notes. useAtomValue(allRoomsAtom); + // Subscribe roomToParentsAtom so the channels-vs-direct branch re-evaluates + // after /sync populates the m.space.parent / m.space.child mappings. Without + // this subscription a cold push for a room-in-space would render once with + // an empty parents map and miss the channels redirect. + const roomToParents = useAtomValue(roomToParentsAtom); const { roomIdOrAlias, eventId } = useParams(); const viaServers = useSearchParamsViaServers(); const roomId = useSelectedRoom(); const room = mx.getRoom(roomId); - // After P3c the Home tab is gone — every non-space room redirects to the - // unified Direct list. /home/{roomId}/ stays as a deep-link compatibility - // shim for cold-start push (sw.ts cannot read mDirectAtom). + // After P3c the Home tab is gone — /home/{roomId}/ stays as a deep-link + // compatibility shim for cold-start push (sw.ts cannot read mDirectAtom or + // roomToParentsAtom). Room-in-space rooms route into /channels/, everything + // else falls through to the universal /direct/ list. if (room && !isSpace(room)) { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); + const orphanParents = getOrphanParents(roomToParents, room.roomId); + if (orphanParents.length > 0) { + const parentSpace = + guessPerfectParent(mx, room.roomId, orphanParents) ?? orphanParents[0]; + const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); + return ( + + ); + } return ; } diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 44430378..afa38dab 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -3,6 +3,7 @@ import { BOTS_BOT_PATH, BOTS_PATH, CHANNELS_PATH, + CHANNELS_ROOM_EVENT_PATH, CHANNELS_ROOM_PATH, CHANNELS_SPACE_PATH, CHANNELS_THREAD_PATH, @@ -171,13 +172,20 @@ export const getChannelsSpacePath = (spaceIdOrAlias: string): string => { }; export const getChannelsRoomPath = ( spaceIdOrAlias: string, - roomIdOrAlias: string + roomIdOrAlias: string, + eventId?: string ): string => { - const params = { + if (eventId) { + return generatePath(CHANNELS_ROOM_EVENT_PATH, { + spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias), + roomIdOrAlias: encodeURIComponent(roomIdOrAlias), + eventId: encodeURIComponent(eventId), + }); + } + return generatePath(CHANNELS_ROOM_PATH, { spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias), roomIdOrAlias: encodeURIComponent(roomIdOrAlias), - }; - return generatePath(CHANNELS_ROOM_PATH, params); + }); }; export const getChannelsThreadPath = ( spaceIdOrAlias: string, diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 1c3d252c..6940a184 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -91,6 +91,12 @@ export const BOTS_BOT_PATH = '/bots/:botId/'; export const CHANNELS_PATH = '/channels/'; export const CHANNELS_SPACE_PATH = '/channels/:spaceIdOrAlias/'; export const CHANNELS_ROOM_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/'; +// Event-anchored channel room URL — discriminated `event/` segment so the +// optional eventId does not collide with the sibling `thread/:rootId/` +// sub-route. Search/inbox/mention/push permalinks land here so the timeline +// can scroll to the cited event without dropping the user out of /channels/. +export const CHANNELS_ROOM_EVENT_PATH = + '/channels/:spaceIdOrAlias/:roomIdOrAlias/event/:eventId/'; export const CHANNELS_THREAD_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/thread/:rootId/'; export const SPACE_SETTINGS_PATH = '/space-settings/'; diff --git a/src/app/utils/routeParent.ts b/src/app/utils/routeParent.ts index c727614f..02606380 100644 --- a/src/app/utils/routeParent.ts +++ b/src/app/utils/routeParent.ts @@ -2,6 +2,7 @@ import { matchPath } from 'react-router-dom'; import { BOTS_PATH, CHANNELS_PATH, + CHANNELS_ROOM_EVENT_PATH, CHANNELS_ROOM_PATH, CHANNELS_SPACE_PATH, CHANNELS_THREAD_PATH, @@ -51,6 +52,19 @@ export const getRouteSectionParent = (pathname: string): string | null => { decodeURIComponent(threadMatch.params.roomIdOrAlias) ); } + // Event-anchored permalink collapses to the same parent room view as + // thread does — so back from a search/notification permalink lands on + // the room timeline first, not on the space root. + const eventMatch = matchPath( + { path: CHANNELS_ROOM_EVENT_PATH, caseSensitive: true, end: false }, + pathname + ); + if (eventMatch?.params.spaceIdOrAlias && eventMatch?.params.roomIdOrAlias) { + return getChannelsRoomPath( + decodeURIComponent(eventMatch.params.spaceIdOrAlias), + decodeURIComponent(eventMatch.params.roomIdOrAlias) + ); + } const roomMatch = matchPath( { path: CHANNELS_ROOM_PATH, caseSensitive: true, end: false }, pathname