Migrate the room list view to shared components (#31921)

* Add NotificationDecoration component

Add the NotificationDecoration component to shared-components.
This is a leaf component that renders notification badges and indicators
for rooms/items including mentions, unread counts, call indicators, etc.

* Add RoomListItem component

Add the RoomListItem component to shared-components.
Includes context menu, hover menu, notification menu, and more options menu.

* Add RoomListPrimaryFilters component

Add filter chips component for filtering the room list by
unread, people, rooms, favourites, mentions, invites, and low priority.

* Update VirtualizedList component

Update VirtualizedList to support the room list virtualization requirements.

* Add RoomList component

Add RoomList component that renders a virtualized list of room items.
Includes story mocks for testing.

* Add RoomListView component

Add RoomListView component that composes RoomList with filters,
empty states, and loading skeleton.

* Export room-list components from shared-components

Add exports for RoomListView, RoomListItem, RoomListPrimaryFilters, and RoomList.
Include i18n strings for room list components.

* Add RoomListItemViewModel

Add view model for individual room list items.
Manages per-room subscriptions and updates only when specific room data changes.

* Add RoomListViewViewModel

Add view model for the room list view.
Manages room list state, filtering, keyboard navigation, and child view models.

* Integrate shared components into RoomListView

Update RoomListView to use the new ViewModels and shared components.
Includes i18n string updates for element-web.

* Remove old room list implementation

Remove old ViewModels, hooks, and view components that are now
replaced by the shared-components implementation.

* Update sliding-sync playwright test

Update test expectations for new room list implementation.

* Add figma links

* Move viewModels to the right folder

* Rename to RoomListEmptyStateView

* Update VirtualizedRoomListView naming

* Update screenshots and snapshots

* Move viewmodel tests to the right location and fix some imports

* lint

* Use unknown as an Opaque type rather than any. It discourages property access within shared components and can still be cast back in EW.

* Update screenshots for new shared component rendering params

* Make room order tests deterministic
This commit is contained in:
David Langley
2026-02-05 21:05:14 +00:00
committed by GitHub
parent 6dba71a453
commit 6da1412de8
140 changed files with 20046 additions and 5950 deletions

View File

@@ -25,6 +25,12 @@
"left_panel": {
"open_dial_pad": "Open dial pad"
},
"notifications": {
"all_messages": "All messages",
"default_settings": "Match default settings",
"mentions_keywords": "Mentions and keywords",
"mute_room": "Mute room"
},
"room": {
"context_menu": {
"title": "Room options"
@@ -50,8 +56,63 @@
}
},
"room_list": {
"a11y": {
"default": "Open room %(roomName)s",
"invitation": "Open room %(roomName)s invitation.",
"mention": {
"one": "Open room %(roomName)s with 1 unread mention.",
"other": "Open room %(roomName)s with %(count)s unread mentions."
},
"unread": {
"one": "Open room %(roomName)s with 1 unread message.",
"other": "Open room %(roomName)s with %(count)s unread messages."
},
"unsent_message": "Open room %(roomName)s with an unsent message."
},
"appearance": "Appearance",
"collapse_filters": "Collapse filter list",
"empty": {
"no_chats": "No chats yet",
"no_chats_description": "Get started by messaging someone or by creating a room",
"no_chats_description_no_room_rights": "Get started by messaging someone",
"no_favourites": "You don't have favourite chats yet",
"no_favourites_description": "You can add a chat to your favourites in the chat settings",
"no_invites": "You don't have any unread invites",
"no_lowpriority": "You don't have any low priority rooms",
"no_mentions": "You don't have any unread mentions",
"no_people": "You dont have direct chats with anyone yet",
"no_people_description": "You can deselect filters in order to see your other chats",
"no_rooms": "Youre not in any room yet",
"no_rooms_description": "You can deselect filters in order to see your other chats",
"no_unread": "Congrats! You dont have any unread messages",
"show_activity": "See all activity",
"show_chats": "Show all chats"
},
"expand_filters": "Expand filter list",
"filters": {
"favourite": "Favourites",
"invites": "Invites",
"low_priority": "Low priority",
"mentions": "Mentions",
"people": "People",
"rooms": "Rooms",
"unread": "Unreads"
},
"list_title": "Room list",
"more_options": {
"copy_link": "Copy room link",
"favourited": "Favourited",
"leave_room": "Leave room",
"low_priority": "Low priority",
"mark_read": "Mark as read",
"mark_unread": "Mark as unread"
},
"notification_options": "Notification options",
"open_space_menu": "Open space menu",
"primary_filters": "Room list filters",
"room": {
"more_options": "More Options"
},
"room_options": "Room Options",
"show_message_previews": "Show message previews",
"sort": "Sort",

View File

@@ -27,6 +27,10 @@ export * from "./rich-list/RichItem";
export * from "./rich-list/RichList";
export * from "./room-list/RoomListHeaderView";
export * from "./room-list/RoomListSearchView";
export * from "./room-list/RoomListView";
export * from "./room-list/RoomListItem";
export * from "./room-list/RoomListPrimaryFilters";
export * from "./room-list/VirtualizedRoomListView";
export * from "./utils/Box";
export * from "./utils/Flex";
export * from "./right-panel/WidgetContextMenu";

View File

@@ -0,0 +1,126 @@
/*
* 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 type { Meta, StoryObj } from "@storybook/react-vite";
import { NotificationDecoration, type NotificationDecorationProps } from "./NotificationDecoration";
const defaultProps: NotificationDecorationProps = {
hasAnyNotificationOrActivity: false,
isUnsentMessage: false,
invited: false,
isMention: false,
isActivityNotification: false,
isNotification: false,
hasUnreadCount: false,
count: 0,
muted: false,
};
const meta = {
title: "Room List/NotificationDecoration",
component: NotificationDecoration,
tags: ["autodocs"],
decorators: [
(Story) => (
<div style={{ padding: "16px", backgroundColor: "var(--cpd-color-bg-canvas-default)" }}>
<Story />
</div>
),
],
args: defaultProps,
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel?node-id=101-13062",
},
},
} satisfies Meta<typeof NotificationDecoration>;
export default meta;
type Story = StoryObj<typeof meta>;
export const NoNotification: Story = {};
export const UnsentMessage: Story = {
args: {
hasAnyNotificationOrActivity: true,
isUnsentMessage: true,
},
};
export const VideoCall: Story = {
args: {
hasAnyNotificationOrActivity: true,
callType: "video",
},
};
export const VoiceCall: Story = {
args: {
hasAnyNotificationOrActivity: true,
callType: "voice",
},
};
export const Invited: Story = {
args: {
hasAnyNotificationOrActivity: true,
invited: true,
},
};
export const Mention: Story = {
args: {
hasAnyNotificationOrActivity: true,
isMention: true,
},
};
export const MentionWithCount: Story = {
args: {
hasAnyNotificationOrActivity: true,
isMention: true,
count: 5,
},
};
export const NotificationWithCount: Story = {
args: {
hasAnyNotificationOrActivity: true,
isNotification: true,
count: 3,
},
};
export const ActivityIndicator: Story = {
args: {
hasAnyNotificationOrActivity: true,
isActivityNotification: true,
},
};
export const Muted: Story = {
args: {
muted: true,
},
};
export const MutedWithoutActivity: Story = {
args: {
hasAnyNotificationOrActivity: false,
muted: true,
},
};
export const VideoCallWithoutActivity: Story = {
args: {
hasAnyNotificationOrActivity: false,
callType: "video",
},
};

View File

@@ -0,0 +1,80 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import * as stories from "./NotificationDecoration.stories";
const {
NoNotification,
UnsentMessage,
VideoCall,
VoiceCall,
Invited,
Mention,
MentionWithCount,
NotificationWithCount,
ActivityIndicator,
Muted,
} = composeStories(stories);
describe("<NotificationDecoration />", () => {
describe("snapshots", () => {
it("renders NoNotification story", () => {
const { container } = render(<NoNotification />);
expect(container).toMatchSnapshot();
});
it("renders UnsentMessage story", () => {
const { container } = render(<UnsentMessage />);
expect(container).toMatchSnapshot();
});
it("renders VideoCall story", () => {
const { container } = render(<VideoCall />);
expect(container).toMatchSnapshot();
});
it("renders VoiceCall story", () => {
const { container } = render(<VoiceCall />);
expect(container).toMatchSnapshot();
});
it("renders Invited story", () => {
const { container } = render(<Invited />);
expect(container).toMatchSnapshot();
});
it("renders Mention story", () => {
const { container } = render(<Mention />);
expect(container).toMatchSnapshot();
});
it("renders MentionWithCount story", () => {
const { container } = render(<MentionWithCount />);
expect(container).toMatchSnapshot();
});
it("renders NotificationWithCount story", () => {
const { container } = render(<NotificationWithCount />);
expect(container).toMatchSnapshot();
});
it("renders ActivityIndicator story", () => {
const { container } = render(<ActivityIndicator />);
expect(container).toMatchSnapshot();
});
it("renders Muted story", () => {
const { container } = render(<Muted />);
expect(container).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,90 @@
/*
* 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 {
MentionIcon,
ErrorSolidIcon,
NotificationsOffSolidIcon,
VideoCallSolidIcon,
EmailSolidIcon,
VoiceCallSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { UnreadCounter, Unread } from "@vector-im/compound-web";
import { Flex } from "../../../utils/Flex";
/**
* Data representing the notification state for a room or item.
* Used in snapshots and passed to the NotificationDecoration component.
*/
export interface NotificationDecorationData {
/** Whether there is any notification or activity to display */
hasAnyNotificationOrActivity: boolean;
/** Whether there's an unsent message */
isUnsentMessage: boolean;
/** Whether the user is invited to the room */
invited: boolean;
/** Whether the notification is a mention */
isMention: boolean;
/** Whether there's activity (not a full notification) */
isActivityNotification: boolean;
/** Whether there's a notification (not just activity) */
isNotification: boolean;
/** Whether there are unread messages with a count */
hasUnreadCount: boolean;
/** Notification count */
count: number;
/** Whether notifications are muted */
muted: boolean;
/** Optional call type indicator */
callType?: "video" | "voice";
}
/**
* Props for the NotificationDecoration component.
*/
export interface NotificationDecorationProps extends NotificationDecorationData {}
/**
* Renders notification badges and indicators for rooms/items
*/
export const NotificationDecoration: React.FC<NotificationDecorationProps> = ({
hasAnyNotificationOrActivity,
muted,
callType,
isUnsentMessage,
invited,
isMention,
isNotification,
isActivityNotification,
count,
}) => {
// Don't render anything if there's nothing to show
if (!hasAnyNotificationOrActivity && !muted && !callType) {
return null;
}
return (
<Flex align="center" justify="center" gap="var(--cpd-space-1x)" data-testid="notification-decoration">
{isUnsentMessage && (
<ErrorSolidIcon width="20px" height="20px" fill="var(--cpd-color-icon-critical-primary)" />
)}
{callType === "video" && (
<VideoCallSolidIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
)}
{callType === "voice" && (
<VoiceCallSolidIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
)}
{invited && <EmailSolidIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{isMention && <MentionIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{(isMention || isNotification) && <UnreadCounter count={count || null} />}
{isActivityNotification && <Unread />}
{muted && <NotificationsOffSolidIcon width="20px" height="20px" fill="var(--cpd-color-icon-tertiary)" />}
</Flex>
);
};

View File

@@ -0,0 +1,242 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<NotificationDecoration /> > snapshots > renders ActivityIndicator story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
>
<div
class="flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<div
class="_unread_cti0f_8"
>
<div />
</div>
</div>
</div>
</div>
`;
exports[`<NotificationDecoration /> > snapshots > renders Invited story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
>
<div
class="flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-accent-primary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2m0 5.111a1 1 0 0 0 .514.874l7 3.89a1 1 0 0 0 .972 0l7-3.89a1 1 0 1 0-.972-1.748L12 11.856 5.486 8.237A1 1 0 0 0 4 9.111"
/>
</svg>
</div>
</div>
</div>
`;
exports[`<NotificationDecoration /> > snapshots > renders Mention story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
>
<div
class="flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-accent-primary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 4a8 8 0 1 0 0 16 1 1 0 1 1 0 2C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10v1.5a3.5 3.5 0 0 1-6.396 1.966A5 5 0 1 1 17 12v1.5a1.5 1.5 0 0 0 3 0V12a8 8 0 0 0-8-8m3 8a3 3 0 1 0-6 0 3 3 0 0 0 6 0"
/>
</svg>
<div
class="_unread-counter_1147r_8"
/>
</div>
</div>
</div>
`;
exports[`<NotificationDecoration /> > snapshots > renders MentionWithCount story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
>
<div
class="flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-accent-primary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 4a8 8 0 1 0 0 16 1 1 0 1 1 0 2C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10v1.5a3.5 3.5 0 0 1-6.396 1.966A5 5 0 1 1 17 12v1.5a1.5 1.5 0 0 0 3 0V12a8 8 0 0 0-8-8m3 8a3 3 0 1 0-6 0 3 3 0 0 0 6 0"
/>
</svg>
<span
class="_unread-counter_1147r_8"
>
5
</span>
</div>
</div>
</div>
`;
exports[`<NotificationDecoration /> > snapshots > renders Muted story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
>
<div
class="flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-tertiary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m4.917 2.083 17 17a1 1 0 0 1-1.414 1.414L19.006 19H4.414c-.89 0-1.337-1.077-.707-1.707L5 16v-6s0-2.034 1.096-3.91L3.504 3.498a1 1 0 0 1 1.414-1.414M19 13.35 9.136 3.484C9.93 3.181 10.874 3 12 3c7 0 7 7 7 7z"
/>
<path
d="M10 20h4a2 2 0 0 1-4 0"
/>
</svg>
</div>
</div>
</div>
`;
exports[`<NotificationDecoration /> > snapshots > renders NoNotification story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
/>
</div>
`;
exports[`<NotificationDecoration /> > snapshots > renders NotificationWithCount story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
>
<div
class="flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<span
class="_unread-counter_1147r_8"
>
3
</span>
</div>
</div>
</div>
`;
exports[`<NotificationDecoration /> > snapshots > renders UnsentMessage story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
>
<div
class="flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-critical-primary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
/>
</svg>
</div>
</div>
</div>
`;
exports[`<NotificationDecoration /> > snapshots > renders VideoCall story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
>
<div
class="flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-accent-primary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
</div>
</div>
</div>
`;
exports[`<NotificationDecoration /> > snapshots > renders VoiceCall story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
>
<div
class="flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-accent-primary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20.958 16.374.039 3.527q0 .427-.33.756-.33.33-.756.33a16 16 0 0 1-6.57-1.105 16.2 16.2 0 0 1-5.563-3.663 16.1 16.1 0 0 1-3.653-5.573 16.3 16.3 0 0 1-1.115-6.56q0-.427.33-.757T4.095 3l3.528.039a1.07 1.07 0 0 1 1.085.93l.543 3.954q.039.271-.039.504a1.1 1.1 0 0 1-.271.426l-1.64 1.64q.505 1.008 1.154 1.909c.433.6 1.444 1.696 1.444 1.696s1.095 1.01 1.696 1.444q.9.65 1.909 1.153l1.64-1.64q.193-.193.426-.27t.504-.04l3.954.543q.406.059.668.359t.262.727"
/>
</svg>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,9 @@
/*
* 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 { NotificationDecoration } from "./NotificationDecoration";
export type { NotificationDecorationProps, NotificationDecorationData } from "./NotificationDecoration";

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,213 @@
/*
* 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 type { Room } from "./RoomListItem";
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: Room, e: React.FocusEvent) => void;
roomIndex: number;
roomCount: number;
renderAvatar: (room: Room) => 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,
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel?node-id=101-13062",
},
},
} 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,207 @@
/*
* 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";
/**
* Opaque type representing a Room object from the parent application
*/
export type Room = unknown;
/**
* 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: Room;
/** 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: Room) => 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,26 @@
/*
* 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 {
Room,
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";

View File

@@ -0,0 +1,32 @@
/*
* 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.
*/
.roomListPrimaryFilters {
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-3x);
}
/* Hide filters that are wrapping when collapsed */
.roomListPrimaryFilters :global(.wrapping) {
display: none;
}
.list {
/**
* The InteractionObserver needs the height to be set to work properly.
*/
height: 100%;
flex: 1;
}
/* IconButton styles for chevron */
.iconButton svg {
transition: transform 0.1s linear;
}
.iconButton[aria-expanded="true"] svg {
transform: rotate(180deg);
}

View File

@@ -0,0 +1,91 @@
/*
* 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 { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
import type { FilterId } from "./useVisibleFilters";
const meta: Meta<typeof RoomListPrimaryFilters> = {
title: "Room List/RoomListPrimaryFilters",
component: RoomListPrimaryFilters,
tags: ["autodocs"],
args: {
onToggleFilter: fn(),
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel-2025?node-id=98-1979&t=vafb4zoYMNLRuAbh-4",
},
},
};
export default meta;
type Story = StoryObj<typeof RoomListPrimaryFilters>;
// All available filter IDs
const allFilterIds: FilterId[] = ["unread", "people", "rooms", "favourite", "mentions", "invites", "low_priority"];
// Subset of filters for narrow container tests
const fewFilterIds: FilterId[] = ["people", "rooms", "unread"];
export const Default: Story = {
args: {
filterIds: allFilterIds,
},
};
export const PeopleSelected: Story = {
args: {
filterIds: allFilterIds,
activeFilterId: "people",
},
};
export const NoFilters: Story = {
args: {
filterIds: [],
},
};
/**
* Narrow container that causes filters to wrap.
* The chevron button should appear to expand/collapse the filter list.
*/
export const NarrowContainer: Story = {
args: {
filterIds: fewFilterIds,
},
decorators: [
(Story) => (
<div style={{ width: "180px", border: "1px dashed var(--cpd-color-border-interactive-secondary)" }}>
<Story />
</div>
),
],
};
/**
* Narrow container with active filter that would wrap.
* When collapsed, the active filter should move to the front.
*/
export const NarrowWithActiveWrappingFilter: Story = {
args: {
filterIds: fewFilterIds,
activeFilterId: "unread",
},
decorators: [
(Story) => (
<div style={{ width: "180px", border: "1px dashed var(--cpd-color-border-interactive-secondary)" }}>
<Story />
</div>
),
],
};

View File

@@ -0,0 +1,140 @@
/*
* 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, { act } 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, vi, beforeEach } from "vitest";
import * as stories from "./RoomListPrimaryFilters.stories";
const { Default, PeopleSelected, NoFilters, NarrowContainer, NarrowWithActiveWrappingFilter } = composeStories(stories);
describe("<RoomListPrimaryFilters /> stories", () => {
describe("snapshots", () => {
it("renders Default story", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders PeopleSelected story", () => {
const { container } = render(<PeopleSelected />);
expect(container).toMatchSnapshot();
});
it("renders NoFilters story", () => {
const { container } = render(<NoFilters />);
expect(container).toMatchSnapshot();
});
it("renders NarrowContainer story", () => {
const { container } = render(<NarrowContainer />);
expect(container).toMatchSnapshot();
});
it("renders NarrowWithActiveWrappingFilter story", () => {
const { container } = render(<NarrowWithActiveWrappingFilter />);
expect(container).toMatchSnapshot();
});
});
describe("behavior", () => {
it("should call onToggleFilter when a filter is clicked", async () => {
const user = userEvent.setup();
render(<Default />);
await user.click(screen.getByRole("option", { name: "People" }));
expect(Default.args.onToggleFilter).toHaveBeenCalled();
});
});
describe("resize behavior", () => {
let resizeCallback: ResizeObserverCallback;
beforeEach(() => {
globalThis.ResizeObserver = class MockResizeObserver {
public constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
public observe = vi.fn();
public unobserve = vi.fn();
public disconnect = vi.fn();
} as unknown as typeof ResizeObserver;
});
function mockFiltersNotWrapping(): void {
vi.spyOn(screen.getByText("People"), "offsetLeft", "get").mockReturnValue(0);
vi.spyOn(screen.getByText("Rooms"), "offsetLeft", "get").mockReturnValue(30);
vi.spyOn(screen.getByText("Unreads"), "offsetLeft", "get").mockReturnValue(60);
const listbox = screen.getByRole("listbox", { name: "Room list filters" });
act(() => resizeCallback([{ target: listbox } as any], {} as ResizeObserver));
}
function mockUnreadWrapping(): void {
vi.spyOn(screen.getByText("People"), "offsetLeft", "get").mockReturnValue(0);
vi.spyOn(screen.getByText("Rooms"), "offsetLeft", "get").mockReturnValue(30);
vi.spyOn(screen.getByText("Unreads"), "offsetLeft", "get").mockReturnValue(0);
const listbox = screen.getByRole("listbox", { name: "Room list filters" });
act(() => resizeCallback([{ target: listbox } as any], {} as ResizeObserver));
}
it("should hide wrapping filters and show chevron", () => {
render(<NarrowContainer />);
mockUnreadWrapping();
expect(screen.queryByRole("option", { name: "Unreads" })).toBeNull();
expect(screen.getByRole("button", { name: "Expand filter list" })).toBeInTheDocument();
});
it("should expand and collapse filter list with chevron button", async () => {
const user = userEvent.setup();
render(<NarrowContainer />);
mockUnreadWrapping();
expect(screen.queryByRole("option", { name: "Unreads" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Expand filter list" }));
expect(screen.getByRole("option", { name: "Unreads" })).toBeVisible();
await user.click(screen.getByRole("button", { name: "Collapse filter list" }));
expect(screen.queryByRole("option", { name: "Unreads" })).toBeNull();
});
it("should move active filter to front when collapsed and wrapping", () => {
render(<NarrowWithActiveWrappingFilter />);
mockUnreadWrapping();
const listbox = screen.getByRole("listbox", { name: "Room list filters" });
expect(listbox.children[0]).toBe(screen.getByRole("option", { name: "Unreads" }));
});
it("should restore original filter order when expanded", async () => {
const user = userEvent.setup();
render(<NarrowWithActiveWrappingFilter />);
mockUnreadWrapping();
await user.click(screen.getByRole("button", { name: "Expand filter list" }));
const listbox = screen.getByRole("listbox", { name: "Room list filters" });
expect(listbox.children[0]).toBe(screen.getByRole("option", { name: "People" }));
});
it("should handle resize from non-wrapping to wrapping", () => {
render(<NarrowContainer />);
mockFiltersNotWrapping();
expect(screen.queryByRole("button", { name: "Expand filter list" })).toBeNull();
mockUnreadWrapping();
expect(screen.getByRole("button", { name: "Expand filter list" })).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,116 @@
/*
* 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, useId, useState } from "react";
import { ChatFilter, IconButton } from "@vector-im/compound-web";
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
import { Flex } from "../../utils/Flex";
import { _t } from "../../utils/i18n";
import { useCollapseFilters } from "./useCollapseFilters";
import { useVisibleFilters, type FilterId } from "./useVisibleFilters";
import styles from "./RoomListPrimaryFilters.module.css";
/**
* Maps filter IDs to translated labels
*/
const filterIdToLabel = (filterId: FilterId): string => {
switch (filterId) {
case "unread":
return _t("room_list|filters|unread");
case "people":
return _t("room_list|filters|people");
case "rooms":
return _t("room_list|filters|rooms");
case "favourite":
return _t("room_list|filters|favourite");
case "mentions":
return _t("room_list|filters|mentions");
case "invites":
return _t("room_list|filters|invites");
case "low_priority":
return _t("room_list|filters|low_priority");
}
};
/**
* Props for RoomListPrimaryFilters component
*/
export interface RoomListPrimaryFiltersProps {
/** Array of filter IDs to display */
filterIds: FilterId[];
/** Currently active filter ID (if any) */
activeFilterId?: FilterId;
/** Callback when a filter is toggled */
onToggleFilter: (filterId: FilterId) => void;
}
/**
* The primary filters component for the room list.
* Displays a collapsible list of filters with expand/collapse functionality.
*/
export const RoomListPrimaryFilters: React.FC<RoomListPrimaryFiltersProps> = ({
filterIds,
activeFilterId,
onToggleFilter,
}): JSX.Element | null => {
const id = useId();
const [isExpanded, setIsExpanded] = useState(false);
const {
ref,
isWrapping: displayChevron,
wrappingIndex,
} = useCollapseFilters<HTMLUListElement>(isExpanded, "wrapping");
const visibleFilterIds = useVisibleFilters(filterIds, activeFilterId, wrappingIndex);
return (
<Flex
className={styles.roomListPrimaryFilters}
data-testid="primary-filters"
gap="var(--cpd-space-3x)"
direction="row-reverse"
justify="space-between"
>
{displayChevron && (
<IconButton
kind="secondary"
aria-expanded={isExpanded}
aria-controls={id}
className={styles.iconButton}
aria-label={isExpanded ? _t("room_list|collapse_filters") : _t("room_list|expand_filters")}
size="28px"
onClick={() => setIsExpanded((expanded) => !expanded)}
>
<ChevronDownIcon />
</IconButton>
)}
<Flex
id={id}
as="div"
role="listbox"
aria-label={_t("room_list|primary_filters")}
align="center"
gap="var(--cpd-space-2x)"
wrap="wrap"
className={styles.list}
ref={ref}
>
{visibleFilterIds.map((filterId, index) => (
<ChatFilter
key={`${filterId}-${index}`}
role="option"
selected={filterId === activeFilterId}
onClick={() => onToggleFilter(filterId)}
>
{filterIdToLabel(filterId)}
</ChatFilter>
))}
</Flex>
</Flex>
);
};

View File

@@ -0,0 +1,388 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<RoomListPrimaryFilters /> stories > snapshots > renders Default story 1`] = `
<div>
<div
class="flex roomListPrimaryFilters"
data-testid="primary-filters"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-controls="_r_0_"
aria-expanded="false"
aria-label="Expand filter list"
class="_icon-button_1215g_8 iconButton"
data-kind="secondary"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</button>
<div
aria-label="Room list filters"
class="flex list"
id="_r_0_"
role="listbox"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Unreads
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
People
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Rooms
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Favourites
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Mentions
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Invites
</button>
<button
aria-hidden="true"
aria-selected="false"
class="_chat-filter_5qdp0_8 wrapping"
role="option"
tabindex="0"
>
Low priority
</button>
</div>
</div>
</div>
`;
exports[`<RoomListPrimaryFilters /> stories > snapshots > renders NarrowContainer story 1`] = `
<div>
<div
style="width: 180px; border: 1px dashed var(--cpd-color-border-interactive-secondary);"
>
<div
class="flex roomListPrimaryFilters"
data-testid="primary-filters"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-controls="_r_3_"
aria-expanded="false"
aria-label="Expand filter list"
class="_icon-button_1215g_8 iconButton"
data-kind="secondary"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</button>
<div
aria-label="Room list filters"
class="flex list"
id="_r_3_"
role="listbox"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
People
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Rooms
</button>
<button
aria-hidden="true"
aria-selected="false"
class="_chat-filter_5qdp0_8 wrapping"
role="option"
tabindex="0"
>
Unreads
</button>
</div>
</div>
</div>
</div>
`;
exports[`<RoomListPrimaryFilters /> stories > snapshots > renders NarrowWithActiveWrappingFilter story 1`] = `
<div>
<div
style="width: 180px; border: 1px dashed var(--cpd-color-border-interactive-secondary);"
>
<div
class="flex roomListPrimaryFilters"
data-testid="primary-filters"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-controls="_r_4_"
aria-expanded="false"
aria-label="Expand filter list"
class="_icon-button_1215g_8 iconButton"
data-kind="secondary"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</button>
<div
aria-label="Room list filters"
class="flex list"
id="_r_4_"
role="listbox"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
>
<button
aria-selected="true"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Unreads
</button>
<button
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
People
</button>
<button
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Rooms
</button>
</div>
</div>
</div>
</div>
`;
exports[`<RoomListPrimaryFilters /> stories > snapshots > renders NoFilters story 1`] = `
<div>
<div
class="flex roomListPrimaryFilters"
data-testid="primary-filters"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
aria-label="Room list filters"
class="flex list"
id="_r_2_"
role="listbox"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
/>
</div>
</div>
`;
exports[`<RoomListPrimaryFilters /> stories > snapshots > renders PeopleSelected story 1`] = `
<div>
<div
class="flex roomListPrimaryFilters"
data-testid="primary-filters"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-controls="_r_1_"
aria-expanded="false"
aria-label="Expand filter list"
class="_icon-button_1215g_8 iconButton"
data-kind="secondary"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</button>
<div
aria-label="Room list filters"
class="flex list"
id="_r_1_"
role="listbox"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Unreads
</button>
<button
aria-hidden="false"
aria-selected="true"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
People
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Rooms
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Favourites
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Mentions
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Invites
</button>
<button
aria-hidden="true"
aria-selected="false"
class="_chat-filter_5qdp0_8 wrapping"
role="option"
tabindex="0"
>
Low priority
</button>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,12 @@
/*
* 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 { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
export type { RoomListPrimaryFiltersProps } from "./RoomListPrimaryFilters";
export { useCollapseFilters } from "./useCollapseFilters";
export { useVisibleFilters } from "./useVisibleFilters";
export type { FilterId } from "./useVisibleFilters";

View File

@@ -0,0 +1,71 @@
/*
* 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 { useEffect, useRef, useState, type RefObject } from "react";
/**
* A hook to manage the wrapping of filters in the room list.
* It observes the filter list and hides filters that are wrapping when the list is not expanded.
* @param isExpanded
* @param wrappingClassName - the CSS class to apply to wrapping filters
* @returns an object containing:
* - `ref`: a ref to put on the filter list element
* - `isWrapping`: a boolean indicating if the filters are wrapping
* - `wrappingIndex`: the index of the first filter that is wrapping
*/
export function useCollapseFilters<T extends HTMLElement>(
isExpanded: boolean,
wrappingClassName: string,
): {
ref: RefObject<T | null>;
isWrapping: boolean;
wrappingIndex: number;
} {
const ref = useRef<T>(null);
const [isWrapping, setIsWrapping] = useState(false);
const [wrappingIndex, setWrappingIndex] = useState(-1);
useEffect(() => {
if (!ref.current) return;
const hideFilters = (list: Element): void => {
let isWrapping = false;
Array.from(list.children).forEach((node, i): void => {
const child = node as HTMLElement;
child.setAttribute("aria-hidden", "false");
child.classList.remove(wrappingClassName);
// If the filter list is expanded, all filters are visible
if (isExpanded) return;
// If the previous element is on the left element of the current one, it means that the filter is wrapping
const previousSibling = child.previousElementSibling as HTMLElement | null;
if (previousSibling && child.offsetLeft <= previousSibling.offsetLeft) {
if (!isWrapping) setWrappingIndex(i);
isWrapping = true;
}
// If the filter is wrapping, we hide it
child.classList.toggle(wrappingClassName, isWrapping);
child.setAttribute("aria-hidden", isWrapping.toString());
});
if (!isWrapping) setWrappingIndex(-1);
setIsWrapping(isExpanded || isWrapping);
};
hideFilters(ref.current);
const observer = new ResizeObserver((entries) => entries.forEach((entry) => hideFilters(entry.target)));
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, [isExpanded, wrappingClassName]);
return { ref, isWrapping, wrappingIndex };
}

View File

@@ -0,0 +1,55 @@
/*
* 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 { useEffect, useState } from "react";
/**
* Standard filter identifiers that can be used across implementations.
* These are stable keys - the view layer maps them to translated labels.
*/
export type FilterId = "unread" | "people" | "rooms" | "favourite" | "mentions" | "invites" | "low_priority";
/**
* A hook to sort the filter IDs by active state.
* The list is sorted if the active filter index is greater than or equal to the wrapping index.
* If the wrapping index is -1, the filters are not sorted.
*
* @param filterIds - the list of filter IDs to sort.
* @param activeFilterId - the currently active filter ID (if any).
* @param wrappingIndex - the index of the first filter that is wrapping.
*/
export function useVisibleFilters(
filterIds: FilterId[],
activeFilterId: FilterId | undefined,
wrappingIndex: number,
): FilterId[] {
// By default, the filters are not sorted
const [sortedFilterIds, setSortedFilterIds] = useState(filterIds);
useEffect(() => {
const activeIndex = activeFilterId ? filterIds.indexOf(activeFilterId) : -1;
const isActiveFilterWrapping = activeIndex >= wrappingIndex;
// If the active filter is not wrapping, we don't need to sort the filters
if (!isActiveFilterWrapping || wrappingIndex === -1) {
setSortedFilterIds(filterIds);
return;
}
// Sort the filters with the active filter at first position
setSortedFilterIds(
filterIds.slice().sort((filterA, filterB) => {
// If the filter is active, it should be at the top of the list
if (filterA === activeFilterId && filterB !== activeFilterId) return -1;
if (filterA !== activeFilterId && filterB === activeFilterId) return 1;
// If both filters are active or not, keep their original order
return 0;
}),
);
}, [filterIds, activeFilterId, wrappingIndex]);
return sortedFilterIds;
}

View File

@@ -0,0 +1,33 @@
/*
* 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.
*/
.genericPlaceholder {
align-self: center;
/** It should take 2/3 of the width **/
width: 66%;
/** It should be positioned at 1/3 of the height **/
padding-top: 33%;
}
.title {
font: var(--cpd-font-body-lg-semibold);
text-align: center;
}
.description {
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
text-align: center;
}
.defaultPlaceholder {
margin-top: var(--cpd-space-4x);
}
.genericPlaceholder button {
width: 100%;
}

View File

@@ -0,0 +1,182 @@
/*
* 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, type ReactNode } from "react";
import { Button } from "@vector-im/compound-web";
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room";
import { Flex } from "../../utils/Flex";
import { _t } from "../../utils/i18n";
import { useViewModel } from "../../viewmodel";
import type { RoomListViewModel } from "./RoomListView";
import styles from "./RoomListEmptyStateView.module.css";
/**
* Props for RoomListEmptyStateView component
*/
export interface RoomListEmptyStateViewProps {
/** The view model containing all data and callbacks */
vm: RoomListViewModel;
}
/**
* Empty state component for the room list.
* Displays appropriate message and actions based on the active filter.
*/
export const RoomListEmptyStateView: React.FC<RoomListEmptyStateViewProps> = ({ vm }): JSX.Element => {
const snapshot = useViewModel(vm);
// If there is no active filter, show the default empty state
if (!snapshot.activeFilterId) {
return (
<GenericPlaceholder
title={_t("room_list|empty|no_chats")}
description={
snapshot.canCreateRoom
? _t("room_list|empty|no_chats_description")
: _t("room_list|empty|no_chats_description_no_room_rights")
}
>
<Flex
className={styles.defaultPlaceholder}
align="center"
justify="center"
direction="column"
gap="var(--cpd-space-4x)"
>
<Button size="sm" kind="secondary" Icon={ChatIcon} onClick={vm.createChatRoom}>
{_t("action|start_chat")}
</Button>
{snapshot.canCreateRoom && (
<Button size="sm" kind="secondary" Icon={RoomIcon} onClick={vm.createRoom}>
{_t("action|new_room")}
</Button>
)}
</Flex>
</GenericPlaceholder>
);
}
// Handle different filter cases based on filter ID
switch (snapshot.activeFilterId) {
case "favourite":
return (
<GenericPlaceholder
title={_t("room_list|empty|no_favourites")}
description={_t("room_list|empty|no_favourites_description")}
/>
);
case "people":
return (
<GenericPlaceholder
title={_t("room_list|empty|no_people")}
description={_t("room_list|empty|no_people_description")}
/>
);
case "rooms":
return (
<GenericPlaceholder
title={_t("room_list|empty|no_rooms")}
description={_t("room_list|empty|no_rooms_description")}
/>
);
case "unread":
return (
<ActionPlaceholder
title={_t("room_list|empty|no_unread")}
action={_t("room_list|empty|show_chats")}
onAction={() => vm.onToggleFilter(snapshot.activeFilterId!)}
/>
);
case "invites":
return (
<ActionPlaceholder
title={_t("room_list|empty|no_invites")}
action={_t("room_list|empty|show_activity")}
onAction={() => vm.onToggleFilter(snapshot.activeFilterId!)}
/>
);
case "mentions":
return (
<ActionPlaceholder
title={_t("room_list|empty|no_mentions")}
action={_t("room_list|empty|show_activity")}
onAction={() => vm.onToggleFilter(snapshot.activeFilterId!)}
/>
);
case "low_priority":
return (
<ActionPlaceholder
title={_t("room_list|empty|no_lowpriority")}
action={_t("room_list|empty|show_activity")}
onAction={() => vm.onToggleFilter(snapshot.activeFilterId!)}
/>
);
default:
return (
<GenericPlaceholder
title={_t("room_list|empty|no_chats")}
description={_t("room_list|empty|no_chats_description")}
/>
);
}
};
interface GenericPlaceholderProps {
/** The title of the placeholder */
title: string;
/** The description of the placeholder */
description?: string;
/** Optional children (e.g., action buttons) */
children?: ReactNode;
}
/**
* A generic placeholder for the room list
*/
function GenericPlaceholder({ title, description, children }: PropsWithChildren<GenericPlaceholderProps>): JSX.Element {
return (
<Flex
data-testid="empty-room-list"
className={styles.genericPlaceholder}
direction="column"
align="stretch"
justify="center"
gap="var(--cpd-space-2x)"
>
<span className={styles.title}>{title}</span>
{description && <span className={styles.description}>{description}</span>}
{children}
</Flex>
);
}
interface ActionPlaceholderProps {
/** The title to display */
title: string;
/** The action button text */
action: string;
/** Callback when the action button is clicked */
onAction?: () => void;
}
/**
* A placeholder for the room list when a filter is active
* The user can take action to toggle the filter
*/
function ActionPlaceholder({ title, action, onAction }: ActionPlaceholderProps): JSX.Element {
return (
<GenericPlaceholder title={title}>
{onAction && (
<Button kind="tertiary" onClick={onAction}>
{action}
</Button>
)}
</GenericPlaceholder>
);
}

View File

@@ -0,0 +1,24 @@
/*
* 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.
*/
.skeleton {
position: relative;
margin-left: 4px;
height: 100%;
flex: 1;
}
.skeleton::before {
background-color: var(--cpd-color-bg-subtle-secondary);
width: 100%;
height: 100%;
content: "";
position: absolute;
mask-repeat: repeat-y;
mask-size: auto 96px;
mask-image: url("./assets/skeleton.svg");
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import styles from "./RoomListLoadingSkeleton.module.css";
/**
* Loading skeleton component for the room list.
* Displays a repeating skeleton pattern while rooms are being fetched.
*/
export const RoomListLoadingSkeleton: React.FC = (): JSX.Element => {
return <div className={styles.skeleton} />;
};

View File

@@ -0,0 +1,221 @@
/*
* 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 type { Room } from "../RoomListItem/RoomListItem";
import type { FilterId } from "../RoomListPrimaryFilters";
import { RoomListView, type RoomListSnapshot, type RoomListViewActions } from "./RoomListView";
import { useMockedViewModel } from "../../viewmodel";
import {
renderAvatar,
createGetRoomItemViewModel,
mockRoomIds,
smallListRoomIds,
largeListRoomIds,
} from "../story-mocks";
type RoomListViewProps = RoomListSnapshot & RoomListViewActions & { renderAvatar: (room: Room) => React.ReactElement };
const mockFilterIds: FilterId[] = ["unread", "people", "rooms", "favourite"];
// Wrapper component that creates a mocked ViewModel
const RoomListViewWrapper = ({
onToggleFilter,
createChatRoom,
createRoom,
getRoomItemViewModel,
updateVisibleRooms,
renderAvatar: renderAvatarProp,
...rest
}: RoomListViewProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
onToggleFilter,
createChatRoom,
createRoom,
getRoomItemViewModel,
updateVisibleRooms,
});
return <RoomListView vm={vm} renderAvatar={renderAvatarProp} />;
};
const meta = {
title: "Room List/RoomListView",
component: RoomListViewWrapper,
tags: ["autodocs"],
decorators: [
(Story) => (
<div
style={{
width: "320px",
height: "600px",
border: "1px solid var(--cpd-color-border-interactive-primary)",
display: "flex",
flexDirection: "column",
resize: "horizontal",
overflow: "auto",
minWidth: "250px",
maxWidth: "800px",
}}
>
<Story />
</div>
),
],
args: {
// Snapshot properties (state)
isLoadingRooms: false,
isRoomListEmpty: false,
filterIds: mockFilterIds,
activeFilterId: undefined,
roomListState: {
activeRoomIndex: undefined,
spaceId: "!space:server",
filterKeys: undefined,
},
roomIds: mockRoomIds,
canCreateRoom: true,
// Action properties (callbacks)
onToggleFilter: fn(),
createChatRoom: fn(),
createRoom: fn(),
getRoomItemViewModel: createGetRoomItemViewModel(mockRoomIds),
updateVisibleRooms: fn(),
renderAvatar,
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel?node-id=2925-19126",
},
},
} satisfies Meta<typeof RoomListViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Loading: Story = {
args: {
isLoadingRooms: true,
},
};
export const Empty: Story = {
args: {
isRoomListEmpty: true,
},
};
export const EmptyWithoutCreatePermission: Story = {
args: {
isRoomListEmpty: true,
canCreateRoom: false,
},
};
export const WithActiveFilter: Story = {
args: {
filterIds: ["unread", "people", "rooms", "favourite"],
activeFilterId: "favourite",
roomListState: {
activeRoomIndex: undefined,
spaceId: "!space:server",
filterKeys: ["favourites"],
},
},
};
export const WithSelection: Story = {
args: {
roomListState: {
activeRoomIndex: 0,
spaceId: "!space:server",
filterKeys: undefined,
},
},
};
export const EmptyFavouriteFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["favourite", "people"],
activeFilterId: "favourite",
},
};
export const EmptyPeopleFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["people", "rooms"],
activeFilterId: "people",
},
};
export const EmptyRoomsFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["rooms", "people"],
activeFilterId: "rooms",
},
};
export const EmptyUnreadFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["unread", "people"],
activeFilterId: "unread",
},
};
export const EmptyInvitesFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["invites", "people"],
activeFilterId: "invites",
},
};
export const EmptyMentionsFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["mentions", "people"],
activeFilterId: "mentions",
},
};
export const EmptyLowPriorityFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["low_priority", "people"],
activeFilterId: "low_priority",
},
};
export const SmallList: Story = {
args: {
roomIds: smallListRoomIds,
getRoomItemViewModel: createGetRoomItemViewModel(smallListRoomIds),
},
};
export const LargeList: Story = {
args: {
roomIds: largeListRoomIds,
getRoomItemViewModel: createGetRoomItemViewModel(largeListRoomIds),
},
};

View File

@@ -0,0 +1,177 @@
/*
* 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 { VirtuosoMockContext } from "react-virtuoso";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import * as stories from "./RoomListView.stories";
const {
Default,
Loading,
Empty,
EmptyWithoutCreatePermission,
WithActiveFilter,
SmallList,
LargeList,
EmptyFavouriteFilter,
EmptyPeopleFilter,
EmptyRoomsFilter,
EmptyUnreadFilter,
EmptyInvitesFilter,
EmptyMentionsFilter,
EmptyLowPriorityFilter,
} = composeStories(stories);
const renderWithMockContext = (component: React.ReactElement): ReturnType<typeof render> => {
return render(component, {
wrapper: ({ children }) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}>
{children}
</VirtuosoMockContext.Provider>
),
});
};
describe("<RoomListView />", () => {
it("renders Default story", () => {
const { container } = renderWithMockContext(<Default />);
expect(container).toMatchSnapshot();
});
it("renders Loading story", () => {
const { container } = renderWithMockContext(<Loading />);
expect(container).toMatchSnapshot();
});
it("renders Empty story", () => {
const { container } = renderWithMockContext(<Empty />);
expect(container).toMatchSnapshot();
});
it("renders EmptyWithoutCreatePermission story", () => {
const { container } = renderWithMockContext(<EmptyWithoutCreatePermission />);
expect(container).toMatchSnapshot();
});
it("renders WithActiveFilter story", () => {
const { container } = renderWithMockContext(<WithActiveFilter />);
expect(container).toMatchSnapshot();
});
it("renders SmallList story", () => {
const { container } = renderWithMockContext(<SmallList />);
expect(container).toMatchSnapshot();
});
it("renders LargeList story", () => {
const { container } = renderWithMockContext(<LargeList />);
expect(container).toMatchSnapshot();
});
it("renders EmptyFavouriteFilter story", () => {
const { container } = renderWithMockContext(<EmptyFavouriteFilter />);
expect(container).toMatchSnapshot();
});
it("renders EmptyPeopleFilter story", () => {
const { container } = renderWithMockContext(<EmptyPeopleFilter />);
expect(container).toMatchSnapshot();
});
it("renders EmptyRoomsFilter story", () => {
const { container } = renderWithMockContext(<EmptyRoomsFilter />);
expect(container).toMatchSnapshot();
});
it("renders EmptyUnreadFilter story", () => {
const { container } = renderWithMockContext(<EmptyUnreadFilter />);
expect(container).toMatchSnapshot();
});
it("renders EmptyInvitesFilter story", () => {
const { container } = renderWithMockContext(<EmptyInvitesFilter />);
expect(container).toMatchSnapshot();
});
it("renders EmptyMentionsFilter story", () => {
const { container } = renderWithMockContext(<EmptyMentionsFilter />);
expect(container).toMatchSnapshot();
});
it("renders EmptyLowPriorityFilter story", () => {
const { container } = renderWithMockContext(<EmptyLowPriorityFilter />);
expect(container).toMatchSnapshot();
});
it("should call onToggleFilter when filter is clicked", async () => {
const user = userEvent.setup();
renderWithMockContext(<Default />);
await user.click(screen.getByRole("option", { name: "People" }));
expect(Default.args.onToggleFilter).toHaveBeenCalled();
});
it("should call createRoom when New room button is clicked", async () => {
const user = userEvent.setup();
renderWithMockContext(<Empty />);
await user.click(screen.getByRole("button", { name: "New room" }));
expect(Empty.args.createRoom).toHaveBeenCalled();
});
it("should call createChatRoom when Start chat button is clicked", async () => {
const user = userEvent.setup();
renderWithMockContext(<Empty />);
await user.click(screen.getByRole("button", { name: "Start chat" }));
expect(Empty.args.createChatRoom).toHaveBeenCalled();
});
it("should call onToggleFilter when Show all chats is clicked in unread empty state", async () => {
const user = userEvent.setup();
renderWithMockContext(<EmptyUnreadFilter />);
await user.click(screen.getByRole("button", { name: "Show all chats" }));
expect(EmptyUnreadFilter.args.onToggleFilter).toHaveBeenCalled();
});
it("should call onToggleFilter when See all activity is clicked in invites empty state", async () => {
const user = userEvent.setup();
renderWithMockContext(<EmptyInvitesFilter />);
await user.click(screen.getByRole("button", { name: "See all activity" }));
expect(EmptyInvitesFilter.args.onToggleFilter).toHaveBeenCalled();
});
it("should call onToggleFilter when See all activity is clicked in mentions empty state", async () => {
const user = userEvent.setup();
renderWithMockContext(<EmptyMentionsFilter />);
await user.click(screen.getByRole("button", { name: "See all activity" }));
expect(EmptyMentionsFilter.args.onToggleFilter).toHaveBeenCalled();
});
it("should call onToggleFilter when See all activity is clicked in low priority empty state", async () => {
const user = userEvent.setup();
renderWithMockContext(<EmptyLowPriorityFilter />);
await user.click(screen.getByRole("button", { name: "See all activity" }));
expect(EmptyLowPriorityFilter.args.onToggleFilter).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,101 @@
/*
* 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 ReactNode } from "react";
import { useViewModel, type ViewModel } from "../../viewmodel";
import { RoomListPrimaryFilters, type FilterId } from "../RoomListPrimaryFilters";
import { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
import { RoomListEmptyStateView } from "./RoomListEmptyStateView";
import { VirtualizedRoomListView, type RoomListViewState } from "../VirtualizedRoomListView";
import { type Room } from "../RoomListItem";
/**
* Snapshot for the room list view
*/
export type RoomListSnapshot = {
/** Whether the rooms are currently loading */
isLoadingRooms: boolean;
/** Whether the room list is empty */
isRoomListEmpty: boolean;
/** Array of filter IDs */
filterIds: FilterId[];
/** Currently active filter ID (if any) */
activeFilterId?: FilterId;
/** Room list state */
roomListState: RoomListViewState;
/** Array of room IDs for virtualization */
roomIds: string[];
/** Optional description for the empty state */
emptyStateDescription?: string;
/** Optional action element for the empty state */
emptyStateAction?: ReactNode;
/** Whether the user can create rooms */
canCreateRoom?: boolean;
};
/**
* Actions interface for room list operations
*/
export interface RoomListViewActions {
/** Called when a filter is toggled */
onToggleFilter: (filterId: FilterId) => void;
/** Called to create a new chat room */
createChatRoom: () => void;
/** Called to create a new room */
createRoom: () => void;
/** Get view model for a specific room (virtualization API) */
getRoomItemViewModel: (roomId: string) => any;
/** Called when the visible range changes (virtualization API) */
updateVisibleRooms: (startIndex: number, endIndex: number) => void;
}
/**
* The view model type for the room list view
*/
export type RoomListViewModel = ViewModel<RoomListSnapshot> & RoomListViewActions;
/**
* Props for RoomListView component
*/
export interface RoomListViewProps {
/** The view model containing all data and callbacks */
vm: RoomListViewModel;
/** Render function for room avatar */
renderAvatar: (room: Room) => ReactNode;
/** Optional callback for keyboard events on the room list */
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
}
/**
* Room list view component that manages filters, loading states, empty states, and the room list.
*/
export const RoomListView: React.FC<RoomListViewProps> = ({ vm, renderAvatar, onKeyDown }): JSX.Element => {
const snapshot = useViewModel(vm);
let listBody: ReactNode;
if (snapshot.isLoadingRooms) {
listBody = <RoomListLoadingSkeleton />;
} else if (snapshot.isRoomListEmpty) {
listBody = <RoomListEmptyStateView vm={vm} />;
} else {
listBody = <VirtualizedRoomListView vm={vm} renderAvatar={renderAvatar} onKeyDown={onKeyDown} />;
}
return (
<>
<div>
<RoomListPrimaryFilters
filterIds={snapshot.filterIds}
activeFilterId={snapshot.activeFilterId}
onToggleFilter={vm.onToggleFilter}
/>
</div>
{listBody}
</>
);
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,12 @@
/*
* 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 { RoomListView } from "./RoomListView";
export type { RoomListViewProps, RoomListViewModel, RoomListSnapshot, RoomListViewActions } from "./RoomListView";
export { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
export { RoomListEmptyStateView } from "./RoomListEmptyStateView";
export type { RoomListEmptyStateViewProps } from "./RoomListEmptyStateView";

View File

@@ -1,10 +1,14 @@
/*
* Copyright 2025 New Vector Ltd.
* 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.
*/
.mx_RoomList {
/**
* Room list container styles
*/
.roomList {
height: 100%;
width: 100%;
}

View File

@@ -0,0 +1,94 @@
/*
* 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 type { Room } from "../RoomListItem/RoomListItem";
import { VirtualizedRoomListView, type RoomListViewState } from "./VirtualizedRoomListView";
import type { RoomListSnapshot, RoomListViewActions } from "../RoomListView";
import { useMockedViewModel } from "../../viewmodel";
import type { FilterId } from "../RoomListPrimaryFilters";
import { renderAvatar, createGetRoomItemViewModel, mockRoomIds } from "../story-mocks";
type RoomListStoryProps = RoomListSnapshot & RoomListViewActions & { renderAvatar: (room: Room) => React.ReactElement };
// Use first 10 room IDs for this story
const storyRoomIds = mockRoomIds.slice(0, 10);
// Wrapper component that creates a mocked ViewModel
const RoomListWrapper = ({
onToggleFilter,
createChatRoom,
createRoom,
getRoomItemViewModel,
updateVisibleRooms,
renderAvatar: renderAvatarProp,
...rest
}: RoomListStoryProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
onToggleFilter,
createChatRoom,
createRoom,
getRoomItemViewModel,
updateVisibleRooms,
});
return (
<div style={{ height: "400px", border: "1px solid #ccc" }}>
<VirtualizedRoomListView vm={vm} renderAvatar={renderAvatarProp} />
</div>
);
};
const mockFilterIds: FilterId[] = ["unread", "people"];
const defaultRoomListState: RoomListViewState = {
activeRoomIndex: 0,
spaceId: "!space:server",
filterKeys: undefined,
};
const meta: Meta<RoomListStoryProps> = {
title: "Room List/VirtualizedRoomListView",
component: RoomListWrapper,
tags: ["autodocs"],
args: {
isLoadingRooms: false,
isRoomListEmpty: false,
filterIds: mockFilterIds,
activeFilterId: undefined,
roomIds: storyRoomIds,
roomListState: defaultRoomListState,
canCreateRoom: true,
onToggleFilter: fn(),
createChatRoom: fn(),
createRoom: fn(),
getRoomItemViewModel: createGetRoomItemViewModel(storyRoomIds),
updateVisibleRooms: fn(),
renderAvatar,
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel-2025?node-id=98-1979&t=vafb4zoYMNLRuAbh-4",
},
},
decorators: [
(Story) => (
<div style={{ width: "300px" }}>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<RoomListStoryProps>;
export const Default: Story = {};

View File

@@ -0,0 +1,67 @@
/*
* 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, fireEvent } from "@test-utils";
import { VirtuosoMockContext } from "react-virtuoso";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import * as stories from "./VirtualizedRoomListView.stories";
const { Default } = composeStories(stories);
const renderWithMockContext = (component: React.ReactElement): ReturnType<typeof render> => {
return render(component, {
wrapper: ({ children }) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}>
{children}
</VirtuosoMockContext.Provider>
),
});
};
describe("<VirtualizedRoomListView />", () => {
it("renders Default story", () => {
const { container } = renderWithMockContext(<Default />);
expect(container).toMatchSnapshot();
});
it("should render the room list listbox", () => {
renderWithMockContext(<Default />);
expect(screen.getByRole("listbox", { name: "Room list" })).toBeInTheDocument();
});
it("should render room items", () => {
renderWithMockContext(<Default />);
const items = screen.getAllByRole("option");
expect(items.length).toBeGreaterThan(0);
});
it("should mark selected room with aria-selected true", () => {
renderWithMockContext(<Default />);
const items = screen.getAllByRole("option");
// The first item (index 0) should be selected based on Default story (activeRoomIndex: 0)
expect(items[0]).toHaveAttribute("aria-selected", "true");
});
it("should handle focus state correctly", () => {
renderWithMockContext(<Default />);
const listbox = screen.getByRole("listbox", { name: "Room list" });
fireEvent.focus(listbox);
const items = screen.getAllByRole("option");
// First item should have tabIndex 0 (focusable) when list is focused
expect(items[0]).toHaveAttribute("tabIndex", "0");
});
it("should call updateVisibleRooms on render", () => {
renderWithMockContext(<Default />);
expect(Default.args.updateVisibleRooms).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,198 @@
/*
* 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, { useCallback, useMemo, useRef, type JSX, type ReactNode } from "react";
import { type ScrollIntoViewLocation } from "react-virtuoso";
import { isEqual } from "lodash";
import type { Room } from "../RoomListItem/RoomListItem";
import { useViewModel } from "../../viewmodel";
import { _t } from "../../utils/i18n";
import { VirtualizedList, type VirtualizedListContext } from "../../utils/VirtualizedList";
import { RoomListItemView } from "../RoomListItem";
import type { RoomListViewModel } from "../RoomListView";
/**
* Filter key type - opaque string type for filter identifiers
*/
export type FilterKey = string;
/**
* State for the room list data (nested within RoomListSnapshot)
*/
export interface RoomListViewState {
/** Optional active room index for keyboard navigation */
activeRoomIndex?: number;
/** Space ID for context tracking */
spaceId?: string;
/** Active filter keys for context tracking */
filterKeys?: FilterKey[];
}
/**
* Props for the VirtualizedRoomListView component
*/
export interface VirtualizedRoomListViewProps {
/**
* The view model containing all room list data and callbacks
*/
vm: RoomListViewModel;
/**
* Render function for room avatar
* @param room - The opaque Room object from the client
*/
renderAvatar: (room: Room) => ReactNode;
/**
* Optional callback for keyboard key down events
*/
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
}
/** Height of a single room list item in pixels */
const ROOM_LIST_ITEM_HEIGHT = 48;
/**
* Type for context used in ListView
*/
type Context = { spaceId: string; filterKeys: FilterKey[] | undefined };
/**
* Amount to extend the top and bottom of the viewport by.
* From manual testing and user feedback 25 items is reported to be enough to avoid blank space
* when using the mouse wheel, and the trackpad scrolling at a slow to moderate speed where you
* can still see/read the content. Using the trackpad to sling through a large percentage of the
* list quickly will still show blank space. We would likely need to simplify the item content to
* improve this case.
*/
const EXTENDED_VIEWPORT_HEIGHT = 25 * ROOM_LIST_ITEM_HEIGHT;
/**
* A virtualized list of rooms.
* This component provides efficient rendering of large room lists using virtualization,
* and renders RoomListItemView components for each room.
*
* @example
* ```tsx
* <VirtualizedRoomListView vm={roomListViewModel} renderAvatar={(room) => <Avatar room={room} />} />
* ```
*/
export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: VirtualizedRoomListViewProps): JSX.Element {
const snapshot = useViewModel(vm);
const { roomListState, roomIds } = snapshot;
const activeRoomIndex = roomListState.activeRoomIndex;
const lastSpaceId = useRef<string | undefined>(undefined);
const lastFilterKeys = useRef<FilterKey[] | undefined>(undefined);
const roomCount = roomIds.length;
/**
* Callback when the visible range changes
* Notifies the view model which rooms are visible
*/
const rangeChanged = useCallback(
(range: { startIndex: number; endIndex: number }) => {
vm.updateVisibleRooms(range.startIndex, range.endIndex);
},
[vm],
);
/**
* Get the item component for a specific index
* Gets the room's view model and passes it to RoomListItemView
*/
const getItemComponent = useCallback(
(
index: number,
roomId: string,
context: VirtualizedListContext<Context>,
onFocus: (item: string, e: React.FocusEvent) => void,
): JSX.Element => {
const isSelected = activeRoomIndex === index;
const roomItemVM = vm.getRoomItemViewModel(roomId);
// Item is focused when the list has focus AND this item's key matches tabIndexKey
// This matches the old RoomList implementation's roving tabindex pattern
const isFocused = context.focused && context.tabIndexKey === roomId;
return (
<RoomListItemView
key={roomId}
vm={roomItemVM}
renderAvatar={renderAvatar}
isSelected={isSelected}
isFocused={isFocused}
onFocus={onFocus}
roomIndex={index}
roomCount={roomCount}
/>
);
},
[activeRoomIndex, roomCount, renderAvatar, vm],
);
/**
* Get the key for a room item
* Since we're using virtualization, items are always room ID strings
*/
const getItemKey = useCallback((item: string): string => {
return item;
}, []);
const context = useMemo(
() => ({ spaceId: roomListState.spaceId || "", filterKeys: roomListState.filterKeys }),
[roomListState.spaceId, roomListState.filterKeys],
);
/**
* Determine if we should scroll the active index into view
* This happens when the space or filters change
*/
const scrollIntoViewOnChange = useCallback(
(params: {
context: VirtualizedListContext<{ spaceId: string; filterKeys: FilterKey[] | undefined }>;
}): ScrollIntoViewLocation | null | undefined | false => {
const { spaceId, filterKeys } = params.context.context;
const shouldScrollIndexIntoView =
lastSpaceId.current !== spaceId || !isEqual(lastFilterKeys.current, filterKeys);
lastFilterKeys.current = filterKeys;
lastSpaceId.current = spaceId;
if (shouldScrollIndexIntoView) {
return {
align: "start",
index: activeRoomIndex || 0,
behavior: "auto",
};
}
return false;
},
[activeRoomIndex],
);
return (
<VirtualizedList
context={context}
scrollIntoViewOnChange={scrollIntoViewOnChange}
initialTopMostItemIndex={activeRoomIndex}
data-testid="room-list"
role="listbox"
aria-label={_t("room_list|list_title")}
fixedItemHeight={ROOM_LIST_ITEM_HEIGHT}
items={roomIds}
getItemComponent={getItemComponent}
getItemKey={getItemKey}
isItemFocusable={() => true}
rangeChanged={rangeChanged}
onKeyDown={onKeyDown}
increaseViewportBy={{
bottom: EXTENDED_VIEWPORT_HEIGHT,
top: EXTENDED_VIEWPORT_HEIGHT,
}}
/>
);
}

View File

@@ -0,0 +1,9 @@
/*
* 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 { VirtualizedRoomListView } from "./VirtualizedRoomListView";
export type { VirtualizedRoomListViewProps, RoomListViewState, FilterKey } from "./VirtualizedRoomListView";

View File

@@ -0,0 +1,138 @@
/*
* 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 { fn } from "storybook/test";
import type { Room } from "./RoomListItem/RoomListItem";
import type { RoomListItemSnapshot } from "./RoomListItem";
import { RoomNotifState } from "./RoomListItem/RoomNotifs";
/**
* Mock avatar component for stories
*/
export const mockAvatar = (name: string): React.ReactElement => (
<div
role="img"
aria-label={`${name} avatar`}
style={{
width: "32px",
height: "32px",
borderRadius: "50%",
backgroundColor: "#0B7F67",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontWeight: "bold",
fontSize: "12px",
}}
>
{name.substring(0, 2).toUpperCase()}
</div>
);
/**
* Render avatar function for stories
*/
export const renderAvatar = (room: Room): React.ReactElement => {
// Cast to any to access properties - in real usage, the room object from the SDK will have these
return mockAvatar((room as any)?.name || "Room");
};
/**
* Room names used for mock data
*/
const roomNames = [
"General",
"Random",
"Engineering",
"Design",
"Product",
"Marketing",
"Sales",
"Support",
"Announcements",
"Off-topic",
"Team Alpha",
"Team Beta",
"Project X",
"Project Y",
"Water Cooler",
"Feedback",
"Ideas",
"Bugs",
"Features",
"Releases",
];
/**
* Create a mock room item snapshot for stories
*/
export const createMockRoomSnapshot = (id: string, name: string, index: number): RoomListItemSnapshot => ({
id,
room: { name },
name,
isBold: index % 3 === 0,
messagePreview: index % 2 === 0 ? `Last message in ${name}` : undefined,
notification: {
hasAnyNotificationOrActivity: index % 5 === 0,
isUnsentMessage: false,
invited: false,
isMention: index % 5 === 0,
isActivityNotification: false,
isNotification: index % 5 === 0,
hasUnreadCount: index % 5 === 0,
count: index % 5 === 0 ? index : 0,
muted: false,
},
showMoreOptionsMenu: true,
showNotificationMenu: true,
isFavourite: false,
isLowPriority: false,
canInvite: true,
canCopyRoomLink: true,
canMarkAsRead: false,
canMarkAsUnread: true,
roomNotifState: RoomNotifState.AllMessages,
});
/**
* Create a mock getRoomItemViewModel function for stories
*/
export const createGetRoomItemViewModel = (roomIds: string[]): ((roomId: string) => any) => {
const viewModels = new Map();
roomIds.forEach((roomId, index) => {
const name = roomNames[index % roomNames.length];
const snapshot = createMockRoomSnapshot(roomId, name, index);
const mockViewModel = {
getSnapshot: () => snapshot,
subscribe: fn(),
unsubscribe: fn(),
onOpenRoom: fn(),
onMarkAsRead: fn(),
onMarkAsUnread: fn(),
onToggleFavorite: fn(),
onToggleLowPriority: fn(),
onInvite: fn(),
onCopyRoomLink: fn(),
onLeaveRoom: fn(),
onSetRoomNotifState: fn(),
};
viewModels.set(roomId, mockViewModel);
});
return (roomId: string) => viewModels.get(roomId);
};
/**
* Mock room IDs for different list sizes
*/
export const mockRoomIds = Array.from({ length: 20 }, (_, i) => `!room${i}:server`);
export const smallListRoomIds = mockRoomIds.slice(0, 5);
export const largeListRoomIds = Array.from({ length: 100 }, (_, i) => `!room${i}:server`);

View File

@@ -1,9 +1,9 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
* 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, { useRef, type JSX, useCallback, useEffect, useState, useMemo } from "react";
import { type VirtuosoHandle, type ListRange, Virtuoso, type VirtuosoProps } from "react-virtuoso";
@@ -95,6 +95,19 @@ export interface IVirtualizedListProps<Item, Context> extends Omit<
* @returns
*/
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
/**
* Optional total count of items (for virtualization with partial data loading).
* If provided, this will be used instead of items.length for the total count.
*/
totalCount?: number;
/**
* Optional callback when the visible range of items changes.
* Useful for loading data on-demand as the user scrolls.
* @param range - The new visible range with startIndex and endIndex
*/
rangeChanged?: (range: ListRange) => void;
}
/**
@@ -113,7 +126,17 @@ export type ScrollIntoViewOnChange<Item, Context = any> = NonNullable<
*/
export function VirtualizedList<Item, Context = any>(props: IVirtualizedListProps<Item, Context>): React.ReactElement {
// Extract our custom props to avoid conflicts with Virtuoso props
const { items, getItemComponent, isItemFocusable, getItemKey, context, onKeyDown, ...virtuosoProps } = props;
const {
items,
getItemComponent,
isItemFocusable,
getItemKey,
context,
onKeyDown,
totalCount,
rangeChanged,
...virtuosoProps
} = props;
/** Reference to the Virtuoso component for programmatic scrolling */
const virtuosoHandleRef = useRef<VirtuosoHandle>(null);
/** Reference to the DOM element containing the virtualized list */
@@ -324,6 +347,15 @@ export function VirtualizedList<Item, Context = any>(props: IVirtualizedListProp
[tabIndexKey, isFocused, props.context],
);
// Combine internal range tracking with optional external callback
const handleRangeChanged = useCallback(
(range: ListRange) => {
setVisibleRange(range);
rangeChanged?.(range);
},
[rangeChanged],
);
return (
<Virtuoso
// note that either the container of direct children must be focusable to be axe
@@ -334,10 +366,11 @@ export function VirtualizedList<Item, Context = any>(props: IVirtualizedListProp
scrollerRef={scrollerRef}
onKeyDown={keyDownCallback}
context={listContext}
rangeChanged={setVisibleRange}
rangeChanged={handleRangeChanged}
// virtuoso errors internally if you pass undefined.
overscan={props.overscan || 0}
data={props.items}
totalCount={totalCount}
onFocus={onFocus}
onBlur={onBlur}
itemContent={getItemComponentInternal}

View File

@@ -12,5 +12,5 @@ export * from "./ViewModelSubscriptions";
export type * from "./ViewModel";
export * from "./MockViewModel";
export * from "./useCreateAutoDisposedViewModel";
export * from "./useViewModel";
export * from "./useMockedViewModel";
export * from "./useViewModel";

View File

@@ -18,19 +18,28 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
util,
msg,
page,
app,
bot,
}) => {
// Create a third room to navigate to
const room3Id = await app.client.createRoom({ name: "Room Gamma", invite: [bot.credentials.userId] });
await bot.awaitRoomMembership(room3Id);
const room3 = { name: "Room Gamma", roomId: room3Id };
await util.goTo(room2);
// Display the unread first room
await util.receiveMessages(room2, ["Msg2"]);
await util.receiveMessages(room1, ["Msg1"]);
await page.reload();
// switch rooms so they can re-order in the list
await util.goTo(room1);
// Switch to room3 so neither room1 nor room2 is selected/sticky
// This allows them to reorder based on activity
await util.goTo(room3);
// Room 1 has an unread message and should be displayed first
// (as the default is to sort by activity)
await util.assertRoomListOrder([room1, room2]);
await util.assertRoomListOrder([room1, room2, room3]);
});
test("Rooms with unread threads appear at the top of room list with default 'activity' order", async ({
@@ -38,18 +47,29 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
roomBeta: room2,
util,
msg,
app,
bot,
}) => {
// Create a third room to navigate to
const room3Id = await app.client.createRoom({ name: "Room Gamma", invite: [bot.credentials.userId] });
await bot.awaitRoomMembership(room3Id);
const room3 = { name: "Room Gamma", roomId: room3Id };
await util.goTo(room2);
await util.receiveMessages(room1, ["Msg1"]);
await util.receiveMessages(room2, ["Msg2"]);
await util.markAsRead(room1);
await util.assertRead(room1);
// Display the unread first room
// Display the unread first room (room1 moves above room2 as it has an unread thread)
await util.receiveMessages(room1, [msg.threadedOff("Msg1", "Resp1")]);
await util.saveAndReload();
// Switch to room3 so neither room1 nor room2 is selected/sticky
await util.goTo(room3);
// Room 1 has an unread message and should be displayed first
await util.assertRoomListOrder([room1, room2]);
await util.assertRoomListOrder([room1, room2, room3]);
});
});
});

View File

@@ -38,7 +38,7 @@ const test = base.extend<{
test.describe("Sliding Sync", () => {
const checkOrder = async (wantOrder: string[], page: Page) => {
await expect(page.getByTestId("room-list").locator(".mx_RoomListItemView_text")).toHaveText(wantOrder);
await expect(page.getByTestId("room-list").getByTestId("room-name")).toHaveText(wantOrder);
};
const bumpRoom = async (roomId: string, app: ElementAppPage) => {

View File

@@ -263,14 +263,7 @@
@import "./views/right_panel/_VerificationPanel.pcss";
@import "./views/right_panel/_WidgetCard.pcss";
@import "./views/room_settings/_AliasSettings.pcss";
@import "./views/rooms/RoomListPanel/_EmptyRoomList.pcss";
@import "./views/rooms/RoomListPanel/_RoomList.pcss";
@import "./views/rooms/RoomListPanel/_RoomListItemMenuView.pcss";
@import "./views/rooms/RoomListPanel/_RoomListItemView.pcss";
@import "./views/rooms/RoomListPanel/_RoomListPanel.pcss";
@import "./views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss";
@import "./views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss";
@import "./views/rooms/RoomListPanel/_RoomListSkeleton.pcss";
@import "./views/rooms/_AppsDrawer.pcss";
@import "./views/rooms/_Autocomplete.pcss";
@import "./views/rooms/_AuxPanel.pcss";

View File

@@ -1,33 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.mx_EmptyRoomList_GenericPlaceholder {
align-self: center;
/** It should take 2/3 of the width **/
width: 66%;
/** It should be positioned at 1/3 of the height **/
padding-top: 33%;
.mx_EmptyRoomList_GenericPlaceholder_title {
font: var(--cpd-font-body-lg-semibold);
text-align: center;
}
.mx_EmptyRoomList_GenericPlaceholder_description {
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
text-align: center;
}
.mx_EmptyRoomList_DefaultPlaceholder {
margin-top: var(--cpd-space-4x);
}
button {
width: 100%;
}
}

View File

@@ -1,12 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.mx_RoomListItemMenuView {
svg {
fill: var(--cpd-color-icon-primary);
}
}

View File

@@ -1,102 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
/**
* The RoomListItemView has the following structure:
* button--------------------------------------------------|
* | <-12px-> container------------------------------------|
* | | room avatar <-8px-> content----------------|
* | | | room_name <- 20px ->|
* | | | --------------------| <-- border
* |-------------------------------------------------------|
*/
.mx_RoomListItemView {
/* Remove button default style */
color: inherit;
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);
/* Hide the menu by default */
.mx_RoomListItemView_menu {
display: none;
}
&:hover,
&:focus-visible,
/* When the context menu is opened */
&[data-state="open"],
/* When the options and notifications menu are opened */
&:has(.mx_RoomListItemMenuView > button[data-state="open"]) {
background-color: var(--cpd-color-bg-action-secondary-hovered);
.mx_RoomListItemView_menu {
display: flex;
}
&.mx_RoomListItemView_has_menu {
/**
* 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
*/
.mx_RoomListItemView_content {
padding-right: 18px;
}
/* When the menu is visible, hide the notification decoration to avoid clutter */
.mx_RoomListItemView_notificationDecoration {
display: none;
}
}
}
.mx_RoomListItemView_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);
.mx_RoomListItemView_text {
min-width: 0;
}
.mx_RoomListItemView_roomName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mx_RoomListItemView_messagePreview {
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.mx_RoomListItemView_selected {
background-color: var(--cpd-color-bg-action-secondary-pressed);
}
.mx_RoomListItemView_bold .mx_RoomListItemView_roomName {
font: var(--cpd-font-body-md-semibold);
}

View File

@@ -1,34 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.mx_RoomListPrimaryFilters {
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-3x);
.mx_RoomListPrimaryFilters_wrapping {
display: none;
}
.mx_RoomListPrimaryFilters_list {
/**
* The InteractionObserver needs the height to be set to work properly.
*/
height: 100%;
flex: 1;
}
.mx_RoomListPrimaryFilters_IconButton {
svg {
transition: transform 0.1s linear;
}
}
.mx_RoomListPrimaryFilters_IconButton[aria-expanded="true"] {
svg {
transform: rotate(180deg);
}
}
}

View File

@@ -1,12 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.mx_RoomListSecondaryFilters {
font: var(--cpd-font-body-md-medium);
margin: var(--cpd-space-2x);
margin-left: var(--cpd-space-1x);
}

View File

@@ -1,24 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.mx_RoomListSkeleton {
position: relative;
margin-left: 4px;
height: 100%;
&::before {
background-color: var(--cpd-color-bg-subtle-secondary);
width: 100%;
height: 100%;
content: "";
position: absolute;
mask-repeat: repeat-y;
mask-size: auto 96px;
mask-image: url("/res/img/element-icons/roomlist/room-list-item-skeleton.svg");
}
}

Some files were not shown because too many files have changed in this diff Show More