Add RoomListItem component

Add the RoomListItem component to shared-components.
Includes context menu, hover menu, notification menu, and more options menu.
This commit is contained in:
David Langley 2026-01-30 09:42:28 +00:00
parent 9e61bfa75e
commit 1ae6478d2b
24 changed files with 2672 additions and 0 deletions

View File

@ -0,0 +1,106 @@
/*
* Copyright 2025 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.
*/
/**
* The RoomListItem has the following structure:
* button--------------------------------------------------|
* | <-12px-> container------------------------------------|
* | | room avatar <-8px-> content----------------|
* | | | room_name <- 20px ->|
* | | | --------------------| <-- border
* |-------------------------------------------------------|
*/
.roomListItem {
/* Remove button default style */
background: unset;
border: none;
padding: 0;
text-align: unset;
cursor: pointer;
height: 48px;
width: 100%;
padding-left: var(--cpd-space-3x);
font: var(--cpd-font-body-md-regular);
color: var(--cpd-color-text-primary);
/* Hide the menu by default */
.hoverMenu {
display: none;
}
}
/* Show hover menu and background on hover/focus/menu-open states */
.roomListItem:hover,
.roomListItem:focus-visible,
/* When the context menu is opened */
.roomListItem[data-state="open"],
/* When the options and notifications menu are opened */
.roomListItem:has(.hoverMenu > button[data-state="open"]) {
background-color: var(--cpd-color-bg-action-secondary-hovered);
.hoverMenu {
display: flex;
}
/* When the menu is visible, hide the notification decoration to avoid clutter */
.notificationDecoration {
display: none;
}
/**
* The figma uses 16px padding (--cpd-space-4x) but due to https://github.com/element-hq/compound-web/issues/331
* the icon size of the menu is 18px instead of 20px with a different internal padding
* We need to use 18px to align the icon with the others icons
* 18px is not available in compound spacing
*/
.content {
padding-right: 18px;
}
}
.content {
height: 100%;
flex: 1;
/* The border is only under the room name and the future hover menu */
border-bottom: var(--cpd-border-width-0-5) solid var(--cpd-color-bg-subtle-secondary);
box-sizing: border-box;
min-width: 0;
padding-right: var(--cpd-space-5x);
}
.text {
min-width: 0;
}
.roomName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.messagePreview {
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.selected {
background-color: var(--cpd-color-bg-action-secondary-pressed);
}
.bold .roomName {
font: var(--cpd-font-body-md-semibold);
}
/* Set icon color for hover menu buttons */
.hoverMenu svg {
fill: var(--cpd-color-icon-primary);
}

View File

@ -0,0 +1,206 @@
/*
* 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, StoryObj } from "@storybook/react-vite";
import { RoomListItemView, type RoomListItemSnapshot, type RoomListItemActions } from "./RoomListItem";
import { useMockedViewModel } from "../../viewmodel";
import { defaultSnapshot } from "./default-snapshot";
import { renderAvatar } from "../story-mocks";
type RoomListItemProps = RoomListItemSnapshot &
RoomListItemActions & {
isSelected: boolean;
isFocused: boolean;
onFocus: (room: any, e: React.FocusEvent) => void;
roomIndex: number;
roomCount: number;
renderAvatar: (room: any) => React.ReactElement;
};
// Wrapper component that creates a mocked ViewModel
const RoomListItemWrapper = ({
onOpenRoom,
onMarkAsRead,
onMarkAsUnread,
onToggleFavorite,
onToggleLowPriority,
onInvite,
onCopyRoomLink,
onLeaveRoom,
onSetRoomNotifState,
isSelected,
isFocused,
onFocus,
roomIndex,
roomCount,
renderAvatar: renderAvatarProp,
...rest
}: RoomListItemProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
onOpenRoom,
onMarkAsRead,
onMarkAsUnread,
onToggleFavorite,
onToggleLowPriority,
onInvite,
onCopyRoomLink,
onLeaveRoom,
onSetRoomNotifState,
});
return (
<RoomListItemView
vm={vm}
isSelected={isSelected}
isFocused={isFocused}
onFocus={onFocus}
roomIndex={roomIndex}
roomCount={roomCount}
renderAvatar={renderAvatarProp}
/>
);
};
const meta = {
title: "Room List/RoomListItem",
component: RoomListItemWrapper,
tags: ["autodocs"],
decorators: [
(Story) => (
<div style={{ width: "320px", padding: "8px" }}>
<div role="listbox" aria-label="Room list">
<Story />
</div>
</div>
),
],
args: {
...defaultSnapshot,
isSelected: false,
isFocused: false,
roomIndex: 0,
roomCount: 10,
onOpenRoom: fn(),
onMarkAsRead: fn(),
onMarkAsUnread: fn(),
onToggleFavorite: fn(),
onToggleLowPriority: fn(),
onInvite: fn(),
onCopyRoomLink: fn(),
onLeaveRoom: fn(),
onSetRoomNotifState: fn(),
onFocus: fn(),
renderAvatar,
},
} satisfies Meta<typeof RoomListItemWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Selected: Story = {
args: {
isSelected: true,
},
};
export const Bold: Story = {
args: {
isBold: true,
name: "Team Updates",
},
};
export const WithNotification: Story = {
args: {
isBold: true,
notification: {
hasAnyNotificationOrActivity: true,
isUnsentMessage: false,
invited: false,
isMention: false,
isActivityNotification: false,
isNotification: true,
hasUnreadCount: true,
count: 3,
muted: false,
},
},
};
export const WithMention: Story = {
args: {
isBold: true,
notification: {
hasAnyNotificationOrActivity: true,
isUnsentMessage: false,
invited: false,
isMention: true,
isActivityNotification: false,
isNotification: true,
hasUnreadCount: true,
count: 1,
muted: false,
},
},
};
export const Invitation: Story = {
args: {
name: "Secret Project",
messagePreview: "Bob invited you",
notification: {
hasAnyNotificationOrActivity: true,
isUnsentMessage: false,
invited: true,
isMention: false,
isActivityNotification: false,
isNotification: false,
hasUnreadCount: false,
count: 0,
muted: false,
},
},
};
export const UnsentMessage: Story = {
args: {
messagePreview: "Failed to send message",
notification: {
hasAnyNotificationOrActivity: true,
isUnsentMessage: true,
invited: false,
isMention: false,
isActivityNotification: false,
isNotification: false,
hasUnreadCount: false,
count: 0,
muted: false,
},
},
};
export const NoMessagePreview: Story = {
args: {
messagePreview: undefined,
},
};
export const WithHoverMenu: Story = {
args: {
showMoreOptionsMenu: true,
},
};
export const WithoutHoverMenu: Story = {
args: {
showMoreOptionsMenu: false,
},
};

View File

@ -0,0 +1,123 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import * as stories from "./RoomListItem.stories";
const {
Default,
Selected,
Bold,
WithNotification,
WithMention,
Invitation,
UnsentMessage,
NoMessagePreview,
WithHoverMenu,
WithoutHoverMenu,
} = composeStories(stories);
describe("<RoomListItemView />", () => {
it("renders Default story", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders Selected story", () => {
const { container } = render(<Selected />);
expect(container).toMatchSnapshot();
});
it("renders Bold story", () => {
const { container } = render(<Bold />);
expect(container).toMatchSnapshot();
});
it("renders WithNotification story", () => {
const { container } = render(<WithNotification />);
expect(container).toMatchSnapshot();
});
it("renders WithMention story", () => {
const { container } = render(<WithMention />);
expect(container).toMatchSnapshot();
});
it("renders Invitation story", () => {
const { container } = render(<Invitation />);
expect(container).toMatchSnapshot();
});
it("renders UnsentMessage story", () => {
const { container } = render(<UnsentMessage />);
expect(container).toMatchSnapshot();
});
it("renders NoMessagePreview story", () => {
const { container } = render(<NoMessagePreview />);
expect(container).toMatchSnapshot();
});
it("renders WithHoverMenu story", () => {
const { container } = render(<WithHoverMenu />);
expect(container).toMatchSnapshot();
});
it("should call onOpenRoom when clicked", async () => {
const user = userEvent.setup();
render(<Default />);
await user.click(screen.getByRole("option"));
expect(Default.args.onOpenRoom).toHaveBeenCalled();
});
it("should have aria-selected true when selected", () => {
render(<Selected />);
expect(screen.getByRole("option")).toHaveAttribute("aria-selected", "true");
});
it("should have aria-selected false when not selected", () => {
render(<Default />);
expect(screen.getByRole("option")).toHaveAttribute("aria-selected", "false");
});
it("should have tabIndex -1 when not focused", () => {
render(<Default />);
expect(screen.getByRole("option")).toHaveAttribute("tabIndex", "-1");
});
it("should call onFocus when focused", () => {
render(<Default />);
screen.getByRole("option").focus();
expect(Default.args.onFocus).toHaveBeenCalled();
});
it("should display notification decoration when present", () => {
render(<WithNotification />);
expect(screen.getByTestId("notification-decoration")).toBeInTheDocument();
});
it("should hide notification decoration when not present", () => {
render(<Default />);
expect(screen.queryByTestId("notification-decoration")).toBeNull();
});
it("should show hover menu when showMoreOptionsMenu is true", () => {
const { container } = render(<WithHoverMenu />);
expect(container.querySelector('[aria-label="More Options"]')).not.toBeNull();
});
it("should hide hover menu when showMoreOptionsMenu is false", () => {
const { container } = render(<WithoutHoverMenu />);
expect(container.querySelector('[aria-label="More Options"]')).toBeNull();
});
});

View File

@ -0,0 +1,202 @@
/*
* 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, memo, useEffect, useRef, type ReactNode } from "react";
import classNames from "classnames";
import { Flex } from "../../utils/Flex";
import { NotificationDecoration, type NotificationDecorationData } from "./NotificationDecoration";
import { RoomListItemHoverMenu } from "./RoomListItemHoverMenu";
import { RoomListItemContextMenu } from "./RoomListItemContextMenu";
import { type RoomNotifState } from "./RoomNotifs";
import styles from "./RoomListItem.module.css";
import { useViewModel, type ViewModel } from "../../viewmodel";
import { _t } from "../../utils/i18n";
/**
* Generate an accessible label for a room based on its notification state.
*/
function getA11yLabel(roomName: string, notification: NotificationDecorationData): string {
if (notification.isUnsentMessage) {
return _t("room_list|a11y|unsent_message", { roomName });
} else if (notification.invited) {
return _t("room_list|a11y|invitation", { roomName });
} else if (notification.isMention && notification.count) {
return _t("room_list|a11y|mention", { roomName, count: notification.count });
} else if (notification.hasUnreadCount && notification.count) {
return _t("room_list|a11y|unread", { roomName, count: notification.count });
} else {
return _t("room_list|a11y|default", { roomName });
}
}
/**
* Snapshot for a room list item.
* Contains all the data needed to render a room in the list.
*/
export interface RoomListItemSnapshot {
/** Unique identifier for the room (used for list keying) */
id: string;
/** The opaque Room object from the client (e.g., matrix-js-sdk Room) */
room: any;
/** The name of the room */
name: string;
/** Whether the room name should be bolded (has unread/activity) */
isBold: boolean;
/** Optional message preview text */
messagePreview?: string;
/** Notification decoration data */
notification: NotificationDecorationData;
/** Whether the more options menu should be shown */
showMoreOptionsMenu: boolean;
/** Whether the notification menu should be shown */
showNotificationMenu: boolean;
/** Whether the room is a favourite room */
isFavourite: boolean;
/** Whether the room is a low priority room */
isLowPriority: boolean;
/** Can invite other users in the room */
canInvite: boolean;
/** Can copy the room link */
canCopyRoomLink: boolean;
/** Can mark the room as read */
canMarkAsRead: boolean;
/** Can mark the room as unread */
canMarkAsUnread: boolean;
/** The room's notification state */
roomNotifState: RoomNotifState;
}
/**
* Actions interface for room list item operations.
* Implemented by the room item view model.
*/
export interface RoomListItemActions {
/** Called when the room should be opened */
onOpenRoom: () => void;
/** Called when the room should be marked as read */
onMarkAsRead: () => void;
/** Called when the room should be marked as unread */
onMarkAsUnread: () => void;
/** Called when the room's favorite status should be toggled */
onToggleFavorite: () => void;
/** Called when the room's low priority status should be toggled */
onToggleLowPriority: () => void;
/** Called when inviting users to the room */
onInvite: () => void;
/** Called when copying the room link */
onCopyRoomLink: () => void;
/** Called when leaving the room */
onLeaveRoom: () => void;
/** Called when setting the room notification state */
onSetRoomNotifState: (state: RoomNotifState) => void;
}
/**
* The view model type for a room list item
*/
export type RoomItemViewModel = ViewModel<RoomListItemSnapshot> & RoomListItemActions;
/**
* Props for RoomListItemView component
*/
export interface RoomListItemViewProps extends Omit<React.HTMLAttributes<HTMLButtonElement>, "onFocus"> {
/** The room item view model */
vm: RoomItemViewModel;
/** Whether the room is selected */
isSelected: boolean;
/** Whether the room should be focused */
isFocused: boolean;
/** Callback when item receives focus */
onFocus: (roomId: string, e: React.FocusEvent) => void;
/** Index of this room in the list (for accessibility) */
roomIndex: number;
/** Total number of rooms in the list (for accessibility) */
roomCount: number;
/** Function to render the room avatar */
renderAvatar: (room: any) => ReactNode;
}
/**
* A presentational room list item component.
* Displays room name, avatar, message preview, and notifications.
*/
export const RoomListItemView = memo(function RoomListItemView({
vm,
isSelected,
isFocused,
onFocus,
roomIndex,
roomCount,
renderAvatar,
...props
}: RoomListItemViewProps): JSX.Element {
const ref = useRef<HTMLButtonElement>(null);
const item = useViewModel(vm);
useEffect(() => {
if (isFocused) {
ref.current?.focus({ preventScroll: true, focusVisible: true } as FocusOptions);
}
}, [isFocused]);
// Generate a11y label from notification state and room name
const a11yLabel = getA11yLabel(item.name, item.notification);
const content = (
<Flex
as="button"
ref={ref}
className={classNames(styles.roomListItem, "mx_RoomListItemView", {
[styles.selected]: isSelected,
[styles.bold]: item.isBold,
mx_RoomListItemView_selected: isSelected,
})}
gap="var(--cpd-space-3x)"
align="center"
type="button"
role="option"
aria-posinset={roomIndex + 1}
aria-setsize={roomCount}
aria-selected={isSelected}
aria-label={a11yLabel}
onClick={vm.onOpenRoom}
onFocus={(e: React.FocusEvent<HTMLButtonElement>) => onFocus(item.id, e)}
tabIndex={isFocused ? 0 : -1}
{...props}
>
{renderAvatar(item.room)}
<Flex className={styles.content} gap="var(--cpd-space-2x)" align="center" justify="space-between">
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
<div className={styles.text}>
<div className={styles.roomName} title={item.name} data-testid="room-name">
{item.name}
</div>
{item.messagePreview && (
<div className={styles.messagePreview} title={item.messagePreview}>
{item.messagePreview}
</div>
)}
</div>
{(item.showMoreOptionsMenu || item.showNotificationMenu) && (
<RoomListItemHoverMenu
showMoreOptionsMenu={item.showMoreOptionsMenu}
showNotificationMenu={item.showNotificationMenu}
vm={vm}
/>
)}
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel */}
<div className={styles.notificationDecoration} aria-hidden={true}>
<NotificationDecoration {...item.notification} />
</div>
</Flex>
</Flex>
);
return <RoomListItemContextMenu vm={vm}>{content}</RoomListItemContextMenu>;
});

