Add RoomListItem component
Add the RoomListItem component to shared-components. Includes context menu, hover menu, notification menu, and more options menu.
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
@ -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);
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>;
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
@ -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";
|
||||