diff --git a/src/app/components/page/MobileNavDrawer.tsx b/src/app/components/page/MobileNavDrawer.tsx
index 95b964438e..959fef3822 100644
--- a/src/app/components/page/MobileNavDrawer.tsx
+++ b/src/app/components/page/MobileNavDrawer.tsx
@@ -12,21 +12,9 @@ import { useAtomValue, useSetAtom } from 'jotai';
import { matchPath, useLocation, useNavigate } from 'react-router-dom';
import { lastVisitedRoomAtom } from '$state/room/lastRoom';
import { usePrefersReducedMotion } from '$hooks/usePrefersReducedMotion';
-import {
- DIRECT_PATH,
- DIRECT_ROOM_PATH,
- DIRECT_ROOM_FORUM_PATH,
- EXPLORE_PATH,
- HOME_PATH,
- HOME_ROOM_PATH,
- HOME_ROOM_FORUM_PATH,
- INBOX_PATH,
- SPACE_PATH,
- SPACE_ROOM_PATH,
- SPACE_ROOM_FORUM_PATH,
-} from '$pages/paths';
+import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from '$pages/paths';
import { resolveSection } from '$pages/pathUtils';
-import { isRoomAlias, isRoomId } from '$utils/matrix';
+import { matchRoomRoute } from '$pages/roomRouteMatch';
import { PersistentRoomHost } from './PersistentRoomHost';
import { MobileNavDrawerContext, type MobileSwipeTarget } from './MobileNavDrawerContext';
import {
@@ -69,19 +57,9 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
openableSection && openableSection.getRoomPath && lastRoom?.[openableSection.key]
);
- const roomMatch =
- matchPath({ path: HOME_ROOM_FORUM_PATH, end: false }, location.pathname) ??
- matchPath({ path: DIRECT_ROOM_FORUM_PATH, end: false }, location.pathname) ??
- matchPath({ path: SPACE_ROOM_FORUM_PATH, end: false }, location.pathname) ??
- matchPath({ path: HOME_ROOM_PATH, end: false }, location.pathname) ??
- matchPath({ path: DIRECT_ROOM_PATH, end: false }, location.pathname) ??
- matchPath({ path: SPACE_ROOM_PATH, end: false }, location.pathname);
- const matchedRoomId = roomMatch?.params.roomIdOrAlias
- ? decodeURIComponent(roomMatch.params.roomIdOrAlias)
- : undefined;
- // `:roomIdOrAlias` also matches non-room segments like `create`, `search`, and `lobby`.
- // Only treat it as a room when it is a real Matrix ID or alias.
- const isRoomRoute = !!matchedRoomId && (isRoomId(matchedRoomId) || isRoomAlias(matchedRoomId));
+ const roomRoute = matchRoomRoute(location.pathname);
+ const matchedRoomId = roomRoute?.roomIdOrAlias;
+ const isRoomRoute = roomRoute !== undefined;
const listView =
matchPath({ path: HOME_PATH, end: true }, location.pathname) !== null ||
diff --git a/src/app/components/page/PersistentRoomHost.test.tsx b/src/app/components/page/PersistentRoomHost.test.tsx
new file mode 100644
index 0000000000..cddc8e072c
--- /dev/null
+++ b/src/app/components/page/PersistentRoomHost.test.tsx
@@ -0,0 +1,121 @@
+import type { ReactNode } from 'react';
+import { render, screen } from '@testing-library/react';
+import { Provider, createStore } from 'jotai';
+import { MemoryRouter, useLocation } from 'react-router-dom';
+import { describe, expect, it, vi } from 'vitest';
+import type { Room as MatrixRoom } from '$types/matrix-sdk';
+import { getHomeForumPath, getSpaceForumPath } from '$pages/pathUtils';
+import { lastVisitedRoomAtom } from '$state/room/lastRoom';
+import { PersistentRoomHost } from './PersistentRoomHost';
+
+const { FORUM_ROOM_ID, ROOM_ID, SPACE_ID, makeRoom } = vi.hoisted(() => {
+ const forumRoomId = '!forum-room:example.com';
+ const roomId = '!room:example.com';
+ return {
+ FORUM_ROOM_ID: forumRoomId,
+ ROOM_ID: roomId,
+ SPACE_ID: '!space:example.com',
+ makeRoom: (idOrAlias: string | undefined, forumType: string) => ({
+ roomId: idOrAlias ?? roomId,
+ getType: () => (idOrAlias === forumRoomId ? forumType : undefined),
+ }),
+ };
+});
+
+vi.mock('$features/room', () => ({
+ Room: () =>
,
+}));
+
+vi.mock('$features/forum', () => ({
+ ForumView: () => ,
+}));
+
+type MockProviderProps = {
+ roomIdOrAlias?: string;
+ eventId?: string;
+ children: ReactNode;
+};
+
+vi.mock('$pages/client/home', async () => {
+ const { RoomProvider } = await import('$hooks/useRoom');
+ const { CustomRoomType } = await import('$types/matrix/room');
+ return {
+ HomeRouteRoomProvider: ({ roomIdOrAlias, children }: MockProviderProps) => (
+
+ {children}
+
+ ),
+ };
+});
+
+vi.mock('$pages/client/direct', async () => {
+ const { RoomProvider } = await import('$hooks/useRoom');
+ const { CustomRoomType } = await import('$types/matrix/room');
+ return {
+ DirectRouteRoomProvider: ({ roomIdOrAlias, children }: MockProviderProps) => (
+
+ {children}
+
+ ),
+ };
+});
+
+vi.mock('$pages/client/space', async () => {
+ const { RoomProvider } = await import('$hooks/useRoom');
+ const { CustomRoomType } = await import('$types/matrix/room');
+ return {
+ SpaceRouteRoomProvider: ({ roomIdOrAlias, children }: MockProviderProps) => (
+
+ {children}
+
+ ),
+ };
+});
+
+function LocationProbe() {
+ const { pathname } = useLocation();
+ return {pathname}
;
+}
+
+const renderHost = (pathname: string, lastRoom?: Record) => {
+ const store = createStore();
+ if (lastRoom) store.set(lastVisitedRoomAtom, lastRoom);
+ return render(
+
+
+
+
+
+
+ );
+};
+
+describe('PersistentRoomHost', () => {
+ it('hosts the timeline for a non-forum room on a room route', () => {
+ renderHost(`/home/${encodeURIComponent(ROOM_ID)}/`);
+ expect(screen.getByTestId('room-timeline')).toBeInTheDocument();
+ expect(screen.getByTestId('pathname')).toHaveTextContent(
+ `/home/${encodeURIComponent(ROOM_ID)}/`
+ );
+ });
+
+ it('redirects a forum room from a home timeline route to the forum route', () => {
+ renderHost(`/home/${encodeURIComponent(FORUM_ROOM_ID)}/`);
+ expect(screen.queryByTestId('room-timeline')).not.toBeInTheDocument();
+ expect(screen.getByTestId('pathname')).toHaveTextContent(getHomeForumPath(FORUM_ROOM_ID));
+ });
+
+ it('redirects a forum room from a space timeline route to the forum route', () => {
+ renderHost(`/${encodeURIComponent(SPACE_ID)}/${encodeURIComponent(FORUM_ROOM_ID)}/`);
+ expect(screen.queryByTestId('room-timeline')).not.toBeInTheDocument();
+ expect(screen.getByTestId('pathname')).toHaveTextContent(
+ getSpaceForumPath(SPACE_ID, FORUM_ROOM_ID)
+ );
+ });
+
+ it('preloads the last visited room on a list route without redirecting', () => {
+ renderHost('/home', { home: FORUM_ROOM_ID });
+ expect(screen.getByTestId('room-timeline')).toBeInTheDocument();
+ expect(screen.getByTestId('pathname')).toHaveTextContent('/home');
+ });
+});
diff --git a/src/app/components/page/PersistentRoomHost.tsx b/src/app/components/page/PersistentRoomHost.tsx
index f8a9cc57a5..f573c63f8e 100644
--- a/src/app/components/page/PersistentRoomHost.tsx
+++ b/src/app/components/page/PersistentRoomHost.tsx
@@ -1,57 +1,25 @@
import type { ReactNode } from 'react';
-import { matchPath, useLocation } from 'react-router-dom';
+import { useLocation } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { Room } from '$features/room';
import { IsInactivePanelProvider } from '$hooks/useRoom';
import { HomeRouteRoomProvider } from '$pages/client/home';
import { DirectRouteRoomProvider } from '$pages/client/direct';
import { SpaceRouteRoomProvider } from '$pages/client/space';
+import { RoomGate, type RoomRouteSection } from '$pages/client/RoomRoute';
import { lastVisitedRoomAtom } from '$state/room/lastRoom';
import { resolveSection, type SectionNav } from '$pages/pathUtils';
-import {
- DIRECT_ROOM_PATH,
- DIRECT_ROOM_FORUM_PATH,
- HOME_ROOM_PATH,
- HOME_ROOM_FORUM_PATH,
- SPACE_ROOM_PATH,
- SPACE_ROOM_FORUM_PATH,
-} from '$pages/paths';
-import { isRoomAlias, isRoomId } from '$utils/matrix';
+import { matchRoomRoute, type RoomRouteMatch } from '$pages/roomRouteMatch';
-type DisplayedRoom = {
- roomIdOrAlias: string;
- eventId?: string;
-};
-
-function useDisplayedRoom(section: SectionNav | null): DisplayedRoom | undefined {
- const location = useLocation();
+function useDisplayedRoom(
+ section: SectionNav | null,
+ roomRoute: RoomRouteMatch | undefined
+): RoomRouteMatch | undefined {
const lastRoom = useAtomValue(lastVisitedRoomAtom);
if (!section || !section.getRoomPath) return undefined;
- const roomMatch =
- matchPath({ path: HOME_ROOM_FORUM_PATH, end: false }, location.pathname) ??
- matchPath({ path: DIRECT_ROOM_FORUM_PATH, end: false }, location.pathname) ??
- matchPath({ path: SPACE_ROOM_FORUM_PATH, end: false }, location.pathname) ??
- matchPath({ path: HOME_ROOM_PATH, end: false }, location.pathname) ??
- matchPath({ path: DIRECT_ROOM_PATH, end: false }, location.pathname) ??
- matchPath({ path: SPACE_ROOM_PATH, end: false }, location.pathname);
-
- if (roomMatch) {
- const encodedId = roomMatch.params.roomIdOrAlias;
- const encodedEvent = roomMatch.params.eventId;
- if (encodedId) {
- const decodedId = decodeURIComponent(encodedId);
- // `:roomIdOrAlias` also matches non-room segments like `create`, `search`, `lobby`.
- // Only treat it as a room when it's a real Matrix id/alias.
- if (isRoomId(decodedId) || isRoomAlias(decodedId)) {
- return {
- roomIdOrAlias: decodedId,
- eventId: encodedEvent ? decodeURIComponent(encodedEvent) : undefined,
- };
- }
- }
- }
+ if (roomRoute) return roomRoute;
const lastRoomId = lastRoom?.[section.key];
if (lastRoomId) {
@@ -61,14 +29,39 @@ function useDisplayedRoom(section: SectionNav | null): DisplayedRoom | undefined
return undefined;
}
+function sectionRoute(
+ sectionKey: string
+): { kind: RoomRouteSection; spaceIdOrAlias?: string } | undefined {
+ if (sectionKey === 'home') return { kind: 'home' };
+ if (sectionKey === 'direct') return { kind: 'direct' };
+ if (sectionKey.startsWith('space:')) {
+ return { kind: 'space', spaceIdOrAlias: sectionKey.slice('space:'.length) };
+ }
+ return undefined;
+}
+
export function PersistentRoomHost({ inactive }: { inactive: boolean }) {
const location = useLocation();
const section = resolveSection(location.pathname);
- const displayed = useDisplayedRoom(section);
+ const roomRoute = matchRoomRoute(location.pathname);
+ const displayed = useDisplayedRoom(section, roomRoute);
if (!displayed) return null;
- const roomNode = ;
+ // The gate redirects forum rooms opened on a timeline route; list preloads keep the plain timeline.
+ const hostedSection = section ? sectionRoute(section.key) : undefined;
+ const roomNode =
+ roomRoute && hostedSection ? (
+
+ ) : (
+
+ );
let hosted: ReactNode = null;
if (section?.key === 'home') {
diff --git a/src/app/components/upload-board/UploadBoard.tsx b/src/app/components/upload-board/UploadBoard.tsx
index 0d690fa1f9..6d0d86e448 100644
--- a/src/app/components/upload-board/UploadBoard.tsx
+++ b/src/app/components/upload-board/UploadBoard.tsx
@@ -11,24 +11,21 @@ import * as css from './UploadBoard.css';
type UploadBoardProps = {
header: ReactNode;
- showUploadCardBottom?: boolean;
};
-export const UploadBoard = as<'div', UploadBoardProps>(
- ({ header, showUploadCardBottom, children, ...props }, ref) => (
-
-
-
-
- {children}
-
-
- {header}
-
+export const UploadBoard = as<'div', UploadBoardProps>(({ header, children, ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+ {header}
- )
-);
+
+));
// Progress ticks re-render this header, so the caller reads uploads on demand here
// instead of subscribing to them itself.
diff --git a/src/app/features/forum/ForumMenu.tsx b/src/app/features/forum/ForumMenu.tsx
index d80a0fd3dc..21cb7159d1 100644
--- a/src/app/features/forum/ForumMenu.tsx
+++ b/src/app/features/forum/ForumMenu.tsx
@@ -23,10 +23,12 @@ import {
GearSix,
Link,
menuIcon,
+ SignOut,
Terminal,
UserCircle,
UserPlus,
} from '$components/icons/phosphor';
+import { useRoomMenuActions } from '$hooks/useRoomMenuActions';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { RoomSettingsPage } from '$state/roomSettings';
@@ -48,6 +50,7 @@ export const ForumMenu = forwardRef(
const navigate = useNavigate();
const parentSpace = useSpaceOptionally();
const isDirectRoom = useIsDirectRoom();
+ const { handleLeaveRoom } = useRoomMenuActions(room);
const [invitePrompt, setInvitePrompt] = useState(false);
@@ -157,6 +160,23 @@ export const ForumMenu = forwardRef(
)}
+
+
+
+
);
}
diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx
index b1219309da..b339b922ea 100644
--- a/src/app/features/lobby/SpaceItem.tsx
+++ b/src/app/features/lobby/SpaceItem.tsx
@@ -15,6 +15,8 @@ import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { mxcUrlToHttp } from '$utils/matrix';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { AddExistingModal } from '$features/add-existing';
+import { BetaNoticeBadge } from '$components/BetaNoticeBadge';
+import { CreateRoomType } from '$components/create-room/types';
import { useOpenShallowRoute } from '$pages/client/useShallowRoute';
import { getCreateRoomPath, getCreateSpacePath } from '$pages/pathUtils';
import { stopPropagation } from '$utils/keyboard';
@@ -234,8 +236,8 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
setCords(evt.currentTarget.getBoundingClientRect());
};
- const handleCreateRoom = () => {
- openShallowRoute(getCreateRoomPath(item.roomId));
+ const handleCreateRoom = (type?: CreateRoomType) => {
+ openShallowRoute(getCreateRoomPath(item.roomId, type));
setCords(undefined);
};
@@ -266,10 +268,20 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
radii="300"
variant="Primary"
fill="None"
- onClick={handleCreateRoom}
+ onClick={() => handleCreateRoom()}
>
New Room
+
diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts
index 85747dbd11..ca44978d86 100644
--- a/src/app/hooks/useRoomNavigate.ts
+++ b/src/app/hooks/useRoomNavigate.ts
@@ -8,11 +8,12 @@ import {
getDirectRoomPath,
getHomeForumPath,
getHomeRoomPath,
- getSpacePath,
getSpaceForumPath,
+ getSpacePath,
getSpaceRoomPath,
resolveSection,
} from '$pages/pathUtils';
+import { CustomRoomType } from '$types/matrix/room';
import { getOrphanParents, guessPerfectParent } from '$utils/room/hierarchy';
import { roomToParentsAtom } from '$state/room/roomToParents';
import { mDirectAtom } from '$state/mDirectList';
@@ -21,7 +22,6 @@ import { settingsAtom } from '$state/settings';
import { useSetting } from '$state/hooks/settings';
import { useSelectedSpace } from './router/useSelectedSpace';
import { useMatrixClient } from './useMatrixClient';
-import { CustomRoomType } from '$types/matrix/room';
export const useRoomNavigate = () => {
const navigate = useNavigate();
diff --git a/src/app/pages/client/RoomRoute.tsx b/src/app/pages/client/RoomRoute.tsx
index 2a1d1b4a63..08a1336ac3 100644
--- a/src/app/pages/client/RoomRoute.tsx
+++ b/src/app/pages/client/RoomRoute.tsx
@@ -13,40 +13,48 @@ import {
} from '$pages/pathUtils';
import { CustomRoomType } from '$types/matrix/room';
-type RoomRouteSection = 'home' | 'direct' | 'space';
+export type RoomRouteSection = 'home' | 'direct' | 'space';
-type RoomRouteProps = {
+type RoomGateProps = {
section: RoomRouteSection;
+ /** Which view the current route shows: forum or timeline. */
forum: boolean;
+ roomIdOrAlias?: string;
+ spaceIdOrAlias?: string;
+ eventId?: string;
};
-const decodeParam = (value: string | undefined): string | undefined =>
- value ? decodeURIComponent(value) : undefined;
-
-export function RoomRoute({ section, forum }: RoomRouteProps) {
+/** Renders the view matching the room type, redirecting when the route shows the other one. */
+export function RoomGate({
+ section,
+ forum,
+ roomIdOrAlias,
+ spaceIdOrAlias,
+ eventId,
+}: RoomGateProps) {
const room = useRoom();
const navigate = useNavigate();
- const { roomIdOrAlias, spaceIdOrAlias, eventId } = useParams();
const isForum = room.getType() === CustomRoomType.Forum;
useEffect(() => {
if (isForum === forum) return;
- const roomRef = decodeParam(roomIdOrAlias);
- if (!roomRef) return;
+ if (!roomIdOrAlias) return;
- const eventRef = decodeParam(eventId);
let path: string;
if (section === 'space') {
- const spaceRef = decodeParam(spaceIdOrAlias);
- if (!spaceRef) return;
+ if (!spaceIdOrAlias) return;
path = isForum
- ? getSpaceForumPath(spaceRef, roomRef, eventRef)
- : getSpaceRoomPath(spaceRef, roomRef, eventRef);
+ ? getSpaceForumPath(spaceIdOrAlias, roomIdOrAlias, eventId)
+ : getSpaceRoomPath(spaceIdOrAlias, roomIdOrAlias, eventId);
} else if (section === 'direct') {
- path = isForum ? getDirectForumPath(roomRef, eventRef) : getDirectRoomPath(roomRef, eventRef);
+ path = isForum
+ ? getDirectForumPath(roomIdOrAlias, eventId)
+ : getDirectRoomPath(roomIdOrAlias, eventId);
} else {
- path = isForum ? getHomeForumPath(roomRef, eventRef) : getHomeRoomPath(roomRef, eventRef);
+ path = isForum
+ ? getHomeForumPath(roomIdOrAlias, eventId)
+ : getHomeRoomPath(roomIdOrAlias, eventId);
}
navigate(path, { replace: true });
@@ -55,3 +63,24 @@ export function RoomRoute({ section, forum }: RoomRouteProps) {
if (isForum !== forum) return null;
return forum ? : ;
}
+
+type RoomRouteProps = {
+ section: RoomRouteSection;
+ forum: boolean;
+};
+
+const decodeParam = (value: string | undefined): string | undefined =>
+ value ? decodeURIComponent(value) : undefined;
+
+export function RoomRoute({ section, forum }: RoomRouteProps) {
+ const { roomIdOrAlias, spaceIdOrAlias, eventId } = useParams();
+ return (
+
+ );
+}
diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx
index a33dc16c70..56ba801515 100644
--- a/src/app/pages/client/home/Home.tsx
+++ b/src/app/pages/client/home/Home.tsx
@@ -25,6 +25,7 @@ import {
getHomeSearchPath,
withSearchParam,
} from '$pages/pathUtils';
+import { CustomRoomType } from '$types/matrix/room';
import { useOpenShallowRoute } from '$pages/client/useShallowRoute';
import { getCanonicalAliasOrRoomId } from '$utils/matrix';
import { useSelectedOrLastRoom } from '$hooks/router/useSelectedRoom';
@@ -65,7 +66,6 @@ import { useClientConfig } from '$hooks/useClientConfig';
import { getMxIdServer } from '$utils/mxIdHelper';
import { NavMenu } from '$components/nav/NavMenu';
import { useMenuAnchor } from '$hooks/useMenuAnchor';
-import { CustomRoomType } from '$types/matrix/room';
type HomeMenuProps = {
requestClose: () => void;
diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts
index 7d6bd6b656..ddf05e3407 100644
--- a/src/app/pages/pathUtils.ts
+++ b/src/app/pages/pathUtils.ts
@@ -183,8 +183,13 @@ export const getExploreServerPath = (server: string): string => {
export const getCreatePath = (): string => CREATE_PATH;
export const getCreateSpacePath = (spaceId?: string): string =>
spaceId ? withSearchParam(CREATE_PATH, { spaceId }) : CREATE_PATH;
-export const getCreateRoomPath = (spaceId?: string): string =>
- spaceId ? withSearchParam(CREATE_ROOM_PATH, { spaceId }) : CREATE_ROOM_PATH;
+export const getCreateRoomPath = (spaceId?: string, type?: string): string => {
+ const params: Record = {};
+ if (spaceId) params.spaceId = spaceId;
+ if (type) params.type = type;
+
+ return Object.keys(params).length ? withSearchParam(CREATE_ROOM_PATH, params) : CREATE_ROOM_PATH;
+};
export const getBugReportPath = (): string => BUG_REPORT_PATH;
export const getNavigatePath = (): string => NAVIGATE_PATH;
export const getProfilePath = (): string => PROFILE_PATH;
diff --git a/src/app/pages/roomRouteMatch.test.ts b/src/app/pages/roomRouteMatch.test.ts
new file mode 100644
index 0000000000..41d5cc2a47
--- /dev/null
+++ b/src/app/pages/roomRouteMatch.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it, vi } from 'vitest';
+import { matchRoomRoute } from './roomRouteMatch';
+
+vi.mock('@tauri-apps/api/core', () => ({ isTauri: () => false, invoke: () => undefined }));
+
+const roomId = '!room:example.com';
+const encodedRoomId = encodeURIComponent(roomId);
+const eventId = '$event-id';
+
+describe('matchRoomRoute', () => {
+ it('matches a bare room route in every section', () => {
+ expect(matchRoomRoute(`/home/${encodedRoomId}/`)).toEqual({
+ roomIdOrAlias: roomId,
+ eventId: undefined,
+ });
+ expect(matchRoomRoute(`/direct/${encodedRoomId}/`)).toEqual({
+ roomIdOrAlias: roomId,
+ eventId: undefined,
+ });
+ expect(matchRoomRoute(`/!space:example.com/${encodedRoomId}/`)).toEqual({
+ roomIdOrAlias: roomId,
+ eventId: undefined,
+ });
+ });
+
+ it('matches a room route with an event id', () => {
+ expect(matchRoomRoute(`/home/${encodedRoomId}/${encodeURIComponent(eventId)}/`)).toEqual({
+ roomIdOrAlias: roomId,
+ eventId,
+ });
+ });
+
+ it('rejects forum routes', () => {
+ expect(matchRoomRoute(`/home/${encodedRoomId}/forum/`)).toBeUndefined();
+ expect(matchRoomRoute(`/direct/${encodedRoomId}/forum/`)).toBeUndefined();
+ expect(matchRoomRoute(`/!space:example.com/${encodedRoomId}/forum/`)).toBeUndefined();
+ });
+
+ it('drops a second segment that is not an event id', () => {
+ expect(matchRoomRoute(`/home/${encodedRoomId}/anything/`)).toEqual({
+ roomIdOrAlias: roomId,
+ eventId: undefined,
+ });
+ });
+
+ it('rejects non-room first segments', () => {
+ expect(matchRoomRoute('/home/create/')).toBeUndefined();
+ expect(matchRoomRoute('/!space:example.com/lobby/')).toBeUndefined();
+ });
+});
diff --git a/src/app/pages/roomRouteMatch.ts b/src/app/pages/roomRouteMatch.ts
new file mode 100644
index 0000000000..bbd4580e9c
--- /dev/null
+++ b/src/app/pages/roomRouteMatch.ts
@@ -0,0 +1,43 @@
+import { matchPath } from 'react-router-dom';
+import { isEventId, isRoomAlias, isRoomId } from '$utils/matrix';
+import {
+ DIRECT_ROOM_FORUM_PATH,
+ DIRECT_ROOM_PATH,
+ HOME_ROOM_FORUM_PATH,
+ HOME_ROOM_PATH,
+ SPACE_ROOM_FORUM_PATH,
+ SPACE_ROOM_PATH,
+} from './paths';
+
+export type RoomRouteMatch = {
+ roomIdOrAlias: string;
+ eventId?: string;
+};
+
+const isForumRoute = (pathname: string): boolean =>
+ [HOME_ROOM_FORUM_PATH, DIRECT_ROOM_FORUM_PATH, SPACE_ROOM_FORUM_PATH].some(
+ (path) => matchPath({ path, end: false }, pathname) !== null
+ );
+
+export const matchRoomRoute = (pathname: string): RoomRouteMatch | undefined => {
+ if (isForumRoute(pathname)) return undefined;
+
+ const match =
+ matchPath({ path: HOME_ROOM_PATH, end: false }, pathname) ??
+ matchPath({ path: DIRECT_ROOM_PATH, end: false }, pathname) ??
+ matchPath({ path: SPACE_ROOM_PATH, end: false }, pathname);
+ if (!match) return undefined;
+
+ const encodedId = match.params.roomIdOrAlias;
+ if (!encodedId) return undefined;
+ const roomIdOrAlias = decodeURIComponent(encodedId);
+ if (!isRoomId(roomIdOrAlias) && !isRoomAlias(roomIdOrAlias)) return undefined;
+
+ const encodedEvent = match.params.eventId;
+ const eventId = encodedEvent ? decodeURIComponent(encodedEvent) : undefined;
+
+ return {
+ roomIdOrAlias,
+ eventId: eventId && isEventId(eventId) ? eventId : undefined,
+ };
+};
diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts
index 043f6f5858..7fbd9bfed0 100644
--- a/src/app/utils/matrix.ts
+++ b/src/app/utils/matrix.ts
@@ -46,6 +46,8 @@ export const isRoomId = (id: string): boolean => id.startsWith('!');
export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#');
+export const isEventId = (id: string): boolean => id.startsWith('$');
+
export const getCanonicalAliasRoomId = (mx: MatrixClient, alias: string): string | undefined =>
mx
.getRooms()