View File

@ -0,0 +1,40 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type PropsWithChildren } from "react";
import { ContextMenu } from "@vector-im/compound-web";
import { _t } from "../../utils/i18n";
import { MoreOptionContent, type RoomItemViewModel } from "./RoomListItemMoreOptionsMenu";
/**
* Props for RoomListItemContextMenu component
*/
export interface RoomListItemContextMenuProps {
/** The room item view model */
vm: RoomItemViewModel;
}
/**
* The context menu for room list items.
* Wraps the trigger element with a right-click context menu displaying room options.
*/
export const RoomListItemContextMenu: React.FC<PropsWithChildren<RoomListItemContextMenuProps>> = ({
vm,
children,
}): JSX.Element => {
return (
<ContextMenu
title={_t("room_list|room|more_options")}
showTitle={false}
hasAccessibleAlternative={true}
trigger={children}
>
<MoreOptionContent vm={vm} />
</ContextMenu>
);
};

View File

@ -0,0 +1,42 @@
/*
* 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 { Flex } from "../../utils/Flex";
import { RoomListItemMoreOptionsMenu, type RoomItemViewModel } from "./RoomListItemMoreOptionsMenu";
import { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
import styles from "./RoomListItem.module.css";
/**
* Props for RoomListItemHoverMenu component
*/
export interface RoomListItemHoverMenuProps {
/** Whether the more options menu should be shown */
showMoreOptionsMenu: boolean;
/** Whether the notification menu should be shown */
showNotificationMenu: boolean;
/** The room item view model */
vm: RoomItemViewModel;
}
/**
* The hover menu for room list items.
* Displays more options and notification settings menus.
*/
export const RoomListItemHoverMenu: React.FC<RoomListItemHoverMenuProps> = ({
showMoreOptionsMenu,
showNotificationMenu,
vm,
}): JSX.Element => {
return (
<Flex className={styles.hoverMenu} align="center" gap="var(--cpd-space-1x)">
{showMoreOptionsMenu && <RoomListItemMoreOptionsMenu vm={vm} />}
{showNotificationMenu && <RoomListItemNotificationMenu vm={vm} />}
</Flex>
);
};

