Room list: add custom section creation in SC (#33138)

* feat: add way to create custom section in SC

* feat: disable custom section in vms

* chore: include design tokens icons in vitest config

* test: update screenshot

* chore: use one react import
This commit is contained in:
Florian Duros
2026-04-16 15:14:10 +02:00
committed by GitHub
parent 64d3802efe
commit d7f5546294
40 changed files with 16386 additions and 13207 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

@@ -199,8 +199,11 @@ export class RoomListHeaderViewModel
SettingsStore.setValue("RoomList.showMessagePreview", null, SettingLevel.DEVICE, isMessagePreviewEnabled); SettingsStore.setValue("RoomList.showMessagePreview", null, SettingLevel.DEVICE, isMessagePreviewEnabled);
this.snapshot.merge({ isMessagePreviewEnabled }); this.snapshot.merge({ isMessagePreviewEnabled });
}; };
}
public createSection = (): void => {
// To be implemented when custom section creation is added in vms
};
}
/** /**
* Get the initial snapshot for the RoomListHeaderViewModel. * Get the initial snapshot for the RoomListHeaderViewModel.
* @param spaceStore - The space store instance. * @param spaceStore - The space store instance.
@@ -280,5 +283,8 @@ function computeHeaderSpaceState(
displaySpaceMenu, displaySpaceMenu,
canInviteInSpace, canInviteInSpace,
canAccessSpaceSettings, canAccessSpaceSettings,
// To be implemented when custom section creation is added in vms
canCreateSection: false,
useComposeIcon: true,
}; };
} }
@@ -303,6 +303,8 @@ export class RoomListItemViewModel
canMarkAsRead, canMarkAsRead,
canMarkAsUnread, canMarkAsUnread,
roomNotifState, roomNotifState,
// To be implemented when custom section creation is added in vms
canMoveToSection: false,
}; };
} }
@@ -381,4 +383,8 @@ export class RoomListItemViewModel
const echoChamber = EchoChamber.forRoom(this.props.room); const echoChamber = EchoChamber.forRoom(this.props.room);
echoChamber.notificationVolume = elementNotifState; echoChamber.notificationVolume = elementNotifState;
}; };
public onCreateSection = (): void => {
// To be implemented when custom section creation is added in vms
};
} }
@@ -572,6 +572,12 @@ export class RoomListViewModel
}); });
} }
}; };
public closeToast: () => void = () => {
this.snapshot.merge({
toast: undefined,
});
};
} }
/** /**
@@ -5,6 +5,7 @@
"action": { "action": {
"back": "Back", "back": "Back",
"click": "Click", "click": "Click",
"close": "Close",
"collapse": "Collapse", "collapse": "Collapse",
"delete": "Delete", "delete": "Delete",
"dismiss": "Dismiss", "dismiss": "Dismiss",
@@ -16,6 +17,7 @@
"invite": "Invite", "invite": "Invite",
"new_conversation": "New conversation", "new_conversation": "New conversation",
"new_room": "New room", "new_room": "New room",
"new_section": "New section",
"new_video_room": "New video room", "new_video_room": "New video room",
"open_menu": "Open menu", "open_menu": "Open menu",
"pause": "Pause", "pause": "Pause",
@@ -132,7 +134,8 @@
"leave_room": "Leave room", "leave_room": "Leave room",
"low_priority": "Low priority", "low_priority": "Low priority",
"mark_read": "Mark as read", "mark_read": "Mark as read",
"mark_unread": "Mark as unread" "mark_unread": "Mark as unread",
"move_to_section": "Move to"
}, },
"notification_options": "Notification options", "notification_options": "Notification options",
"open_space_menu": "Open space menu", "open_space_menu": "Open space menu",
@@ -141,6 +144,7 @@
"more_options": "More Options" "more_options": "More Options"
}, },
"room_options": "Room Options", "room_options": "Room Options",
"section_created": "Section created",
"section_header": { "section_header": {
"toggle": "Toggle %(section)s section", "toggle": "Toggle %(section)s section",
"toggle_unread": "Toggle %(section)s section with unread room(s)" "toggle_unread": "Toggle %(section)s section with unread room(s)"
@@ -30,6 +30,7 @@ const RoomListHeaderViewWrapperImpl = ({
openSpacePreferences, openSpacePreferences,
sort, sort,
toggleMessagePreview, toggleMessagePreview,
createSection,
...rest ...rest
}: RoomListHeaderProps): JSX.Element => { }: RoomListHeaderProps): JSX.Element => {
const vm = useMockedViewModel(rest, { const vm = useMockedViewModel(rest, {
@@ -42,6 +43,7 @@ const RoomListHeaderViewWrapperImpl = ({
sort, sort,
openSpacePreferences, openSpacePreferences,
toggleMessagePreview, toggleMessagePreview,
createSection,
}); });
return <RoomListHeaderView vm={vm} />; return <RoomListHeaderView vm={vm} />;
}; };
@@ -62,6 +64,7 @@ const meta = {
sort: fn(), sort: fn(),
openSpacePreferences: fn(), openSpacePreferences: fn(),
toggleMessagePreview: fn(), toggleMessagePreview: fn(),
createSection: fn(),
}, },
parameters: { parameters: {
design: { design: {
@@ -100,3 +103,9 @@ export const LongTitle: Story = {
title: "Loooooooooooooooooooooooooooooooooooooong title", title: "Loooooooooooooooooooooooooooooooooooooong title",
}, },
}; };
export const PlusIcon: Story = {
args: {
useComposeIcon: false,
},
};
@@ -8,6 +8,7 @@
import React, { type JSX } from "react"; import React, { type JSX } from "react";
import { IconButton, H1 } from "@vector-im/compound-web"; import { IconButton, H1 } from "@vector-im/compound-web";
import ComposeIcon from "@vector-im/compound-design-tokens/assets/web/icons/compose"; import ComposeIcon from "@vector-im/compound-design-tokens/assets/web/icons/compose";
import PlusIcon from "@vector-im/compound-design-tokens/assets/web/icons/plus";
import { type ViewModel, useViewModel } from "../../core/viewmodel"; import { type ViewModel, useViewModel } from "../../core/viewmodel";
import { Flex } from "../../core/utils/Flex"; import { Flex } from "../../core/utils/Flex";
@@ -59,6 +60,14 @@ export interface RoomListHeaderViewSnapshot {
* Whether message previews are enabled in the room list. * Whether message previews are enabled in the room list.
*/ */
isMessagePreviewEnabled: boolean; isMessagePreviewEnabled: boolean;
/**
* Whether the user can create sections in the room list.
*/
canCreateSection: boolean;
/**
* Whether to use the compose icon instead of the create icon.
*/
useComposeIcon: boolean;
} }
export interface RoomListHeaderViewActions { export interface RoomListHeaderViewActions {
@@ -98,6 +107,10 @@ export interface RoomListHeaderViewActions {
* Toggle message preview display in the room list. * Toggle message preview display in the room list.
*/ */
toggleMessagePreview: () => void; toggleMessagePreview: () => void;
/**
* Create a new section in the room list.
*/
createSection: () => void;
} }
/** /**
@@ -123,7 +136,7 @@ interface RoomListHeaderViewProps {
*/ */
export function RoomListHeaderView({ vm }: Readonly<RoomListHeaderViewProps>): JSX.Element { export function RoomListHeaderView({ vm }: Readonly<RoomListHeaderViewProps>): JSX.Element {
const { translate: _t } = useI18n(); const { translate: _t } = useI18n();
const { title, displaySpaceMenu, displayComposeMenu } = useViewModel(vm); const { title, displaySpaceMenu, displayComposeMenu, useComposeIcon } = useViewModel(vm);
return ( return (
<Flex <Flex
@@ -153,7 +166,11 @@ export function RoomListHeaderView({ vm }: Readonly<RoomListHeaderViewProps>): J
onClick={(e) => vm.createChatRoom(e.nativeEvent)} onClick={(e) => vm.createChatRoom(e.nativeEvent)}
tooltip={_t("action|new_conversation")} tooltip={_t("action|new_conversation")}
> >
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden /> {useComposeIcon ? (
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
) : (
<PlusIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
)}
</IconButton> </IconButton>
)} )}
</Flex> </Flex>
@@ -110,6 +110,7 @@ exports[`RoomListHeaderView > renders the default state 1`] = `
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
color="var(--cpd-color-icon-secondary)"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -340,6 +341,7 @@ exports[`RoomListHeaderView > renders without space menu 1`] = `
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
color="var(--cpd-color-icon-secondary)"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -17,4 +17,6 @@ export const defaultSnapshot: RoomListHeaderViewSnapshot = {
canAccessSpaceSettings: true, canAccessSpaceSettings: true,
activeSortOption: "recent", activeSortOption: "recent",
isMessagePreviewEnabled: true, isMessagePreviewEnabled: true,
useComposeIcon: true,
canCreateSection: true,
}; };
@@ -101,4 +101,15 @@ describe("<ComposeMenuView />", () => {
expect(vm.createVideoRoom).toHaveBeenCalledTimes(1); expect(vm.createVideoRoom).toHaveBeenCalledTimes(1);
}); });
it("should create a new section", async () => {
const user = userEvent.setup();
const vm = new MockedViewModel({ ...defaultSnapshot, isMessagePreviewEnabled: true });
render(<ComposeMenuView vm={vm} />);
await user.click(screen.getByRole("button", { name: "New conversation" }));
await user.click(screen.getByRole("menuitem", { name: "New section" }));
expect(vm.createSection).toHaveBeenCalled();
});
}); });
@@ -11,6 +11,8 @@ import ComposeIcon from "@vector-im/compound-design-tokens/assets/web/icons/comp
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call"; import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call";
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat"; import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room"; import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room";
import SectionIcon from "@vector-im/compound-design-tokens/assets/web/icons/section";
import PlusIcon from "@vector-im/compound-design-tokens/assets/web/icons/plus";
import { type RoomListHeaderViewModel } from "../RoomListHeaderView"; import { type RoomListHeaderViewModel } from "../RoomListHeaderView";
import { useI18n } from "../../../core/i18n/i18nContext"; import { useI18n } from "../../../core/i18n/i18nContext";
@@ -35,7 +37,7 @@ interface ComposeMenuViewProps {
export function ComposeMenuView({ vm }: ComposeMenuViewProps): JSX.Element { export function ComposeMenuView({ vm }: ComposeMenuViewProps): JSX.Element {
const { translate: _t } = useI18n(); const { translate: _t } = useI18n();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { canCreateRoom, canCreateVideoRoom } = useViewModel(vm); const { canCreateRoom, canCreateVideoRoom, canCreateSection, useComposeIcon } = useViewModel(vm);
return ( return (
<Menu <Menu
@@ -47,7 +49,11 @@ export function ComposeMenuView({ vm }: ComposeMenuViewProps): JSX.Element {
trigger={ trigger={
// 28px button with a 20px icon // 28px button with a 20px icon
<IconButton size="28px" style={{ padding: "4px" }} tooltip={_t("action|new_conversation")}> <IconButton size="28px" style={{ padding: "4px" }} tooltip={_t("action|new_conversation")}>
<ComposeIcon aria-hidden /> {useComposeIcon ? (
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
) : (
<PlusIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
)}
</IconButton> </IconButton>
} }
> >
@@ -63,6 +69,9 @@ export function ComposeMenuView({ vm }: ComposeMenuViewProps): JSX.Element {
hideChevron hideChevron
/> />
)} )}
{canCreateSection && (
<MenuItem Icon={SectionIcon} label={_t("action|new_section")} onSelect={vm.createSection} hideChevron />
)}
</Menu> </Menu>
); );
} }
@@ -22,6 +22,7 @@ exports[`<ComposeMenuView /> > should match snapshot 1`] = `
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
color="var(--cpd-color-icon-secondary)"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -23,6 +23,7 @@ export class MockedViewModel extends MockViewModel<RoomListHeaderViewSnapshot> i
public sort = vi.fn<() => void>(); public sort = vi.fn<() => void>();
public openSpacePreferences = vi.fn<() => void>(); public openSpacePreferences = vi.fn<() => void>();
public toggleMessagePreview = vi.fn<() => void>(); public toggleMessagePreview = vi.fn<() => void>();
public createSection = vi.fn<() => void>();
} }
export { defaultSnapshot } from "./default-snapshot"; export { defaultSnapshot } from "./default-snapshot";
@@ -0,0 +1,15 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.toast {
position: absolute;
bottom: var(--cpd-space-4x);
left: var(--cpd-space-3x);
right: var(--cpd-space-3x);
width: fit-content;
margin: 0 auto;
}
@@ -0,0 +1,40 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RoomListToast } from "./RoomListToast";
const meta = {
title: "Room List/RoomListView/RoomListToast",
component: RoomListToast,
tags: ["autodocs"],
args: {
type: "section_created",
onClose: fn(),
},
argTypes: {
type: {
control: "select",
options: ["section_created"],
},
},
decorators: [
(Story) => (
<div style={{ position: "relative", width: "320px", height: "100px", backgroundColor: "grey" }}>
<Story />
</div>
),
],
} satisfies Meta<typeof RoomListToast>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SectionCreated: Story = {};
@@ -0,0 +1,31 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import userEvent from "@testing-library/user-event";
import * as stories from "./RoomListToast.stories";
const { SectionCreated } = composeStories(stories);
describe("<RoomListToast />", () => {
it("renders SectionCreated story", () => {
const { container } = render(<SectionCreated />);
expect(container).toMatchSnapshot();
});
it("calls onClose when the close button is clicked", async () => {
const user = userEvent.setup();
render(<SectionCreated />);
const closeButton = screen.getByRole("button", { name: "Close" });
await user.click(closeButton);
expect(SectionCreated.args.onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,47 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type ComponentType, type JSX, type MouseEventHandler } from "react";
import { Toast } from "@vector-im/compound-web";
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
import styles from "./RoomListToast.module.css";
import { useI18n } from "../../../core/i18n/i18nContext";
export type ToastType = "section_created";
interface RoomListToastProps {
/** The type of toast to display */
type: ToastType;
/** Callback when the close button is clicked */
onClose: MouseEventHandler<HTMLButtonElement>;
}
/**
* A toast component used for displaying temporary messages in the room list view.
*
* @example
* ```tsx
* <RoomListToast type="section_created" onClose={onCloseHandler} />
* ```
*/
export function RoomListToast({ type, onClose }: Readonly<RoomListToastProps>): JSX.Element {
const { translate: _t } = useI18n();
let content: { text: string; icon: ComponentType<React.SVGAttributes<SVGElement>> };
switch (type) {
case "section_created":
content = { text: _t("room_list|section_created"), icon: CheckIcon };
break;
}
return (
<Toast className={styles.toast} Icon={content.icon} onClose={onClose} tooltip={_t("action|close")}>
{content.text}
</Toast>
);
}
@@ -0,0 +1,57 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<RoomListToast /> > renders SectionCreated story 1`] = `
<div>
<div
style="position: relative; width: 320px; height: 100px; background-color: grey;"
>
<div
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 _toast-container_1ysb3_8 RoomListToast-module_toast _has-close_1ysb3_30"
>
<div
class="_content_1ysb3_34"
>
<svg
aria-hidden="true"
class="_icon_1ysb3_26"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
Section created
</div>
<button
aria-labelledby="_r_0_"
class="_icon-button_1215g_8 _close_1ysb3_41 _no-background_1215g_42"
data-kind="secondary"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</button>
</div>
</div>
</div>
`;
@@ -0,0 +1,9 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export type { ToastType } from "./RoomListToast";
export { RoomListToast } from "./RoomListToast";
@@ -0,0 +1,11 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.list {
position: relative;
flex: 1;
}
@@ -39,6 +39,7 @@ const RoomListViewWrapperImpl = ({
getSectionHeaderViewModel, getSectionHeaderViewModel,
updateVisibleRooms, updateVisibleRooms,
renderAvatar: renderAvatarProp, renderAvatar: renderAvatarProp,
closeToast,
...rest ...rest
}: RoomListViewProps): JSX.Element => { }: RoomListViewProps): JSX.Element => {
const vm = useMockedViewModel(rest, { const vm = useMockedViewModel(rest, {
@@ -48,6 +49,7 @@ const RoomListViewWrapperImpl = ({
getRoomItemViewModel, getRoomItemViewModel,
getSectionHeaderViewModel, getSectionHeaderViewModel,
updateVisibleRooms, updateVisibleRooms,
closeToast,
}); });
return <RoomListView vm={vm} renderAvatar={renderAvatarProp} />; return <RoomListView vm={vm} renderAvatar={renderAvatarProp} />;
}; };
@@ -98,6 +100,8 @@ const meta = {
updateVisibleRooms: fn(), updateVisibleRooms: fn(),
renderAvatar, renderAvatar,
isFlatList: true, isFlatList: true,
toast: undefined,
closeToast: fn(),
}, },
parameters: { parameters: {
design: { design: {
@@ -245,3 +249,9 @@ export const LargeSectionList: Story = {
getSectionHeaderViewModel: createGetSectionHeaderViewModel(mockLargeListSections.map((section) => section.id)), getSectionHeaderViewModel: createGetSectionHeaderViewModel(mockLargeListSections.map((section) => section.id)),
}, },
}; };
export const Toast: Story = {
args: {
toast: "section_created",
},
};
@@ -31,6 +31,7 @@ const {
EmptyInvitesFilter, EmptyInvitesFilter,
EmptyMentionsFilter, EmptyMentionsFilter,
EmptyLowPriorityFilter, EmptyLowPriorityFilter,
Toast,
} = composeStories(stories); } = composeStories(stories);
const renderWithMockContext = (component: React.ReactElement): ReturnType<typeof render> => { const renderWithMockContext = (component: React.ReactElement): ReturnType<typeof render> => {
@@ -124,6 +125,11 @@ describe("<RoomListView />", () => {
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it("renders Toast story", () => {
const { container } = renderWithMockContext(<Toast />);
expect(container).toMatchSnapshot();
});
it("should call onToggleFilter when filter is clicked", async () => { it("should call onToggleFilter when filter is clicked", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithMockContext(<Default />); renderWithMockContext(<Default />);
@@ -186,4 +192,13 @@ describe("<RoomListView />", () => {
expect(EmptyLowPriorityFilter.args.onToggleFilter).toHaveBeenCalled(); expect(EmptyLowPriorityFilter.args.onToggleFilter).toHaveBeenCalled();
}); });
it("should call closeToast when close button is clicked on toast", async () => {
const user = userEvent.setup();
renderWithMockContext(<Toast />);
await user.click(screen.getByRole("button", { name: "Close" }));
expect(EmptyLowPriorityFilter.args.closeToast).toHaveBeenCalled();
});
}); });
@@ -17,6 +17,9 @@ import {
type RoomListItemViewModel, type RoomListItemViewModel,
} from "../VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView"; } from "../VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView";
import { type RoomListSectionHeaderViewModel } from "../VirtualizedRoomListView/RoomListSectionHeaderView"; import { type RoomListSectionHeaderViewModel } from "../VirtualizedRoomListView/RoomListSectionHeaderView";
import { type ToastType, RoomListToast } from "./RoomListToast";
import styles from "./RoomListView.module.css";
import { Flex } from "../../core/utils/Flex";
export type RoomListSection = { export type RoomListSection = {
/** Unique identifier for the section */ /** Unique identifier for the section */
@@ -49,6 +52,8 @@ export type RoomListViewSnapshot = {
canCreateRoom?: boolean; canCreateRoom?: boolean;
/** Whether the room list is displayed as a flat list */ /** Whether the room list is displayed as a flat list */
isFlatList: boolean; isFlatList: boolean;
/** Optional toast to display */
toast?: ToastType;
}; };
/** /**
@@ -70,6 +75,8 @@ export interface RoomListViewActions {
updateVisibleRooms: (startIndex: number, endIndex: number) => void; updateVisibleRooms: (startIndex: number, endIndex: number) => void;
/** Get view model for a specific section header (virtualization API) */ /** Get view model for a specific section header (virtualization API) */
getSectionHeaderViewModel: (sectionId: string) => RoomListSectionHeaderViewModel; getSectionHeaderViewModel: (sectionId: string) => RoomListSectionHeaderViewModel;
/** Called to close the toast message */
closeToast: () => void;
} }
/** /**
@@ -113,7 +120,10 @@ export const RoomListView: React.FC<RoomListViewProps> = ({ vm, renderAvatar, on
onToggleFilter={vm.onToggleFilter} onToggleFilter={vm.onToggleFilter}
/> />
</div> </div>
{listBody} <Flex direction="column" className={styles.list}>
{listBody}
{snapshot.toast && <RoomListToast type={snapshot.toast} onClose={vm.closeToast} />}
</Flex>
</> </>
); );
}; };
@@ -10,7 +10,7 @@ import { render, screen } from "@test-utils";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { RoomListItemMoreOptionsMenu } from "./RoomListItemMoreOptionsMenu"; import { RoomListItemMoreOptionsMenu, MoreOptionContent } from "./RoomListItemMoreOptionsMenu";
import { useMockedViewModel } from "../../../../core/viewmodel"; import { useMockedViewModel } from "../../../../core/viewmodel";
import type { RoomListItemViewSnapshot } from "./RoomListItemView"; import type { RoomListItemViewSnapshot } from "./RoomListItemView";
import { defaultSnapshot } from "./default-snapshot"; import { defaultSnapshot } from "./default-snapshot";
@@ -26,6 +26,7 @@ describe("<RoomListItemMoreOptionsMenu />", () => {
onCopyRoomLink: vi.fn(), onCopyRoomLink: vi.fn(),
onLeaveRoom: vi.fn(), onLeaveRoom: vi.fn(),
onSetRoomNotifState: vi.fn(), onSetRoomNotifState: vi.fn(),
onCreateSection: vi.fn(),
}; };
const renderMenu = (overrides: Partial<RoomListItemViewSnapshot> = {}): ReturnType<typeof render> => { const renderMenu = (overrides: Partial<RoomListItemViewSnapshot> = {}): ReturnType<typeof render> => {
@@ -224,4 +225,19 @@ describe("<RoomListItemMoreOptionsMenu />", () => {
expect(mockCallbacks.onLeaveRoom).toHaveBeenCalled(); expect(mockCallbacks.onLeaveRoom).toHaveBeenCalled();
}); });
it("should call onCreateSection when new section is clicked", async () => {
const user = userEvent.setup();
// We need to render the MoreOptionContent directly here as radix is kind of messing in the test env
const TestComponent = (): JSX.Element => {
const vm = useMockedViewModel(defaultSnapshot, mockCallbacks);
return <MoreOptionContent vm={vm} />;
};
render(<TestComponent />);
const newSection = screen.getByRole("menuitem", { name: "New section" });
await user.click(newSection);
expect(mockCallbacks.onCreateSection).toHaveBeenCalled();
});
}); });
@@ -6,7 +6,7 @@
*/ */
import React, { useState, type JSX } from "react"; import React, { useState, type JSX } from "react";
import { IconButton, Menu, MenuItem, Separator, ToggleMenuItem } from "@vector-im/compound-web"; import { IconButton, Menu, MenuItem, Separator, SubMenu, ToggleMenuItem } from "@vector-im/compound-web";
import { import {
MarkAsReadIcon, MarkAsReadIcon,
MarkAsUnreadIcon, MarkAsUnreadIcon,
@@ -16,6 +16,7 @@ import {
LinkIcon, LinkIcon,
LeaveIcon, LeaveIcon,
OverflowHorizontalIcon, OverflowHorizontalIcon,
ArrowRightIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons"; } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../../core/i18n/i18n"; import { _t } from "../../../../core/i18n/i18n";
@@ -106,6 +107,7 @@ export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
onSelect={vm.onToggleLowPriority} onSelect={vm.onToggleLowPriority}
onClick={(evt) => evt.stopPropagation()} onClick={(evt) => evt.stopPropagation()}
/> />
<Separator />
{snapshot.canInvite && ( {snapshot.canInvite && (
<MenuItem <MenuItem
Icon={UserAddIcon} Icon={UserAddIcon}
@@ -124,6 +126,19 @@ export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
hideChevron={true} hideChevron={true}
/> />
)} )}
{snapshot.canMoveToSection && (
<SubMenu
trigger={
<MenuItem
Icon={ArrowRightIcon}
label={_t("room_list|more_options|move_to_section")}
onSelect={null}
/>
}
>
<MenuItem label={_t("action|new_section")} onSelect={vm.onCreateSection} hideChevron={true} />
</SubMenu>
)}
<Separator /> <Separator />
<MenuItem <MenuItem
kind="critical" kind="critical"
@@ -27,6 +27,7 @@ describe("<RoomListItemNotificationMenu />", () => {
onCopyRoomLink: vi.fn(), onCopyRoomLink: vi.fn(),
onLeaveRoom: vi.fn(), onLeaveRoom: vi.fn(),
onSetRoomNotifState: vi.fn(), onSetRoomNotifState: vi.fn(),
onCreateSection: vi.fn(),
}; };
const renderMenu = (roomNotifState: RoomNotifState = RoomNotifState.AllMessages): ReturnType<typeof render> => { const renderMenu = (roomNotifState: RoomNotifState = RoomNotifState.AllMessages): ReturnType<typeof render> => {
@@ -38,6 +38,7 @@ const RoomListItemWrapperImpl = ({
onCopyRoomLink, onCopyRoomLink,
onLeaveRoom, onLeaveRoom,
onSetRoomNotifState, onSetRoomNotifState,
onCreateSection,
isSelected, isSelected,
isFocused, isFocused,
onFocus, onFocus,
@@ -56,6 +57,7 @@ const RoomListItemWrapperImpl = ({
onCopyRoomLink, onCopyRoomLink,
onLeaveRoom, onLeaveRoom,
onSetRoomNotifState, onSetRoomNotifState,
onCreateSection,
}); });
return ( return (
<RoomListItemView <RoomListItemView
@@ -79,6 +79,8 @@ export interface RoomListItemViewSnapshot {
canMarkAsUnread: boolean; canMarkAsUnread: boolean;
/** The room's notification state */ /** The room's notification state */
roomNotifState: RoomNotifState; roomNotifState: RoomNotifState;
/** Whether the room can be moved to a section */
canMoveToSection: boolean;
} }
/** /**
@@ -104,6 +106,8 @@ export interface RoomListItemViewActions {
onLeaveRoom: () => void; onLeaveRoom: () => void;
/** Called when setting the room notification state */ /** Called when setting the room notification state */
onSetRoomNotifState: (state: RoomNotifState) => void; onSetRoomNotifState: (state: RoomNotifState) => void;
/** Called when creating a new section */
onCreateSection: () => void;
} }
/** /**
@@ -36,4 +36,5 @@ export const defaultSnapshot: RoomListItemViewSnapshot = {
canMarkAsRead: false, canMarkAsRead: false,
canMarkAsUnread: true, canMarkAsUnread: true,
roomNotifState: RoomNotifState.AllMessages, roomNotifState: RoomNotifState.AllMessages,
canMoveToSection: true,
}; };
@@ -19,4 +19,5 @@ export const mockedActions: RoomListItemViewActions = {
onCopyRoomLink: fn(), onCopyRoomLink: fn(),
onLeaveRoom: fn(), onLeaveRoom: fn(),
onSetRoomNotifState: fn(), onSetRoomNotifState: fn(),
onCreateSection: fn(),
}; };
@@ -9,6 +9,5 @@
* Room list container styles * Room list container styles
*/ */
.roomList { .roomList {
height: 100%;
width: 100%; width: 100%;
} }
@@ -34,6 +34,7 @@ const RoomListWrapperImpl = ({
getRoomItemViewModel, getRoomItemViewModel,
getSectionHeaderViewModel, getSectionHeaderViewModel,
updateVisibleRooms, updateVisibleRooms,
closeToast,
renderAvatar: renderAvatarProp, renderAvatar: renderAvatarProp,
...rest ...rest
}: RoomListStoryProps): JSX.Element => { }: RoomListStoryProps): JSX.Element => {
@@ -44,6 +45,7 @@ const RoomListWrapperImpl = ({
getRoomItemViewModel, getRoomItemViewModel,
getSectionHeaderViewModel, getSectionHeaderViewModel,
updateVisibleRooms, updateVisibleRooms,
closeToast,
}); });
return ( return (
@@ -82,6 +84,7 @@ const meta = {
updateVisibleRooms: fn(), updateVisibleRooms: fn(),
renderAvatar, renderAvatar,
isFlatList: true, isFlatList: true,
closeToast: fn(),
}, },
parameters: { parameters: {
design: { design: {
@@ -21,6 +21,7 @@ import type { RoomListViewSnapshot, RoomListViewModel } from "../RoomListView";
import { GroupedVirtualizedList } from "../../core/VirtualizedList"; import { GroupedVirtualizedList } from "../../core/VirtualizedList";
import { RoomListSectionHeaderView } from "./RoomListSectionHeaderView"; import { RoomListSectionHeaderView } from "./RoomListSectionHeaderView";
import { RoomListItemAccessibilityWrapper } from "./RoomListItemAccessibilityWrapper"; import { RoomListItemAccessibilityWrapper } from "./RoomListItemAccessibilityWrapper";
import styles from "./VirtualizedRoomListView.module.css";
/** /**
* Filter key type - opaque string type for filter identifiers * Filter key type - opaque string type for filter identifiers
@@ -350,6 +351,7 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
rangeChanged, rangeChanged,
onKeyDown, onKeyDown,
increaseViewportBy, increaseViewportBy,
className: styles.roomList,
}; };
if (isFlatList) { if (isFlatList) {
@@ -10,6 +10,7 @@ exports[`<VirtualizedRoomListView /> > renders Default story 1`] = `
> >
<div <div
aria-label="Room list" aria-label="Room list"
class="VirtualizedRoomListView-module_roomList"
data-testid="room-list" data-testid="room-list"
data-virtuoso-scroller="true" data-virtuoso-scroller="true"
role="listbox" role="listbox"
@@ -105,6 +105,7 @@ export const createMockRoomSnapshot = (id: string, name: string, index: number):
canMarkAsRead: false, canMarkAsRead: false,
canMarkAsUnread: true, canMarkAsUnread: true,
roomNotifState: RoomNotifState.AllMessages, roomNotifState: RoomNotifState.AllMessages,
canMoveToSection: true,
}); });
export function createMockRoomItemViewModel(roomId: string, name: string, index: number): RoomListItemViewModel { export function createMockRoomItemViewModel(roomId: string, name: string, index: number): RoomListItemViewModel {
@@ -121,6 +122,7 @@ export function createMockRoomItemViewModel(roomId: string, name: string, index:
onCopyRoomLink: fn(), onCopyRoomLink: fn(),
onLeaveRoom: fn(), onLeaveRoom: fn(),
onSetRoomNotifState: fn(), onSetRoomNotifState: fn(),
onCreateSection: fn(),
}; };
} }
+5 -1
View File
@@ -150,7 +150,11 @@ export default defineConfig({
], ],
}, },
optimizeDeps: { optimizeDeps: {
include: ["vite-plugin-node-polyfills/shims/buffer", "vite-plugin-node-polyfills/shims/process"], include: [
"vite-plugin-node-polyfills/shims/buffer",
"vite-plugin-node-polyfills/shims/process",
"@vector-im/compound-design-tokens/assets/web/icons",
],
}, },
resolve: { resolve: {
alias: { alias: {