RoomList: move room list header to shared components (#31675)
* chore: ignore jest-sonar.xml in gitconfig * chore: add missing rtl types to shared component * chore: add `symbol` to `Disposables.trackListener` * feat: add room list header view to shared components * fix: change `Space Settings` to `Space settings` * feat: add room list header view model * chore: remove old room list header * chore: update i18n * test: fix Room-test * test: update playwright screenshot * fix: remove extra margin at the top of Sort title in room options * test: fix room status bar test * fix: change for correct copyright * refactor: use `Disposables#track` instead of manually disposing the listener * refactor: avoid to recompute all the snapshot of `RoomListHeaderViewModel` * wip * fix: make header buttons the same size than figma * test: update shared component snapshots * test: update shared component screenshots * test: update EW screenshots
1
.gitignore
vendored
@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
@ -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"
|
||||
},
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 <RoomListHeaderView vm={vm} />;
|
||||
};
|
||||
|
||||
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<typeof RoomListHeaderViewWrapper>;
|
||||
|
||||
const Template: StoryFn<typeof RoomListHeaderViewWrapper> = (args) => <RoomListHeaderViewWrapper {...args} />;
|
||||
|
||||
export const Default = Template.bind({});
|
||||
|
||||
export const NoSpaceMenu = Template.bind({});
|
||||
NoSpaceMenu.args = {
|
||||
displaySpaceMenu: false,
|
||||
};
|
||||
|
||||
export const NoComposeMenu = Template.bind({});
|
||||
NoComposeMenu.args = {
|
||||
displayComposeMenu: false,
|
||||
};
|
||||
@ -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(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders without compose menu", () => {
|
||||
const { container } = render(<NoComposeMenu />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders without space menu", () => {
|
||||
const { container } = render(<NoSpaceMenu />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@ -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<RoomListHeaderViewSnapshot> & 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
|
||||
* <RoomListHeaderView vm={roomListHeaderViewModel} />
|
||||
* ```
|
||||
*/
|
||||
export function RoomListHeaderView({ vm }: Readonly<RoomListHeaderViewProps>): JSX.Element {
|
||||
const { translate: _t } = useI18n();
|
||||
const { title, displaySpaceMenu, displayComposeMenu } = useViewModel(vm);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
as="header"
|
||||
className={styles.header}
|
||||
aria-label={_t("room|context_menu|title")}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
data-testid="room-list-header"
|
||||
>
|
||||
<Flex className={styles.title} align="center" gap="var(--cpd-space-1x)">
|
||||
<H1 size="sm" title={title}>
|
||||
{title}
|
||||
</H1>
|
||||
{displaySpaceMenu && <SpaceMenuView vm={vm} />}
|
||||
</Flex>
|
||||
<Flex align="center" gap="var(--cpd-space-2x)">
|
||||
<OptionMenuView vm={vm} />
|
||||
|
||||
{/* If we don't display the compose menu, it means that the user can only send DM */}
|
||||
{displayComposeMenu ? (
|
||||
<ComposeMenuView vm={vm} />
|
||||
) : (
|
||||
<IconButton
|
||||
onClick={(e) => vm.createChatRoom(e.nativeEvent)}
|
||||
tooltip={_t("action|new_conversation")}
|
||||
>
|
||||
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
|
||||
</IconButton>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,349 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`RoomListHeaderView renders the default state 1`] = `
|
||||
<div>
|
||||
<header
|
||||
aria-label="Room options"
|
||||
class="flex header"
|
||||
data-testid="room-list-header"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="flex title"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<h1
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||
title="Rooms"
|
||||
>
|
||||
Rooms
|
||||
</h1>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open space menu"
|
||||
class="_icon-button_1215g_8 button"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_0_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 24px; padding: 2px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Room Options"
|
||||
aria-labelledby="_r_4_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_2_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 28px; padding: 4px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-labelledby="_r_b_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_9_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 36px; padding: 6px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M16.937 2.82a2 2 0 0 1 2.828 0l1.415 1.414a2 2 0 0 1 0 2.829l-7.071 7.07c-.195.196-.42.342-.66.44a1 1 0 0 1-.168.072l-3.993 1.331a1 1 0 0 1-1.265-1.265l1.331-3.992q.03-.09.073-.168m10.338-4.903-6.717 6.718-1.414-1.414 6.717-6.718z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M3 5a2 2 0 0 1 2-2h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`RoomListHeaderView renders without compose menu 1`] = `
|
||||
<div>
|
||||
<header
|
||||
aria-label="Room options"
|
||||
class="flex header"
|
||||
data-testid="room-list-header"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="flex title"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<h1
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||
title="Rooms"
|
||||
>
|
||||
Rooms
|
||||
</h1>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open space menu"
|
||||
class="_icon-button_1215g_8 button"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_i_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 24px; padding: 2px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Room Options"
|
||||
aria-labelledby="_r_m_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_k_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 28px; padding: 4px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="_r_r_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
color="var(--cpd-color-icon-secondary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M16.937 2.82a2 2 0 0 1 2.828 0l1.415 1.414a2 2 0 0 1 0 2.829l-7.071 7.07c-.195.196-.42.342-.66.44a1 1 0 0 1-.168.072l-3.993 1.331a1 1 0 0 1-1.265-1.265l1.331-3.992q.03-.09.073-.168m10.338-4.903-6.717 6.718-1.414-1.414 6.717-6.718z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M3 5a2 2 0 0 1 2-2h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`RoomListHeaderView renders without space menu 1`] = `
|
||||
<div>
|
||||
<header
|
||||
aria-label="Room options"
|
||||
class="flex header"
|
||||
data-testid="room-list-header"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="flex title"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<h1
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||
title="Rooms"
|
||||
>
|
||||
Rooms
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
class="flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Room Options"
|
||||
aria-labelledby="_r_14_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_12_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 28px; padding: 4px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-labelledby="_r_1b_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_19_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 36px; padding: 6px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M16.937 2.82a2 2 0 0 1 2.828 0l1.415 1.414a2 2 0 0 1 0 2.829l-7.071 7.07c-.195.196-.42.342-.66.44a1 1 0 0 1-.168.072l-3.993 1.331a1 1 0 0 1-1.265-1.265l1.331-3.992q.03-.09.073-.168m10.338-4.903-6.717 6.718-1.414-1.414 6.717-6.718z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M3 5a2 2 0 0 1 2-2h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
`;
|
||||
@ -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";
|
||||
@ -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("<ComposeMenuView />", () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should match snapshot", () => {
|
||||
const vm = new MockedViewModel(defaultSnapshot);
|
||||
const { asFragment } = render(<ComposeMenuView vm={vm} />);
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should display all menu options when fully enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const vm = new MockedViewModel(defaultSnapshot);
|
||||
render(<ComposeMenuView vm={vm} />);
|
||||
|
||||
// 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(<ComposeMenuView vm={vm} />);
|
||||
|
||||
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(<ComposeMenuView vm={vm} />);
|
||||
|
||||
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(<ComposeMenuView vm={vm} />);
|
||||
|
||||
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(<ComposeMenuView vm={vm} />);
|
||||
|
||||
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(<ComposeMenuView vm={vm} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "New conversation" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "New video room" }));
|
||||
|
||||
expect(vm.createVideoRoom).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
* <ComposeMenuView vm={roomListHeaderViewModel} />
|
||||
* ```
|
||||
*/
|
||||
export function ComposeMenuView({ vm }: ComposeMenuViewProps): JSX.Element {
|
||||
const { translate: _t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { canCreateRoom, canCreateVideoRoom } = useViewModel(vm);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
showTitle={false}
|
||||
title={_t("action|open_menu")}
|
||||
align="start"
|
||||
trigger={
|
||||
// 36px button with a 24px icon
|
||||
<IconButton size="36px" style={{ padding: "6px" }} tooltip={_t("action|new_conversation")}>
|
||||
<ComposeIcon aria-hidden />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<MenuItem Icon={ChatIcon} label={_t("action|start_chat")} onSelect={vm.createChatRoom} hideChevron />
|
||||
{canCreateRoom && (
|
||||
<MenuItem Icon={RoomIcon} label={_t("action|new_room")} onSelect={vm.createRoom} hideChevron />
|
||||
)}
|
||||
{canCreateVideoRoom && (
|
||||
<MenuItem
|
||||
Icon={VideoCallIcon}
|
||||
label={_t("action|new_video_room")}
|
||||
onSelect={vm.createVideoRoom}
|
||||
hideChevron
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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("<OptionMenuView />", () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("<RoomListOptionsMenu />", () => {
|
||||
it("should match snapshot", () => {
|
||||
const vm = {
|
||||
sort: jest.fn(),
|
||||
} as unknown as RoomListHeaderViewState;
|
||||
|
||||
const { asFragment } = render(<RoomListOptionsMenu vm={vm} />);
|
||||
const vm = new MockedViewModel(defaultSnapshot);
|
||||
const { asFragment } = render(<OptionMenuView vm={vm} />);
|
||||
|
||||
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(<RoomListOptionsMenu vm={vm} />);
|
||||
const vm = new MockedViewModel({ ...defaultSnapshot, activeSortOption: "alphabetical" });
|
||||
render(<OptionMenuView vm={vm} />);
|
||||
|
||||
// Open the menu
|
||||
const button = screen.getByRole("button", { name: "Room Options" });
|
||||
@ -41,15 +38,11 @@ describe("<RoomListOptionsMenu />", () => {
|
||||
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(<RoomListOptionsMenu vm={vm} />);
|
||||
const vm = new MockedViewModel({ ...defaultSnapshot, activeSortOption: "recent" });
|
||||
render(<OptionMenuView vm={vm} />);
|
||||
|
||||
// Open the menu
|
||||
const button = screen.getByRole("button", { name: "Room Options" });
|
||||
@ -62,33 +55,26 @@ describe("<RoomListOptionsMenu />", () => {
|
||||
it("should sort A to Z", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const vm = {
|
||||
sort: jest.fn(),
|
||||
} as unknown as RoomListHeaderViewState;
|
||||
|
||||
render(<RoomListOptionsMenu vm={vm} />);
|
||||
const vm = new MockedViewModel(defaultSnapshot);
|
||||
render(<OptionMenuView vm={vm} />);
|
||||
|
||||
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(<RoomListOptionsMenu vm={vm} />);
|
||||
const vm = new MockedViewModel({ ...defaultSnapshot, activeSortOption: "recent" });
|
||||
render(<OptionMenuView vm={vm} />);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
* <OptionMenuView vm={roomListHeaderViewModel} />
|
||||
* ```
|
||||
*/
|
||||
export function OptionMenuView({ vm }: OptionMenuViewProps): JSX.Element {
|
||||
const { translate: _t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { activeSortOption } = useViewModel(vm);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={_t("room_list|room_options")}
|
||||
showTitle={false}
|
||||
align="start"
|
||||
trigger={
|
||||
<IconButton
|
||||
tooltip={_t("room_list|room_options")}
|
||||
aria-label={_t("room_list|room_options")}
|
||||
// 28px icon with a 20px icon
|
||||
size="28px"
|
||||
style={{ padding: "4px" }}
|
||||
>
|
||||
<OverflowHorizontalIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<MenuTitle title={_t("room_list|sort")} className={styles.title} />
|
||||
<RadioMenuItem
|
||||
label={_t("room_list|sort_type|activity")}
|
||||
checked={activeSortOption === "recent"}
|
||||
onSelect={() => vm.sort("recent")}
|
||||
/>
|
||||
<RadioMenuItem
|
||||
label={_t("room_list|sort_type|atoz")}
|
||||
checked={activeSortOption === "alphabetical"}
|
||||
onSelect={() => vm.sort("alphabetical")}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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("<SpaceMenuView />", () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should match snapshot", () => {
|
||||
const vm = new MockedViewModel(defaultSnapshot);
|
||||
const { asFragment } = render(<SpaceMenuView vm={vm} />);
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should display the menu when button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const vm = new MockedViewModel(defaultSnapshot);
|
||||
render(<SpaceMenuView vm={vm} />);
|
||||
|
||||
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(<SpaceMenuView vm={vm} />);
|
||||
|
||||
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(<SpaceMenuView vm={vm} />);
|
||||
|
||||
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(<SpaceMenuView vm={vm} />);
|
||||
|
||||
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(<SpaceMenuView vm={vm} />);
|
||||
|
||||
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(<SpaceMenuView vm={vm} />);
|
||||
|
||||
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(<SpaceMenuView vm={vm} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Open space menu" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Space settings" }));
|
||||
|
||||
expect(vm.openSpaceSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
* <SpaceMenuView vm={roomListHeaderViewModel} />
|
||||
* ```
|
||||
*/
|
||||
export function SpaceMenuView({ vm }: SpaceMenuViewProps): JSX.Element {
|
||||
const { translate: _t } = useI18n();
|
||||
const { canInviteInSpace, canAccessSpaceSettings, title } = useViewModel(vm);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={title}
|
||||
align="start"
|
||||
trigger={
|
||||
<IconButton
|
||||
className={styles.button}
|
||||
aria-label={_t("room_list|open_space_menu")}
|
||||
// 24px icon with a 20px icon
|
||||
size="24px"
|
||||
style={{ padding: "2px" }}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<MenuItem Icon={HomeIcon} label={_t("room_list|space_menu|home")} onSelect={vm.openSpaceHome} hideChevron />
|
||||
{canInviteInSpace && (
|
||||
<MenuItem Icon={UserAddIcon} label={_t("action|invite")} onSelect={vm.inviteInSpace} hideChevron />
|
||||
)}
|
||||
<MenuItem
|
||||
Icon={PreferencesIcon}
|
||||
label={_t("common|preferences")}
|
||||
onSelect={vm.openSpacePreferences}
|
||||
hideChevron
|
||||
/>
|
||||
{canAccessSpaceSettings && (
|
||||
<MenuItem
|
||||
Icon={SettingsIcon}
|
||||
label={_t("room_list|space_menu|space_settings")}
|
||||
onSelect={vm.openSpaceSettings}
|
||||
hideChevron
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`<ComposeMenuView /> should match snapshot 1`] = `
|
||||
<DocumentFragment>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-labelledby="_r_2_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_0_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 36px; padding: 6px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M16.937 2.82a2 2 0 0 1 2.828 0l1.415 1.414a2 2 0 0 1 0 2.829l-7.071 7.07c-.195.196-.42.342-.66.44a1 1 0 0 1-.168.072l-3.993 1.331a1 1 0 0 1-1.265-1.265l1.331-3.992q.03-.09.073-.168m10.338-4.903-6.717 6.718-1.414-1.414 6.717-6.718z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M3 5a2 2 0 0 1 2-2h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@ -1,6 +1,6 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`<RoomListOptionsMenu /> should match snapshot 1`] = `
|
||||
exports[`<OptionMenuView /> should match snapshot 1`] = `
|
||||
<DocumentFragment>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
@ -13,7 +13,7 @@ exports[`<RoomListOptionsMenu /> 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[`<RoomListOptionsMenu /> should match snapshot 1`] = `
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
color="var(--cpd-color-icon-secondary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@ -0,0 +1,37 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`<SpaceMenuView /> should match snapshot 1`] = `
|
||||
<DocumentFragment>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open space menu"
|
||||
class="_icon-button_1215g_8 button"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_0_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 24px; padding: 2px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@ -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";
|
||||
@ -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<RoomListHeaderViewSnapshot> 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",
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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<ViewRoomPayload>({
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -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!],
|
||||
};
|
||||
}
|
||||
@ -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 (
|
||||
<Flex
|
||||
as="header"
|
||||
className="mx_RoomListHeaderView"
|
||||
aria-label={_t("room|context_menu|title")}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
data-testid="room-list-header"
|
||||
>
|
||||
<Flex className="mx_RoomListHeaderView_title" align="center" gap="var(--cpd-space-1x)">
|
||||
<h1 title={vm.title}>{vm.title}</h1>
|
||||
{vm.displaySpaceMenu && <SpaceMenu vm={vm} />}
|
||||
</Flex>
|
||||
<Flex align="center" gap="var(--cpd-space-2x)">
|
||||
<div className="mx_RoomListHeaderView_ReleaseAnnouncementAnchor">
|
||||
<RoomListOptionsMenu vm={vm} />
|
||||
</div>
|
||||
|
||||
{/* If we don't display the compose menu, it means that the user can only send DM */}
|
||||
<div className="mx_RoomListHeaderView_ReleaseAnnouncementAnchor">
|
||||
{vm.displayComposeMenu ? (
|
||||
<ComposeMenu vm={vm} />
|
||||
) : (
|
||||
<IconButton
|
||||
onClick={(e) => vm.createChatRoom(e.nativeEvent)}
|
||||
tooltip={_t("action|new_conversation")}
|
||||
>
|
||||
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={vm.title}
|
||||
side="right"
|
||||
align="start"
|
||||
trigger={
|
||||
<IconButton className="mx_SpaceMenu_button" aria-label={_t("room_list|open_space_menu")} size="20px">
|
||||
<ChevronDownIcon color="var(--cpd-color-icon-secondary)" />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<MenuItem
|
||||
Icon={HomeIcon}
|
||||
label={_t("room_list|space_menu|home")}
|
||||
onSelect={vm.openSpaceHome}
|
||||
hideChevron={true}
|
||||
/>
|
||||
{vm.canInviteInSpace && (
|
||||
<MenuItem
|
||||
Icon={UserAddIcon}
|
||||
label={_t("action|invite")}
|
||||
onSelect={vm.inviteInSpace}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
Icon={PreferencesIcon}
|
||||
label={_t("common|preferences")}
|
||||
onSelect={vm.openSpacePreferences}
|
||||
hideChevron={true}
|
||||
/>
|
||||
{vm.canAccessSpaceSettings && (
|
||||
<MenuItem
|
||||
Icon={SettingsIcon}
|
||||
label={_t("room_list|space_menu|space_settings")}
|
||||
onSelect={vm.openSpaceSettings}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
showTitle={false}
|
||||
title={_t("action|open_menu")}
|
||||
side="right"
|
||||
align="start"
|
||||
trigger={
|
||||
<IconButton tooltip={_t("action|new_conversation")}>
|
||||
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<MenuItem Icon={ChatIcon} label={_t("action|start_chat")} onSelect={vm.createChatRoom} hideChevron={true} />
|
||||
{vm.canCreateRoom && (
|
||||
<MenuItem Icon={RoomIcon} label={_t("action|new_room")} onSelect={vm.createRoom} hideChevron={true} />
|
||||
)}
|
||||
{vm.canCreateVideoRoom && (
|
||||
<MenuItem
|
||||
Icon={VideoCallIcon}
|
||||
label={_t("action|new_video_room")}
|
||||
onSelect={vm.createVideoRoom}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@ -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<typeof IconButton> {
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
const MenuTrigger = ({ ref, ...props }: MenuTriggerProps): JSX.Element => (
|
||||
<Tooltip label={_t("room_list|room_options")}>
|
||||
<IconButton aria-label={_t("room_list|room_options")} {...props} ref={ref}>
|
||||
<OverflowHorizontalIcon color="var(--cpd-color-icon-secondary)" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
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 (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={_t("room_list|room_options")}
|
||||
showTitle={false}
|
||||
align="start"
|
||||
trigger={<MenuTrigger />}
|
||||
>
|
||||
<MenuTitle title={_t("room_list|sort")} />
|
||||
<RadioMenuItem
|
||||
label={_t("room_list|sort_type|activity")}
|
||||
checked={vm.activeSortOption === SortOption.Activity}
|
||||
onSelect={onActivitySelected}
|
||||
/>
|
||||
<RadioMenuItem
|
||||
label={_t("room_list|sort_type|atoz")}
|
||||
checked={vm.activeSortOption === SortOption.AToZ}
|
||||
onSelect={onAtoZSelected}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@ -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<RoomListPanelProps> = ({ activeSpace }) =>
|
||||
[focusedElement],
|
||||
);
|
||||
|
||||
const matrixClient = useMatrixClientContext();
|
||||
const vm = useCreateAutoDisposedViewModel(
|
||||
() => new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }),
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
as="nav"
|
||||
@ -70,7 +77,7 @@ export const RoomListPanel: React.FC<RoomListPanelProps> = ({ activeSpace }) =>
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{displayRoomSearch && <RoomListSearch activeSpace={activeSpace} />}
|
||||
<RoomListHeaderView />
|
||||
<RoomListHeaderView vm={vm} />
|
||||
<RoomListView />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@ -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"
|
||||
|
||||
241
src/viewmodels/room-list/RoomListHeaderViewModel.ts
Normal file
@ -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<RoomListHeaderViewSnapshot, Props>
|
||||
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<ViewRoomPayload>({
|
||||
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<RoomListHeaderViewSnapshot, "activeSortOption"> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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("<RoomListHeaderView />", () => {
|
||||
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(<RoomListHeaderView />);
|
||||
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(<RoomListHeaderView />);
|
||||
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(<RoomListHeaderView />);
|
||||
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(<RoomListHeaderView />);
|
||||
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(<RoomListHeaderView />);
|
||||
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(<RoomListHeaderView />);
|
||||
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(<RoomListHeaderView />);
|
||||
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(<RoomListHeaderView />);
|
||||
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(<RoomListHeaderView />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,653 +0,0 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`<RoomListHeaderView /> compose menu should display the compose menu 1`] = `
|
||||
<DocumentFragment>
|
||||
<header
|
||||
aria-label="Room options"
|
||||
class="_flex_4dswl_9 mx_RoomListHeaderView"
|
||||
data-testid="room-list-header"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_RoomListHeaderView_title"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<h1
|
||||
title="title"
|
||||
>
|
||||
title
|
||||
</h1>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open space menu"
|
||||
class="_icon-button_1215g_8 mx_SpaceMenu_button"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_i_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 20px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
color="var(--cpd-color-icon-secondary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="_flex_4dswl_9"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Room Options"
|
||||
aria-labelledby="_r_m_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_k_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
color="var(--cpd-color-icon-secondary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-labelledby="_r_t_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_r_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
color="var(--cpd-color-icon-secondary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M16.937 2.82a2 2 0 0 1 2.828 0l1.415 1.414a2 2 0 0 1 0 2.829l-7.071 7.07c-.195.196-.42.342-.66.44a1 1 0 0 1-.168.072l-3.993 1.331a1 1 0 0 1-1.265-1.265l1.331-3.992q.03-.09.073-.168m10.338-4.903-6.717 6.718-1.414-1.414 6.717-6.718z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M3 5a2 2 0 0 1 2-2h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<RoomListHeaderView /> compose menu should not display the compose menu 1`] = `
|
||||
<DocumentFragment>
|
||||
<header
|
||||
aria-label="Room options"
|
||||
class="_flex_4dswl_9 mx_RoomListHeaderView"
|
||||
data-testid="room-list-header"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_RoomListHeaderView_title"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<h1
|
||||
title="title"
|
||||
>
|
||||
title
|
||||
</h1>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open space menu"
|
||||
class="_icon-button_1215g_8 mx_SpaceMenu_button"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_14_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 20px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
color="var(--cpd-color-icon-secondary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="_flex_4dswl_9"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Room Options"
|
||||
aria-labelledby="_r_18_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_16_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
color="var(--cpd-color-icon-secondary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_1d_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
color="var(--cpd-color-icon-secondary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M16.937 2.82a2 2 0 0 1 2.828 0l1.415 1.414a2 2 0 0 1 0 2.829l-7.071 7.07c-.195.196-.42.342-.66.44a1 1 0 0 1-.168.072l-3.993 1.331a1 1 0 0 1-1.265-1.265l1.331-3.992q.03-.09.073-.168m10.338-4.903-6.717 6.718-1.414-1.414 6.717-6.718z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M3 5a2 2 0 0 1 2-2h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<RoomListHeaderView /> should render 'room options' button 1`] = `
|
||||
<DocumentFragment>
|
||||
<header
|
||||
aria-label="Room options"
|
||||
class="_flex_4dswl_9 mx_RoomListHeaderView"
|
||||
data-testid="room-list-header"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_RoomListHeaderView_title"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<h1
|
||||
title="title"
|
||||
>
|
||||
title
|
||||
</h1>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open space menu"
|
||||
class="_icon-button_1215g_8 mx_SpaceMenu_button"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_0_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 20px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
color="var(--cpd-color-icon-secondary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="_flex_4dswl_9"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Room Options"
|
||||
aria-labelledby="_r_4_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_2_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
color="var(--cpd-color-icon-secondary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-labelledby="_r_b_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_9_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
color="var(--cpd-color-icon-secondary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M16.937 2.82a2 2 0 0 1 2.828 0l1.415 1.414a2 2 0 0 1 0 2.829l-7.071 7.07c-.195.196-.42.342-.66.44a1 1 0 0 1-.168.072l-3.993 1.331a1 1 0 0 1-1.265-1.265l1.331-3.992q.03-.09.073-.168m10.338-4.903-6.717 6.718-1.414-1.414 6.717-6.718z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M3 5a2 2 0 0 1 2-2h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<RoomListHeaderView /> space menu should display the space menu 1`] = `
|
||||
<DocumentFragment>
|
||||
<header
|
||||
aria-label="Room options"
|
||||
class="_flex_4dswl_9 mx_RoomListHeaderView"
|
||||
data-testid="room-list-header"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_RoomListHeaderView_title"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<h1
|
||||
title="title"
|
||||
>
|
||||
title
|
||||
</h1>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open space menu"
|
||||
class="_icon-button_1215g_8 mx_SpaceMenu_button"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_36_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 20px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
color="var(--cpd-color-icon-secondary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="_flex_4dswl_9"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Room Options"
|
||||
aria-labelledby="_r_3a_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_38_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
color="var(--cpd-color-icon-secondary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-labelledby="_r_3h_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_3f_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
color="var(--cpd-color-icon-secondary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M16.937 2.82a2 2 0 0 1 2.828 0l1.415 1.414a2 2 0 0 1 0 2.829l-7.071 7.07c-.195.196-.42.342-.66.44a1 1 0 0 1-.168.072l-3.993 1.331a1 1 0 0 1-1.265-1.265l1.331-3.992q.03-.09.073-.168m10.338-4.903-6.717 6.718-1.414-1.414 6.717-6.718z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M3 5a2 2 0 0 1 2-2h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<RoomListHeaderView /> space menu should not display the space menu 1`] = `
|
||||
<DocumentFragment>
|
||||
<header
|
||||
aria-label="Room options"
|
||||
class="_flex_4dswl_9 mx_RoomListHeaderView"
|
||||
data-testid="room-list-header"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_RoomListHeaderView_title"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<h1
|
||||
title="title"
|
||||
>
|
||||
title
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
class="_flex_4dswl_9"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Room Options"
|
||||
aria-labelledby="_r_3q_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_3o_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
color="var(--cpd-color-icon-secondary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-labelledby="_r_41_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_3v_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
color="var(--cpd-color-icon-secondary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M16.937 2.82a2 2 0 0 1 2.828 0l1.415 1.414a2 2 0 0 1 0 2.829l-7.071 7.07c-.195.196-.42.342-.66.44a1 1 0 0 1-.168.072l-3.993 1.331a1 1 0 0 1-1.265-1.265l1.331-3.992q.03-.09.073-.168m10.338-4.903-6.717 6.718-1.414-1.414 6.717-6.718z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M3 5a2 2 0 0 1 2-2h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
272
test/viewmodels/room-list/RoomListHeaderViewModel-test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -43,7 +43,7 @@ describe("RoomStatusBarViewModel", () => {
|
||||
beforeEach(() => {
|
||||
client = stubClient() as MockedObject<MatrixClient>;
|
||||
room = mkRoom(client, "!example");
|
||||
room.on.mockImplementationOnce((_event, fn) => {
|
||||
jest.spyOn(room, "on").mockImplementationOnce((_event, fn) => {
|
||||
roomEmitFn = fn as any;
|
||||
return room;
|
||||
});
|
||||
|
||||