From 3023192ce712cb5aded4da888aa1190c48ceb67b Mon Sep 17 00:00:00 2001 From: David Langley Date: Sun, 7 Dec 2025 18:13:22 +0000 Subject: [PATCH] Remove RoomListItemViewModel, just use dumb components . --- packages/shared-components/jest-sonar.xml | 230 +++++++-------- .../AudioPlayerView.test.tsx.snap | 8 +- .../PlayPauseButton.test.tsx.snap | 4 +- packages/shared-components/src/index.ts | 1 + .../NotificationDecoration.tsx | 32 +-- .../NotificationDecoration/index.tsx | 2 +- .../Pill/__snapshots__/Pill.test.tsx.snap | 2 +- .../room-list/RoomList/RoomList.stories.tsx | 77 ++--- .../src/room-list/RoomList/RoomList.test.tsx | 83 +++--- .../src/room-list/RoomList/RoomList.tsx | 97 +++++-- .../src/room-list/RoomList/index.ts | 9 +- .../RoomListItem/RoomListItem.stories.tsx | 117 ++++---- .../RoomListItem/RoomListItem.test.tsx | 108 ++++--- .../room-list/RoomListItem/RoomListItem.tsx | 96 ++++--- .../RoomListItem/RoomListItemContextMenu.tsx | 18 +- .../RoomListItem/RoomListItemHoverMenu.tsx | 264 +++--------------- .../RoomListItemMoreOptionsMenu.tsx | 184 ++++++++++++ .../RoomListItemNotificationMenu.tsx | 139 +++++++++ .../src/room-list/RoomListItem/index.ts | 6 +- .../RoomListPanel/RoomListPanel.stories.tsx | 99 ++++--- .../RoomListPanel/RoomListPanel.test.tsx | 35 ++- .../room-list/RoomListPanel/RoomListPanel.tsx | 8 +- .../src/room-list/RoomListPanel/index.tsx | 2 +- .../RoomListView/RoomListLoadingSkeleton.tsx | 2 +- .../RoomListView/RoomListView.module.css | 7 +- .../room-list/RoomListView/RoomListView.tsx | 12 +- .../RoomListView/assets/skeleton.svg | 14 + .../src/room-list/RoomListView/index.tsx | 2 +- packages/shared-components/tsconfig.json | 1 - playwright/tsconfig.json | 3 +- tsconfig.json | 1 - 31 files changed, 1010 insertions(+), 653 deletions(-) create mode 100644 packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.tsx create mode 100644 packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.tsx create mode 100644 packages/shared-components/src/room-list/RoomListView/assets/skeleton.svg diff --git a/packages/shared-components/jest-sonar.xml b/packages/shared-components/jest-sonar.xml index 9b21e7ffc0..2368a86674 100644 --- a/packages/shared-components/jest-sonar.xml +++ b/packages/shared-components/jest-sonar.xml @@ -1,163 +1,163 @@ - - - - - - - + + - - - - - - - - - - - - - + + - + + + + - - - - - - + - - - + + + + + + + + + + + + + + + + - - - + + + - - - - - - - - - - - + + + + + + + + + + + + + + + - + - - + + - + - + - + - + - - - - - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - + + + + + - - - - - + + + + + - - - + + + - - + + + + + + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - + + + + + + + - - - - - + + + + + \ No newline at end of file diff --git a/packages/shared-components/src/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap b/packages/shared-components/src/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap index 89b47dfecb..85004e4ba5 100644 --- a/packages/shared-components/src/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap +++ b/packages/shared-components/src/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap @@ -23,7 +23,7 @@ exports[`AudioPlayerView renders the audio player in default state 1`] = ` tabindex="-1" >
= ({ viewModel }) => { +export const NotificationDecoration: React.FC = ({ data }) => { // Don't render anything if there's nothing to show - if (!viewModel.hasAnyNotificationOrActivity && !viewModel.muted && !viewModel.callType) { + if (!data.hasAnyNotificationOrActivity && !data.muted && !data.callType) { return null; } return ( - {viewModel.isUnsentMessage && ( + {data.isUnsentMessage && ( )} - {viewModel.callType === "video" && ( + {data.callType === "video" && ( )} - {viewModel.callType === "voice" && ( + {data.callType === "voice" && ( )} - {viewModel.invited && } - {viewModel.isMention && ( + {data.invited && } + {data.isMention && ( )} - {(viewModel.isMention || viewModel.isNotification) && } - {viewModel.isActivityNotification && } - {viewModel.muted && ( + {(data.isMention || data.isNotification) && } + {data.isActivityNotification && } + {data.muted && ( )} diff --git a/packages/shared-components/src/notifications/NotificationDecoration/index.tsx b/packages/shared-components/src/notifications/NotificationDecoration/index.tsx index 7d14b7d44d..fc8a24e048 100644 --- a/packages/shared-components/src/notifications/NotificationDecoration/index.tsx +++ b/packages/shared-components/src/notifications/NotificationDecoration/index.tsx @@ -6,4 +6,4 @@ */ export { NotificationDecoration } from "./NotificationDecoration"; -export type { NotificationDecorationProps, NotificationDecorationViewModel } from "./NotificationDecoration"; +export type { NotificationDecorationProps, NotificationDecorationData } from "./NotificationDecoration"; diff --git a/packages/shared-components/src/pill-input/Pill/__snapshots__/Pill.test.tsx.snap b/packages/shared-components/src/pill-input/Pill/__snapshots__/Pill.test.tsx.snap index 81a541272c..2ef6575b20 100644 --- a/packages/shared-components/src/pill-input/Pill/__snapshots__/Pill.test.tsx.snap +++ b/packages/shared-components/src/pill-input/Pill/__snapshots__/Pill.test.tsx.snap @@ -25,7 +25,7 @@ exports[`Pill renders the pill 1`] = ` tabindex="0" >
(
); -// Generate mock rooms with ViewModels -const generateMockRooms = (count: number): RoomListItemViewModel[] => { - const mockNotificationViewModel: NotificationDecorationViewModel = { +// Generate mock rooms with data +const generateMockRooms = (count: number): RoomListItem[] => { + const mockNotificationData: NotificationDecorationData = { hasAnyNotificationOrActivity: false, isUnsentMessage: false, invited: false, isMention: false, isActivityNotification: false, isNotification: false, - count: 0, muted: false, }; - const mockMenuViewModel: RoomListItemMenuViewModel = { - showMoreOptionsMenu: true, - showNotificationMenu: true, + const mockMoreOptionsState: MoreOptionsMenuState = { isFavourite: false, isLowPriority: false, canInvite: true, canCopyRoomLink: true, canMarkAsRead: true, canMarkAsUnread: true, + }; + + const mockNotificationState: NotificationMenuState = { isNotificationAllMessage: true, isNotificationAllMessageLoud: false, isNotificationMentionOnly: false, isNotificationMute: false, - markAsRead: () => console.log("Mark as read"), - markAsUnread: () => console.log("Mark as unread"), - toggleFavorite: () => console.log("Toggle favorite"), - toggleLowPriority: () => console.log("Toggle low priority"), - invite: () => console.log("Invite"), - copyRoomLink: () => console.log("Copy room link"), - leaveRoom: () => console.log("Leave room"), - setRoomNotifState: (state: RoomNotifState) => console.log("Set notification state:", state), }; return Array.from({ length: count }, (_, i) => { @@ -77,7 +70,7 @@ const generateMockRooms = (count: number): RoomListItemViewModel[] => { const hasNotification = Math.random() > 0.8; const isMention = Math.random() > 0.9; - const notificationViewModel: NotificationDecorationViewModel = hasUnread + const notificationData: NotificationDecorationData = hasUnread ? { hasAnyNotificationOrActivity: true, isUnsentMessage: false, @@ -88,17 +81,19 @@ const generateMockRooms = (count: number): RoomListItemViewModel[] => { count: unreadCount, muted: false, } - : mockNotificationViewModel; + : mockNotificationData; return { id: `!room${i}:server`, name: `Room ${i + 1}`, - openRoom: () => console.log(`Opening room: Room ${i + 1}`), a11yLabel: unreadCount > 0 ? `Room ${i + 1}, ${unreadCount} unread messages` : `Room ${i + 1}`, isBold: unreadCount > 0, messagePreview: undefined, - notificationViewModel, - menuViewModel: mockMenuViewModel, + notification: notificationData, + showMoreOptionsMenu: true, + showNotificationMenu: true, + moreOptionsState: mockMoreOptionsState, + notificationState: mockNotificationState, }; }); }; @@ -109,21 +104,33 @@ const mockRoomsResult: RoomsResult = { rooms: generateMockRooms(50), }; -function createMockViewModel(snapshot: RoomListSnapshot): ViewModel { +// Create stable unsubscribe function +const noop = (): void => {}; + +function createMockViewModel(snapshot: RoomListViewSnapshot): RoomListViewModel { return { getSnapshot: () => snapshot, - subscribe: () => () => {}, + subscribe: () => noop, + onOpenRoom: (roomId: string) => console.log("Open room:", roomId), + onMarkAsRead: (roomId: string) => console.log("Mark as read:", roomId), + onMarkAsUnread: (roomId: string) => console.log("Mark as unread:", roomId), + onToggleFavorite: (roomId: string) => console.log("Toggle favorite:", roomId), + onToggleLowPriority: (roomId: string) => console.log("Toggle low priority:", roomId), + onInvite: (roomId: string) => console.log("Invite to room:", roomId), + onCopyRoomLink: (roomId: string) => console.log("Copy room link:", roomId), + onLeaveRoom: (roomId: string) => console.log("Leave room:", roomId), + onSetRoomNotifState: (roomId: string, state: RoomNotifState) => + console.log("Set notification state:", roomId, state), }; } -const mockViewModel: ViewModel = createMockViewModel({ +const mockViewModel: RoomListViewModel = createMockViewModel({ roomsResult: mockRoomsResult, activeRoomIndex: undefined, - onKeyDown: undefined, }); -const renderAvatar = (roomViewModel: RoomListItemViewModel): React.ReactElement => { - return mockAvatar(roomViewModel.name); +const renderAvatar = (roomItem: RoomListItem): React.ReactElement => { + return mockAvatar(roomItem.name); }; const meta = { @@ -146,14 +153,15 @@ const meta = { export default meta; type Story = StoryObj; -export const Default: Story = {}; +export const Default: Story = { + args: {}, +}; export const WithSelection: Story = { args: { vm: createMockViewModel({ roomsResult: mockRoomsResult, activeRoomIndex: 5, - onKeyDown: undefined, }), }, }; @@ -167,7 +175,6 @@ export const SmallList: Story = { rooms: generateMockRooms(5), }, activeRoomIndex: undefined, - onKeyDown: undefined, }), }, }; @@ -181,7 +188,6 @@ export const LargeList: Story = { rooms: generateMockRooms(200), }, activeRoomIndex: undefined, - onKeyDown: undefined, }), }, }; @@ -195,7 +201,6 @@ export const EmptyList: Story = { rooms: [], }, activeRoomIndex: undefined, - onKeyDown: undefined, }), }, }; diff --git a/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx b/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx index 7fd10c586f..f08a3caddd 100644 --- a/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx +++ b/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx @@ -5,84 +5,100 @@ * Please see LICENSE files in the repository root for full details. */ -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import React from "react"; -import { RoomList, type RoomListSnapshot, type RoomsResult } from "./RoomList"; -import type { RoomListItemViewModel } from "../RoomListItem"; -import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration"; -import type { RoomListItemMenuViewModel } from "../RoomListItem/RoomListItemMenuViewModel"; -import { type ViewModel } from "../../viewmodel/ViewModel"; +import { + RoomList, + type RoomListViewModel, + type RoomListViewSnapshot, + type RoomListViewActions, + type RoomsResult, +} from "./RoomList"; +import type { RoomListItem } from "../RoomListItem"; +import type { NotificationDecorationData } from "../../notifications/NotificationDecoration"; +import type { MoreOptionsMenuState } from "../RoomListItem/RoomListItemMoreOptionsMenu"; +import type { NotificationMenuState } from "../RoomListItem/RoomListItemNotificationMenu"; -function createMockViewModel(snapshot: RoomListSnapshot): ViewModel { +function createMockViewModel( + snapshot: RoomListViewSnapshot, + actions: Partial = {}, +): RoomListViewModel { return { getSnapshot: () => snapshot, subscribe: () => () => {}, + onOpenRoom: actions.onOpenRoom || jest.fn(), + onMarkAsRead: actions.onMarkAsRead || jest.fn(), + onMarkAsUnread: actions.onMarkAsUnread || jest.fn(), + onToggleFavorite: actions.onToggleFavorite || jest.fn(), + onToggleLowPriority: actions.onToggleLowPriority || jest.fn(), + onInvite: actions.onInvite || jest.fn(), + onCopyRoomLink: actions.onCopyRoomLink || jest.fn(), + onLeaveRoom: actions.onLeaveRoom || jest.fn(), + onSetRoomNotifState: actions.onSetRoomNotifState || jest.fn(), }; } describe("RoomList", () => { - const mockNotificationViewModel: NotificationDecorationViewModel = { + const mockNotificationData: NotificationDecorationData = { hasAnyNotificationOrActivity: false, isUnsentMessage: false, invited: false, isMention: false, isActivityNotification: false, isNotification: false, - count: 0, muted: false, }; - const mockMenuViewModel: RoomListItemMenuViewModel = { - showMoreOptionsMenu: true, - showNotificationMenu: true, + const mockMoreOptionsState: MoreOptionsMenuState = { isFavourite: false, isLowPriority: false, canInvite: true, canCopyRoomLink: true, canMarkAsRead: true, canMarkAsUnread: true, + }; + + const mockNotificationState: NotificationMenuState = { isNotificationAllMessage: true, isNotificationAllMessageLoud: false, isNotificationMentionOnly: false, isNotificationMute: false, - markAsRead: jest.fn(), - markAsUnread: jest.fn(), - toggleFavorite: jest.fn(), - toggleLowPriority: jest.fn(), - invite: jest.fn(), - copyRoomLink: jest.fn(), - leaveRoom: jest.fn(), - setRoomNotifState: jest.fn(), }; - const mockRooms: RoomListItemViewModel[] = [ + const mockRooms: RoomListItem[] = [ { id: "!room1:server", name: "Room 1", - openRoom: jest.fn(), a11yLabel: "Room 1", isBold: false, - notificationViewModel: mockNotificationViewModel, - menuViewModel: mockMenuViewModel, + notification: mockNotificationData, + showMoreOptionsMenu: true, + showNotificationMenu: true, + moreOptionsState: mockMoreOptionsState, + notificationState: mockNotificationState, }, { id: "!room2:server", name: "Room 2", - openRoom: jest.fn(), a11yLabel: "Room 2", isBold: false, - notificationViewModel: mockNotificationViewModel, - menuViewModel: mockMenuViewModel, + notification: mockNotificationData, + showMoreOptionsMenu: true, + showNotificationMenu: true, + moreOptionsState: mockMoreOptionsState, + notificationState: mockNotificationState, }, { id: "!room3:server", name: "Room 3", - openRoom: jest.fn(), a11yLabel: "Room 3", isBold: false, - notificationViewModel: mockNotificationViewModel, - menuViewModel: mockMenuViewModel, + notification: mockNotificationData, + showMoreOptionsMenu: true, + showNotificationMenu: true, + moreOptionsState: mockMoreOptionsState, + notificationState: mockNotificationState, }, ]; @@ -92,14 +108,13 @@ describe("RoomList", () => { rooms: mockRooms, }; - const mockRenderAvatar = jest.fn((roomViewModel: RoomListItemViewModel) => ( -
{roomViewModel.name[0]}
+ const mockRenderAvatar = jest.fn((roomItem: RoomListItem) => ( +
{roomItem.name[0]}
)); const mockViewModel = createMockViewModel({ roomsResult: mockRoomsResult, activeRoomIndex: undefined, - onKeyDown: undefined, }); beforeEach(() => { @@ -146,7 +161,6 @@ describe("RoomList", () => { const emptyViewModel = createMockViewModel({ roomsResult: emptyResult, activeRoomIndex: undefined, - onKeyDown: undefined, }); render(); @@ -159,7 +173,6 @@ describe("RoomList", () => { const vmWithActive = createMockViewModel({ roomsResult: mockRoomsResult, activeRoomIndex: 1, - onKeyDown: undefined, }); render(); diff --git a/packages/shared-components/src/room-list/RoomList/RoomList.tsx b/packages/shared-components/src/room-list/RoomList/RoomList.tsx index fe9b34f491..2a89975186 100644 --- a/packages/shared-components/src/room-list/RoomList/RoomList.tsx +++ b/packages/shared-components/src/room-list/RoomList/RoomList.tsx @@ -13,7 +13,8 @@ import { type ViewModel } from "../../viewmodel/ViewModel"; import { useViewModel } from "../../useViewModel"; import { _t } from "../../utils/i18n"; import { ListView, type ListContext } from "../../utils/ListView"; -import { RoomListItem, type RoomListItemViewModel } from "../RoomListItem"; +import { RoomListItemView, type RoomListItem } from "../RoomListItem"; +import { type RoomNotifState } from "../../notifications/RoomNotifs"; /** * Filter key type - opaque string type for filter identifiers @@ -28,36 +29,65 @@ export interface RoomsResult { spaceId: string; /** Active filter keys */ filterKeys: FilterKey[] | undefined; - /** Array of room item view models */ - rooms: RoomListItemViewModel[]; + /** Array of room items */ + rooms: RoomListItem[]; } /** - * Snapshot for RoomList + * Snapshot for RoomList view state */ -export type RoomListSnapshot = { +export interface RoomListViewSnapshot { /** The rooms result containing the list of rooms */ roomsResult: RoomsResult; /** Optional active room index */ activeRoomIndex?: number; /** Optional keyboard event handler */ onKeyDown?: (ev: React.KeyboardEvent) => void; -}; +} + +/** + * Actions available for RoomList + */ +export interface RoomListViewActions { + /** Callback to open a room */ + onOpenRoom: (roomId: string) => void; + /** Callback to mark a room as read */ + onMarkAsRead: (roomId: string) => void; + /** Callback to mark a room as unread */ + onMarkAsUnread: (roomId: string) => void; + /** Callback to toggle a room as favourite */ + onToggleFavorite: (roomId: string) => void; + /** Callback to toggle a room as low priority */ + onToggleLowPriority: (roomId: string) => void; + /** Callback to invite users to a room */ + onInvite: (roomId: string) => void; + /** Callback to copy the room link */ + onCopyRoomLink: (roomId: string) => void; + /** Callback to leave a room */ + onLeaveRoom: (roomId: string) => void; + /** Callback to set the room notification state */ + onSetRoomNotifState: (roomId: string, state: RoomNotifState) => void; +} + +/** + * The view model for the room list. + */ +export type RoomListViewModel = ViewModel & RoomListViewActions; /** * Props for the RoomList component */ export interface RoomListProps { /** - * The view model containing room list data + * The view model containing room list data and actions */ - vm: ViewModel; + vm: RoomListViewModel; /** * Render function for room avatar - * @param roomViewModel - The room item view model + * @param roomItem - The room item data */ - renderAvatar: (roomViewModel: RoomListItemViewModel) => ReactNode; + renderAvatar: (roomItem: RoomListItem) => ReactNode; } /** Height of a single room list item in pixels */ @@ -76,11 +106,27 @@ 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 RoomListItem components for each room. + * and renders RoomListItemView components for each room. + * + * @example + * ```tsx + * } /> + * ``` */ export function RoomList({ vm, renderAvatar }: RoomListProps): JSX.Element { const snapshot = useViewModel(vm); const { roomsResult, activeRoomIndex, onKeyDown } = snapshot; + const { + onOpenRoom, + onMarkAsRead, + onMarkAsUnread, + onToggleFavorite, + onToggleLowPriority, + onInvite, + onCopyRoomLink, + onLeaveRoom, + onSetRoomNotifState, + } = vm; const lastSpaceId = useRef(undefined); const lastFilterKeys = useRef(undefined); const roomCount = roomsResult.rooms.length; @@ -91,22 +137,39 @@ export function RoomList({ vm, renderAvatar }: RoomListProps): JSX.Element { const getItemComponent = useCallback( ( index: number, - item: RoomListItemViewModel, + item: RoomListItem, context: ListContext<{ spaceId: string; filterKeys: FilterKey[] | undefined; }>, - onFocus: (item: RoomListItemViewModel, e: React.FocusEvent) => void, + onFocus: (item: RoomListItem, e: React.FocusEvent) => void, ): JSX.Element => { const itemKey = item.id; const isRovingItem = itemKey === context.tabIndexKey; const isFocused = isRovingItem && context.focused; const isSelected = activeRoomIndex === index; + const callbacks = { + onOpenRoom: () => onOpenRoom(item.id), + moreOptionsCallbacks: { + onMarkAsRead: () => onMarkAsRead(item.id), + onMarkAsUnread: () => onMarkAsUnread(item.id), + onToggleFavorite: () => onToggleFavorite(item.id), + onToggleLowPriority: () => onToggleLowPriority(item.id), + onInvite: () => onInvite(item.id), + onCopyRoomLink: () => onCopyRoomLink(item.id), + onLeaveRoom: () => onLeaveRoom(item.id), + }, + notificationCallbacks: { + onSetRoomNotifState: (state: RoomNotifState) => onSetRoomNotifState(item.id, state), + }, + }; + return (
- onFocus(item, e)} @@ -117,13 +180,13 @@ export function RoomList({ vm, renderAvatar }: RoomListProps): JSX.Element {
); }, - [activeRoomIndex, roomCount, renderAvatar], + [activeRoomIndex, roomCount, renderAvatar, vm], ); /** * Get the key for a room item */ - const getItemKey = useCallback((item: RoomListItemViewModel): string => { + const getItemKey = useCallback((item: RoomListItem): string => { return item.id; }, []); diff --git a/packages/shared-components/src/room-list/RoomList/index.ts b/packages/shared-components/src/room-list/RoomList/index.ts index f89fa238a4..107e3f0909 100644 --- a/packages/shared-components/src/room-list/RoomList/index.ts +++ b/packages/shared-components/src/room-list/RoomList/index.ts @@ -6,4 +6,11 @@ */ export { RoomList } from "./RoomList"; -export type { RoomListProps, RoomListSnapshot, RoomsResult, FilterKey } from "./RoomList"; +export type { + RoomListProps, + RoomListViewModel, + RoomListViewSnapshot, + RoomListViewActions, + RoomsResult, + FilterKey +} from "./RoomList"; diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx index 55126897b2..382ae601aa 100644 --- a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx @@ -7,9 +7,10 @@ import React from "react"; -import { RoomListItem, type RoomListItemViewModel } from "./RoomListItem"; -import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration"; -import type { RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel"; +import { RoomListItemView, type RoomListItem, type RoomListItemCallbacks } from "./RoomListItem"; +import type { NotificationDecorationData } from "../../notifications/NotificationDecoration"; +import type { MoreOptionsMenuState, MoreOptionsMenuCallbacks } from "./RoomListItemMoreOptionsMenu"; +import type { NotificationMenuState, NotificationMenuCallbacks } from "./RoomListItemNotificationMenu"; import type { RoomNotifState } from "../../notifications/RoomNotifs"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -32,8 +33,8 @@ const mockAvatar = (
); -// Mock notification view model with notifications -const mockNotificationViewModel: NotificationDecorationViewModel = { +// Mock notification data with notifications +const mockNotificationData: NotificationDecorationData = { hasAnyNotificationOrActivity: true, isUnsentMessage: false, invited: false, @@ -44,56 +45,72 @@ const mockNotificationViewModel: NotificationDecorationViewModel = { muted: false, }; -// Mock notification view model without notifications -const mockEmptyNotificationViewModel: NotificationDecorationViewModel = { +// Mock notification data without notifications +const mockEmptyNotificationData: NotificationDecorationData = { hasAnyNotificationOrActivity: false, isUnsentMessage: false, invited: false, isMention: false, isActivityNotification: false, isNotification: false, - count: 0, muted: false, }; -// Mock menu view model -const mockMenuViewModel: RoomListItemMenuViewModel = { - showMoreOptionsMenu: true, - showNotificationMenu: true, +// Mock more options menu state +const mockMoreOptionsState: MoreOptionsMenuState = { isFavourite: false, isLowPriority: false, canInvite: true, canCopyRoomLink: true, canMarkAsRead: true, canMarkAsUnread: true, +}; + +// Mock notification menu state +const mockNotificationState: NotificationMenuState = { isNotificationAllMessage: true, isNotificationAllMessageLoud: false, isNotificationMentionOnly: false, isNotificationMute: false, - markAsRead: () => console.log("Mark as read"), - markAsUnread: () => console.log("Mark as unread"), - toggleFavorite: () => console.log("Toggle favorite"), - toggleLowPriority: () => console.log("Toggle low priority"), - invite: () => console.log("Invite"), - copyRoomLink: () => console.log("Copy room link"), - leaveRoom: () => console.log("Leave room"), - setRoomNotifState: (state: RoomNotifState) => console.log("Set notification state:", state), }; -const baseViewModel: RoomListItemViewModel = { +// Mock callbacks +const mockMoreOptionsCallbacks: MoreOptionsMenuCallbacks = { + onMarkAsRead: () => console.log("Mark as read"), + onMarkAsUnread: () => console.log("Mark as unread"), + onToggleFavorite: () => console.log("Toggle favorite"), + onToggleLowPriority: () => console.log("Toggle low priority"), + onInvite: () => console.log("Invite"), + onCopyRoomLink: () => console.log("Copy room link"), + onLeaveRoom: () => console.log("Leave room"), +}; + +const mockNotificationCallbacks: NotificationMenuCallbacks = { + onSetRoomNotifState: (state: RoomNotifState) => console.log("Set notification state:", state), +}; + +const baseItem: RoomListItem = { id: "!test:example.org", name: "Test Room", - openRoom: () => console.log("Opening room"), a11yLabel: "Test Room, no unread messages", isBold: false, messagePreview: undefined, - notificationViewModel: mockEmptyNotificationViewModel, - menuViewModel: mockMenuViewModel, + notification: mockEmptyNotificationData, + showMoreOptionsMenu: true, + showNotificationMenu: true, + moreOptionsState: mockMoreOptionsState, + notificationState: mockNotificationState, +}; + +const baseCallbacks: RoomListItemCallbacks = { + onOpenRoom: () => console.log("Opening room"), + moreOptionsCallbacks: mockMoreOptionsCallbacks, + notificationCallbacks: mockNotificationCallbacks, }; const meta = { title: "Room List/RoomListItem", - component: RoomListItem, + component: RoomListItemView, tags: ["autodocs"], decorators: [ (Story) => ( @@ -103,7 +120,8 @@ const meta = { ), ], args: { - viewModel: baseViewModel, + item: baseItem, + callbacks: baseCallbacks, isSelected: false, isFocused: false, onFocus: () => {}, @@ -111,7 +129,7 @@ const meta = { roomCount: 10, avatar: mockAvatar, }, -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; @@ -120,8 +138,8 @@ export const Default: Story = {}; export const WithMessagePreview: Story = { args: { - viewModel: { - ...baseViewModel, + item: { + ...baseItem, messagePreview: "Alice: Hey, are you coming to the meeting?", }, }, @@ -129,12 +147,12 @@ export const WithMessagePreview: Story = { export const WithUnread: Story = { args: { - viewModel: { - ...baseViewModel, + item: { + ...baseItem, name: "Team Chat", isBold: true, a11yLabel: "Team Chat, 3 unread messages", - notificationViewModel: mockNotificationViewModel, + notification: mockNotificationData, }, }, }; @@ -153,8 +171,8 @@ export const Focused: Story = { export const LongRoomName: Story = { args: { - viewModel: { - ...baseViewModel, + item: { + ...baseItem, name: "This is a very long room name that should be truncated with ellipsis when it exceeds the available width", messagePreview: "And this is also a very long message preview that should also be truncated", }, @@ -163,12 +181,12 @@ export const LongRoomName: Story = { export const BoldWithPreview: Story = { args: { - viewModel: { - ...baseViewModel, + item: { + ...baseItem, name: "Design Team", isBold: true, messagePreview: "Bob shared a new design file", - notificationViewModel: mockNotificationViewModel, + notification: mockNotificationData, }, }, }; @@ -176,8 +194,9 @@ export const BoldWithPreview: Story = { export const AllStates: Story = { render: (): React.ReactElement => (
- {}} @@ -185,8 +204,9 @@ export const AllStates: Story = { roomCount={5} avatar={mockAvatar} /> - {}} @@ -194,8 +214,9 @@ export const AllStates: Story = { roomCount={5} avatar={mockAvatar} /> - {}} @@ -203,8 +224,9 @@ export const AllStates: Story = { roomCount={5} avatar={mockAvatar} /> - {}} @@ -212,8 +234,9 @@ export const AllStates: Story = { roomCount={5} avatar={mockAvatar} /> - {}} diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx index f7d182f141..58aae7f8d2 100644 --- a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx @@ -9,54 +9,69 @@ import { render, screen } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import React from "react"; -import { RoomListItem, type RoomListItemViewModel } from "./RoomListItem"; -import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration"; -import type { RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel"; +import { RoomListItemView, type RoomListItem, type RoomListItemCallbacks } from "./RoomListItem"; +import type { NotificationDecorationData } from "../../notifications/NotificationDecoration"; +import type { MoreOptionsMenuState, MoreOptionsMenuCallbacks } from "./RoomListItemMoreOptionsMenu"; +import type { NotificationMenuState, NotificationMenuCallbacks } from "./RoomListItemNotificationMenu"; describe("RoomListItem", () => { - const mockNotificationViewModel: NotificationDecorationViewModel = { + const mockNotificationData: NotificationDecorationData = { hasAnyNotificationOrActivity: false, isUnsentMessage: false, invited: false, isMention: false, isActivityNotification: false, isNotification: false, - count: 0, muted: false, }; - const mockMenuViewModel: RoomListItemMenuViewModel = { - showMoreOptionsMenu: true, - showNotificationMenu: true, + const mockMoreOptionsState: MoreOptionsMenuState = { isFavourite: false, isLowPriority: false, canInvite: true, canCopyRoomLink: true, canMarkAsRead: true, canMarkAsUnread: true, + }; + + const mockNotificationState: NotificationMenuState = { isNotificationAllMessage: true, isNotificationAllMessageLoud: false, isNotificationMentionOnly: false, isNotificationMute: false, - markAsRead: jest.fn(), - markAsUnread: jest.fn(), - toggleFavorite: jest.fn(), - toggleLowPriority: jest.fn(), - invite: jest.fn(), - copyRoomLink: jest.fn(), - leaveRoom: jest.fn(), - setRoomNotifState: jest.fn(), }; - const mockViewModel: RoomListItemViewModel = { + const mockMoreOptionsCallbacks: MoreOptionsMenuCallbacks = { + onMarkAsRead: jest.fn(), + onMarkAsUnread: jest.fn(), + onToggleFavorite: jest.fn(), + onToggleLowPriority: jest.fn(), + onInvite: jest.fn(), + onCopyRoomLink: jest.fn(), + onLeaveRoom: jest.fn(), + }; + + const mockNotificationCallbacks: NotificationMenuCallbacks = { + onSetRoomNotifState: jest.fn(), + }; + + const mockItem: RoomListItem = { id: "!test:example.org", name: "Test Room", - openRoom: jest.fn(), a11yLabel: "Test Room, no unread messages", isBold: false, messagePreview: undefined, - notificationViewModel: mockNotificationViewModel, - menuViewModel: mockMenuViewModel, + notification: mockNotificationData, + showMoreOptionsMenu: true, + showNotificationMenu: true, + moreOptionsState: mockMoreOptionsState, + notificationState: mockNotificationState, + }; + + const mockCallbacks: RoomListItemCallbacks = { + onOpenRoom: jest.fn(), + moreOptionsCallbacks: mockMoreOptionsCallbacks, + notificationCallbacks: mockNotificationCallbacks, }; const mockAvatar =
Avatar
; @@ -67,8 +82,9 @@ describe("RoomListItem", () => { it("renders room name and avatar", () => { render( - { }); it("renders with message preview", () => { - const vmWithPreview = { ...mockViewModel, messagePreview: "Latest message preview" }; + const itemWithPreview = { ...mockItem, messagePreview: "Latest message preview" }; render( - { it("applies selected styles when selected", () => { render( - { }); it("applies bold styles when room has unread", () => { - const vmWithUnread = { ...mockViewModel, isBold: true }; + const itemWithUnread = { ...mockItem, isBold: true }; render( - { it("calls openRoom when clicked", async () => { const user = userEvent.setup(); render( - { ); await user.click(screen.getByRole("option")); - expect(mockViewModel.openRoom).toHaveBeenCalledTimes(1); + expect(mockCallbacks.onOpenRoom).toHaveBeenCalledTimes(1); }); it("calls onFocus when focused", async () => { const onFocus = jest.fn(); render( - { }); it("renders notification decoration when hasAnyNotificationOrActivity is true", () => { - const notificationVM: NotificationDecorationViewModel = { + const notificationData: NotificationDecorationData = { hasAnyNotificationOrActivity: true, isUnsentMessage: false, invited: false, isMention: false, isActivityNotification: true, isNotification: false, - count: 0, muted: false, }; - const vmWithNotification = { ...mockViewModel, notificationViewModel: notificationVM }; + const itemWithNotification = { ...mockItem, notification: notificationData }; render( - { it("sets correct ARIA attributes", () => { render( - { const button = screen.getByRole("option"); expect(button).toHaveAttribute("aria-posinset", "6"); // index + 1 expect(button).toHaveAttribute("aria-setsize", "20"); - expect(button).toHaveAttribute("aria-label", mockViewModel.a11yLabel); + expect(button).toHaveAttribute("aria-label", mockItem.a11yLabel); }); }); diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx index cdabaa7cbb..c873c706ca 100644 --- a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx @@ -9,44 +9,64 @@ import React, { type JSX, memo, useCallback, useEffect, useRef, useState, type R import classNames from "classnames"; import { Flex } from "../../utils/Flex"; +import { NotificationDecoration, type NotificationDecorationData } from "../../notifications/NotificationDecoration"; import { - NotificationDecoration, - type NotificationDecorationViewModel, -} from "../../notifications/NotificationDecoration"; -import { type RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel"; -import { RoomListItemHoverMenu } from "./RoomListItemHoverMenu"; + RoomListItemHoverMenu, + type MoreOptionsMenuState, + type MoreOptionsMenuCallbacks, + type NotificationMenuState, + type NotificationMenuCallbacks, +} from "./RoomListItemHoverMenu"; import { RoomListItemContextMenu } from "./RoomListItemContextMenu"; import styles from "./RoomListItem.module.css"; /** - * ViewModel interface for RoomListItem - * Element-web will provide implementations that connect to Matrix SDK + * Data interface for a room list item. + * Contains all the data needed to render a room in the list. */ -export interface RoomListItemViewModel { +export interface RoomListItem { /** Unique identifier for the room (used for list keying) */ id: string; /** The name of the room */ name: string; - /** Callback to open the room */ - openRoom: () => void; /** Accessibility label for the room list item */ a11yLabel: string; /** Whether the room name should be bolded (has unread/activity) */ isBold: boolean; /** Optional message preview text */ messagePreview?: string; - /** Notification decoration view model */ - notificationViewModel: NotificationDecorationViewModel; - /** Menu view model (for hover and context menus) */ - menuViewModel: RoomListItemMenuViewModel; + /** Notification decoration data */ + notification: NotificationDecorationData; + /** Whether the more options menu should be shown */ + showMoreOptionsMenu: boolean; + /** Whether the notification menu should be shown */ + showNotificationMenu: boolean; + /** More options menu state */ + moreOptionsState: MoreOptionsMenuState; + /** Notification menu state */ + notificationState: NotificationMenuState; } /** - * Props for RoomListItem component + * Callbacks for room list item interactions */ -export interface RoomListItemProps extends Omit, "onFocus"> { - /** The view model containing room data and actions */ - viewModel: RoomListItemViewModel; +export interface RoomListItemCallbacks { + /** Callback to open the room */ + onOpenRoom: () => void; + /** More options menu callbacks */ + moreOptionsCallbacks: MoreOptionsMenuCallbacks; + /** Notification menu callbacks */ + notificationCallbacks: NotificationMenuCallbacks; +} + +/** + * Props for RoomListItemView component + */ +export interface RoomListItemViewProps extends Omit, "onFocus"> { + /** The room data to display */ + item: RoomListItem; + /** The room callbacks */ + callbacks: RoomListItemCallbacks; /** Whether the room is currently selected */ isSelected: boolean; /** Whether the room is currently focused */ @@ -64,10 +84,11 @@ export interface RoomListItemProps extends Omit(null); const [isHover, setHover] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -105,7 +126,7 @@ export const RoomListItem = memo(function RoomListItem({ [styles.hover]: showHoverDecoration, [styles.menuOpen]: showHoverMenu, [styles.selected]: isSelected, - [styles.bold]: viewModel.isBold, + [styles.bold]: item.isBold, })} gap="var(--cpd-space-3x)" align="center" @@ -114,8 +135,8 @@ export const RoomListItem = memo(function RoomListItem({ aria-posinset={roomIndex + 1} aria-setsize={roomCount} aria-selected={isSelected} - aria-label={viewModel.a11yLabel} - onClick={() => viewModel.openRoom()} + aria-label={item.a11yLabel} + onClick={callbacks.onOpenRoom} onFocus={onFocus} onMouseOver={() => setHover(true)} onMouseOut={() => setHover(false)} @@ -127,25 +148,30 @@ export const RoomListItem = memo(function RoomListItem({ {/* We truncate the room name when too long. Title here is to show the full name on hover */}
-
- {viewModel.name} +
+ {item.name}
- {viewModel.messagePreview && ( -
- {viewModel.messagePreview} + {item.messagePreview && ( +
+ {item.messagePreview}
)}
{showHoverMenu ? ( (isOpen ? setIsMenuOpen(true) : closeMenu())} /> ) : ( <> {/* aria-hidden because we summarise the unread count/notification status in a11yLabel */}
- +
)} @@ -154,7 +180,11 @@ export const RoomListItem = memo(function RoomListItem({ ); return ( - + {content} ); diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemContextMenu.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItemContextMenu.tsx index cd02d59b71..556a787421 100644 --- a/packages/shared-components/src/room-list/RoomListItem/RoomListItemContextMenu.tsx +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemContextMenu.tsx @@ -9,15 +9,20 @@ import React, { type JSX, type PropsWithChildren } from "react"; import { ContextMenu } from "@vector-im/compound-web"; import { _t } from "../../utils/i18n"; -import { MoreOptionContent } from "./RoomListItemHoverMenu"; -import { type RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel"; +import { + MoreOptionContent, + type MoreOptionsMenuState, + type MoreOptionsMenuCallbacks, +} from "./RoomListItemMoreOptionsMenu"; /** * Props for RoomListItemContextMenu component */ export interface RoomListItemContextMenuProps { - /** The view model containing menu data and callbacks */ - viewModel: RoomListItemMenuViewModel; + /** More options menu state */ + state: MoreOptionsMenuState; + /** More options menu callbacks */ + callbacks: MoreOptionsMenuCallbacks; /** Callback when menu open state changes */ onMenuOpenChange: (isOpen: boolean) => void; } @@ -27,7 +32,8 @@ export interface RoomListItemContextMenuProps { * Wraps the trigger element with a right-click context menu displaying room options. */ export const RoomListItemContextMenu: React.FC> = ({ - viewModel, + state, + callbacks, onMenuOpenChange, children, }): JSX.Element => { @@ -39,7 +45,7 @@ export const RoomListItemContextMenu: React.FC - + ); }; diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx index a829ff1abf..79f26fb7fc 100644 --- a/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx @@ -5,31 +5,36 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { useState, useCallback, type JSX, type ComponentProps } from "react"; -import { IconButton, Menu, MenuItem, Separator, ToggleMenuItem, Tooltip } from "@vector-im/compound-web"; -import MarkAsReadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-read"; -import MarkAsUnreadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-unread"; -import FavouriteIcon from "@vector-im/compound-design-tokens/assets/web/icons/favourite"; -import ArrowDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/arrow-down"; -import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add"; -import LinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link"; -import LeaveIcon from "@vector-im/compound-design-tokens/assets/web/icons/leave"; -import OverflowIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal"; -import NotificationIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid"; -import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-off-solid"; -import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"; +import React, { type JSX } from "react"; import { Flex } from "../../utils/Flex"; -import { _t } from "../../utils/i18n"; -import { RoomNotifState } from "../../notifications/RoomNotifs"; -import { type RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel"; +import { + RoomListItemMoreOptionsMenu, + type MoreOptionsMenuState, + type MoreOptionsMenuCallbacks, +} from "./RoomListItemMoreOptionsMenu"; +import { + RoomListItemNotificationMenu, + type NotificationMenuState, + type NotificationMenuCallbacks, +} from "./RoomListItemNotificationMenu"; /** * Props for RoomListItemHoverMenu component */ export interface RoomListItemHoverMenuProps { - /** The view model containing menu data and callbacks */ - viewModel: RoomListItemMenuViewModel; + /** Whether the more options menu should be shown */ + showMoreOptionsMenu: boolean; + /** Whether the notification menu should be shown */ + showNotificationMenu: boolean; + /** More options menu state */ + moreOptionsState: MoreOptionsMenuState; + /** More options menu callbacks */ + moreOptionsCallbacks: MoreOptionsMenuCallbacks; + /** Notification menu state */ + notificationState: NotificationMenuState; + /** Notification menu callbacks */ + notificationCallbacks: NotificationMenuCallbacks; /** Callback when menu open state changes */ onMenuOpenChange: (isOpen: boolean) => void; } @@ -39,215 +44,34 @@ export interface RoomListItemHoverMenuProps { * Displays more options and notification settings menus. */ export const RoomListItemHoverMenu: React.FC = ({ - viewModel, + showMoreOptionsMenu, + showNotificationMenu, + moreOptionsState, + moreOptionsCallbacks, + notificationState, + notificationCallbacks, onMenuOpenChange, }): JSX.Element => { return ( - {viewModel.showMoreOptionsMenu && ( - + {showMoreOptionsMenu && ( + )} - {viewModel.showNotificationMenu && ( - + {showNotificationMenu && ( + )} ); }; -interface MoreOptionsMenuProps { - viewModel: RoomListItemMenuViewModel; - onMenuOpenChange: (isOpen: boolean) => void; -} - -function MoreOptionsMenu({ viewModel, onMenuOpenChange }: MoreOptionsMenuProps): JSX.Element { - const [open, setOpen] = useState(false); - - const handleOpenChange = useCallback( - (isOpen: boolean) => { - setOpen(isOpen); - onMenuOpenChange(isOpen); - }, - [onMenuOpenChange], - ); - - return ( - } - > - - - ); -} - -interface MoreOptionContentProps { - viewModel: RoomListItemMenuViewModel; -} - -export function MoreOptionContent({ viewModel }: MoreOptionContentProps): JSX.Element { - return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions -
e.stopPropagation()}> - {viewModel.canMarkAsRead && ( - evt.stopPropagation()} - hideChevron={true} - /> - )} - {viewModel.canMarkAsUnread && ( - evt.stopPropagation()} - hideChevron={true} - /> - )} - evt.stopPropagation()} - /> - evt.stopPropagation()} - /> - {viewModel.canInvite && ( - evt.stopPropagation()} - hideChevron={true} - /> - )} - {viewModel.canCopyRoomLink && ( - evt.stopPropagation()} - hideChevron={true} - /> - )} - - evt.stopPropagation()} - hideChevron={true} - /> -
- ); -} - -const MoreOptionsButton = function MoreOptionsButton(props: ComponentProps): JSX.Element { - return ( - - - - - - ); -}; - -interface NotificationMenuProps { - viewModel: RoomListItemMenuViewModel; - onMenuOpenChange: (isOpen: boolean) => void; -} - -function NotificationMenu({ viewModel, onMenuOpenChange }: NotificationMenuProps): JSX.Element { - const [open, setOpen] = useState(false); - - const handleOpenChange = useCallback( - (isOpen: boolean) => { - setOpen(isOpen); - onMenuOpenChange(isOpen); - }, - [onMenuOpenChange], - ); - - const checkComponent = ; - - return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions -
e.stopPropagation()}> - } - > - viewModel.setRoomNotifState(RoomNotifState.AllMessages)} - onClick={(evt) => evt.stopPropagation()} - > - {viewModel.isNotificationAllMessage && checkComponent} - - viewModel.setRoomNotifState(RoomNotifState.AllMessagesLoud)} - onClick={(evt) => evt.stopPropagation()} - > - {viewModel.isNotificationAllMessageLoud && checkComponent} - - viewModel.setRoomNotifState(RoomNotifState.MentionsOnly)} - onClick={(evt) => evt.stopPropagation()} - > - {viewModel.isNotificationMentionOnly && checkComponent} - - viewModel.setRoomNotifState(RoomNotifState.Mute)} - onClick={(evt) => evt.stopPropagation()} - > - {viewModel.isNotificationMute && checkComponent} - - -
- ); -} - -interface NotificationButtonProps extends ComponentProps { - isRoomMuted: boolean; -} - -const NotificationButton = function NotificationButton({ - isRoomMuted, - ...props -}: NotificationButtonProps): JSX.Element { - return ( - - - {isRoomMuted ? : } - - - ); -}; +// Re-export types for convenience +export type { MoreOptionsMenuState, MoreOptionsMenuCallbacks } from "./RoomListItemMoreOptionsMenu"; +export type { NotificationMenuState, NotificationMenuCallbacks } from "./RoomListItemNotificationMenu"; 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..08ba087d70 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.tsx @@ -0,0 +1,184 @@ +/* + * 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. + */ + +import React, { useState, useCallback, type JSX, type ComponentProps } from "react"; +import { IconButton, Menu, MenuItem, Separator, ToggleMenuItem, Tooltip } from "@vector-im/compound-web"; +import MarkAsReadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-read"; +import MarkAsUnreadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-unread"; +import FavouriteIcon from "@vector-im/compound-design-tokens/assets/web/icons/favourite"; +import ArrowDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/arrow-down"; +import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add"; +import LinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link"; +import LeaveIcon from "@vector-im/compound-design-tokens/assets/web/icons/leave"; +import OverflowIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal"; + +import { _t } from "../../utils/i18n"; + +/** + * State for the more options menu + */ +export interface MoreOptionsMenuState { + /** 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; +} + +/** + * Callbacks for the more options menu + */ +export interface MoreOptionsMenuCallbacks { + /** Mark the room as read */ + onMarkAsRead: () => void; + /** Mark the room as unread */ + onMarkAsUnread: () => void; + /** Toggle the room as favourite */ + onToggleFavorite: () => void; + /** Toggle the room as low priority */ + onToggleLowPriority: () => void; + /** Invite other users in the room */ + onInvite: () => void; + /** Copy the room link to clipboard */ + onCopyRoomLink: () => void; + /** Leave the room */ + onLeaveRoom: () => void; +} + +/** + * Props for RoomListItemMoreOptionsMenu component + */ +export interface RoomListItemMoreOptionsMenuProps { + /** More options menu state */ + state: MoreOptionsMenuState; + /** More options menu callbacks */ + callbacks: MoreOptionsMenuCallbacks; + /** Callback when menu open state changes */ + onMenuOpenChange: (isOpen: boolean) => void; +} + +/** + * The more options menu for room list items. + * Displays additional room actions like mark as read/unread, favorite, invite, etc. + */ +export function RoomListItemMoreOptionsMenu({ + state, + callbacks, + onMenuOpenChange, +}: RoomListItemMoreOptionsMenuProps): JSX.Element { + const [open, setOpen] = useState(false); + + const handleOpenChange = useCallback( + (isOpen: boolean) => { + setOpen(isOpen); + onMenuOpenChange(isOpen); + }, + [onMenuOpenChange], + ); + + return ( + } + > + + + ); +} + +interface MoreOptionContentProps { + state: MoreOptionsMenuState; + callbacks: MoreOptionsMenuCallbacks; +} + +export function MoreOptionContent({ state, callbacks }: MoreOptionContentProps): JSX.Element { + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
e.stopPropagation()}> + {state.canMarkAsRead && ( + evt.stopPropagation()} + hideChevron={true} + /> + )} + {state.canMarkAsUnread && ( + evt.stopPropagation()} + hideChevron={true} + /> + )} + evt.stopPropagation()} + /> + evt.stopPropagation()} + /> + {state.canInvite && ( + evt.stopPropagation()} + hideChevron={true} + /> + )} + {state.canCopyRoomLink && ( + evt.stopPropagation()} + hideChevron={true} + /> + )} + + evt.stopPropagation()} + hideChevron={true} + /> +
+ ); +} + +const MoreOptionsButton = function MoreOptionsButton(props: ComponentProps): JSX.Element { + return ( + + + + + + ); +}; diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.tsx new file mode 100644 index 0000000000..19a0d31234 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.tsx @@ -0,0 +1,139 @@ +/* + * 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. + */ + +import React, { useState, useCallback, type JSX, type ComponentProps } from "react"; +import { IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web"; +import NotificationIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid"; +import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-off-solid"; +import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"; + +import { _t } from "../../utils/i18n"; +import { RoomNotifState } from "../../notifications/RoomNotifs"; + +/** + * State for the notification menu + */ +export interface NotificationMenuState { + /** Whether the notification is set to all messages */ + isNotificationAllMessage: boolean; + /** Whether the notification is set to all messages loud */ + isNotificationAllMessageLoud: boolean; + /** Whether the notification is set to mentions and keywords only */ + isNotificationMentionOnly: boolean; + /** Whether the notification is muted */ + isNotificationMute: boolean; +} + +/** + * Callbacks for the notification menu + */ +export interface NotificationMenuCallbacks { + /** Set the room notification state */ + onSetRoomNotifState: (state: RoomNotifState) => void; +} + +/** + * Props for RoomListItemNotificationMenu component + */ +export interface RoomListItemNotificationMenuProps { + /** Notification menu state */ + state: NotificationMenuState; + /** Notification menu callbacks */ + callbacks: NotificationMenuCallbacks; + /** Callback when menu open state changes */ + onMenuOpenChange: (isOpen: boolean) => void; +} + +/** + * The notification settings menu for room list items. + * Displays options to change notification settings. + */ +export function RoomListItemNotificationMenu({ + state, + callbacks, + onMenuOpenChange, +}: RoomListItemNotificationMenuProps): JSX.Element { + const [open, setOpen] = useState(false); + + const handleOpenChange = useCallback( + (isOpen: boolean) => { + setOpen(isOpen); + onMenuOpenChange(isOpen); + }, + [onMenuOpenChange], + ); + + const checkComponent = ; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
e.stopPropagation()}> + } + > + callbacks.onSetRoomNotifState(RoomNotifState.AllMessages)} + onClick={(evt) => evt.stopPropagation()} + > + {state.isNotificationAllMessage && checkComponent} + + callbacks.onSetRoomNotifState(RoomNotifState.AllMessagesLoud)} + onClick={(evt) => evt.stopPropagation()} + > + {state.isNotificationAllMessageLoud && checkComponent} + + callbacks.onSetRoomNotifState(RoomNotifState.MentionsOnly)} + onClick={(evt) => evt.stopPropagation()} + > + {state.isNotificationMentionOnly && checkComponent} + + callbacks.onSetRoomNotifState(RoomNotifState.Mute)} + onClick={(evt) => evt.stopPropagation()} + > + {state.isNotificationMute && checkComponent} + + +
+ ); +} + +interface NotificationButtonProps extends ComponentProps { + isRoomMuted: boolean; +} + +const NotificationButton = function NotificationButton({ + isRoomMuted, + ...props +}: NotificationButtonProps): JSX.Element { + return ( + + + {isRoomMuted ? : } + + + ); +}; diff --git a/packages/shared-components/src/room-list/RoomListItem/index.ts b/packages/shared-components/src/room-list/RoomListItem/index.ts index 748211e1d0..7d887eb82b 100644 --- a/packages/shared-components/src/room-list/RoomListItem/index.ts +++ b/packages/shared-components/src/room-list/RoomListItem/index.ts @@ -5,5 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -export { RoomListItem } from "./RoomListItem"; -export type { RoomListItemProps, RoomListItemViewModel } from "./RoomListItem"; +export { RoomListItemView } from "./RoomListItem"; +export type { RoomListItem, RoomListItemViewProps, RoomListItemCallbacks } from "./RoomListItem"; +export type { MoreOptionsMenuState, MoreOptionsMenuCallbacks } from "./RoomListItemMoreOptionsMenu"; +export type { NotificationMenuState, NotificationMenuCallbacks } from "./RoomListItemNotificationMenu"; diff --git a/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.stories.tsx b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.stories.tsx index 2aaabe7f37..e5a2dbf52c 100644 --- a/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.stories.tsx +++ b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.stories.tsx @@ -8,21 +8,22 @@ import React from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration"; +import type { NotificationDecorationData } from "../../notifications/NotificationDecoration"; import type { RoomsResult } from "../RoomList"; -import type { RoomListItemViewModel } from "../RoomListItem"; +import type { RoomListItem } from "../RoomListItem"; +import type { MoreOptionsMenuState } from "../RoomListItem/RoomListItemMoreOptionsMenu"; +import type { NotificationMenuState } from "../RoomListItem/RoomListItemNotificationMenu"; import { SortOption } from "../RoomListHeader/SortOptionsMenu"; import { RoomListPanel, type RoomListPanelSnapshot } from "./RoomListPanel"; import type { FilterViewModel } from "../RoomListPrimaryFilters/useVisibleFilters"; import { type ViewModel } from "../../viewmodel/ViewModel"; import type { RoomListSearchSnapshot } from "../RoomListSearch"; import type { RoomListHeaderSnapshot, SortOptionsMenuSnapshot } from "../RoomListHeader"; -import type { RoomListViewSnapshot } from "../RoomListView"; +import type { RoomListViewWrapperSnapshot } from "../RoomListView"; import type { RoomListPrimaryFiltersSnapshot } from "../RoomListPrimaryFilters"; -import type { RoomListSnapshot } from "../RoomList"; // Mock avatar component -const mockAvatar = (roomViewModel: RoomListItemViewModel): React.ReactElement => ( +const mockAvatar = (roomItem: RoomListItem): React.ReactElement => (
fontSize: "12px", }} > - {roomViewModel.name.substring(0, 2).toUpperCase()} + {roomItem.name.substring(0, 2).toUpperCase()}
); // Generate mock rooms -const generateMockRooms = (count: number): RoomListItemViewModel[] => { +const generateMockRooms = (count: number): RoomListItem[] => { return Array.from({ length: count }, (_, i) => { const unreadCount = Math.random() > 0.7 ? Math.floor(Math.random() * 10) : 0; const hasNotification = Math.random() > 0.8; - const notificationViewModel: NotificationDecorationViewModel = { + const notificationData: NotificationDecorationData = { hasAnyNotificationOrActivity: unreadCount > 0, isUnsentMessage: false, invited: false, @@ -58,36 +59,33 @@ const generateMockRooms = (count: number): RoomListItemViewModel[] => { muted: false, }; + const moreOptionsState: MoreOptionsMenuState = { + isFavourite: false, + isLowPriority: false, + canInvite: true, + canCopyRoomLink: true, + canMarkAsRead: unreadCount > 0, + canMarkAsUnread: unreadCount === 0, + }; + + const notificationState: NotificationMenuState = { + isNotificationAllMessage: true, + isNotificationAllMessageLoud: false, + isNotificationMentionOnly: false, + isNotificationMute: false, + }; + return { id: `!room${i}:server`, name: `Room ${i + 1}`, - openRoom: () => console.log(`Opening room: Room ${i + 1}`), a11yLabel: unreadCount ? `Room ${i + 1}, ${unreadCount} unread messages` : `Room ${i + 1}`, isBold: unreadCount > 0, messagePreview: undefined, - notificationViewModel, - menuViewModel: { - showMoreOptionsMenu: true, - showNotificationMenu: true, - canMarkAsRead: unreadCount > 0, - canMarkAsUnread: unreadCount === 0, - isFavourite: false, - isLowPriority: false, - canInvite: true, - canCopyRoomLink: true, - isNotificationAllMessage: true, - isNotificationAllMessageLoud: false, - isNotificationMentionOnly: false, - isNotificationMute: false, - markAsRead: () => console.log(`Mark read: Room ${i + 1}`), - markAsUnread: () => console.log(`Mark unread: Room ${i + 1}`), - toggleFavorite: () => console.log(`Toggle favorite: Room ${i + 1}`), - toggleLowPriority: () => console.log(`Toggle low priority: Room ${i + 1}`), - invite: () => console.log(`Invite: Room ${i + 1}`), - copyRoomLink: () => console.log(`Copy link: Room ${i + 1}`), - leaveRoom: () => console.log(`Leave: Room ${i + 1}`), - setRoomNotifState: (state) => console.log(`Set notif state: ${state}`), - }, + notification: notificationData, + showMoreOptionsMenu: true, + showNotificationMenu: true, + moreOptionsState, + notificationState, }; }); }; @@ -117,13 +115,37 @@ const meta: Meta = { export default meta; type Story = StoryObj; +// Create stable unsubscribe function +const noop = (): void => {}; + function createMockViewModel(snapshot: T): ViewModel { return { getSnapshot: () => snapshot, - subscribe: () => () => {}, + subscribe: () => noop, }; } +// Create stable snapshot for RoomListViewModel +const mockRoomListSnapshot = { + roomsResult: mockRoomsResult, + activeRoomIndex: 0, +}; + +// Create stable RoomListViewModel +const mockRoomListViewModel = { + getSnapshot: () => mockRoomListSnapshot, + subscribe: () => noop, + onOpenRoom: (roomId: string) => console.log("Open room:", roomId), + onMarkAsRead: (roomId: string) => console.log("Mark as read:", roomId), + onMarkAsUnread: (roomId: string) => console.log("Mark as unread:", roomId), + onToggleFavorite: (roomId: string) => console.log("Toggle favorite:", roomId), + onToggleLowPriority: (roomId: string) => console.log("Toggle low priority:", roomId), + onInvite: (roomId: string) => console.log("Invite:", roomId), + onCopyRoomLink: (roomId: string) => console.log("Copy room link:", roomId), + onLeaveRoom: (roomId: string) => console.log("Leave room:", roomId), + onSetRoomNotifState: (roomId: string, state: any) => console.log("Set notification:", roomId, state), +}; + const baseViewModel: ViewModel = createMockViewModel({ ariaLabel: "Room list navigation", searchVm: createMockViewModel({ @@ -142,16 +164,13 @@ const baseViewModel: ViewModel = createMockViewModel({ sort: (option) => console.log(`Sort: ${option}`), }), }), - viewVm: createMockViewModel({ + viewVm: createMockViewModel({ isLoadingRooms: false, isRoomListEmpty: false, filtersVm: createMockViewModel({ filters: createFilters(), }), - roomListVm: createMockViewModel({ - roomsResult: mockRoomsResult, - activeRoomIndex: 0, - }), + roomListVm: mockRoomListViewModel, emptyStateTitle: "No rooms", emptyStateDescription: "Join a room to get started", }), @@ -196,7 +215,7 @@ export const Loading: Story = { ariaLabel: "Room list navigation", searchVm: baseViewModel.getSnapshot().searchVm, headerVm: baseViewModel.getSnapshot().headerVm, - viewVm: createMockViewModel({ + viewVm: createMockViewModel({ ...baseViewModel.getSnapshot().viewVm.getSnapshot(), isLoadingRooms: true, }), @@ -218,7 +237,7 @@ export const Empty: Story = { ariaLabel: "Room list navigation", searchVm: baseViewModel.getSnapshot().searchVm, headerVm: baseViewModel.getSnapshot().headerVm, - viewVm: createMockViewModel({ + viewVm: createMockViewModel({ ...baseViewModel.getSnapshot().viewVm.getSnapshot(), isRoomListEmpty: true, emptyStateTitle: "No rooms to display", diff --git a/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.test.tsx b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.test.tsx index d83cbdc2f7..6239d17e86 100644 --- a/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.test.tsx +++ b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.test.tsx @@ -11,12 +11,12 @@ import React from "react"; import { RoomListPanel, type RoomListPanelSnapshot } from "./RoomListPanel"; import { type ViewModel } from "../../viewmodel/ViewModel"; import { SortOption } from "../RoomListHeader"; -import type { RoomListItemViewModel } from "../RoomListItem"; +import type { RoomListItem } from "../RoomListItem"; import type { RoomListSearchSnapshot } from "../RoomListSearch"; import type { RoomListHeaderSnapshot } from "../RoomListHeader"; -import type { RoomListViewSnapshot } from "../RoomListView"; +import type { RoomListViewWrapperSnapshot } from "../RoomListView"; import type { RoomListPrimaryFiltersSnapshot } from "../RoomListPrimaryFilters"; -import type { RoomListSnapshot } from "../RoomList"; +import type { RoomListViewModel, RoomListViewSnapshot } from "../RoomList"; import type { SortOptionsMenuSnapshot } from "../RoomListHeader/SortOptionsMenu"; // Mock ResizeObserver which is used by RoomListPrimaryFilters @@ -34,8 +34,8 @@ describe("RoomListPanel", () => { }; } - const mockRenderAvatar = jest.fn((roomViewModel: RoomListItemViewModel) => ( -
{roomViewModel.name[0]}
+ const mockRenderAvatar = jest.fn((roomItem: RoomListItem) => ( +
{roomItem.name[0]}
)); const searchSnapshot: RoomListSearchSnapshot = { @@ -61,22 +61,35 @@ describe("RoomListPanel", () => { filters: [], }; - const roomListSnapshot: RoomListSnapshot = { + const roomListSnapshot: RoomListViewSnapshot = { roomsResult: { spaceId: "!space:server", filterKeys: undefined, rooms: [], }, activeRoomIndex: undefined, - onKeyDown: undefined, }; - const viewSnapshot: RoomListViewSnapshot = { + const roomListViewModel: RoomListViewModel = { + getSnapshot: () => roomListSnapshot, + subscribe: () => () => {}, + onOpenRoom: jest.fn(), + onMarkAsRead: jest.fn(), + onMarkAsUnread: jest.fn(), + onToggleFavorite: jest.fn(), + onToggleLowPriority: jest.fn(), + onInvite: jest.fn(), + onCopyRoomLink: jest.fn(), + onLeaveRoom: jest.fn(), + onSetRoomNotifState: jest.fn(), + }; + + const viewSnapshot: RoomListViewWrapperSnapshot = { isLoadingRooms: false, isRoomListEmpty: false, emptyStateTitle: "No rooms", filtersVm: createMockViewModel(filtersSnapshot), - roomListVm: createMockViewModel(roomListSnapshot), + roomListVm: roomListViewModel, }; const mockSnapshot: RoomListPanelSnapshot = { @@ -109,7 +122,7 @@ describe("RoomListPanel", () => { }); it("renders loading state", () => { - const loadingViewSnapshot: RoomListViewSnapshot = { + const loadingViewSnapshot: RoomListViewWrapperSnapshot = { ...viewSnapshot, isLoadingRooms: true, isRoomListEmpty: false, @@ -129,7 +142,7 @@ describe("RoomListPanel", () => { }); it("renders empty state", () => { - const emptyViewSnapshot: RoomListViewSnapshot = { + const emptyViewSnapshot: RoomListViewWrapperSnapshot = { ...viewSnapshot, isLoadingRooms: false, isRoomListEmpty: true, diff --git a/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.tsx b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.tsx index 1ef5f15910..86f4e7fdf1 100644 --- a/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.tsx +++ b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.tsx @@ -12,8 +12,8 @@ import { useViewModel } from "../../useViewModel"; import { Flex } from "../../utils/Flex"; import { RoomListSearch, type RoomListSearchSnapshot } from "../RoomListSearch"; import { RoomListHeader, type RoomListHeaderSnapshot } from "../RoomListHeader"; -import { RoomListView, type RoomListViewSnapshot } from "../RoomListView"; -import { type RoomListItemViewModel } from "../RoomListItem"; +import { RoomListView, type RoomListViewWrapperSnapshot } from "../RoomListView"; +import { type RoomListItem } from "../RoomListItem"; import styles from "./RoomListPanel.module.css"; /** @@ -27,7 +27,7 @@ export type RoomListPanelSnapshot = { /** Header view model */ headerVm: ViewModel; /** View model for the main content area */ - viewVm: ViewModel; + viewVm: ViewModel; }; /** @@ -37,7 +37,7 @@ export interface RoomListPanelProps extends React.HTMLAttributes { /** The view model containing all data and callbacks */ vm: ViewModel; /** Render function for room avatar */ - renderAvatar: (roomViewModel: RoomListItemViewModel) => ReactNode; + renderAvatar: (roomItem: RoomListItem) => ReactNode; } /** diff --git a/packages/shared-components/src/room-list/RoomListPanel/index.tsx b/packages/shared-components/src/room-list/RoomListPanel/index.tsx index e6a2d37f7a..69bdf8e06e 100644 --- a/packages/shared-components/src/room-list/RoomListPanel/index.tsx +++ b/packages/shared-components/src/room-list/RoomListPanel/index.tsx @@ -6,4 +6,4 @@ */ export { RoomListPanel } from "./RoomListPanel"; -export type { RoomListPanelProps } from "./RoomListPanel"; +export type { RoomListPanelProps, RoomListPanelSnapshot } from "./RoomListPanel"; diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListLoadingSkeleton.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListLoadingSkeleton.tsx index e5c2aff5de..cf5e1f0953 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListLoadingSkeleton.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListLoadingSkeleton.tsx @@ -11,7 +11,7 @@ import styles from "./RoomListView.module.css"; /** * Loading skeleton component for the room list. - * Displays a simple loading indicator while rooms are being fetched. + * Displays a repeating skeleton pattern while rooms are being fetched. */ export const RoomListLoadingSkeleton: React.FC = (): JSX.Element => { return
; diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.module.css b/packages/shared-components/src/room-list/RoomListView/RoomListView.module.css index 9e77b8023a..6baefec275 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.module.css +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.module.css @@ -12,7 +12,6 @@ flex: 1; } -/* Skeleton animation - note: mask-image requires SVG from element-web */ .skeleton::before { background-color: var(--cpd-color-bg-subtle-secondary); width: 100%; @@ -21,9 +20,5 @@ position: absolute; mask-repeat: repeat-y; mask-size: auto 96px; -} - -/* Element-web provides the actual mask-image */ -:global(.mx_RoomListSkeleton)::before { - mask-image: url("$(res)/img/element-icons/roomlist/room-list-item-skeleton.svg"); + mask-image: url("./assets/skeleton.svg"); } diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx index b7eee06b28..8a51fa51d3 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx @@ -12,13 +12,13 @@ import { useViewModel } from "../../useViewModel"; import { RoomListPrimaryFilters, type RoomListPrimaryFiltersSnapshot } from "../RoomListPrimaryFilters"; import { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton"; import { RoomListEmptyState } from "./RoomListEmptyState"; -import { RoomList, type RoomListSnapshot } from "../RoomList"; -import { type RoomListItemViewModel } from "../RoomListItem"; +import { RoomList, type RoomListViewModel } from "../RoomList"; +import { type RoomListItem } from "../RoomListItem"; /** * Snapshot for RoomListView */ -export type RoomListViewSnapshot = { +export type RoomListViewWrapperSnapshot = { /** Whether the rooms are currently loading */ isLoadingRooms: boolean; /** Whether the room list is empty */ @@ -26,7 +26,7 @@ export type RoomListViewSnapshot = { /** View model for the primary filters */ filtersVm: ViewModel; /** View model for the room list */ - roomListVm: ViewModel; + roomListVm: RoomListViewModel; /** Title for the empty state */ emptyStateTitle: string; /** Optional description for the empty state */ @@ -40,9 +40,9 @@ export type RoomListViewSnapshot = { */ export interface RoomListViewProps { /** The view model containing list data */ - vm: ViewModel; + vm: ViewModel; /** Render function for room avatar */ - renderAvatar: (roomViewModel: RoomListItemViewModel) => ReactNode; + renderAvatar: (roomItem: RoomListItem) => ReactNode; } /** diff --git a/packages/shared-components/src/room-list/RoomListView/assets/skeleton.svg b/packages/shared-components/src/room-list/RoomListView/assets/skeleton.svg new file mode 100644 index 0000000000..adf56e4ed8 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListView/assets/skeleton.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/shared-components/src/room-list/RoomListView/index.tsx b/packages/shared-components/src/room-list/RoomListView/index.tsx index bc1737ee1b..2641e80c49 100644 --- a/packages/shared-components/src/room-list/RoomListView/index.tsx +++ b/packages/shared-components/src/room-list/RoomListView/index.tsx @@ -6,7 +6,7 @@ */ export { RoomListView } from "./RoomListView"; -export type { RoomListViewProps, RoomListViewSnapshot } from "./RoomListView"; +export type { RoomListViewProps, RoomListViewWrapperSnapshot } from "./RoomListView"; export { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton"; export { RoomListEmptyState } from "./RoomListEmptyState"; export type { RoomListEmptyStateProps } from "./RoomListEmptyState"; diff --git a/packages/shared-components/tsconfig.json b/packages/shared-components/tsconfig.json index 025901c97d..5d006cc071 100644 --- a/packages/shared-components/tsconfig.json +++ b/packages/shared-components/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "allowImportingTsExtensions": true, "experimentalDecorators": false, "emitDecoratorMetadata": false, "resolveJsonModule": true, diff --git a/playwright/tsconfig.json b/playwright/tsconfig.json index 442e2527b3..5f3083fd57 100644 --- a/playwright/tsconfig.json +++ b/playwright/tsconfig.json @@ -6,8 +6,7 @@ "resolveJsonModule": true, "esModuleInterop": true, "moduleResolution": "node", - "module": "es2022", - "allowImportingTsExtensions": true + "module": "es2022" }, "include": [ "**/*.ts", diff --git a/tsconfig.json b/tsconfig.json index ca876bff0b..791e3a292b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "allowImportingTsExtensions": true, "experimentalDecorators": false, "emitDecoratorMetadata": false, "resolveJsonModule": true,