diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/bold-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/bold-auto.png
new file mode 100644
index 0000000000..d36edbed0c
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/bold-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/default-auto.png
new file mode 100644
index 0000000000..24dc3f42d3
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/default-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/invitation-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/invitation-auto.png
new file mode 100644
index 0000000000..803922f580
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/invitation-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/no-message-preview-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/no-message-preview-auto.png
new file mode 100644
index 0000000000..2c15716e96
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/no-message-preview-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/selected-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/selected-auto.png
new file mode 100644
index 0000000000..146160ecde
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/selected-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/unsent-message-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/unsent-message-auto.png
new file mode 100644
index 0000000000..0c3c72e202
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/unsent-message-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-hover-menu-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-hover-menu-auto.png
new file mode 100644
index 0000000000..24dc3f42d3
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-hover-menu-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-mention-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-mention-auto.png
new file mode 100644
index 0000000000..5c38984488
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-mention-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-notification-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-notification-auto.png
new file mode 100644
index 0000000000..0ee34aabfe
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-notification-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/without-hover-menu-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/without-hover-menu-auto.png
new file mode 100644
index 0000000000..24dc3f42d3
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/without-hover-menu-auto.png differ
diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.module.css b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.module.css
new file mode 100644
index 0000000000..008a4462ac
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.module.css
@@ -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);
+}
diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx
new file mode 100644
index 0000000000..0da88a6739
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx
@@ -0,0 +1,206 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import React, { type JSX } from "react";
+import { fn } from "storybook/test";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { RoomListItemView, type RoomListItemSnapshot, type RoomListItemActions } from "./RoomListItem";
+import { useMockedViewModel } from "../../viewmodel";
+import { defaultSnapshot } from "./default-snapshot";
+import { renderAvatar } from "../story-mocks";
+
+type RoomListItemProps = RoomListItemSnapshot &
+ RoomListItemActions & {
+ isSelected: boolean;
+ isFocused: boolean;
+ onFocus: (room: any, e: React.FocusEvent) => void;
+ roomIndex: number;
+ roomCount: number;
+ renderAvatar: (room: any) => React.ReactElement;
+ };
+
+// Wrapper component that creates a mocked ViewModel
+const RoomListItemWrapper = ({
+ onOpenRoom,
+ onMarkAsRead,
+ onMarkAsUnread,
+ onToggleFavorite,
+ onToggleLowPriority,
+ onInvite,
+ onCopyRoomLink,
+ onLeaveRoom,
+ onSetRoomNotifState,
+ isSelected,
+ isFocused,
+ onFocus,
+ roomIndex,
+ roomCount,
+ renderAvatar: renderAvatarProp,
+ ...rest
+}: RoomListItemProps): JSX.Element => {
+ const vm = useMockedViewModel(rest, {
+ onOpenRoom,
+ onMarkAsRead,
+ onMarkAsUnread,
+ onToggleFavorite,
+ onToggleLowPriority,
+ onInvite,
+ onCopyRoomLink,
+ onLeaveRoom,
+ onSetRoomNotifState,
+ });
+ return (
+
+ );
+};
+
+const meta = {
+ title: "Room List/RoomListItem",
+ component: RoomListItemWrapper,
+ tags: ["autodocs"],
+ decorators: [
+ (Story) => (
+
+
+
+
+
+ ),
+ ],
+ args: {
+ ...defaultSnapshot,
+ isSelected: false,
+ isFocused: false,
+ roomIndex: 0,
+ roomCount: 10,
+ onOpenRoom: fn(),
+ onMarkAsRead: fn(),
+ onMarkAsUnread: fn(),
+ onToggleFavorite: fn(),
+ onToggleLowPriority: fn(),
+ onInvite: fn(),
+ onCopyRoomLink: fn(),
+ onLeaveRoom: fn(),
+ onSetRoomNotifState: fn(),
+ onFocus: fn(),
+ renderAvatar,
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+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,
+ },
+};
diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx
new file mode 100644
index 0000000000..788c9f317f
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx
@@ -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("", () => {
+ it("renders Default story", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders Selected story", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders Bold story", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders WithNotification story", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders WithMention story", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders Invitation story", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders UnsentMessage story", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders NoMessagePreview story", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders WithHoverMenu story", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should call onOpenRoom when clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("option"));
+ expect(Default.args.onOpenRoom).toHaveBeenCalled();
+ });
+
+ it("should have aria-selected true when selected", () => {
+ render();
+ expect(screen.getByRole("option")).toHaveAttribute("aria-selected", "true");
+ });
+
+ it("should have aria-selected false when not selected", () => {
+ render();
+ expect(screen.getByRole("option")).toHaveAttribute("aria-selected", "false");
+ });
+
+ it("should have tabIndex -1 when not focused", () => {
+ render();
+ expect(screen.getByRole("option")).toHaveAttribute("tabIndex", "-1");
+ });
+
+ it("should call onFocus when focused", () => {
+ render();
+ screen.getByRole("option").focus();
+ expect(Default.args.onFocus).toHaveBeenCalled();
+ });
+
+ it("should display notification decoration when present", () => {
+ render();
+ expect(screen.getByTestId("notification-decoration")).toBeInTheDocument();
+ });
+
+ it("should hide notification decoration when not present", () => {
+ render();
+ expect(screen.queryByTestId("notification-decoration")).toBeNull();
+ });
+
+ it("should show hover menu when showMoreOptionsMenu is true", () => {
+ const { container } = render();
+ expect(container.querySelector('[aria-label="More Options"]')).not.toBeNull();
+ });
+
+ it("should hide hover menu when showMoreOptionsMenu is false", () => {
+ const { container } = render();
+ expect(container.querySelector('[aria-label="More Options"]')).toBeNull();
+ });
+});
diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx
new file mode 100644
index 0000000000..ddde7ffeee
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx
@@ -0,0 +1,202 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import React, { type JSX, memo, useEffect, useRef, type ReactNode } from "react";
+import classNames from "classnames";
+
+import { Flex } from "../../utils/Flex";
+import { NotificationDecoration, type NotificationDecorationData } from "./NotificationDecoration";
+import { RoomListItemHoverMenu } from "./RoomListItemHoverMenu";
+import { RoomListItemContextMenu } from "./RoomListItemContextMenu";
+import { type RoomNotifState } from "./RoomNotifs";
+import styles from "./RoomListItem.module.css";
+import { useViewModel, type ViewModel } from "../../viewmodel";
+import { _t } from "../../utils/i18n";
+
+/**
+ * Generate an accessible label for a room based on its notification state.
+ */
+function getA11yLabel(roomName: string, notification: NotificationDecorationData): string {
+ if (notification.isUnsentMessage) {
+ return _t("room_list|a11y|unsent_message", { roomName });
+ } else if (notification.invited) {
+ return _t("room_list|a11y|invitation", { roomName });
+ } else if (notification.isMention && notification.count) {
+ return _t("room_list|a11y|mention", { roomName, count: notification.count });
+ } else if (notification.hasUnreadCount && notification.count) {
+ return _t("room_list|a11y|unread", { roomName, count: notification.count });
+ } else {
+ return _t("room_list|a11y|default", { roomName });
+ }
+}
+
+/**
+ * Snapshot for a room list item.
+ * Contains all the data needed to render a room in the list.
+ */
+export interface RoomListItemSnapshot {
+ /** Unique identifier for the room (used for list keying) */
+ id: string;
+ /** The opaque Room object from the client (e.g., matrix-js-sdk Room) */
+ room: any;
+ /** The name of the room */
+ name: string;
+ /** Whether the room name should be bolded (has unread/activity) */
+ isBold: boolean;
+ /** Optional message preview text */
+ messagePreview?: string;
+ /** Notification decoration data */
+ notification: NotificationDecorationData;
+ /** Whether the more options menu should be shown */
+ showMoreOptionsMenu: boolean;
+ /** Whether the notification menu should be shown */
+ showNotificationMenu: boolean;
+ /** Whether the room is a favourite room */
+ isFavourite: boolean;
+ /** Whether the room is a low priority room */
+ isLowPriority: boolean;
+ /** Can invite other users in the room */
+ canInvite: boolean;
+ /** Can copy the room link */
+ canCopyRoomLink: boolean;
+ /** Can mark the room as read */
+ canMarkAsRead: boolean;
+ /** Can mark the room as unread */
+ canMarkAsUnread: boolean;
+ /** The room's notification state */
+ roomNotifState: RoomNotifState;
+}
+
+/**
+ * Actions interface for room list item operations.
+ * Implemented by the room item view model.
+ */
+export interface RoomListItemActions {
+ /** Called when the room should be opened */
+ onOpenRoom: () => void;
+ /** Called when the room should be marked as read */
+ onMarkAsRead: () => void;
+ /** Called when the room should be marked as unread */
+ onMarkAsUnread: () => void;
+ /** Called when the room's favorite status should be toggled */
+ onToggleFavorite: () => void;
+ /** Called when the room's low priority status should be toggled */
+ onToggleLowPriority: () => void;
+ /** Called when inviting users to the room */
+ onInvite: () => void;
+ /** Called when copying the room link */
+ onCopyRoomLink: () => void;
+ /** Called when leaving the room */
+ onLeaveRoom: () => void;
+ /** Called when setting the room notification state */
+ onSetRoomNotifState: (state: RoomNotifState) => void;
+}
+
+/**
+ * The view model type for a room list item
+ */
+export type RoomItemViewModel = ViewModel & RoomListItemActions;
+
+/**
+ * Props for RoomListItemView component
+ */
+export interface RoomListItemViewProps extends Omit, "onFocus"> {
+ /** The room item view model */
+ vm: RoomItemViewModel;
+ /** Whether the room is selected */
+ isSelected: boolean;
+ /** Whether the room should be focused */
+ isFocused: boolean;
+ /** Callback when item receives focus */
+ onFocus: (roomId: string, e: React.FocusEvent) => void;
+ /** Index of this room in the list (for accessibility) */
+ roomIndex: number;
+ /** Total number of rooms in the list (for accessibility) */
+ roomCount: number;
+ /** Function to render the room avatar */
+ renderAvatar: (room: any) => ReactNode;
+}
+
+/**
+ * A presentational room list item component.
+ * Displays room name, avatar, message preview, and notifications.
+ */
+export const RoomListItemView = memo(function RoomListItemView({
+ vm,
+ isSelected,
+ isFocused,
+ onFocus,
+ roomIndex,
+ roomCount,
+ renderAvatar,
+ ...props
+}: RoomListItemViewProps): JSX.Element {
+ const ref = useRef(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 = (
+ ) => onFocus(item.id, e)}
+ tabIndex={isFocused ? 0 : -1}
+ {...props}
+ >
+ {renderAvatar(item.room)}
+
+ {/* We truncate the room name when too long. Title here is to show the full name on hover */}
+
+
+ {item.name}
+
+ {item.messagePreview && (
+
+ {item.messagePreview}
+
+ )}
+
+ {(item.showMoreOptionsMenu || item.showNotificationMenu) && (
+
+ )}
+
+ {/* aria-hidden because we summarise the unread count/notification status in a11yLabel */}
+
+
+
+
+
+ );
+
+ return {content};
+});
diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemContextMenu.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItemContextMenu.tsx
new file mode 100644
index 0000000000..0d202474f8
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemContextMenu.tsx
@@ -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> = ({
+ vm,
+ children,
+}): JSX.Element => {
+ return (
+
+
+
+ );
+};
diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx
new file mode 100644
index 0000000000..9a453b2014
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx
@@ -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 = ({
+ showMoreOptionsMenu,
+ showNotificationMenu,
+ vm,
+}): JSX.Element => {
+ return (
+
+ {showMoreOptionsMenu && }
+ {showNotificationMenu && }
+
+ );
+};
diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.test.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.test.tsx
new file mode 100644
index 0000000000..40b9917c5b
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.test.tsx
@@ -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("", () => {
+ 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 = {}): ReturnType => {
+ const TestComponent = (): JSX.Element => {
+ const vm = useMockedViewModel(
+ {
+ ...defaultSnapshot,
+ showMoreOptionsMenu: true,
+ showNotificationMenu: false,
+ ...overrides,
+ } as RoomListItemSnapshot,
+ mockCallbacks,
+ );
+ return ;
+ };
+ return render();
+ };
+
+ 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();
+ });
+});
diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.tsx
new file mode 100644
index 0000000000..d10b5c32ec
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.tsx
@@ -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 & 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 (
+
+ );
+}
+
+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
+