feat(channels): route room-in-space navigations through /channels/ with eventId-anchored permalinks and channels-aware cold-push redirect

This commit is contained in:
v.lagerev 2026-05-10 20:13:50 +03:00
parent dd55900dd6
commit 19f2d64c0d
6 changed files with 71 additions and 16 deletions

View file

@ -2,7 +2,7 @@ import { useCallback, useRef } from 'react';
import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom'; import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { getCanonicalAliasOrRoomId } from '../utils/matrix'; import { getCanonicalAliasOrRoomId } from '../utils/matrix';
import { getDirectRoomPath, getSpacePath, getSpaceRoomPath } from '../pages/pathUtils'; import { getChannelsRoomPath, getDirectRoomPath, getSpacePath } from '../pages/pathUtils';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room'; import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room';
import { roomToParentsAtom } from '../state/room/roomToParents'; import { roomToParentsAtom } from '../state/room/roomToParents';
@ -61,9 +61,18 @@ export const useRoomNavigate = () => {
const navigateRoom = useCallback( const navigateRoom = useCallback(
(roomId: string, eventId?: string, opts?: NavigateOptions) => { (roomId: string, eventId?: string, opts?: NavigateOptions) => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); 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/<space>/<space>/ 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) { if (orphanParents.length > 0) {
let parentSpace: string; let parentSpace: string;
if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) { if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) {
@ -74,10 +83,7 @@ export const useRoomNavigate = () => {
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace);
safeNavigate( safeNavigate(getChannelsRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId), opts);
getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId),
opts
);
return; return;
} }

View file

@ -15,6 +15,7 @@ import { AuthLayout, Login, Register, ResetPassword } from './auth';
import { import {
BOTS_PATH, BOTS_PATH,
CHANNELS_PATH, CHANNELS_PATH,
CHANNELS_ROOM_EVENT_PATH,
CHANNELS_ROOM_PATH, CHANNELS_ROOM_PATH,
CHANNELS_SPACE_PATH, CHANNELS_SPACE_PATH,
CHANNELS_THREAD_PATH, CHANNELS_THREAD_PATH,
@ -323,6 +324,10 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
SpaceRouteRoomProvider lives on the parent route and SpaceRouteRoomProvider lives on the parent route and
stays mounted across the roomthread URL flip. */} stays mounted across the roomthread URL flip. */}
<Route path={CHANNELS_THREAD_PATH.slice(CHANNELS_ROOM_PATH.length)} element={null} /> <Route path={CHANNELS_THREAD_PATH.slice(CHANNELS_ROOM_PATH.length)} element={null} />
{/* 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. */}
<Route path={CHANNELS_ROOM_EVENT_PATH.slice(CHANNELS_ROOM_PATH.length)} element={null} />
</Route> </Route>
</Route> </Route>
</Route> </Route>

View file

@ -6,9 +6,10 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers'; import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
import { allRoomsAtom } from '../../../state/room-list/roomList'; 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 { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { isSpace } from '../../../utils/room'; import { getOrphanParents, guessPerfectParent, isSpace } from '../../../utils/room';
export function HomeRouteRoomProvider({ children: _children }: { children: ReactNode }) { export function HomeRouteRoomProvider({ children: _children }: { children: ReactNode }) {
const mx = useMatrixClient(); 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 // explicitly so the redirect re-evaluates after /sync. See plan §6.7 / §6.8
// / round-5 review notes. // / round-5 review notes.
useAtomValue(allRoomsAtom); 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 { roomIdOrAlias, eventId } = useParams();
const viaServers = useSearchParamsViaServers(); const viaServers = useSearchParamsViaServers();
const roomId = useSelectedRoom(); const roomId = useSelectedRoom();
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
// After P3c the Home tab is gone — every non-space room redirects to the // After P3c the Home tab is gone — /home/{roomId}/ stays as a deep-link
// unified Direct list. /home/{roomId}/ stays as a deep-link compatibility // compatibility shim for cold-start push (sw.ts cannot read mDirectAtom or
// shim for cold-start push (sw.ts cannot read mDirectAtom). // roomToParentsAtom). Room-in-space rooms route into /channels/, everything
// else falls through to the universal /direct/ list.
if (room && !isSpace(room)) { if (room && !isSpace(room)) {
const alias = getCanonicalAliasOrRoomId(mx, room.roomId); 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 (
<Navigate to={getChannelsRoomPath(pSpaceIdOrAlias, alias, eventId)} replace />
);
}
return <Navigate to={getDirectRoomPath(alias, eventId)} replace />; return <Navigate to={getDirectRoomPath(alias, eventId)} replace />;
} }

View file

@ -3,6 +3,7 @@ import {
BOTS_BOT_PATH, BOTS_BOT_PATH,
BOTS_PATH, BOTS_PATH,
CHANNELS_PATH, CHANNELS_PATH,
CHANNELS_ROOM_EVENT_PATH,
CHANNELS_ROOM_PATH, CHANNELS_ROOM_PATH,
CHANNELS_SPACE_PATH, CHANNELS_SPACE_PATH,
CHANNELS_THREAD_PATH, CHANNELS_THREAD_PATH,
@ -171,13 +172,20 @@ export const getChannelsSpacePath = (spaceIdOrAlias: string): string => {
}; };
export const getChannelsRoomPath = ( export const getChannelsRoomPath = (
spaceIdOrAlias: string, spaceIdOrAlias: string,
roomIdOrAlias: string roomIdOrAlias: string,
eventId?: string
): 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), spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias),
roomIdOrAlias: encodeURIComponent(roomIdOrAlias), roomIdOrAlias: encodeURIComponent(roomIdOrAlias),
}; });
return generatePath(CHANNELS_ROOM_PATH, params);
}; };
export const getChannelsThreadPath = ( export const getChannelsThreadPath = (
spaceIdOrAlias: string, spaceIdOrAlias: string,

View file

@ -91,6 +91,12 @@ export const BOTS_BOT_PATH = '/bots/:botId/';
export const CHANNELS_PATH = '/channels/'; export const CHANNELS_PATH = '/channels/';
export const CHANNELS_SPACE_PATH = '/channels/:spaceIdOrAlias/'; export const CHANNELS_SPACE_PATH = '/channels/:spaceIdOrAlias/';
export const CHANNELS_ROOM_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/'; 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 CHANNELS_THREAD_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/thread/:rootId/';
export const SPACE_SETTINGS_PATH = '/space-settings/'; export const SPACE_SETTINGS_PATH = '/space-settings/';

View file

@ -2,6 +2,7 @@ import { matchPath } from 'react-router-dom';
import { import {
BOTS_PATH, BOTS_PATH,
CHANNELS_PATH, CHANNELS_PATH,
CHANNELS_ROOM_EVENT_PATH,
CHANNELS_ROOM_PATH, CHANNELS_ROOM_PATH,
CHANNELS_SPACE_PATH, CHANNELS_SPACE_PATH,
CHANNELS_THREAD_PATH, CHANNELS_THREAD_PATH,
@ -51,6 +52,19 @@ export const getRouteSectionParent = (pathname: string): string | null => {
decodeURIComponent(threadMatch.params.roomIdOrAlias) 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( const roomMatch = matchPath(
{ path: CHANNELS_ROOM_PATH, caseSensitive: true, end: false }, { path: CHANNELS_ROOM_PATH, caseSensitive: true, end: false },
pathname pathname