diff --git a/.gitignore b/.gitignore index 3d6d723ac3..9583107920 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ electron/pub /index.html # version file and tarball created by `npm pack` / `yarn pack` /git-revision.txt +jest-sonar.xml *storybook.log storybook-static diff --git a/packages/shared-components/playwright/snapshots/room-list-roomlistheaderview--default-linux.png b/packages/shared-components/playwright/snapshots/room-list-roomlistheaderview--default-linux.png new file mode 100644 index 0000000000..aa0e385a97 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-list-roomlistheaderview--default-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-list-roomlistheaderview--no-compose-menu-linux.png b/packages/shared-components/playwright/snapshots/room-list-roomlistheaderview--no-compose-menu-linux.png new file mode 100644 index 0000000000..4e67ae0129 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-list-roomlistheaderview--no-compose-menu-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-list-roomlistheaderview--no-space-menu-linux.png b/packages/shared-components/playwright/snapshots/room-list-roomlistheaderview--no-space-menu-linux.png new file mode 100644 index 0000000000..1466544712 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-list-roomlistheaderview--no-space-menu-linux.png differ diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index ce72683f8a..016b996b6f 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -6,15 +6,27 @@ "delete": "Delete", "dismiss": "Dismiss", "explore_rooms": "Explore rooms", + "invite": "Invite", + "new_conversation": "New conversation", + "new_room": "New room", + "new_video_room": "New video room", + "open_menu": "Open menu", "pause": "Pause", "play": "Play", "retry": "Retry", - "search": "Search" + "search": "Search", + "start_chat": "Start chat" + }, + "common": { + "preferences": "Preferences" }, "left_panel": { "open_dial_pad": "Open dial pad" }, "room": { + "context_menu": { + "title": "Room options" + }, "status_bar": { "delete_all": "Delete all", "exceeded_resource_limit_description": "Please contact your service administrator to continue using the service.", @@ -31,6 +43,19 @@ "some_messages_not_sent": "Some of your messages have not been sent" } }, + "room_list": { + "open_space_menu": "Open space menu", + "room_options": "Room Options", + "sort": "Sort", + "sort_type": { + "activity": "Activity", + "atoz": "A-Z" + }, + "space_menu": { + "home": "Space home", + "space_settings": "Space settings" + } + }, "terms": { "tac_button": "Review terms and conditions" }, diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 7b9162a3f7..758010e313 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -20,6 +20,7 @@ export * from "./pill-input/PillInput"; export * from "./room/RoomStatusBar"; export * from "./rich-list/RichItem"; export * from "./rich-list/RichList"; +export * from "./room-list/RoomListHeaderView"; export * from "./room-list/RoomListSearchView"; export * from "./utils/Box"; export * from "./utils/Flex"; diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.module.css b/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.module.css new file mode 100644 index 0000000000..0ed647c777 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.module.css @@ -0,0 +1,23 @@ +/* + * 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. + */ + +.header { + flex: 0 0 60px; + padding: 0 var(--cpd-space-3x); +} + +.title { + min-width: 0; + + h1 { + /* Remove default h1 margin */ + margin: unset; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } +} diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.stories.tsx b/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.stories.tsx new file mode 100644 index 0000000000..83babc68d1 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.stories.tsx @@ -0,0 +1,81 @@ +/* + * 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 JSX } from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryFn } from "@storybook/react-vite"; +import { + RoomListHeaderView, + type RoomListHeaderViewActions, + type RoomListHeaderViewSnapshot, +} from "./RoomListHeaderView"; +import { useMockedViewModel } from "../../useMockedViewModel"; +import { defaultSnapshot } from "./test-utils"; + +type RoomListHeaderProps = RoomListHeaderViewSnapshot & RoomListHeaderViewActions; + +const RoomListHeaderViewWrapper = ({ + createChatRoom, + createRoom, + createVideoRoom, + openSpaceHome, + openSpaceSettings, + inviteInSpace, + openSpacePreferences, + sort, + ...rest +}: RoomListHeaderProps): JSX.Element => { + const vm = useMockedViewModel(rest, { + createChatRoom, + createRoom, + createVideoRoom, + openSpaceHome, + openSpaceSettings, + inviteInSpace, + sort, + openSpacePreferences, + }); + return ; +}; + +export default { + title: "Room List/RoomListHeaderView", + component: RoomListHeaderViewWrapper, + tags: ["autodocs"], + args: { + ...defaultSnapshot, + createChatRoom: fn(), + createRoom: fn(), + createVideoRoom: fn(), + openSpaceHome: fn(), + openSpaceSettings: fn(), + inviteInSpace: fn(), + sort: fn(), + openSpacePreferences: fn(), + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel?node-id=2925-19173", + }, + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); + +export const NoSpaceMenu = Template.bind({}); +NoSpaceMenu.args = { + displaySpaceMenu: false, +}; + +export const NoComposeMenu = Template.bind({}); +NoComposeMenu.args = { + displayComposeMenu: false, +}; diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.test.tsx b/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.test.tsx new file mode 100644 index 0000000000..960d708082 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.test.tsx @@ -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 { composeStories } from "@storybook/react-vite"; +import { render } from "jest-matrix-react"; +import React from "react"; + +import * as stories from "./RoomListHeaderView.stories"; + +const { Default, NoComposeMenu, NoSpaceMenu } = composeStories(stories); + +describe("RoomListHeaderView", () => { + it("renders the default state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders without compose menu", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders without space menu", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.tsx b/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.tsx new file mode 100644 index 0000000000..59cd6909e5 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeaderView/RoomListHeaderView.tsx @@ -0,0 +1,153 @@ +/* + * 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 JSX } from "react"; +import { IconButton, H1 } from "@vector-im/compound-web"; +import ComposeIcon from "@vector-im/compound-design-tokens/assets/web/icons/compose"; + +import { type ViewModel } from "../../viewmodel/ViewModel"; +import { useViewModel } from "../../useViewModel"; +import { Flex } from "../../utils/Flex"; +import { useI18n } from "../../utils/i18nContext"; +import { ComposeMenuView, OptionMenuView, SpaceMenuView } from "./menu"; +import styles from "./RoomListHeaderView.module.css"; + +/** + * The available sorting options for the room list. + */ +export type SortOption = "recent" | "alphabetical"; + +export interface RoomListHeaderViewSnapshot { + /** + * The title of the room list + */ + title: string; + /** + * Whether to display the compose menu + * True if the user can create rooms + */ + displayComposeMenu: boolean; + /** + * Whether to display the space menu + * True if there is an active space + */ + displaySpaceMenu: boolean; + /** + * Whether the user can create rooms + */ + canCreateRoom: boolean; + /** + * Whether the user can create video rooms + */ + canCreateVideoRoom: boolean; + /** + * Whether the user can invite in the active space + */ + canInviteInSpace: boolean; + /** + * Whether the user can access space settings + */ + canAccessSpaceSettings: boolean; + /** + * The currently active sort option. + */ + activeSortOption: SortOption; +} + +export interface RoomListHeaderViewActions { + /** + * Create a chat room + */ + createChatRoom: (e: Event) => void; + /** + * Create a room + */ + createRoom: (e: Event) => void; + /** + * Create a video room + */ + createVideoRoom: () => void; + /** + * Open the active space home + */ + openSpaceHome: () => void; + /** + * Display the space invite dialog + */ + inviteInSpace: () => void; + /** + * Open the space preferences + */ + openSpacePreferences: () => void; + /** + * Open the space settings + */ + openSpaceSettings: () => void; + /** + * Change the sort order of the room-list. + */ + sort: (option: SortOption) => void; +} + +/** + * The view model for the room list header component. + */ +export type RoomListHeaderViewModel = ViewModel & RoomListHeaderViewActions; + +interface RoomListHeaderViewProps { + /** + * The view model for the room list header component. + */ + vm: RoomListHeaderViewModel; +} + +/** + * The header view for the room list + * The space name is displayed and a compose menu is shown if the user can create rooms + * + * @example + * ```tsx + * + * ``` + */ +export function RoomListHeaderView({ vm }: Readonly): JSX.Element { + const { translate: _t } = useI18n(); + const { title, displaySpaceMenu, displayComposeMenu } = useViewModel(vm); + + return ( + + + + {title} + + {displaySpaceMenu && } + + + + + {/* If we don't display the compose menu, it means that the user can only send DM */} + {displayComposeMenu ? ( + + ) : ( + vm.createChatRoom(e.nativeEvent)} + tooltip={_t("action|new_conversation")} + > + + + )} + + + ); +} diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/__snapshots__/RoomListHeaderView.test.tsx.snap b/packages/shared-components/src/room-list/RoomListHeaderView/__snapshots__/RoomListHeaderView.test.tsx.snap new file mode 100644 index 0000000000..135e2c06ad --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeaderView/__snapshots__/RoomListHeaderView.test.tsx.snap @@ -0,0 +1,349 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`RoomListHeaderView renders the default state 1`] = ` + + + + + Rooms + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`RoomListHeaderView renders without compose menu 1`] = ` + + + + + Rooms + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`RoomListHeaderView renders without space menu 1`] = ` + + + + + Rooms + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/index.ts b/packages/shared-components/src/room-list/RoomListHeaderView/index.ts new file mode 100644 index 0000000000..a0b6edee11 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeaderView/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright 2025 New Vector 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 { + RoomListHeaderViewModel, + RoomListHeaderViewSnapshot, + RoomListHeaderViewActions, + SortOption, +} from "./RoomListHeaderView"; +export { RoomListHeaderView } from "./RoomListHeaderView"; diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/menu/ComposeMenuView.test.tsx b/packages/shared-components/src/room-list/RoomListHeaderView/menu/ComposeMenuView.test.tsx new file mode 100644 index 0000000000..71e95bc4e5 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeaderView/menu/ComposeMenuView.test.tsx @@ -0,0 +1,103 @@ +/* + * 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 "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; + +import { ComposeMenuView } from "./ComposeMenuView"; +import { defaultSnapshot, MockedViewModel } from "../test-utils"; + +describe("", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should match snapshot", () => { + const vm = new MockedViewModel(defaultSnapshot); + const { asFragment } = render(); + + expect(asFragment()).toMatchSnapshot(); + }); + + it("should display all menu options when fully enabled", async () => { + const user = userEvent.setup(); + + const vm = new MockedViewModel(defaultSnapshot); + render(); + + // Open the menu + const button = screen.getByRole("button", { name: "New conversation" }); + await user.click(button); + + expect(screen.getByRole("menuitem", { name: "Start chat" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "New room" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "New video room" })).toBeInTheDocument(); + }); + + it("should hide new room option when canCreateRoom is false", async () => { + const user = userEvent.setup(); + + const vm = new MockedViewModel({ ...defaultSnapshot, canCreateRoom: false }); + render(); + + const button = screen.getByRole("button", { name: "New conversation" }); + await user.click(button); + + expect(screen.queryByRole("menuitem", { name: "New room" })).not.toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Start chat" })).toBeInTheDocument(); + }); + + it("should hide video room option when canCreateVideoRoom is false", async () => { + const user = userEvent.setup(); + + const vm = new MockedViewModel({ ...defaultSnapshot, canCreateVideoRoom: false }); + render(); + + const button = screen.getByRole("button", { name: "New conversation" }); + await user.click(button); + + expect(screen.queryByRole("menuitem", { name: "New video room" })).not.toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Start chat" })).toBeInTheDocument(); + }); + + it("should call createChatRoom when Start chat is clicked", async () => { + const user = userEvent.setup(); + + const vm = new MockedViewModel(defaultSnapshot); + render(); + + await user.click(screen.getByRole("button", { name: "New conversation" })); + await user.click(screen.getByRole("menuitem", { name: "Start chat" })); + + expect(vm.createChatRoom).toHaveBeenCalledTimes(1); + }); + + it("should call createRoom when New room is clicked", async () => { + const user = userEvent.setup(); + + const vm = new MockedViewModel(defaultSnapshot); + render(); + + await user.click(screen.getByRole("button", { name: "New conversation" })); + await user.click(screen.getByRole("menuitem", { name: "New room" })); + + expect(vm.createRoom).toHaveBeenCalledTimes(1); + }); + + it("should call createVideoRoom when New video room is clicked", async () => { + const user = userEvent.setup(); + + const vm = new MockedViewModel(defaultSnapshot); + render(); + + await user.click(screen.getByRole("button", { name: "New conversation" })); + await user.click(screen.getByRole("menuitem", { name: "New video room" })); + + expect(vm.createVideoRoom).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/menu/ComposeMenuView.tsx b/packages/shared-components/src/room-list/RoomListHeaderView/menu/ComposeMenuView.tsx new file mode 100644 index 0000000000..ed3b0ac89f --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeaderView/menu/ComposeMenuView.tsx @@ -0,0 +1,68 @@ +/* + * 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, { useState, type JSX } from "react"; +import { IconButton, Menu, MenuItem } from "@vector-im/compound-web"; +import ComposeIcon from "@vector-im/compound-design-tokens/assets/web/icons/compose"; +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 RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room"; + +import { type RoomListHeaderViewModel } from "../RoomListHeaderView"; +import { useI18n } from "../../../utils/i18nContext"; +import { useViewModel } from "../../../useViewModel"; + +interface ComposeMenuViewProps { + /** + * The view model for the room list header + */ + vm: RoomListHeaderViewModel; +} + +/** + * A menu component that provides options for creating new conversations. + * Displays a dropdown menu with options to start a chat, create a room, or create a video room. + * + * @example + * ```tsx + * + * ``` + */ +export function ComposeMenuView({ vm }: ComposeMenuViewProps): JSX.Element { + const { translate: _t } = useI18n(); + const [open, setOpen] = useState(false); + const { canCreateRoom, canCreateVideoRoom } = useViewModel(vm); + + return ( + + + + } + > + + {canCreateRoom && ( + + )} + {canCreateVideoRoom && ( + + )} + + ); +} diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/menu/OptionMenuView.module.css b/packages/shared-components/src/room-list/RoomListHeaderView/menu/OptionMenuView.module.css new file mode 100644 index 0000000000..11a81da947 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeaderView/menu/OptionMenuView.module.css @@ -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. + */ + +.title { + /* For first title, there is already enough space at the top */ + margin-top: 0 !important; +} diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListOptionsMenu-test.tsx b/packages/shared-components/src/room-list/RoomListHeaderView/menu/OptionMenuView.test.tsx similarity index 52% rename from test/unit-tests/components/views/rooms/RoomListPanel/RoomListOptionsMenu-test.tsx rename to packages/shared-components/src/room-list/RoomListHeaderView/menu/OptionMenuView.test.tsx index 02ba7eb54d..c18c99b2d3 100644 --- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListOptionsMenu-test.tsx +++ b/packages/shared-components/src/room-list/RoomListHeaderView/menu/OptionMenuView.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2025 New Vector Ltd. + * 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. @@ -9,29 +9,26 @@ import React from "react"; import { render, screen } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; -import { RoomListOptionsMenu } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListOptionsMenu"; -import { type RoomListHeaderViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListHeaderViewModel"; +import { OptionMenuView } from "./OptionMenuView"; +import { defaultSnapshot, MockedViewModel } from "../test-utils"; + +describe("", () => { + afterEach(() => { + jest.clearAllMocks(); + }); -describe("", () => { it("should match snapshot", () => { - const vm = { - sort: jest.fn(), - } as unknown as RoomListHeaderViewState; - - const { asFragment } = render(); + const vm = new MockedViewModel(defaultSnapshot); + const { asFragment } = render(); expect(asFragment()).toMatchSnapshot(); }); - it("should show A to Z selected if activeSortOption is Alphabetic", async () => { + it("should show A to Z selected if activeSortOption is alphabetical", async () => { const user = userEvent.setup(); - const vm = { - sort: jest.fn(), - activeSortOption: "Alphabetic", - } as unknown as RoomListHeaderViewState; - - render(); + const vm = new MockedViewModel({ ...defaultSnapshot, activeSortOption: "alphabetical" }); + render(); // Open the menu const button = screen.getByRole("button", { name: "Room Options" }); @@ -41,15 +38,11 @@ describe("", () => { expect(screen.getByRole("menuitemradio", { name: "Activity" })).not.toBeChecked(); }); - it("should show Activity selected if activeSortOption is Recency", async () => { + it("should show Activity selected if activeSortOption is recent", async () => { const user = userEvent.setup(); - const vm = { - sort: jest.fn(), - activeSortOption: "Recency", - } as unknown as RoomListHeaderViewState; - - render(); + const vm = new MockedViewModel({ ...defaultSnapshot, activeSortOption: "recent" }); + render(); // Open the menu const button = screen.getByRole("button", { name: "Room Options" }); @@ -62,33 +55,26 @@ describe("", () => { it("should sort A to Z", async () => { const user = userEvent.setup(); - const vm = { - sort: jest.fn(), - } as unknown as RoomListHeaderViewState; - - render(); + const vm = new MockedViewModel(defaultSnapshot); + render(); await user.click(screen.getByRole("button", { name: "Room Options" })); await user.click(screen.getByRole("menuitemradio", { name: "A-Z" })); - expect(vm.sort).toHaveBeenCalledWith("Alphabetic"); + expect(vm.sort).toHaveBeenCalledWith("alphabetical"); }); it("should sort by activity", async () => { const user = userEvent.setup(); - const vm = { - sort: jest.fn(), - activeSortOption: "Alphabetic", - } as unknown as RoomListHeaderViewState; - - render(); + const vm = new MockedViewModel({ ...defaultSnapshot, activeSortOption: "recent" }); + render(); await user.click(screen.getByRole("button", { name: "Room Options" })); await user.click(screen.getByRole("menuitemradio", { name: "Activity" })); - expect(vm.sort).toHaveBeenCalledWith("Recency"); + expect(vm.sort).toHaveBeenCalledWith("recent"); }); }); diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/menu/OptionMenuView.tsx b/packages/shared-components/src/room-list/RoomListHeaderView/menu/OptionMenuView.tsx new file mode 100644 index 0000000000..ba21222d69 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeaderView/menu/OptionMenuView.tsx @@ -0,0 +1,70 @@ +/* + * 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 { IconButton, Menu, MenuTitle, RadioMenuItem } from "@vector-im/compound-web"; +import React, { type JSX, useState } from "react"; +import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal"; + +import { type RoomListHeaderViewModel } from "../RoomListHeaderView"; +import { useViewModel } from "../../../useViewModel"; +import { useI18n } from "../../../utils/i18nContext"; +import styles from "./OptionMenuView.module.css"; + +interface OptionMenuViewProps { + /** + * The view model for the room list header + */ + vm: RoomListHeaderViewModel; +} + +/** + * A menu component that provides sorting options for the room list. + * Displays a dropdown menu with radio buttons to sort rooms by activity or alphabetically. + * + * @example + * ```tsx + * + * ``` + */ +export function OptionMenuView({ vm }: OptionMenuViewProps): JSX.Element { + const { translate: _t } = useI18n(); + const [open, setOpen] = useState(false); + const { activeSortOption } = useViewModel(vm); + + return ( + + + + } + > + + vm.sort("recent")} + /> + vm.sort("alphabetical")} + /> + + ); +} diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/menu/SpaceMenuView.module.css b/packages/shared-components/src/room-list/RoomListHeaderView/menu/SpaceMenuView.module.css new file mode 100644 index 0000000000..ab29d4bc2a --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeaderView/menu/SpaceMenuView.module.css @@ -0,0 +1,18 @@ +/* + * 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. + */ + +.button { + svg { + transition: transform 0.1s linear; + } +} + +.button[aria-expanded="true"] { + svg { + transform: rotate(180deg); + } +} diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/menu/SpaceMenuView.test.tsx b/packages/shared-components/src/room-list/RoomListHeaderView/menu/SpaceMenuView.test.tsx new file mode 100644 index 0000000000..8da1b37019 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeaderView/menu/SpaceMenuView.test.tsx @@ -0,0 +1,115 @@ +/* + * 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 "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; + +import { SpaceMenuView } from "./SpaceMenuView"; +import { defaultSnapshot, MockedViewModel } from "../test-utils"; + +describe("", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should match snapshot", () => { + const vm = new MockedViewModel(defaultSnapshot); + const { asFragment } = render(); + + expect(asFragment()).toMatchSnapshot(); + }); + + it("should display the menu when button is clicked", async () => { + const user = userEvent.setup(); + + const vm = new MockedViewModel(defaultSnapshot); + render(); + + const button = screen.getByRole("button", { name: "Open space menu" }); + await user.click(button); + + expect(screen.getByRole("menuitem", { name: "Space home" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Invite" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Preferences" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Space settings" })).toBeInTheDocument(); + }); + + it("should hide invite option when canInviteInSpace is false", async () => { + const user = userEvent.setup(); + + const vm = new MockedViewModel({ ...defaultSnapshot, canInviteInSpace: false }); + render(); + + const button = screen.getByRole("button", { name: "Open space menu" }); + await user.click(button); + + expect(screen.queryByRole("menuitem", { name: "Invite" })).not.toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Space home" })).toBeInTheDocument(); + }); + + it("should hide space settings option when canAccessSpaceSettings is false", async () => { + const user = userEvent.setup(); + + const vm = new MockedViewModel({ ...defaultSnapshot, canAccessSpaceSettings: false }); + render(); + + const button = screen.getByRole("button", { name: "Open space menu" }); + await user.click(button); + + expect(screen.queryByRole("menuitem", { name: "Space settings" })).not.toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Space home" })).toBeInTheDocument(); + }); + + it("should call openSpaceHome when Home is clicked", async () => { + const user = userEvent.setup(); + + const vm = new MockedViewModel(defaultSnapshot); + render(); + + await user.click(screen.getByRole("button", { name: "Open space menu" })); + await user.click(screen.getByRole("menuitem", { name: "Space home" })); + + expect(vm.openSpaceHome).toHaveBeenCalledTimes(1); + }); + + it("should call inviteInSpace when Invite is clicked", async () => { + const user = userEvent.setup(); + + const vm = new MockedViewModel(defaultSnapshot); + render(); + + await user.click(screen.getByRole("button", { name: "Open space menu" })); + await user.click(screen.getByRole("menuitem", { name: "Invite" })); + + expect(vm.inviteInSpace).toHaveBeenCalledTimes(1); + }); + + it("should call openSpacePreferences when Preferences is clicked", async () => { + const user = userEvent.setup(); + + const vm = new MockedViewModel(defaultSnapshot); + render(); + + await user.click(screen.getByRole("button", { name: "Open space menu" })); + await user.click(screen.getByRole("menuitem", { name: "Preferences" })); + + expect(vm.openSpacePreferences).toHaveBeenCalledTimes(1); + }); + + it("should call openSpaceSettings when Space settings is clicked", async () => { + const user = userEvent.setup(); + + const vm = new MockedViewModel(defaultSnapshot); + render(); + + await user.click(screen.getByRole("button", { name: "Open space menu" })); + await user.click(screen.getByRole("menuitem", { name: "Space settings" })); + + expect(vm.openSpaceSettings).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/menu/SpaceMenuView.tsx b/packages/shared-components/src/room-list/RoomListHeaderView/menu/SpaceMenuView.tsx new file mode 100644 index 0000000000..29966a9318 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeaderView/menu/SpaceMenuView.tsx @@ -0,0 +1,81 @@ +/* + * 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 JSX, useState } from "react"; +import { IconButton, Menu, MenuItem } from "@vector-im/compound-web"; +import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down"; +import HomeIcon from "@vector-im/compound-design-tokens/assets/web/icons/home"; +import SettingsIcon from "@vector-im/compound-design-tokens/assets/web/icons/settings"; +import PreferencesIcon from "@vector-im/compound-design-tokens/assets/web/icons/preferences"; +import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add"; + +import styles from "./SpaceMenuView.module.css"; +import { useViewModel } from "../../../useViewModel"; +import { useI18n } from "../../../utils/i18nContext"; +import { type RoomListHeaderViewModel } from "../RoomListHeaderView"; + +interface SpaceMenuViewProps { + /** + * The view model for the room list header + */ + vm: RoomListHeaderViewModel; +} + +/** + * A menu component that provides space-specific actions. + * Displays a dropdown menu with options to navigate to space home, invite users, + * access preferences, and manage space settings. + * + * @example + * ```tsx + * + * ``` + */ +export function SpaceMenuView({ vm }: SpaceMenuViewProps): JSX.Element { + const { translate: _t } = useI18n(); + const { canInviteInSpace, canAccessSpaceSettings, title } = useViewModel(vm); + const [open, setOpen] = useState(false); + + return ( + + + + } + > + + {canInviteInSpace && ( + + )} + + {canAccessSpaceSettings && ( + + )} + + ); +} diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/menu/__snapshots__/ComposeMenuView.test.tsx.snap b/packages/shared-components/src/room-list/RoomListHeaderView/menu/__snapshots__/ComposeMenuView.test.tsx.snap new file mode 100644 index 0000000000..e31dc3ff1b --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeaderView/menu/__snapshots__/ComposeMenuView.test.tsx.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[` should match snapshot 1`] = ` + + + + + + + + + + +`; diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListOptionsMenu-test.tsx.snap b/packages/shared-components/src/room-list/RoomListHeaderView/menu/__snapshots__/OptionMenuView.test.tsx.snap similarity index 88% rename from test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListOptionsMenu-test.tsx.snap rename to packages/shared-components/src/room-list/RoomListHeaderView/menu/__snapshots__/OptionMenuView.test.tsx.snap index 40d66e26dd..cfa329c647 100644 --- a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListOptionsMenu-test.tsx.snap +++ b/packages/shared-components/src/room-list/RoomListHeaderView/menu/__snapshots__/OptionMenuView.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[` should match snapshot 1`] = ` +exports[` should match snapshot 1`] = ` should match snapshot 1`] = ` data-state="closed" id="radix-_r_0_" role="button" - style="--cpd-icon-button-size: 32px;" + style="--cpd-icon-button-size: 28px; padding: 4px;" tabindex="0" type="button" > @@ -22,7 +22,6 @@ exports[` should match snapshot 1`] = ` style="--cpd-icon-button-size: 100%;" > should match snapshot 1`] = ` + + + + + + + + + +`; diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/menu/index.ts b/packages/shared-components/src/room-list/RoomListHeaderView/menu/index.ts new file mode 100644 index 0000000000..b3f90d914b --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeaderView/menu/index.ts @@ -0,0 +1,10 @@ +/* + * 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 { OptionMenuView } from "./OptionMenuView"; +export { SpaceMenuView } from "./SpaceMenuView"; +export { ComposeMenuView } from "./ComposeMenuView"; diff --git a/packages/shared-components/src/room-list/RoomListHeaderView/test-utils.ts b/packages/shared-components/src/room-list/RoomListHeaderView/test-utils.ts new file mode 100644 index 0000000000..995e4fd775 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeaderView/test-utils.ts @@ -0,0 +1,34 @@ +/* + * 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 { MockViewModel } from "../../viewmodel"; +import { type RoomListHeaderViewActions, type RoomListHeaderViewSnapshot } from "./RoomListHeaderView"; + +/** + * A mocked ViewModel for the RoomListHeaderView, for use in tests. + */ +export class MockedViewModel extends MockViewModel implements RoomListHeaderViewActions { + public createChatRoom = jest.fn(); + public createRoom = jest.fn(); + public createVideoRoom = jest.fn(); + public openSpaceHome = jest.fn(); + public openSpaceSettings = jest.fn(); + public inviteInSpace = jest.fn(); + public sort = jest.fn(); + public openSpacePreferences = jest.fn(); +} + +export const defaultSnapshot: RoomListHeaderViewSnapshot = { + title: "Rooms", + displayComposeMenu: true, + displaySpaceMenu: true, + canCreateRoom: true, + canCreateVideoRoom: true, + canInviteInSpace: true, + canAccessSpaceSettings: true, + activeSortOption: "recent", +}; diff --git a/packages/shared-components/src/test/setupTests.ts b/packages/shared-components/src/test/setupTests.ts index 2e3481f4c5..e214a564ef 100644 --- a/packages/shared-components/src/test/setupTests.ts +++ b/packages/shared-components/src/test/setupTests.ts @@ -5,6 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import "@testing-library/jest-dom"; import fetchMock from "@fetch-mock/jest"; import { setLanguage } from "../../src/utils/i18n"; diff --git a/packages/shared-components/src/viewmodel/Disposables.ts b/packages/shared-components/src/viewmodel/Disposables.ts index 77df53d097..34934a55b7 100644 --- a/packages/shared-components/src/viewmodel/Disposables.ts +++ b/packages/shared-components/src/viewmodel/Disposables.ts @@ -49,7 +49,7 @@ export class Disposables { /** * Add an event listener that will be removed on dispose */ - public trackListener(emitter: EventEmitter, event: string, callback: (...args: unknown[]) => void): void { + public trackListener(emitter: EventEmitter, event: string | symbol, callback: (...args: unknown[]) => void): void { this.throwIfDisposed(); emitter.on(event, callback); this.track(() => { diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/room-panel-empty-room-list-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/room-panel-empty-room-list-linux.png index ac3f26e529..64d49f10bc 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/room-panel-empty-room-list-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/room-panel-empty-room-list-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-linux.png index 5e6ddff442..8256ddbe61 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-space-menu-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-space-menu-linux.png index 43d8781239..583b9f36f9 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-space-menu-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-space-menu-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-space-header-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-space-header-linux.png index c42c449281..008c9076b3 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-space-header-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-space-header-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png index 3795176be2..269c64d00c 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-smallscreen-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-smallscreen-linux.png index f21e92a373..3fa9fd4b0b 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-smallscreen-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-smallscreen-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png index a991d72bba..061a19617e 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png index 450cb384d6..721130747c 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png index 950bc3a0eb..b69b5fc013 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png index 5a9190aa6b..dc019d4fd5 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png index 1911271597..cb2e03ee47 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png index 9ca5dee856..201253ca4d 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png differ diff --git a/res/css/_components.pcss b/res/css/_components.pcss index db922e5404..a93f040b6c 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -268,7 +268,6 @@ @import "./views/room_settings/_AliasSettings.pcss"; @import "./views/rooms/RoomListPanel/_EmptyRoomList.pcss"; @import "./views/rooms/RoomListPanel/_RoomList.pcss"; -@import "./views/rooms/RoomListPanel/_RoomListHeaderView.pcss"; @import "./views/rooms/RoomListPanel/_RoomListItemMenuView.pcss"; @import "./views/rooms/RoomListPanel/_RoomListItemView.pcss"; @import "./views/rooms/RoomListPanel/_RoomListPanel.pcss"; diff --git a/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss b/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss deleted file mode 100644 index 5427e1f133..0000000000 --- a/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2025 New Vector 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. - */ - -.mx_RoomListHeaderView { - flex: 0 0 60px; - padding: 0 var(--cpd-space-3x); - - .mx_RoomListHeaderView_title { - min-width: 0; - - h1 { - all: unset; - font: var(--cpd-font-heading-sm-semibold); - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - } - - .mx_SpaceMenu_button { - svg { - transition: transform 0.1s linear; - } - } - - .mx_SpaceMenu_button[aria-expanded="true"] { - svg { - transform: rotate(180deg); - } - } - - .mx_RoomListHeaderView_ReleaseAnnouncementAnchor { - display: inline-flex; - } -} diff --git a/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx b/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx deleted file mode 100644 index 451a4898b7..0000000000 --- a/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Copyright 2025 New Vector 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 { useCallback } from "react"; -import { JoinRule, type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix"; - -import { useFeatureEnabled } from "../../../hooks/useSettings"; -import defaultDispatcher from "../../../dispatcher/dispatcher"; -import PosthogTrackers from "../../../PosthogTrackers"; -import { Action } from "../../../dispatcher/actions"; -import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; -import { - getMetaSpaceName, - type MetaSpace, - type SpaceKey, - UPDATE_HOME_BEHAVIOUR, - UPDATE_SELECTED_SPACE, -} from "../../../stores/spaces"; -import SpaceStore from "../../../stores/spaces/SpaceStore"; -import { - shouldShowSpaceSettings, - showCreateNewRoom, - showSpaceInvite, - showSpacePreferences, - showSpaceSettings, -} from "../../../utils/space"; -import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; -import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import { createRoom, hasCreateRoomRights } from "./utils"; -import { type SortOption, useSorter } from "./useSorter"; - -/** - * Hook to get the active space and its title. - */ -function useSpace(): { activeSpace: Room | null; title: string } { - const [spaceKey, activeSpace] = useEventEmitterState<[SpaceKey, Room | null]>( - SpaceStore.instance, - UPDATE_SELECTED_SPACE, - () => [SpaceStore.instance.activeSpace, SpaceStore.instance.activeSpaceRoom], - ); - const spaceName = useTypedEventEmitterState(activeSpace ?? undefined, RoomEvent.Name, () => activeSpace?.name); - const allRoomsInHome = useEventEmitterState( - SpaceStore.instance, - UPDATE_HOME_BEHAVIOUR, - () => SpaceStore.instance.allRoomsInHome, - ); - - const title = spaceName ?? getMetaSpaceName(spaceKey as MetaSpace, allRoomsInHome); - - return { - activeSpace, - title, - }; -} - -export interface RoomListHeaderViewState { - /** - * The title of the room list - */ - title: string; - /** - * Whether to display the compose menu - * True if the user can create rooms - */ - displayComposeMenu: boolean; - /** - * Whether to display the space menu - * True if there is an active space - */ - displaySpaceMenu: boolean; - /** - * Whether the user can create rooms - */ - canCreateRoom: boolean; - /** - * Whether the user can create video rooms - */ - canCreateVideoRoom: boolean; - /** - * Whether the user can invite in the active space - */ - canInviteInSpace: boolean; - /** - * Whether the user can access space settings - */ - canAccessSpaceSettings: boolean; - /** - * Create a chat room - * @param e - The click event - */ - createChatRoom: (e: Event) => void; - /** - * Create a room - * @param e - The click event - */ - createRoom: (e: Event) => void; - /** - * Create a video room - */ - createVideoRoom: () => void; - /** - * Open the active space home - */ - openSpaceHome: () => void; - /** - * Display the space invite dialog - */ - inviteInSpace: () => void; - /** - * Open the space preferences - */ - openSpacePreferences: () => void; - /** - * Open the space settings - */ - openSpaceSettings: () => void; - /** - * Change the sort order of the room-list. - */ - sort: (option: SortOption) => void; - /** - * The currently active sort option. - */ - activeSortOption: SortOption; -} - -/** - * View model for the RoomListHeader. - */ -export function useRoomListHeaderViewModel(): RoomListHeaderViewState { - const matrixClient = useMatrixClientContext(); - const { activeSpace, title } = useSpace(); - const isSpaceRoom = Boolean(activeSpace); - - const canCreateRoom = hasCreateRoomRights(matrixClient, activeSpace); - const canCreateVideoRoom = useFeatureEnabled("feature_video_rooms") && canCreateRoom; - const displayComposeMenu = canCreateRoom; - const displaySpaceMenu = isSpaceRoom; - const canInviteInSpace = Boolean( - activeSpace?.getJoinRule() === JoinRule.Public || activeSpace?.canInvite(matrixClient.getSafeUserId()), - ); - const canAccessSpaceSettings = Boolean(activeSpace && shouldShowSpaceSettings(activeSpace)); - - /* Actions */ - - const { activeSortOption, sort } = useSorter(); - - const createChatRoom = useCallback((e: Event) => { - defaultDispatcher.fire(Action.CreateChat); - PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateChatItem", e); - }, []); - - const createRoomMemoized = useCallback( - (e: Event) => { - createRoom(activeSpace); - PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e); - }, - [activeSpace], - ); - - const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms"); - const createVideoRoom = useCallback(() => { - const type = elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo; - if (activeSpace) { - showCreateNewRoom(activeSpace, type); - } else { - defaultDispatcher.dispatch({ - action: Action.CreateRoom, - type, - }); - } - }, [activeSpace, elementCallVideoRoomsEnabled]); - - const openSpaceHome = useCallback(() => { - // openSpaceHome is only available when there is an active space - if (!activeSpace) return; - defaultDispatcher.dispatch({ - action: Action.ViewRoom, - room_id: activeSpace.roomId, - metricsTrigger: undefined, - }); - }, [activeSpace]); - - const inviteInSpace = useCallback(() => { - // inviteInSpace is only available when there is an active space - if (!activeSpace) return; - showSpaceInvite(activeSpace); - }, [activeSpace]); - - const openSpacePreferences = useCallback(() => { - // openSpacePreferences is only available when there is an active space - if (!activeSpace) return; - showSpacePreferences(activeSpace); - }, [activeSpace]); - - const openSpaceSettings = useCallback(() => { - // openSpaceSettings is only available when there is an active space - if (!activeSpace) return; - showSpaceSettings(activeSpace); - }, [activeSpace]); - - return { - title, - displayComposeMenu, - displaySpaceMenu, - canCreateRoom, - canCreateVideoRoom, - canInviteInSpace, - canAccessSpaceSettings, - createChatRoom, - createRoom: createRoomMemoized, - createVideoRoom, - openSpaceHome, - inviteInSpace, - openSpacePreferences, - openSpaceSettings, - activeSortOption, - sort, - }; -} diff --git a/src/components/viewmodels/roomlist/useSorter.ts b/src/components/viewmodels/roomlist/useSorter.ts deleted file mode 100644 index c7a880d430..0000000000 --- a/src/components/viewmodels/roomlist/useSorter.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* -Copyright 2025 New Vector 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 { useState } from "react"; - -import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3"; -import { SortingAlgorithm } from "../../../stores/room-list-v3/skip-list/sorters"; -import SettingsStore from "../../../settings/SettingsStore"; - -/** - * Sorting options made available to the view. - */ -export const enum SortOption { - Activity = SortingAlgorithm.Recency, - AToZ = SortingAlgorithm.Alphabetic, -} - -/** - * {@link SortOption} holds almost the same information as - * {@link SortingAlgorithm}. This is done intentionally to - * prevent the view from having a dependence on the - * model (which is the store in this case). - */ -const sortingAlgorithmToSortingOption = { - [SortingAlgorithm.Alphabetic]: SortOption.AToZ, - [SortingAlgorithm.Recency]: SortOption.Activity, -}; - -const sortOptionToSortingAlgorithm = { - [SortOption.AToZ]: SortingAlgorithm.Alphabetic, - [SortOption.Activity]: SortingAlgorithm.Recency, -}; - -interface SortState { - sort: (option: SortOption) => void; - activeSortOption: SortOption; -} - -/** - * This hook does two things: - * - Provides a way to track the currently active sort option. - * - Provides a function to resort the room list. - */ -export function useSorter(): SortState { - const [activeSortingAlgorithm, setActiveSortingAlgorithm] = useState(() => - SettingsStore.getValue("RoomList.preferredSorting"), - ); - - const sort = (option: SortOption): void => { - const sortingAlgorithm = sortOptionToSortingAlgorithm[option]; - RoomListStoreV3.instance.resort(sortingAlgorithm); - setActiveSortingAlgorithm(sortingAlgorithm); - }; - - return { - sort, - activeSortOption: sortingAlgorithmToSortingOption[activeSortingAlgorithm!], - }; -} diff --git a/src/components/views/rooms/RoomListPanel/RoomListHeaderView.tsx b/src/components/views/rooms/RoomListPanel/RoomListHeaderView.tsx deleted file mode 100644 index 5fdc5e4bc4..0000000000 --- a/src/components/views/rooms/RoomListPanel/RoomListHeaderView.tsx +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright 2025 New Vector 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 JSX, useState } from "react"; -import { IconButton, Menu, MenuItem } from "@vector-im/compound-web"; -import ComposeIcon from "@vector-im/compound-design-tokens/assets/web/icons/compose"; -import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add"; -import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down"; -import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room"; -import HomeIcon from "@vector-im/compound-design-tokens/assets/web/icons/home"; -import PreferencesIcon from "@vector-im/compound-design-tokens/assets/web/icons/preferences"; -import SettingsIcon from "@vector-im/compound-design-tokens/assets/web/icons/settings"; -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 { Flex } from "@element-hq/web-shared-components"; - -import { _t } from "../../../../languageHandler"; -import { - type RoomListHeaderViewState, - useRoomListHeaderViewModel, -} from "../../../viewmodels/roomlist/RoomListHeaderViewModel"; -import { RoomListOptionsMenu } from "./RoomListOptionsMenu"; - -/** - * The header view for the room list - * The space name is displayed and a compose menu is shown if the user can create rooms - */ -export function RoomListHeaderView(): JSX.Element { - const vm = useRoomListHeaderViewModel(); - - return ( - - - {vm.title} - {vm.displaySpaceMenu && } - - - - - - - {/* If we don't display the compose menu, it means that the user can only send DM */} - - {vm.displayComposeMenu ? ( - - ) : ( - vm.createChatRoom(e.nativeEvent)} - tooltip={_t("action|new_conversation")} - > - - - )} - - - - ); -} - -interface SpaceMenuProps { - /** - * The view model for the room list header - */ - vm: RoomListHeaderViewState; -} - -/** - * The space menu for the room list header - */ -function SpaceMenu({ vm }: SpaceMenuProps): JSX.Element { - const [open, setOpen] = useState(false); - - return ( - - - - } - > - - {vm.canInviteInSpace && ( - - )} - - {vm.canAccessSpaceSettings && ( - - )} - - ); -} - -interface ComposeMenuProps { - /** - * The view model for the room list header - */ - vm: RoomListHeaderViewState; -} - -/** - * The compose menu for the room list header - */ -function ComposeMenu({ vm }: ComposeMenuProps): JSX.Element { - const [open, setOpen] = useState(false); - - return ( - - - - } - > - - {vm.canCreateRoom && ( - - )} - {vm.canCreateVideoRoom && ( - - )} - - ); -} diff --git a/src/components/views/rooms/RoomListPanel/RoomListOptionsMenu.tsx b/src/components/views/rooms/RoomListPanel/RoomListOptionsMenu.tsx deleted file mode 100644 index d851ca34b5..0000000000 --- a/src/components/views/rooms/RoomListPanel/RoomListOptionsMenu.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2025 New Vector 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 { IconButton, Menu, MenuTitle, Tooltip, RadioMenuItem } from "@vector-im/compound-web"; -import React, { type Ref, type JSX, useState, useCallback } from "react"; -import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal"; - -import { _t } from "../../../../languageHandler"; -import { SortOption } from "../../../viewmodels/roomlist/useSorter"; -import { type RoomListHeaderViewState } from "../../../viewmodels/roomlist/RoomListHeaderViewModel"; - -interface MenuTriggerProps extends React.ComponentProps { - ref?: Ref; -} - -const MenuTrigger = ({ ref, ...props }: MenuTriggerProps): JSX.Element => ( - - - - - -); - -interface Props { - /** - * The view model for the room list view - */ - vm: RoomListHeaderViewState; -} - -export function RoomListOptionsMenu({ vm }: Props): JSX.Element { - const [open, setOpen] = useState(false); - - const onActivitySelected = useCallback(() => { - vm.sort(SortOption.Activity); - }, [vm]); - - const onAtoZSelected = useCallback(() => { - vm.sort(SortOption.AToZ); - }, [vm]); - - return ( - } - > - - - - - ); -} diff --git a/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx b/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx index 5701cea905..aa7c0cf76b 100644 --- a/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx @@ -6,18 +6,20 @@ Please see LICENSE files in the repository root for full details. */ import React, { useState, useCallback } from "react"; -import { Flex } from "@element-hq/web-shared-components"; +import { Flex, RoomListHeaderView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../../settings/UIFeature"; import { RoomListSearch } from "./RoomListSearch"; -import { RoomListHeaderView } from "./RoomListHeaderView"; import { RoomListView } from "./RoomListView"; import { _t } from "../../../../languageHandler"; import { getKeyBindingsManager } from "../../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts"; import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation"; import { type IState as IRovingTabIndexState } from "../../../../accessibility/RovingTabIndex"; +import { RoomListHeaderViewModel } from "../../../../viewmodels/room-list/RoomListHeaderViewModel"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import SpaceStore from "../../../../stores/spaces/SpaceStore"; type RoomListPanelProps = { /** @@ -58,6 +60,11 @@ export const RoomListPanel: React.FC = ({ activeSpace }) => [focusedElement], ); + const matrixClient = useMatrixClientContext(); + const vm = useCreateAutoDisposedViewModel( + () => new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }), + ); + return ( = ({ activeSpace }) => onKeyDown={onKeyDown} > {displayRoomSearch && } - + ); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index c3b2e8371d..8299d762a6 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -93,14 +93,12 @@ "maximise": "Maximise", "mention": "Mention", "minimise": "Minimise", - "new_conversation": "New conversation", "new_room": "New room", "new_video_room": "New video room", "next": "Next", "no": "No", "ok": "OK", "open": "Open", - "open_menu": "Open menu", "pin": "Pin", "proceed": "Proceed", "quote": "Quote", @@ -2202,7 +2200,6 @@ "mark_unread": "Mark as unread" }, "notification_options": "Notification options", - "open_space_menu": "Open space menu", "primary_filters": "Room list filters", "redacting_messages_status": { "one": "Currently removing messages in %(count)s room", @@ -2212,26 +2209,16 @@ "more_options": "More Options", "open_room": "Open room %(roomName)s" }, - "room_options": "Room Options", "show_less": "Show less", "show_n_more": { "one": "Show %(count)s more", "other": "Show %(count)s more" }, "show_previews": "Show previews of messages", - "sort": "Sort", "sort_by": "Sort by", "sort_by_activity": "Activity", "sort_by_alphabet": "A-Z", - "sort_type": { - "activity": "Activity", - "atoz": "A-Z" - }, "sort_unread_first": "Show rooms with unread messages first", - "space_menu": { - "home": "Space home", - "space_settings": "Space Settings" - }, "space_menu_label": "%(spaceName)s menu", "sublist_options": "List options", "suggested_rooms_heading": "Suggested Rooms" diff --git a/src/viewmodels/room-list/RoomListHeaderViewModel.ts b/src/viewmodels/room-list/RoomListHeaderViewModel.ts new file mode 100644 index 0000000000..d5587a5629 --- /dev/null +++ b/src/viewmodels/room-list/RoomListHeaderViewModel.ts @@ -0,0 +1,241 @@ +/* + * Copyright 2025 New Vector 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 { JoinRule, type MatrixClient, type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix"; +import { + BaseViewModel, + type RoomListHeaderViewSnapshot, + type RoomListHeaderViewModel as RoomListHeaderViewModelInterface, + type SortOption, +} from "@element-hq/web-shared-components"; + +import defaultDispatcher from "../../dispatcher/dispatcher"; +import PosthogTrackers from "../../PosthogTrackers"; +import { Action } from "../../dispatcher/actions"; +import { getMetaSpaceName, type MetaSpace, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../../stores/spaces"; +import { type SpaceStoreClass } from "../../stores/spaces/SpaceStore"; +import { + shouldShowSpaceSettings, + showCreateNewRoom, + showSpaceInvite, + showSpacePreferences, + showSpaceSettings, +} from "../../utils/space"; +import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; +import { createRoom, hasCreateRoomRights } from "../../components/viewmodels/roomlist/utils"; +import SettingsStore from "../../settings/SettingsStore"; +import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3"; +import { SortingAlgorithm } from "../../stores/room-list-v3/skip-list/sorters"; + +export interface Props { + /** + * The Matrix client instance. + */ + matrixClient: MatrixClient; + spaceStore: SpaceStoreClass; +} + +/** + * ViewModel for the RoomListHeader. + * Manages the state and actions for the room list header. + */ +export class RoomListHeaderViewModel + extends BaseViewModel + implements RoomListHeaderViewModelInterface +{ + /** + * Reference to the currently active space. + * Used to manage event listeners. + */ + private activeSpace: Room | null; + + public constructor(props: Props) { + super(props, getInitialSnapshot(props.spaceStore, props.matrixClient)); + + // Listen for video rooms feature flag changes + const settingsFeatureVideoRef = SettingsStore.watchSetting( + "feature_video_rooms", + null, + this.onVideoRoomsFeatureFlagChange, + ); + this.disposables.track(() => SettingsStore.unwatchSetting(settingsFeatureVideoRef)); + + // Listen for space changes + this.disposables.trackListener(props.spaceStore, UPDATE_SELECTED_SPACE, this.onSpaceChange); + this.disposables.trackListener(props.spaceStore, UPDATE_HOME_BEHAVIOUR, this.onHomeBehaviourChange); + + // Listen for space name changes + this.activeSpace = props.spaceStore.activeSpaceRoom; + if (this.activeSpace) { + this.disposables.trackListener(this.activeSpace, RoomEvent.Name, this.onSpaceNameChange); + } + } + + /** + * Handles space change events. + */ + private readonly onSpaceChange = (): void => { + const activeSpace = this.props.spaceStore.activeSpaceRoom; + + this.activeSpace?.off(RoomEvent.Name, this.onSpaceNameChange); + this.activeSpace = activeSpace; + + // Add new room listener if needed + if (this.activeSpace) { + this.disposables.trackListener(this.activeSpace, RoomEvent.Name, this.onSpaceNameChange); + } + + this.snapshot.merge({ + ...computeHeaderSpaceState(this.props.spaceStore, this.props.matrixClient), + }); + }; + + /** + * Handles home behaviour change events. + */ + private readonly onHomeBehaviourChange = (): void => { + this.snapshot.merge({ title: getHeaderTitle(this.props.spaceStore) }); + }; + + /** + * Handles space name change events. + */ + private onSpaceNameChange = (): void => { + this.snapshot.merge({ title: getHeaderTitle(this.props.spaceStore) }); + }; + + /** + * Handles video rooms feature flag change events. + */ + private readonly onVideoRoomsFeatureFlagChange = (): void => { + this.snapshot.merge({ + canCreateVideoRoom: getCanCreateVideoRoom(this.snapshot.current.canCreateRoom), + }); + }; + + public createChatRoom = (e: Event): void => { + defaultDispatcher.fire(Action.CreateChat); + PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateChatItem", e); + }; + + public createRoom = (e: Event): void => { + createRoom(this.activeSpace); + PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e); + }; + + public createVideoRoom = (): void => { + const type = SettingsStore.getValue("feature_element_call_video_rooms") + ? RoomType.UnstableCall + : RoomType.ElementVideo; + if (this.activeSpace) { + showCreateNewRoom(this.activeSpace, type); + } else { + defaultDispatcher.dispatch({ + action: Action.CreateRoom, + type, + }); + } + }; + + public openSpaceHome = (): void => { + if (!this.activeSpace) return; + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: this.activeSpace.roomId, + metricsTrigger: undefined, + }); + }; + + public inviteInSpace = (): void => { + if (!this.activeSpace) return; + showSpaceInvite(this.activeSpace); + }; + + public openSpacePreferences = (): void => { + if (!this.activeSpace) return; + showSpacePreferences(this.activeSpace); + }; + + public openSpaceSettings = (): void => { + if (!this.activeSpace) return; + showSpaceSettings(this.activeSpace); + }; + + public sort = (option: SortOption): void => { + const sortingAlgorithm = option === "recent" ? SortingAlgorithm.Recency : SortingAlgorithm.Alphabetic; + RoomListStoreV3.instance.resort(sortingAlgorithm); + this.snapshot.merge({ activeSortOption: option }); + }; +} + +/** + * Get the initial snapshot for the RoomListHeaderViewModel. + * @param spaceStore - The space store instance. + * @param matrixClient - The Matrix client instance. + * @returns + */ +function getInitialSnapshot(spaceStore: SpaceStoreClass, matrixClient: MatrixClient): RoomListHeaderViewSnapshot { + const sortingAlgorithm = SettingsStore.getValue("RoomList.preferredSorting"); + const activeSortOption = + sortingAlgorithm === SortingAlgorithm.Recency ? ("recent" as const) : ("alphabetical" as const); + + return { + activeSortOption, + ...computeHeaderSpaceState(spaceStore, matrixClient), + }; +} + +/** + * Get the header title based on the active space. + * @param spaceStore - The space store instance. + */ +function getHeaderTitle(spaceStore: SpaceStoreClass): string { + const activeSpace = spaceStore.activeSpaceRoom; + const spaceName = activeSpace?.name; + return spaceName ?? getMetaSpaceName(spaceStore.activeSpace as MetaSpace, spaceStore.allRoomsInHome); +} + +/** + * Determine if the user can create a video room. + * @param canCreateRoom - Whether the user can create a room. + */ +function getCanCreateVideoRoom(canCreateRoom: boolean): boolean { + return SettingsStore.getValue("feature_video_rooms") && canCreateRoom; +} + +/** + * Computes the header space state based on the active space and user permissions. + * @param spaceStore - The space store instance. + * @param matrixClient - The Matrix client instance. + * @returns The header space state containing title, permissions, and display flags. + */ +function computeHeaderSpaceState( + spaceStore: SpaceStoreClass, + matrixClient: MatrixClient, +): Omit { + const activeSpace = spaceStore.activeSpaceRoom; + const title = getHeaderTitle(spaceStore); + + const canCreateRoom = hasCreateRoomRights(matrixClient, activeSpace); + const canCreateVideoRoom = getCanCreateVideoRoom(canCreateRoom); + const displayComposeMenu = canCreateRoom; + const displaySpaceMenu = Boolean(activeSpace); + const canInviteInSpace = Boolean( + activeSpace?.getJoinRule() === JoinRule.Public || activeSpace?.canInvite(matrixClient.getSafeUserId()), + ); + const canAccessSpaceSettings = Boolean(activeSpace && shouldShowSpaceSettings(activeSpace)); + + return { + title, + canCreateRoom, + canCreateVideoRoom, + displayComposeMenu, + displaySpaceMenu, + canInviteInSpace, + canAccessSpaceSettings, + }; +} diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 4dfd5e4de8..9ebfdbf1f3 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -658,6 +658,9 @@ export function mkStubRoom( getEvents: (): MatrixEvent[] => [], getState: (): RoomState | undefined => state, } as unknown as EventTimeline; + + const eventEmitter = new EventEmitter(); + return { canInvite: jest.fn().mockReturnValue(false), client, @@ -728,9 +731,11 @@ export function mkStubRoom( myUserId: client?.getUserId(), name, normalizedName: normalize(name || ""), - off: jest.fn(), - on: jest.fn(), - removeListener: jest.fn(), + on: eventEmitter.on.bind(eventEmitter), + once: eventEmitter.once.bind(eventEmitter), + off: eventEmitter.off.bind(eventEmitter), + removeListener: eventEmitter.removeListener.bind(eventEmitter), + emit: eventEmitter.emit.bind(eventEmitter), roomId, setBlacklistUnverifiedDevices: jest.fn(), setUnreadNotificationCount: jest.fn(), diff --git a/test/unit-tests/components/viewmodels/roomlist/RoomListHeaderViewModel-test.tsx b/test/unit-tests/components/viewmodels/roomlist/RoomListHeaderViewModel-test.tsx deleted file mode 100644 index ba93dc9072..0000000000 --- a/test/unit-tests/components/viewmodels/roomlist/RoomListHeaderViewModel-test.tsx +++ /dev/null @@ -1,243 +0,0 @@ -/* - * Copyright 2025 New Vector 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 { renderHook, act } from "jest-matrix-react"; -import { JoinRule, type MatrixClient, type Room, RoomType } from "matrix-js-sdk/src/matrix"; -import { mocked } from "jest-mock"; -import { range } from "lodash"; - -import { useRoomListHeaderViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListHeaderViewModel"; -import SpaceStore from "../../../../../src/stores/spaces/SpaceStore"; -import { mkStubRoom, stubClient, withClientContextRenderOptions } from "../../../../test-utils"; -import SettingsStore from "../../../../../src/settings/SettingsStore"; -import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; -import { Action } from "../../../../../src/dispatcher/actions"; -import { - shouldShowSpaceSettings, - showCreateNewRoom, - showSpaceInvite, - showSpacePreferences, - showSpaceSettings, -} from "../../../../../src/utils/space"; -import { createRoom, hasCreateRoomRights } from "../../../../../src/components/viewmodels/roomlist/utils"; -import RoomListStoreV3 from "../../../../../src/stores/room-list-v3/RoomListStoreV3"; -import { SortOption } from "../../../../../src/components/viewmodels/roomlist/useSorter"; -import { SortingAlgorithm } from "../../../../../src/stores/room-list-v3/skip-list/sorters"; - -jest.mock("../../../../../src/components/viewmodels/roomlist/utils", () => ({ - hasCreateRoomRights: jest.fn().mockReturnValue(false), - createRoom: jest.fn(), -})); - -jest.mock("../../../../../src/utils/space", () => ({ - shouldShowSpaceSettings: jest.fn(), - showCreateNewRoom: jest.fn(), - showSpaceInvite: jest.fn(), - showSpacePreferences: jest.fn(), - showSpaceSettings: jest.fn(), -})); - -describe("useRoomListHeaderViewModel", () => { - let matrixClient: MatrixClient; - let space: Room; - - beforeEach(() => { - matrixClient = stubClient(); - space = mkStubRoom("spaceId", "spaceName", matrixClient); - jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string): any => { - if (name === "RoomList.preferredSorting") return SortingAlgorithm.Recency; - }); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - function render() { - return renderHook(() => useRoomListHeaderViewModel(), withClientContextRenderOptions(matrixClient)); - } - - describe("title", () => { - it("should return Home as title", () => { - const { result } = render(); - expect(result.current.title).toStrictEqual("Home"); - }); - - it("should return the current space name as title", () => { - jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space); - const { result } = render(); - - expect(result.current.title).toStrictEqual("spaceName"); - }); - }); - - it("should be displayComposeMenu=true and canCreateRoom=true if the user can creates room", () => { - mocked(hasCreateRoomRights).mockReturnValue(false); - const { result, rerender } = render(); - expect(result.current.displayComposeMenu).toBe(false); - expect(result.current.canCreateRoom).toBe(false); - - mocked(hasCreateRoomRights).mockReturnValue(true); - rerender(); - expect(result.current.displayComposeMenu).toBe(true); - expect(result.current.canCreateRoom).toBe(true); - }); - - it("should be displaySpaceMenu=true if the user is in a space", () => { - jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space); - const { result } = render(); - expect(result.current.displaySpaceMenu).toBe(true); - }); - - it("should be canInviteInSpace=true if the space join rule is public", () => { - jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space); - jest.spyOn(space, "getJoinRule").mockReturnValue(JoinRule.Public); - - const { result } = render(); - expect(result.current.displaySpaceMenu).toBe(true); - }); - - it("should be canInviteInSpace=true if the user has the right", () => { - jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space); - jest.spyOn(space, "canInvite").mockReturnValue(true); - - const { result } = render(); - expect(result.current.displaySpaceMenu).toBe(true); - }); - - it("should be canAccessSpaceSettings=true if the user has the right", () => { - jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space); - mocked(shouldShowSpaceSettings).mockReturnValue(true); - - const { result } = render(); - expect(result.current.canAccessSpaceSettings).toBe(true); - }); - - it("should be canCreateVideoRoom=true if feature_video_rooms is enabled and can create room", () => { - mocked(hasCreateRoomRights).mockReturnValue(true); - jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); - - const { result } = render(); - expect(result.current.canCreateVideoRoom).toBe(true); - }); - - it("should fire Action.CreateChat when createChatRoom is called", () => { - const spy = jest.spyOn(defaultDispatcher, "fire"); - const { result } = render(); - result.current.createChatRoom(new Event("click")); - - expect(spy).toHaveBeenCalledWith(Action.CreateChat); - }); - - it("should call createRoom from utils when createRoom is called", () => { - const { result } = render(); - result.current.createRoom(new Event("click")); - - expect(createRoom).toHaveBeenCalled(); - }); - - it("should call createRoom from utils when createRoom is called in a space", () => { - jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space); - const { result } = render(); - result.current.createRoom(new Event("click")); - - expect(createRoom).toHaveBeenCalledWith(space); - }); - - it("should fire Action.CreateRoom with RoomType.UnstableCall when createVideoRoom is called and feature_element_call_video_rooms is enabled", () => { - jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); - const spy = jest.spyOn(defaultDispatcher, "dispatch"); - const { result } = render(); - result.current.createVideoRoom(); - - expect(spy).toHaveBeenCalledWith({ action: Action.CreateRoom, type: RoomType.UnstableCall }); - }); - - it("should fire Action.CreateRoom with RoomType.ElementVideo when createVideoRoom is called and feature_element_call_video_rooms is disabled", () => { - jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); - const spy = jest.spyOn(defaultDispatcher, "dispatch"); - const { result } = render(); - result.current.createVideoRoom(); - - expect(spy).toHaveBeenCalledWith({ action: Action.CreateRoom, type: RoomType.ElementVideo }); - }); - - it("should call showCreateNewRoom when createVideoRoom is called in a space", () => { - jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space); - const { result } = render(); - result.current.createVideoRoom(); - - expect(showCreateNewRoom).toHaveBeenCalledWith(space, RoomType.ElementVideo); - }); - - it("should fire Action.ViewRoom when openSpaceHome is called in a space", () => { - jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space); - const spy = jest.spyOn(defaultDispatcher, "dispatch"); - const { result } = render(); - result.current.openSpaceHome(); - - expect(spy).toHaveBeenCalledWith({ action: Action.ViewRoom, room_id: space.roomId, metricsTrigger: undefined }); - }); - - it("should call showSpaceInvite when inviteInSpace is called in a space", () => { - jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space); - const { result } = render(); - result.current.inviteInSpace(); - - expect(showSpaceInvite).toHaveBeenCalledWith(space); - }); - - it("should call showSpacePreferences when openSpacePreferences is called in a space", () => { - jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space); - const { result } = render(); - result.current.openSpacePreferences(); - - expect(showSpacePreferences).toHaveBeenCalledWith(space); - }); - - it("should call showSpaceSettings when openSpaceSettings is called in a space", () => { - jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space); - const { result } = render(); - result.current.openSpaceSettings(); - - expect(showSpaceSettings).toHaveBeenCalledWith(space); - }); - - describe("Sorting", () => { - function mockAndCreateRooms() { - const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined)); - const fn = jest - .spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace") - .mockImplementation(() => ({ spaceId: "home", rooms: [...rooms] })); - return { rooms, fn }; - } - - it("should change sort order", () => { - mockAndCreateRooms(); - const { result: vm } = render(); - - const resort = jest.spyOn(RoomListStoreV3.instance, "resort").mockImplementation(() => {}); - - // Change the sort option - act(() => { - vm.current.sort(SortOption.AToZ); - }); - - // Resort method in RLS must have been called - expect(resort).toHaveBeenCalledWith(SortingAlgorithm.Alphabetic); - }); - - it("should set activeSortOption based on value from settings", () => { - // Let's say that the user's preferred sorting is alphabetic - jest.spyOn(SettingsStore, "getValue").mockImplementation(() => SortingAlgorithm.Alphabetic); - - mockAndCreateRooms(); - const { result: vm } = render(); - expect(vm.current.activeSortOption).toEqual(SortOption.AToZ); - }); - }); -}); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListHeaderView-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListHeaderView-test.tsx deleted file mode 100644 index 8a8c441246..0000000000 --- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListHeaderView-test.tsx +++ /dev/null @@ -1,166 +0,0 @@ -/* - * Copyright 2025 New Vector 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 { mocked } from "jest-mock"; -import { render, screen } from "jest-matrix-react"; -import userEvent from "@testing-library/user-event"; - -import { - type RoomListHeaderViewState, - useRoomListHeaderViewModel, -} from "../../../../../../src/components/viewmodels/roomlist/RoomListHeaderViewModel"; -import { RoomListHeaderView } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListHeaderView"; -import { SortOption } from "../../../../../../src/components/viewmodels/roomlist/useSorter"; - -jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListHeaderViewModel", () => ({ - useRoomListHeaderViewModel: jest.fn(), -})); - -describe("", () => { - const defaultValue: RoomListHeaderViewState = { - title: "title", - displayComposeMenu: true, - displaySpaceMenu: true, - canCreateRoom: true, - canCreateVideoRoom: true, - canInviteInSpace: true, - canAccessSpaceSettings: true, - sort: jest.fn(), - activeSortOption: SortOption.Activity, - createRoom: jest.fn(), - createVideoRoom: jest.fn(), - createChatRoom: jest.fn(), - openSpaceHome: jest.fn(), - inviteInSpace: jest.fn(), - openSpacePreferences: jest.fn(), - openSpaceSettings: jest.fn(), - }; - - afterEach(() => { - jest.resetAllMocks(); - }); - - it("should render 'room options' button", async () => { - mocked(useRoomListHeaderViewModel).mockReturnValue(defaultValue); - const { asFragment } = render(); - expect(screen.getByRole("button", { name: "Room Options" })).toBeInTheDocument(); - expect(asFragment()).toMatchSnapshot(); - }); - - describe("compose menu", () => { - it("should display the compose menu", () => { - mocked(useRoomListHeaderViewModel).mockReturnValue(defaultValue); - - const { asFragment } = render(); - expect(screen.queryByRole("button", { name: "New conversation" })).toBeInTheDocument(); - expect(asFragment()).toMatchSnapshot(); - }); - - it("should not display the compose menu", async () => { - const user = userEvent.setup(); - mocked(useRoomListHeaderViewModel).mockReturnValue({ ...defaultValue, displayComposeMenu: false }); - - const { asFragment } = render(); - expect(screen.queryByRole("button", { name: "New conversation" })).toBeInTheDocument(); - expect(asFragment()).toMatchSnapshot(); - - await user.click(screen.getByRole("button", { name: "New conversation" })); - expect(defaultValue.createChatRoom).toHaveBeenCalled(); - }); - - it("should display all the buttons when the menu is opened", async () => { - const user = userEvent.setup(); - mocked(useRoomListHeaderViewModel).mockReturnValue(defaultValue); - render(); - const openMenu = screen.getByRole("button", { name: "New conversation" }); - await user.click(openMenu); - - await user.click(screen.getByRole("menuitem", { name: "Start chat" })); - expect(defaultValue.createChatRoom).toHaveBeenCalled(); - - await user.click(openMenu); - await user.click(screen.getByRole("menuitem", { name: "New room" })); - expect(defaultValue.createRoom).toHaveBeenCalled(); - - await user.click(openMenu); - await user.click(screen.getByRole("menuitem", { name: "New video room" })); - expect(defaultValue.createVideoRoom).toHaveBeenCalled(); - }); - - it("should display only the new message button", async () => { - const user = userEvent.setup(); - mocked(useRoomListHeaderViewModel).mockReturnValue({ - ...defaultValue, - canCreateRoom: false, - canCreateVideoRoom: false, - }); - - render(); - await user.click(screen.getByRole("button", { name: "New conversation" })); - - expect(screen.queryByRole("menuitem", { name: "New room" })).toBeNull(); - expect(screen.queryByRole("menuitem", { name: "New video room" })).toBeNull(); - }); - }); - - describe("space menu", () => { - it("should display the space menu", () => { - mocked(useRoomListHeaderViewModel).mockReturnValue(defaultValue); - - const { asFragment } = render(); - expect(screen.queryByRole("button", { name: "Open space menu" })).toBeInTheDocument(); - expect(asFragment()).toMatchSnapshot(); - }); - - it("should not display the space menu", () => { - mocked(useRoomListHeaderViewModel).mockReturnValue({ ...defaultValue, displaySpaceMenu: false }); - - const { asFragment } = render(); - expect(screen.queryByRole("button", { name: "Open space menu" })).toBeNull(); - expect(asFragment()).toMatchSnapshot(); - }); - - it("should display all the buttons when the space menu is opened", async () => { - const user = userEvent.setup(); - mocked(useRoomListHeaderViewModel).mockReturnValue(defaultValue); - render(); - const openMenu = screen.getByRole("button", { name: "Open space menu" }); - await user.click(openMenu); - - await user.click(screen.getByRole("menuitem", { name: "Space home" })); - expect(defaultValue.openSpaceHome).toHaveBeenCalled(); - - await user.click(openMenu); - await user.click(screen.getByRole("menuitem", { name: "Invite" })); - expect(defaultValue.inviteInSpace).toHaveBeenCalled(); - - await user.click(openMenu); - await user.click(screen.getByRole("menuitem", { name: "Preferences" })); - expect(defaultValue.openSpacePreferences).toHaveBeenCalled(); - - await user.click(openMenu); - await user.click(screen.getByRole("menuitem", { name: "Space Settings" })); - expect(defaultValue.openSpaceSettings).toHaveBeenCalled(); - }); - - it("should display only the home and preference buttons", async () => { - const user = userEvent.setup(); - mocked(useRoomListHeaderViewModel).mockReturnValue({ - ...defaultValue, - canInviteInSpace: false, - canAccessSpaceSettings: false, - }); - - render(); - await user.click(screen.getByRole("button", { name: "Open space menu" })); - - expect(screen.queryByRole("menuitem", { name: "Invite" })).toBeNull(); - expect(screen.queryByRole("menuitem", { name: "Space Setting" })).toBeNull(); - }); - }); -}); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListHeaderView-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListHeaderView-test.tsx.snap deleted file mode 100644 index 3e8fdc1566..0000000000 --- a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListHeaderView-test.tsx.snap +++ /dev/null @@ -1,653 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[` compose menu should display the compose menu 1`] = ` - - - - - title - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[` compose menu should not display the compose menu 1`] = ` - - - - - title - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[` should render 'room options' button 1`] = ` - - - - - title - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[` space menu should display the space menu 1`] = ` - - - - - title - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[` space menu should not display the space menu 1`] = ` - - - - - title - - - - - - - - - - - - - - - - - - - - - - - - - -`; diff --git a/test/unit-tests/modules/models/Room-test.ts b/test/unit-tests/modules/models/Room-test.ts index d149c8cdf0..51bf8bde7a 100644 --- a/test/unit-tests/modules/models/Room-test.ts +++ b/test/unit-tests/modules/models/Room-test.ts @@ -39,12 +39,14 @@ describe("Room", () => { const room = new Room(sdkRoom); const fn = jest.fn(); + const onSpy = jest.spyOn(sdkRoom, "on"); + const offSpy = jest.spyOn(sdkRoom, "off"); room.name.watch(fn); - expect(sdkRoom.on).toHaveBeenCalledTimes(1); + expect(onSpy).toHaveBeenCalledTimes(1); room.name.unwatch(fn); - expect(sdkRoom.off).toHaveBeenCalledTimes(1); + expect(offSpy).toHaveBeenCalledTimes(1); }); }); }); diff --git a/test/viewmodels/room-list/RoomListHeaderViewModel-test.ts b/test/viewmodels/room-list/RoomListHeaderViewModel-test.ts new file mode 100644 index 0000000000..f9d6544130 --- /dev/null +++ b/test/viewmodels/room-list/RoomListHeaderViewModel-test.ts @@ -0,0 +1,272 @@ +/* + * 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 { mocked } from "jest-mock"; +import { JoinRule, type MatrixClient, type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix"; + +import { RoomListHeaderViewModel } from "../../../src/viewmodels/room-list/RoomListHeaderViewModel"; +import { MetaSpace, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../../../src/stores/spaces"; +import SpaceStore from "../../../src/stores/spaces/SpaceStore"; +import defaultDispatcher from "../../../src/dispatcher/dispatcher"; +import { Action } from "../../../src/dispatcher/actions"; +import SettingsStore from "../../../src/settings/SettingsStore"; +import { SortingAlgorithm } from "../../../src/stores/room-list-v3/skip-list/sorters"; +import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3"; +import { + shouldShowSpaceSettings, + showCreateNewRoom, + showSpaceInvite, + showSpacePreferences, + showSpaceSettings, +} from "../../../src/utils/space"; +import { createRoom, hasCreateRoomRights } from "../../../src/components/viewmodels/roomlist/utils"; +import { createTestClient, mkSpace } from "../../test-utils"; + +jest.mock("../../../src/PosthogTrackers", () => ({ + trackInteraction: jest.fn(), +})); + +jest.mock("../../../src/utils/space", () => ({ + shouldShowSpaceSettings: jest.fn(), + showCreateNewRoom: jest.fn(), + showSpaceInvite: jest.fn(), + showSpacePreferences: jest.fn(), + showSpaceSettings: jest.fn(), +})); + +jest.mock("../../../src/components/viewmodels/roomlist/utils", () => ({ + createRoom: jest.fn(), + hasCreateRoomRights: jest.fn(), +})); + +describe("RoomListHeaderViewModel", () => { + let matrixClient: MatrixClient; + let mockSpace: Room; + let vm: RoomListHeaderViewModel; + + beforeEach(() => { + matrixClient = createTestClient(); + + mockSpace = mkSpace(matrixClient, "!space:server"); + + mocked(hasCreateRoomRights).mockReturnValue(true); + mocked(shouldShowSpaceSettings).mockReturnValue(true); + + jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => { + if (settingName === "RoomList.preferredSorting") return SortingAlgorithm.Recency; + if (settingName === "feature_video_rooms") return true; + if (settingName === "feature_element_call_video_rooms") return true; + return false; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + vm.dispose(); + }); + + describe("snapshot", () => { + it("should compute snapshot for Home space", () => { + jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(MetaSpace.Home); + jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(null); + + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + + const snapshot = vm.getSnapshot(); + expect(snapshot.title).toBe("Home"); + expect(snapshot.displayComposeMenu).toBe(true); + expect(snapshot.displaySpaceMenu).toBe(false); + expect(snapshot.canCreateRoom).toBe(true); + expect(snapshot.canCreateVideoRoom).toBe(true); + expect(snapshot.activeSortOption).toBe("recent"); + }); + + it("should compute snapshot for active space", () => { + jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId); + jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace); + + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + + const snapshot = vm.getSnapshot(); + expect(snapshot.title).toBe(mockSpace.roomId); + }); + + it("should hide video room option when feature is disabled", () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => { + if (settingName === "feature_video_rooms") return false; + return false; + }); + + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + expect(vm.getSnapshot().canCreateVideoRoom).toBe(false); + }); + + it("should show alphabetical sort option when RoomList.preferredSorting is Alphabetic", () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => { + if (settingName === "RoomList.preferredSorting") return SortingAlgorithm.Alphabetic; + return false; + }); + + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + expect(vm.getSnapshot().activeSortOption).toBe("alphabetical"); + }); + + it("should hide compose menu when user cannot create rooms", () => { + mocked(hasCreateRoomRights).mockReturnValue(false); + + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + + const snapshot = vm.getSnapshot(); + expect(snapshot.displayComposeMenu).toBe(false); + expect(snapshot.canCreateRoom).toBe(false); + }); + + it("should show invite option when space is public", () => { + jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId); + jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace); + jest.spyOn(mockSpace, "getJoinRule").mockReturnValue(JoinRule.Public); + + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + expect(vm.getSnapshot().canInviteInSpace).toBe(true); + }); + + it("should hide invite option when user cannot invite", () => { + mocked(mockSpace.canInvite).mockReturnValue(false); + + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + expect(vm.getSnapshot().canInviteInSpace).toBe(false); + }); + + it("should hide space settings when user cannot access them", () => { + jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId); + mocked(shouldShowSpaceSettings).mockReturnValue(false); + + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + expect(vm.getSnapshot().canAccessSpaceSettings).toBe(false); + }); + }); + + describe("event listeners", () => { + it.each([UPDATE_SELECTED_SPACE, UPDATE_HOME_BEHAVIOUR])( + "should update snapshot when %s event is emitted", + (event) => { + jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(MetaSpace.Home); + jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(null); + + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + + jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId); + jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace); + SpaceStore.instance.emit(event); + + expect(vm.getSnapshot().title).toBe(mockSpace.roomId); + }, + ); + + it("should update snapshot when space name changes", () => { + jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId); + jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace); + + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + + mockSpace.name = "new name"; + mockSpace.emit(RoomEvent.Name, mockSpace); + + expect(vm.getSnapshot().title).toBe("new name"); + }); + }); + + describe("actions", () => { + beforeEach(() => { + jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId); + jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace); + }); + + it("should fire CreateChat action when createChatRoom is called", () => { + const fireSpy = jest.spyOn(defaultDispatcher, "fire"); + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + + vm.createChatRoom(new Event("click")); + expect(fireSpy).toHaveBeenCalledWith(Action.CreateChat); + }); + + it("should call createRoom with active space when in a space", () => { + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + vm.createRoom(new Event("click")); + + expect(createRoom).toHaveBeenCalledWith(mockSpace); + }); + + it("should show create video room dialog for space when createVideoRoom is called", () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => { + if (settingName === "feature_element_call_video_rooms") return false; + return false; + }); + + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + vm.createVideoRoom(); + expect(showCreateNewRoom).toHaveBeenCalledWith(mockSpace, RoomType.ElementVideo); + }); + + it("should use UnstableCall type when element_call_video_rooms is enabled", () => { + jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(null); + + const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch"); + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + vm.createVideoRoom(); + + expect(dispatchSpy).toHaveBeenCalledWith({ + action: Action.CreateRoom, + type: RoomType.UnstableCall, + }); + }); + + it("should dispatch ViewRoom action when openSpaceHome is called", () => { + const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch"); + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + vm.openSpaceHome(); + + expect(dispatchSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: "!space:server", + metricsTrigger: undefined, + }); + }); + + it("should show space invite dialog when inviteInSpace is called", () => { + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + vm.inviteInSpace(); + + expect(showSpaceInvite).toHaveBeenCalledWith(mockSpace); + }); + + it("should show space preferences dialog when openSpacePreferences is called", () => { + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + vm.openSpacePreferences(); + + expect(showSpacePreferences).toHaveBeenCalledWith(mockSpace); + }); + + it("should show space settings dialog when openSpaceSettings is called", () => { + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + vm.openSpaceSettings(); + + expect(showSpaceSettings).toHaveBeenCalledWith(mockSpace); + }); + + it.each([ + ["recent" as const, SortingAlgorithm.Recency], + ["alphabetical" as const, SortingAlgorithm.Alphabetic], + ])("should resort when sort is called with '%s'", (option, expectedAlgorithm) => { + const resortSpy = jest.spyOn(RoomListStoreV3.instance, "resort").mockImplementation(jest.fn()); + vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); + vm.sort(option); + + expect(resortSpy).toHaveBeenCalledWith(expectedAlgorithm); + }); + }); +}); diff --git a/test/viewmodels/room/RoomStatusBar-test.ts b/test/viewmodels/room/RoomStatusBar-test.ts index ced015de94..9d7cf55a7c 100644 --- a/test/viewmodels/room/RoomStatusBar-test.ts +++ b/test/viewmodels/room/RoomStatusBar-test.ts @@ -43,7 +43,7 @@ describe("RoomStatusBarViewModel", () => { beforeEach(() => { client = stubClient() as MockedObject; room = mkRoom(client, "!example"); - room.on.mockImplementationOnce((_event, fn) => { + jest.spyOn(room, "on").mockImplementationOnce((_event, fn) => { roomEmitFn = fn as any; return room; });