View File

@ -0,0 +1,227 @@
/*
* 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 { render, screen } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { RoomListItemMoreOptionsMenu } from "./RoomListItemMoreOptionsMenu";
import { useMockedViewModel } from "../../viewmodel";
import type { RoomListItemSnapshot } from "./RoomListItem";
import { defaultSnapshot } from "./default-snapshot";
describe("<RoomListItemMoreOptionsMenu />", () => {
const mockCallbacks = {
onOpenRoom: vi.fn(),
onMarkAsRead: vi.fn(),
onMarkAsUnread: vi.fn(),
onToggleFavorite: vi.fn(),
onToggleLowPriority: vi.fn(),
onInvite: vi.fn(),
onCopyRoomLink: vi.fn(),
onLeaveRoom: vi.fn(),
onSetRoomNotifState: vi.fn(),
};
const renderMenu = (overrides: Partial<RoomListItemSnapshot> = {}): ReturnType<typeof render> => {
const TestComponent = (): JSX.Element => {
const vm = useMockedViewModel(
{
...defaultSnapshot,
showMoreOptionsMenu: true,
showNotificationMenu: false,
...overrides,
} as RoomListItemSnapshot,
mockCallbacks,
);
return <RoomListItemMoreOptionsMenu vm={vm} />;
};
return render(<TestComponent />);
};
it("should render the more options button", () => {
renderMenu();
expect(screen.getByRole("button", { name: "More Options" })).toBeInTheDocument();
});
it("should open menu when clicked", async () => {
const user = userEvent.setup();
renderMenu();
const button = screen.getByRole("button", { name: "More Options" });
await user.click(button);
expect(screen.getByRole("menu")).toBeInTheDocument();
});
it("should show mark as read option when canMarkAsRead is true", async () => {
const user = userEvent.setup();
renderMenu({ canMarkAsRead: true });
const button = screen.getByRole("button", { name: "More Options" });
await user.click(button);
expect(screen.getByRole("menuitem", { name: "Mark as read" })).toBeInTheDocument();
});
it("should not show mark as read option when canMarkAsRead is false", async () => {
const user = userEvent.setup();
renderMenu({ canMarkAsRead: false });
const button = screen.getByRole("button", { name: "More Options" });
await user.click(button);
expect(screen.queryByRole("menuitem", { name: "Mark as read" })).not.toBeInTheDocument();
});
it("should call onMarkAsRead when mark as read clicked", async () => {
const user = userEvent.setup();
renderMenu({ canMarkAsRead: true });
const button = screen.getByRole("button", { name: "More Options" });
await user.click(button);
const markAsReadOption = screen.getByRole("menuitem", { name: "Mark as read" });
await user.click(markAsReadOption);
expect(mockCallbacks.onMarkAsRead).toHaveBeenCalled();
});
it("should show mark as unread option when canMarkAsUnread is true", async () => {
const user = userEvent.setup();
renderMenu({ canMarkAsUnread: true });
const button = screen.getByRole("button", { name: "More Options" });
await user.click(button);
expect(screen.getByRole("menuitem", { name: "Mark as unread" })).toBeInTheDocument();
});
it("should call onMarkAsUnread when mark as unread clicked", async () => {
const user = userEvent.setup();
renderMenu({ canMarkAsUnread: true });
const button = screen.getByRole("button", { name: "More Options" });
await user.click(button);
const markAsUnreadOption = screen.getByRole("menuitem", { name: "Mark as unread" });
await user.click(markAsUnreadOption);
expect(mockCallbacks.onMarkAsUnread).toHaveBeenCalled();
});
it("should show favorite option and call onToggleFavorite", async () => {
const user = userEvent.setup();
renderMenu({ isFavourite: false });
const button = screen.getByRole("button", { name: "More Options" });
await user.click(button);
const favoriteOption = screen.getByRole("menuitemcheckbox", { name: "Favourited" });
expect(favoriteOption).toBeInTheDocument();
expect(favoriteOption).toHaveAttribute("aria-checked", "false");
await user.click(favoriteOption);
expect(mockCallbacks.onToggleFavorite).toHaveBeenCalled();
});
it("should show favorite as checked when isFavourite is true", async () => {
const user = userEvent.setup();
renderMenu({ isFavourite: true });
const button = screen.getByRole("button", { name: "More Options" });
await user.click(button);
const favoriteOption = screen.getByRole("menuitemcheckbox", { name: "Favourited" });
expect(favoriteOption).toHaveAttribute("aria-checked", "true");
});
it("should show low priority option and call onToggleLowPriority", async () => {
const user = userEvent.setup();
renderMenu({ isLowPriority: false });
const button = screen.getByRole("button", { name: "More Options" });
await user.click(button);
const lowPriorityOption = screen.getByRole("menuitemcheckbox", { name: "Low priority" });
expect(lowPriorityOption).toBeInTheDocument();
expect(lowPriorityOption).toHaveAttribute("aria-checked", "false");
await user.click(lowPriorityOption);
expect(mockCallbacks.onToggleLowPriority).toHaveBeenCalled();
});
it("should show invite option when canInvite is true", async () => {
const user = userEvent.setup();
renderMenu({ canInvite: true });
const button = screen.getByRole("button", { name: "More Options" });
await user.click(button);
expect(screen.getByRole("menuitem", { name: "Invite" })).toBeInTheDocument();
});
it("should call onInvite when invite clicked", async () => {
const user = userEvent.setup();
renderMenu({ canInvite: true });
const button = screen.getByRole("button", { name: "More Options" });
await user.click(button);
const inviteOption = screen.getByRole("menuitem", { name: "Invite" });
await user.click(inviteOption);
expect(mockCallbacks.onInvite).toHaveBeenCalled();
});
it("should show copy link option when canCopyRoomLink is true", async () => {
const user = userEvent.setup();
renderMenu({ canCopyRoomLink: true });
const button = screen.getByRole("button", { name: "More Options" });
await user.click(button);
expect(screen.getByRole("menuitem", { name: "Copy room link" })).toBeInTheDocument();
});
it("should call onCopyRoomLink when copy link clicked", async () => {
const user = userEvent.setup();
renderMenu({ canCopyRoomLink: true });
const button = screen.getByRole("button", { name: "More Options" });
await user.click(button);
const copyLinkOption = screen.getByRole("menuitem", { name: "Copy room link" });
await user.click(copyLinkOption);
expect(mockCallbacks.onCopyRoomLink).toHaveBeenCalled();
});
it("should show leave room option", async () => {
const user = userEvent.setup();
renderMenu();
const button = screen.getByRole("button", { name: "More Options" });
await user.click(button);
expect(screen.getByRole("menuitem", { name: "Leave room" })).toBeInTheDocument();
});
it("should call onLeaveRoom when leave room clicked", async () => {
const user = userEvent.setup();
renderMenu();
const button = screen.getByRole("button", { name: "More Options" });
await user.click(button);
const leaveRoomOption = screen.getByRole("menuitem", { name: "Leave room" });
await user.click(leaveRoomOption);
expect(mockCallbacks.onLeaveRoom).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,137 @@
/*
* 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, Separator, ToggleMenuItem } from "@vector-im/compound-web";
import {
MarkAsReadIcon,
MarkAsUnreadIcon,
FavouriteIcon,
ArrowDownIcon,
UserAddIcon,
LinkIcon,
LeaveIcon,
OverflowHorizontalIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../utils/i18n";
import { useViewModel, type ViewModel } from "../../viewmodel";
import type { RoomListItemSnapshot, RoomListItemActions } from "./RoomListItem";
/**
* View model type for room list item
*/
export type RoomItemViewModel = ViewModel<RoomListItemSnapshot> & RoomListItemActions;
/**
* Props for RoomListItemMoreOptionsMenu component
*/
export interface RoomListItemMoreOptionsMenuProps {
/** The room item view model */
vm: RoomItemViewModel;
}
/**
* The more options menu for room list items.
* Displays additional room actions like mark as read/unread, favorite, invite, etc.
*/
export function RoomListItemMoreOptionsMenu({ vm }: RoomListItemMoreOptionsMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={setOpen}
title={_t("room_list|room|more_options")}
showTitle={false}
align="start"
trigger={
<IconButton
tooltip={_t("room_list|room|more_options")}
aria-label={_t("room_list|room|more_options")}
size="24px"
>
<OverflowHorizontalIcon />
</IconButton>
}
>
<MoreOptionContent vm={vm} />
</Menu>
);
}
interface MoreOptionContentProps {
vm: RoomItemViewModel;
}
export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
const snapshot = useViewModel(vm);
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div onKeyDown={(e) => e.stopPropagation()}>
{snapshot.canMarkAsRead && (
<MenuItem
Icon={MarkAsReadIcon}
label={_t("room_list|more_options|mark_read")}
onSelect={vm.onMarkAsRead}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
{snapshot.canMarkAsUnread && (
<MenuItem
Icon={MarkAsUnreadIcon}
label={_t("room_list|more_options|mark_unread")}
onSelect={vm.onMarkAsUnread}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
<ToggleMenuItem
checked={snapshot.isFavourite}
Icon={FavouriteIcon}
label={_t("room_list|more_options|favourited")}
onSelect={vm.onToggleFavorite}
onClick={(evt) => evt.stopPropagation()}
/>
<ToggleMenuItem
checked={snapshot.isLowPriority}
Icon={ArrowDownIcon}
label={_t("room_list|more_options|low_priority")}
onSelect={vm.onToggleLowPriority}
onClick={(evt) => evt.stopPropagation()}
/>
{snapshot.canInvite && (
<MenuItem
Icon={UserAddIcon}
label={_t("action|invite")}
onSelect={vm.onInvite}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
{snapshot.canCopyRoomLink && (
<MenuItem
Icon={LinkIcon}
label={_t("room_list|more_options|copy_link")}
onSelect={vm.onCopyRoomLink}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
<Separator />
<MenuItem
kind="critical"
Icon={LeaveIcon}
label={_t("room_list|more_options|leave_room")}
onSelect={vm.onLeaveRoom}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
</div>
);
}

View File

@ -0,0 +1,164 @@
/*
* 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 { render, screen } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
import { RoomNotifState } from "./RoomNotifs";
import { useMockedViewModel } from "../../viewmodel";
import type { RoomListItemSnapshot } from "./RoomListItem";
import { defaultSnapshot } from "./default-snapshot";
describe("<RoomListItemNotificationMenu />", () => {
const mockCallbacks = {
onOpenRoom: vi.fn(),
onMarkAsRead: vi.fn(),
onMarkAsUnread: vi.fn(),
onToggleFavorite: vi.fn(),
onToggleLowPriority: vi.fn(),
onInvite: vi.fn(),
onCopyRoomLink: vi.fn(),
onLeaveRoom: vi.fn(),
onSetRoomNotifState: vi.fn(),
};
const renderMenu = (roomNotifState: RoomNotifState = RoomNotifState.AllMessages): ReturnType<typeof render> => {
const TestComponent = (): JSX.Element => {
const vm = useMockedViewModel(
{
...defaultSnapshot,
showMoreOptionsMenu: false,
showNotificationMenu: true,
roomNotifState,
} as RoomListItemSnapshot,
mockCallbacks,
);
return <RoomListItemNotificationMenu vm={vm} />;
};
return render(<TestComponent />);
};
it("should render the notification menu button", () => {
renderMenu();
expect(screen.getByRole("button", { name: "Notification options" })).toBeInTheDocument();
});
it("should show muted icon when notifications are muted", () => {
renderMenu(RoomNotifState.Mute);
const button = screen.getByRole("button", { name: "Notification options" });
expect(button.querySelector("svg")).toBeInTheDocument();
});
it("should open menu when clicked", async () => {
const user = userEvent.setup();
renderMenu();
const button = screen.getByRole("button", { name: "Notification options" });
await user.click(button);
expect(screen.getByRole("menu")).toBeInTheDocument();
});
it("should call onSetRoomNotifState with AllMessages when default settings selected", async () => {
const user = userEvent.setup();
renderMenu();
const button = screen.getByRole("button", { name: "Notification options" });
await user.click(button);
const defaultOption = screen.getByRole("menuitem", { name: "Match default settings" });
await user.click(defaultOption);
expect(mockCallbacks.onSetRoomNotifState).toHaveBeenCalledWith(RoomNotifState.AllMessages);
});
it("should call onSetRoomNotifState with AllMessagesLoud when all messages selected", async () => {
const user = userEvent.setup();
renderMenu();
const button = screen.getByRole("button", { name: "Notification options" });
await user.click(button);
const allMessagesOption = screen.getByRole("menuitem", { name: "All messages" });
await user.click(allMessagesOption);
expect(mockCallbacks.onSetRoomNotifState).toHaveBeenCalledWith(RoomNotifState.AllMessagesLoud);
});
it("should call onSetRoomNotifState with MentionsOnly when mentions and keywords selected", async () => {
const user = userEvent.setup();
renderMenu();
const button = screen.getByRole("button", { name: "Notification options" });
await user.click(button);
const mentionsOption = screen.getByRole("menuitem", { name: "Mentions and keywords" });
await user.click(mentionsOption);
expect(mockCallbacks.onSetRoomNotifState).toHaveBeenCalledWith(RoomNotifState.MentionsOnly);
});
it("should call onSetRoomNotifState with Mute when mute selected", async () => {
const user = userEvent.setup();
renderMenu();
const button = screen.getByRole("button", { name: "Notification options" });
await user.click(button);
const muteOption = screen.getByRole("menuitem", { name: "Mute room" });
await user.click(muteOption);
expect(mockCallbacks.onSetRoomNotifState).toHaveBeenCalledWith(RoomNotifState.Mute);
});
it("should show check mark next to selected option - AllMessage", async () => {
const user = userEvent.setup();
renderMenu(RoomNotifState.AllMessages);
const button = screen.getByRole("button", { name: "Notification options" });
await user.click(button);
const defaultOption = screen.getByRole("menuitem", { name: "Match default settings" });
expect(defaultOption).toHaveAttribute("aria-selected", "true");
});
it("should show check mark next to selected option - AllMessagesLoud", async () => {
const user = userEvent.setup();
renderMenu(RoomNotifState.AllMessagesLoud);
const button = screen.getByRole("button", { name: "Notification options" });
await user.click(button);
const allMessagesOption = screen.getByRole("menuitem", { name: "All messages" });
expect(allMessagesOption).toHaveAttribute("aria-selected", "true");
});
it("should show check mark next to selected option - MentionsOnly", async () => {
const user = userEvent.setup();
renderMenu(RoomNotifState.MentionsOnly);
const button = screen.getByRole("button", { name: "Notification options" });
await user.click(button);
const mentionsOption = screen.getByRole("menuitem", { name: "Mentions and keywords" });
expect(mentionsOption).toHaveAttribute("aria-selected", "true");
});
it("should show check mark next to selected option - Mute", async () => {
const user = userEvent.setup();
renderMenu(RoomNotifState.Mute);
const button = screen.getByRole("button", { name: "Notification options" });
await user.click(button);
const muteOption = screen.getByRole("menuitem", { name: "Mute room" });
expect(muteOption).toHaveAttribute("aria-selected", "true");
});
});

View File

@ -0,0 +1,105 @@
/*
* 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 {
NotificationsSolidIcon,
NotificationsOffSolidIcon,
CheckIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../utils/i18n";
import { RoomNotifState } from "./RoomNotifs";
import { useViewModel, type ViewModel } from "../../viewmodel";
import type { RoomListItemSnapshot, RoomListItemActions } from "./RoomListItem";
/**
* View model type for room list item
*/
export type RoomItemViewModel = ViewModel<RoomListItemSnapshot> & RoomListItemActions;
/**
* Props for RoomListItemNotificationMenu component
*/
export interface RoomListItemNotificationMenuProps {
/** The room item view model */
vm: RoomItemViewModel;
}
/**
* The notification settings menu for room list items.
* Displays options to change notification settings.
*/
export function RoomListItemNotificationMenu({ vm }: RoomListItemNotificationMenuProps): JSX.Element {
const snapshot = useViewModel(vm);
const [open, setOpen] = useState(false);
const isMuted = snapshot.roomNotifState === RoomNotifState.Mute;
const checkComponent = <CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-primary)" />;
return (
<Menu
open={open}
onOpenChange={setOpen}
title={_t("room_list|notification_options")}
showTitle={false}
align="start"
trigger={
<IconButton
size="24px"
tooltip={_t("room_list|notification_options")}
aria-label={_t("room_list|notification_options")}
>
{isMuted ? <NotificationsOffSolidIcon /> : <NotificationsSolidIcon />}
</IconButton>
}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
// We don't want keyboard navigation events to bubble up to the ListView changing the focused item
onKeyDown={(e) => e.stopPropagation()}
>
<MenuItem
aria-selected={snapshot.roomNotifState === RoomNotifState.AllMessages}
hideChevron={true}
label={_t("notifications|default_settings")}
onSelect={() => vm.onSetRoomNotifState(RoomNotifState.AllMessages)}
onClick={(evt) => evt.stopPropagation()}
>
{snapshot.roomNotifState === RoomNotifState.AllMessages && checkComponent}
</MenuItem>
<MenuItem
aria-selected={snapshot.roomNotifState === RoomNotifState.AllMessagesLoud}
hideChevron={true}
label={_t("notifications|all_messages")}
onSelect={() => vm.onSetRoomNotifState(RoomNotifState.AllMessagesLoud)}
onClick={(evt) => evt.stopPropagation()}
>
{snapshot.roomNotifState === RoomNotifState.AllMessagesLoud && checkComponent}
</MenuItem>
<MenuItem
aria-selected={snapshot.roomNotifState === RoomNotifState.MentionsOnly}
hideChevron={true}
label={_t("notifications|mentions_keywords")}
onSelect={() => vm.onSetRoomNotifState(RoomNotifState.MentionsOnly)}
onClick={(evt) => evt.stopPropagation()}
>
{snapshot.roomNotifState === RoomNotifState.MentionsOnly && checkComponent}
</MenuItem>
<MenuItem
aria-selected={snapshot.roomNotifState === RoomNotifState.Mute}
hideChevron={true}
label={_t("notifications|mute_room")}
onSelect={() => vm.onSetRoomNotifState(RoomNotifState.Mute)}
onClick={(evt) => evt.stopPropagation()}
>
{snapshot.roomNotifState === RoomNotifState.Mute && checkComponent}
</MenuItem>
</div>
</Menu>
);
}

View File

@ -0,0 +1,20 @@
/*
* 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.
*/
/**
* Notification state for a room.
*/
export enum RoomNotifState {
/** All messages (default) */
AllMessages = "all_messages",
/** All messages with sound */
AllMessagesLoud = "all_messages_loud",
/** Only mentions and keywords */
MentionsOnly = "mentions_only",
/** Muted */
Mute = "mute",
}

View File

@ -0,0 +1,39 @@
/*
* 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 { type RoomListItemSnapshot } from "./RoomListItem";
import { RoomNotifState } from "./RoomNotifs";
export const mockRoom = { name: "General" };
export const defaultSnapshot: RoomListItemSnapshot = {
id: "!room:server",
room: mockRoom,
name: "General",
isBold: false,
messagePreview: "Alice: Hey everyone!",
notification: {
hasAnyNotificationOrActivity: false,
isUnsentMessage: false,
invited: false,
isMention: false,
isActivityNotification: false,
isNotification: false,
hasUnreadCount: false,
count: 0,
muted: false,
},
showMoreOptionsMenu: true,
showNotificationMenu: true,
isFavourite: false,
isLowPriority: false,
canInvite: true,
canCopyRoomLink: true,
canMarkAsRead: false,
canMarkAsUnread: true,
roomNotifState: RoomNotifState.AllMessages,
};

View File

@ -0,0 +1,25 @@
/*
* 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 { RoomListItemView } from "./RoomListItem";
export type {
RoomListItemSnapshot,
RoomItemViewModel,
RoomListItemActions,
RoomListItemViewProps,
} from "./RoomListItem";
export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
export type { RoomListItemNotificationMenuProps } from "./RoomListItemNotificationMenu";
export { RoomListItemMoreOptionsMenu, MoreOptionContent } from "./RoomListItemMoreOptionsMenu";
export type { RoomListItemMoreOptionsMenuProps } from "./RoomListItemMoreOptionsMenu";
export { RoomListItemHoverMenu } from "./RoomListItemHoverMenu";
export type { RoomListItemHoverMenuProps } from "./RoomListItemHoverMenu";
export { RoomListItemContextMenu } from "./RoomListItemContextMenu";
export type { RoomListItemContextMenuProps } from "./RoomListItemContextMenu";
export { NotificationDecoration } from "./NotificationDecoration";
export type { NotificationDecorationProps, NotificationDecorationData } from "./NotificationDecoration";
export { RoomNotifState } from "./RoomNotifs";