Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 5 additions & 27 deletions src/app/components/page/MobileNavDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ||
Expand Down
121 changes: 121 additions & 0 deletions src/app/components/page/PersistentRoomHost.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="room-timeline" />,
}));

vi.mock('$features/forum', () => ({
ForumView: () => <div data-testid="forum-view" />,
}));

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) => (
<RoomProvider value={makeRoom(roomIdOrAlias, CustomRoomType.Forum) as unknown as MatrixRoom}>
{children}
</RoomProvider>
),
};
});

vi.mock('$pages/client/direct', async () => {
const { RoomProvider } = await import('$hooks/useRoom');
const { CustomRoomType } = await import('$types/matrix/room');
return {
DirectRouteRoomProvider: ({ roomIdOrAlias, children }: MockProviderProps) => (
<RoomProvider value={makeRoom(roomIdOrAlias, CustomRoomType.Forum) as unknown as MatrixRoom}>
{children}
</RoomProvider>
),
};
});

vi.mock('$pages/client/space', async () => {
const { RoomProvider } = await import('$hooks/useRoom');
const { CustomRoomType } = await import('$types/matrix/room');
return {
SpaceRouteRoomProvider: ({ roomIdOrAlias, children }: MockProviderProps) => (
<RoomProvider value={makeRoom(roomIdOrAlias, CustomRoomType.Forum) as unknown as MatrixRoom}>
{children}
</RoomProvider>
),
};
});

function LocationProbe() {
const { pathname } = useLocation();
return <div data-testid="pathname">{pathname}</div>;
}

const renderHost = (pathname: string, lastRoom?: Record<string, string>) => {
const store = createStore();
if (lastRoom) store.set(lastVisitedRoomAtom, lastRoom);
return render(
<Provider store={store}>
<MemoryRouter initialEntries={[pathname]}>
<PersistentRoomHost inactive={false} />
<LocationProbe />
</MemoryRouter>
</Provider>
);
};

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');
});
});
77 changes: 35 additions & 42 deletions src/app/components/page/PersistentRoomHost.tsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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 = <Room />;
// 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 ? (
<RoomGate
section={hostedSection.kind}
forum={false}
roomIdOrAlias={displayed.roomIdOrAlias}
spaceIdOrAlias={hostedSection.spaceIdOrAlias}
eventId={displayed.eventId}
/>
) : (
<Room />
);

let hosted: ReactNode = null;
if (section?.key === 'home') {
Expand Down
25 changes: 11 additions & 14 deletions src/app/components/upload-board/UploadBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<Box className={css.UploadBoardBase} {...props} ref={ref}>
<Box className={css.UploadBoardContainer} justifyContent="End">
<Box className={classNames(css.UploadBoard)} direction="Column">
<Box grow="Yes" direction="Column">
{children}
</Box>
<Box direction="Column" shrink="No">
{header}
</Box>
export const UploadBoard = as<'div', UploadBoardProps>(({ header, children, ...props }, ref) => (
<Box className={css.UploadBoardBase} {...props} ref={ref}>
<Box className={css.UploadBoardContainer} justifyContent="End">
<Box className={classNames(css.UploadBoard)} direction="Column">
<Box grow="Yes" direction="Column">
{children}
</Box>
<Box direction="Column" shrink="No">
{header}
</Box>
</Box>
</Box>
)
);
</Box>
));

// Progress ticks re-render this header, so the caller reads uploads on demand here
// instead of subscribing to them itself.
Expand Down
20 changes: 20 additions & 0 deletions src/app/features/forum/ForumMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -48,6 +50,7 @@ export const ForumMenu = forwardRef<HTMLDivElement, ForumMenuProps>(
const navigate = useNavigate();
const parentSpace = useSpaceOptionally();
const isDirectRoom = useIsDirectRoom();
const { handleLeaveRoom } = useRoomMenuActions(room);

const [invitePrompt, setInvitePrompt] = useState(false);

Expand Down Expand Up @@ -157,6 +160,23 @@ export const ForumMenu = forwardRef<HTMLDivElement, ForumMenuProps>(
</MenuItem>
)}
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={async () => {
if (await handleLeaveRoom()) requestClose();
}}
variant="Critical"
fill="None"
size="300"
after={menuIcon(SignOut)}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Leave Room
</Text>
</MenuItem>
</Box>
</Menu>
);
}
Expand Down
18 changes: 15 additions & 3 deletions src/app/features/lobby/SpaceItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
};

Expand Down Expand Up @@ -266,10 +268,20 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
radii="300"
variant="Primary"
fill="None"
onClick={handleCreateRoom}
onClick={() => handleCreateRoom()}
>
<Text size="T300">New Room</Text>
</MenuItem>
<MenuItem
size="300"
radii="300"
variant="Primary"
fill="None"
onClick={() => handleCreateRoom(CreateRoomType.ForumRoom)}
after={<BetaNoticeBadge />}
>
<Text size="T300">Forum Room</Text>
</MenuItem>
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
<Text size="T300">Existing Room</Text>
</MenuItem>
Expand Down
Loading
Loading