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
This commit is contained in:
Florian Duros
2026-01-21 10:06:01 +01:00
committed by GitHub
parent d2d61a3203
commit 9edddce149
55 changed files with 1831 additions and 1688 deletions

View File

@@ -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"
},

View File

@@ -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";

View File

@@ -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;
}
}

View File

@@ -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,
};

View File

@@ -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();
});
});

View File

@@ -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>
);
}

View File

@@ -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>
`;

View File

@@ -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";

View File

@@ -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);
});
});

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -0,0 +1,80 @@
/*
* 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 { OptionMenuView } from "./OptionMenuView";
import { defaultSnapshot, MockedViewModel } from "../test-utils";
describe("<OptionMenuView />", () => {
afterEach(() => {
jest.clearAllMocks();
});
it("should match snapshot", () => {
const vm = new MockedViewModel(defaultSnapshot);
const { asFragment } = render(<OptionMenuView vm={vm} />);
expect(asFragment()).toMatchSnapshot();
});
it("should show A to Z selected if activeSortOption is alphabetical", async () => {
const user = userEvent.setup();
const vm = new MockedViewModel({ ...defaultSnapshot, activeSortOption: "alphabetical" });
render(<OptionMenuView vm={vm} />);
// Open the menu
const button = screen.getByRole("button", { name: "Room Options" });
await user.click(button);
expect(screen.getByRole("menuitemradio", { name: "A-Z" })).toBeChecked();
expect(screen.getByRole("menuitemradio", { name: "Activity" })).not.toBeChecked();
});
it("should show Activity selected if activeSortOption is recent", async () => {
const user = userEvent.setup();
const vm = new MockedViewModel({ ...defaultSnapshot, activeSortOption: "recent" });
render(<OptionMenuView vm={vm} />);
// Open the menu
const button = screen.getByRole("button", { name: "Room Options" });
await user.click(button);
expect(screen.getByRole("menuitemradio", { name: "A-Z" })).not.toBeChecked();
expect(screen.getByRole("menuitemradio", { name: "Activity" })).toBeChecked();
});
it("should sort A to Z", async () => {
const user = userEvent.setup();
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("alphabetical");
});
it("should sort by activity", async () => {
const user = userEvent.setup();
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("recent");
});
});

View File

@@ -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>
);
}

View File

@@ -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);
}
}

View File

@@ -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);
});
});

View File

@@ -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>
);
}

View File

@@ -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>
`;

View File

@@ -0,0 +1,38 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<OptionMenuView /> should match snapshot 1`] = `
<DocumentFragment>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Room Options"
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: 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>
</DocumentFragment>
`;

View File

@@ -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>
`;

View File

@@ -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";

View File

@@ -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",
};

View File

@@ -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";

View File

@@ -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(() => {