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( )} + + + { + if (await handleLeaveRoom()) requestClose(); + }} + variant="Critical" + fill="None" + size="300" + after={menuIcon(SignOut)} + radii="300" + > + + Leave Room + + + ); } 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 + handleCreateRoom(CreateRoomType.ForumRoom)} + after={} + > + Forum Room + Existing 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()