Detangle state from callbacks

This commit is contained in:
David Langley 2025-12-10 17:02:07 +00:00
parent c3ecfd2083
commit 99e170f8b4
46 changed files with 1240 additions and 1762 deletions

View File

@ -1,163 +1,12 @@
<testExecutions version="1">
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/audio/SeekBar/SeekBar.test.tsx">
<testCase name="Seekbar renders the clock" duration="106"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/audio/Clock/Clock.test.tsx">
<testCase name="Clock renders the clock" duration="160"/>
<testCase name="Clock renders the clock with a lot of seconds" duration="39"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/event-tiles/TextualEventView/TextualEventView.test.tsx">
<testCase name="TextualEventView renders a textual event" duration="93"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/avatar/AvatarWithDetails/AvatarWithDetails.test.tsx">
<testCase name="AvatarWithDetails renders a textual event" duration="138"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/message-body/MediaBody/MediaBody.test.tsx">
<testCase name="MediaBody renders the media body" duration="103"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/rich-list/RichItem/RichItem.test.tsx">
<testCase name="RichItem renders the item in default state" duration="159"/>
<testCase name="RichItem renders the item in selected state" duration="10"/>
<testCase name="RichItem renders the item without timestamp" duration="30"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/rich-list/RichList/RichList.test.tsx">
<testCase name="RichItem renders the list" duration="194"/>
<testCase name="RichItem renders the list with isEmpty=true" duration="23"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/pill-input/Pill/Pill.test.tsx">
<testCase name="Pill renders the pill" duration="168"/>
<testCase name="Pill renders the pill without close button" duration="41"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/pill-input/PillInput/PillInput.test.tsx">
<testCase name="PillInput renders the pill input" duration="153"/>
<testCase name="PillInput renders only the input without children" duration="6"/>
<testCase name="PillInput calls onRemoveChildren when backspace is pressed and input is empty" duration="305"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/audio/PlayPauseButton/PlayPauseButton.test.tsx">
<testCase name="PlayPauseButton renders the button in default state" duration="367"/>
<testCase name="PlayPauseButton renders the button in playing state" duration="68"/>
<testCase name="PlayPauseButton calls togglePlay when clicked" duration="297"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/utils/humanize.test.ts">
<testCase name="humanizeTime returns &apos;a few seconds ago&apos; for &lt;15s ago" duration="2"/>
<testCase name="humanizeTime returns &apos;about a minute ago&apos; for &lt;75s ago" duration="4"/>
<testCase name="humanizeTime returns &apos;20 minutes ago&apos; for &lt;45min ago" duration="2"/>
<testCase name="humanizeTime returns &apos;about an hour ago&apos; for &lt;75min ago" duration="0"/>
<testCase name="humanizeTime returns &apos;5 hours ago&apos; for &lt;23h ago" duration="1"/>
<testCase name="humanizeTime returns &apos;about a day ago&apos; for &lt;26h ago" duration="1"/>
<testCase name="humanizeTime returns &apos;3 days ago&apos; for &gt;26h ago" duration="1"/>
<testCase name="humanizeTime returns &apos;a few seconds from now&apos; for &lt;15s ahead" duration="2"/>
<testCase name="humanizeTime returns &apos;about a minute from now&apos; for &lt;75s ahead" duration="1"/>
<testCase name="humanizeTime returns &apos;20 minutes from now&apos; for &lt;45min ahead" duration="0"/>
<testCase name="humanizeTime returns &apos;about an hour from now&apos; for &lt;75min ahead" duration="0"/>
<testCase name="humanizeTime returns &apos;5 hours from now&apos; for &lt;23h ahead" duration="1"/>
<testCase name="humanizeTime returns &apos;about a day from now&apos; for &lt;26h ahead" duration="2"/>
<testCase name="humanizeTime returns &apos;3 days from now&apos; for &gt;26h ahead" duration="2"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/utils/numbers.test.ts">
<testCase name="numbers defaultNumber should use the default when the input is not a number" duration="1"/>
<testCase name="numbers defaultNumber should use the number when it is a number" duration="1"/>
<testCase name="numbers clamp should clamp high numbers" duration="0"/>
<testCase name="numbers clamp should clamp low numbers" duration="1"/>
<testCase name="numbers clamp should not clamp numbers in range" duration="0"/>
<testCase name="numbers clamp should clamp floats" duration="1"/>
<testCase name="numbers sum should sum" duration="1"/>
<testCase name="numbers percentageWithin should work within 0-100" duration="0"/>
<testCase name="numbers percentageWithin should work within 0-100 when pct &gt; 1" duration="1"/>
<testCase name="numbers percentageWithin should work within 0-100 when pct &lt; 0" duration="0"/>
<testCase name="numbers percentageWithin should work with ranges other than 0-100" duration="0"/>
<testCase name="numbers percentageWithin should work with ranges other than 0-100 when pct &gt; 1" duration="0"/>
<testCase name="numbers percentageWithin should work with ranges other than 0-100 when pct &lt; 0" duration="0"/>
<testCase name="numbers percentageWithin should work with floats" duration="0"/>
<testCase name="numbers percentageOf should work within 0-100" duration="1"/>
<testCase name="numbers percentageOf should work within 0-100 when val &gt; 100" duration="0"/>
<testCase name="numbers percentageOf should work within 0-100 when val &lt; 0" duration="0"/>
<testCase name="numbers percentageOf should work with ranges other than 0-100" duration="1"/>
<testCase name="numbers percentageOf should work with ranges other than 0-100 when val &gt; 100" duration="0"/>
<testCase name="numbers percentageOf should work with ranges other than 0-100 when val &lt; 0" duration="1"/>
<testCase name="numbers percentageOf should work with floats" duration="0"/>
<testCase name="numbers percentageOf should return 0 for values that cause a division by zero" duration="1"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/viewmodel/tests/Disposables.test.ts">
<testCase name="Disposable isDisposed is true after dispose() is called" duration="18"/>
<testCase name="Disposable dispose() calls the correct disposing function" duration="10"/>
<testCase name="Disposable Throws error if acting on already disposed disposables" duration="21"/>
<testCase name="Disposable Removes tracked event listeners on dispose" duration="1"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/viewmodel/tests/Snapshot.test.ts">
<testCase name="Snapshot should accept an initial value" duration="9"/>
<testCase name="Snapshot should call emit callback when state changes" duration="1"/>
<testCase name="Snapshot should swap out entire snapshot on set call" duration="1"/>
<testCase name="Snapshot should merge partial snapshot on merge call" duration="0"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.test.tsx">
<testCase name="AudioPlayerView renders the audio player in default state" duration="490"/>
<testCase name="AudioPlayerView renders the audio player without media name" duration="146"/>
<testCase name="AudioPlayerView renders the audio player without size" duration="139"/>
<testCase name="AudioPlayerView renders the audio player in error state" duration="71"/>
<testCase name="AudioPlayerView should attach vm methods" duration="382"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/hooks/useListKeyboardNavigation.test.ts">
<testCase name="useListKeyDown should handle Enter key to click active element" duration="14"/>
<testCase name="useListKeyDown should handle Space key to click active element" duration="3"/>
<testCase name="useListKeyDown should handle ArrowDown to focus the 1nth element" duration="4"/>
<testCase name="useListKeyDown should handle ArrowUp to focus the 1nth element" duration="3"/>
<testCase name="useListKeyDown should handle Home to focus the 0nth element" duration="2"/>
<testCase name="useListKeyDown should handle End to focus the 2nth element" duration="2"/>
<testCase name="useListKeyDown should not handle ArrowDown when active element is not in list" duration="29"/>
<testCase name="useListKeyDown should not handle ArrowUp when active element is not in list" duration="25"/>
<testCase name="useListKeyDown should not prevent default for unhandled keys" duration="4"/>
<testCase name="useListKeyDown should focus the first item if list itself is focused" duration="4"/>
<testCase name="useListKeyDown should focus the selected item if list itself is focused" duration="2"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/utils/i18n.test.ts">
<testCase name="i18n utils should wrap registerTranslations" duration="3"/>
<testCase name="i18n utils should wrap setMissingEntryGenerator" duration="1"/>
<testCase name="i18n utils should wrap getLocale" duration="1"/>
<testCase name="i18n utils should wrap setLocale" duration="1"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx">
<testCase name="RoomList renders the room list with correct aria attributes" duration="150"/>
<testCase name="RoomList renders with correct aria-label" duration="31"/>
<testCase name="RoomList calls renderAvatar for each room" duration="29"/>
<testCase name="RoomList handles empty room list" duration="32"/>
<testCase name="RoomList passes activeRoomIndex correctly" duration="35"/>
<testCase name="RoomList accepts onKeyDown callback" duration="33"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.test.tsx">
<testCase name="RoomListSearch renders search button with shortcut" duration="163"/>
<testCase name="RoomListSearch calls onSearchClick when search button is clicked" duration="74"/>
<testCase name="RoomListSearch renders dial pad button when showDialPad is true" duration="34"/>
<testCase name="RoomListSearch calls onDialPadClick when dial pad button is clicked" duration="36"/>
<testCase name="RoomListSearch renders explore button when showExplore is true" duration="31"/>
<testCase name="RoomListSearch calls onExploreClick when explore button is clicked" duration="54"/>
<testCase name="RoomListSearch renders all buttons when showDialPad and showExplore are true" duration="29"/>
<testCase name="RoomListSearch does not render dial pad or explore buttons when flags are false" duration="15"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx">
<testCase name="RoomListItem renders room name and avatar" duration="78"/>
<testCase name="RoomListItem renders with message preview" duration="44"/>
<testCase name="RoomListItem applies selected styles when selected" duration="97"/>
<testCase name="RoomListItem applies bold styles when room has unread" duration="40"/>
<testCase name="RoomListItem calls openRoom when clicked" duration="225"/>
<testCase name="RoomListItem calls onFocus when focused" duration="13"/>
<testCase name="RoomListItem renders notification decoration when hasAnyNotificationOrActivity is true" duration="10"/>
<testCase name="RoomListItem sets correct ARIA attributes" duration="11"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.test.tsx">
<testCase name="RoomListHeader renders title" duration="382"/>
<testCase name="RoomListHeader renders space menu when isSpace is true" duration="114"/>
<testCase name="RoomListHeader renders compose menu when displayComposeMenu is true" duration="117"/>
<testCase name="RoomListHeader renders compose icon button when displayComposeMenu is false" duration="51"/>
<testCase name="RoomListHeader renders sort options menu" duration="54"/>
<testCase name="RoomListHeader truncates long titles with title attribute" duration="69"/>
<testCase name="RoomListHeader renders data-testid attribute" duration="64"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.test.tsx">
<testCase name="RoomListPanel renders with search, header, and content" duration="241"/>
<testCase name="RoomListPanel renders without search" duration="65"/>
<testCase name="RoomListPanel renders loading state" duration="43"/>
<testCase name="RoomListPanel renders empty state" duration="40"/>
<testCase name="RoomListPanel passes additional HTML attributes" duration="44"/>
<testCase name="RoomListItem renders room name and avatar" duration="63"/>
<testCase name="RoomListItem renders with message preview" duration="14"/>
<testCase name="RoomListItem applies selected styles when selected" duration="85"/>
<testCase name="RoomListItem applies bold styles when room has unread" duration="14"/>
<testCase name="RoomListItem calls openRoom when clicked" duration="233"/>
<testCase name="RoomListItem calls onFocus when focused" duration="12"/>
<testCase name="RoomListItem renders notification decoration when hasAnyNotificationOrActivity is true" duration="6"/>
<testCase name="RoomListItem sets correct ARIA attributes" duration="8"/>
</file>
</testExecutions>

View File

@ -23,7 +23,7 @@ exports[`AudioPlayerView renders the audio player in default state 1`] = `
tabindex="-1"
>
<div
class="_indicator-icon_147l5_17"
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
@ -114,7 +114,7 @@ exports[`AudioPlayerView renders the audio player in error state 1`] = `
tabindex="-1"
>
<div
class="_indicator-icon_147l5_17"
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
@ -210,7 +210,7 @@ exports[`AudioPlayerView renders the audio player without media name 1`] = `
tabindex="-1"
>
<div
class="_indicator-icon_147l5_17"
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
@ -301,7 +301,7 @@ exports[`AudioPlayerView renders the audio player without size 1`] = `
tabindex="-1"
>
<div
class="_indicator-icon_147l5_17"
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg

View File

@ -13,7 +13,7 @@ exports[`PlayPauseButton renders the button in default state 1`] = `
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
@ -45,7 +45,7 @@ exports[`PlayPauseButton renders the button in playing state 1`] = `
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg

View File

@ -25,7 +25,7 @@ exports[`Pill renders the pill 1`] = `
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg

View File

@ -7,12 +7,14 @@
import React from "react";
import { RoomList, type RoomListViewModel, type RoomListViewSnapshot, type RoomsResult } from "./RoomList";
import { RoomList, type RoomsResult } from "./RoomList";
import type { RoomListViewModel, RoomListSnapshot } from "../RoomListView";
import type { RoomListItem } from "../RoomListItem";
import type { NotificationDecorationData } from "../../notifications/NotificationDecoration";
import type { MoreOptionsMenuState } from "../RoomListItem/RoomListItemMoreOptionsMenu";
import type { NotificationMenuState } from "../RoomListItem/RoomListItemNotificationMenu";
import { type RoomNotifState } from "../../notifications/RoomNotifs";
import { SortOption } from "../RoomListHeader/SortOptionsMenu";
import type { Meta, StoryObj } from "@storybook/react-vite";
@ -107,10 +109,24 @@ const mockRoomsResult: RoomsResult = {
// Create stable unsubscribe function
const noop = (): void => {};
function createMockViewModel(snapshot: RoomListViewSnapshot): RoomListViewModel {
function createMockViewModel(snapshot: RoomListSnapshot): RoomListViewModel {
return {
getSnapshot: () => snapshot,
subscribe: () => noop,
showDialPad: false,
showExplore: false,
onSearchClick: () => {},
onDialPadClick: () => {},
onExploreClick: () => {},
onComposeClick: () => {},
openSpaceHome: () => {},
inviteInSpace: () => {},
openSpacePreferences: () => {},
openSpaceSettings: () => {},
createChatRoom: () => {},
createRoom: () => {},
createVideoRoom: () => {},
sort: () => {},
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),
@ -121,12 +137,26 @@ function createMockViewModel(snapshot: RoomListViewSnapshot): RoomListViewModel
onLeaveRoom: (roomId: string) => console.log("Leave room:", roomId),
onSetRoomNotifState: (roomId: string, state: RoomNotifState) =>
console.log("Set notification state:", roomId, state),
onToggleFilter: (filter) => console.log("Toggle filter:", filter),
};
}
const mockViewModel: RoomListViewModel = createMockViewModel({
roomsResult: mockRoomsResult,
activeRoomIndex: undefined,
headerState: {
title: "Test",
isSpace: false,
displayComposeMenu: false,
activeSortOption: SortOption.Activity,
},
isLoadingRooms: false,
isRoomListEmpty: false,
filters: [],
roomListState: {
rooms: mockRoomsResult.rooms,
activeRoomIndex: undefined,
spaceId: mockRoomsResult.spaceId,
filterKeys: mockRoomsResult.filterKeys,
},
});
const renderAvatar = (roomItem: RoomListItem): React.ReactElement => {
@ -160,8 +190,21 @@ export const Default: Story = {
export const WithSelection: Story = {
args: {
vm: createMockViewModel({
roomsResult: mockRoomsResult,
activeRoomIndex: 5,
headerState: {
title: "Test",
isSpace: false,
displayComposeMenu: false,
activeSortOption: SortOption.AToZ,
},
isLoadingRooms: false,
isRoomListEmpty: false,
filters: [],
roomListState: {
rooms: mockRoomsResult.rooms,
activeRoomIndex: 5,
spaceId: mockRoomsResult.spaceId,
filterKeys: mockRoomsResult.filterKeys,
},
}),
},
};
@ -169,12 +212,21 @@ export const WithSelection: Story = {
export const SmallList: Story = {
args: {
vm: createMockViewModel({
roomsResult: {
headerState: {
title: "Test",
isSpace: false,
displayComposeMenu: false,
activeSortOption: SortOption.Activity,
},
isLoadingRooms: false,
isRoomListEmpty: false,
filters: [],
roomListState: {
spaceId: "!space:server",
filterKeys: undefined,
rooms: generateMockRooms(5),
activeRoomIndex: undefined,
},
activeRoomIndex: undefined,
}),
},
};
@ -182,12 +234,21 @@ export const SmallList: Story = {
export const LargeList: Story = {
args: {
vm: createMockViewModel({
roomsResult: {
headerState: {
title: "Test",
isSpace: false,
displayComposeMenu: false,
activeSortOption: SortOption.Activity,
},
isLoadingRooms: false,
isRoomListEmpty: false,
filters: [],
roomListState: {
spaceId: "!space:server",
filterKeys: undefined,
rooms: generateMockRooms(200),
activeRoomIndex: undefined,
},
activeRoomIndex: undefined,
}),
},
};
@ -195,12 +256,21 @@ export const LargeList: Story = {
export const EmptyList: Story = {
args: {
vm: createMockViewModel({
roomsResult: {
headerState: {
title: "Test",
isSpace: false,
displayComposeMenu: false,
activeSortOption: SortOption.Activity,
},
isLoadingRooms: false,
isRoomListEmpty: false,
filters: [],
roomListState: {
spaceId: "!space:server",
filterKeys: undefined,
rooms: [],
activeRoomIndex: undefined,
},
activeRoomIndex: undefined,
}),
},
};

View File

@ -8,34 +8,31 @@
import { render, screen } from "@testing-library/react";
import React from "react";
import {
RoomList,
type RoomListViewModel,
type RoomListViewSnapshot,
type RoomListViewActions,
type RoomsResult,
} from "./RoomList";
import { RoomList } from "./RoomList";
import type { RoomListViewModel, RoomListSnapshot } from "../RoomListView";
import type { RoomListItem } from "../RoomListItem";
import type { NotificationDecorationData } from "../../notifications/NotificationDecoration";
import type { MoreOptionsMenuState } from "../RoomListItem/RoomListItemMoreOptionsMenu";
import type { NotificationMenuState } from "../RoomListItem/RoomListItemNotificationMenu";
import { SortOption } from "../RoomListHeader/SortOptionsMenu";
function createMockViewModel(
snapshot: RoomListViewSnapshot,
actions: Partial<RoomListViewActions> = {},
): RoomListViewModel {
function createMockViewModel(snapshot: RoomListSnapshot): 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(),
onToggleFilter: jest.fn(),
onSearchClick: jest.fn(),
onDialPadClick: jest.fn(),
onExploreClick: jest.fn(),
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(),
};
}
@ -102,19 +99,29 @@ describe("RoomList", () => {
},
];
const mockRoomsResult: RoomsResult = {
spaceId: "!space:server",
filterKeys: undefined,
rooms: mockRooms,
};
const mockRenderAvatar = jest.fn((roomItem: RoomListItem) => (
<div data-testid={`avatar-${roomItem.id}`}>{roomItem.name[0]}</div>
));
const mockViewModel = createMockViewModel({
roomsResult: mockRoomsResult,
activeRoomIndex: undefined,
roomListState: {
rooms: mockRooms,
activeRoomIndex: undefined,
spaceId: "!space:server",
filterKeys: undefined,
},
headerState: {
title: "Rooms",
isSpace: false,
displayComposeMenu: false,
sortOptionsMenuProps: {
activeSortOption: SortOption.Activity,
sort: jest.fn(),
},
},
isLoadingRooms: false,
isRoomListEmpty: false,
filters: [],
});
beforeEach(() => {
@ -152,15 +159,25 @@ describe("RoomList", () => {
});
it("handles empty room list", () => {
const emptyResult: RoomsResult = {
spaceId: "!space:server",
filterKeys: undefined,
rooms: [],
};
const emptyViewModel = createMockViewModel({
roomsResult: emptyResult,
activeRoomIndex: undefined,
roomListState: {
rooms: [],
activeRoomIndex: undefined,
spaceId: "!space:server",
filterKeys: undefined,
},
headerState: {
title: "Rooms",
isSpace: false,
displayComposeMenu: false,
sortOptionsMenuProps: {
activeSortOption: "activity" as any,
sort: jest.fn(),
},
},
isLoadingRooms: false,
isRoomListEmpty: false,
filters: [],
});
render(<RoomList vm={emptyViewModel} renderAvatar={mockRenderAvatar} />);
@ -171,8 +188,24 @@ describe("RoomList", () => {
it("passes activeRoomIndex correctly", () => {
const vmWithActive = createMockViewModel({
roomsResult: mockRoomsResult,
activeRoomIndex: 1,
roomListState: {
rooms: mockRooms,
activeRoomIndex: 1,
spaceId: "!space:server",
filterKeys: undefined,
},
headerState: {
title: "Rooms",
isSpace: false,
displayComposeMenu: false,
sortOptionsMenuProps: {
activeSortOption: "activity" as any,
sort: jest.fn(),
},
},
isLoadingRooms: false,
isRoomListEmpty: false,
filters: [],
});
render(<RoomList vm={vmWithActive} renderAvatar={mockRenderAvatar} />);
@ -183,11 +216,25 @@ describe("RoomList", () => {
});
it("accepts onKeyDown callback", () => {
const onKeyDown = jest.fn();
const vmWithKeyDown = createMockViewModel({
roomsResult: mockRoomsResult,
activeRoomIndex: undefined,
onKeyDown,
roomListState: {
rooms: mockRooms,
activeRoomIndex: undefined,
spaceId: "!space:server",
filterKeys: undefined,
},
headerState: {
title: "Rooms",
isSpace: false,
displayComposeMenu: false,
sortOptionsMenuProps: {
activeSortOption: "activity" as any,
sort: jest.fn(),
},
},
isLoadingRooms: false,
isRoomListEmpty: false,
filters: [],
});
render(<RoomList vm={vmWithKeyDown} renderAvatar={mockRenderAvatar} />);

View File

@ -9,12 +9,12 @@ import React, { useCallback, useRef, type JSX, type ReactNode } from "react";
import { type ScrollIntoViewLocation } from "react-virtuoso";
import { isEqual } from "lodash";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { _t } from "../../utils/i18n";
import { ListView, type ListContext } from "../../utils/ListView";
import { RoomListItemView, type RoomListItem } from "../RoomListItem";
import { type RoomNotifState } from "../../notifications/RoomNotifs";
import type { RoomListViewModel } from "../RoomListView";
/**
* Filter key type - opaque string type for filter identifiers
@ -34,52 +34,25 @@ export interface RoomsResult {
}
/**
* Snapshot for RoomList view state
* State for the room list data (nested within RoomListSnapshot)
*/
export interface RoomListViewSnapshot {
/** The rooms result containing the list of rooms */
roomsResult: RoomsResult;
/** Optional active room index */
export interface RoomListViewState {
/** Array of room items */
rooms: RoomListItem[];
/** Optional active room index for keyboard navigation */
activeRoomIndex?: number;
/** Optional keyboard event handler */
onKeyDown?: (ev: React.KeyboardEvent) => void;
/** Space ID for context tracking */
spaceId?: string;
/** Active filter keys for context tracking */
filterKeys?: FilterKey[];
}
/**
* 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<RoomListViewSnapshot> & RoomListViewActions;
/**
* Props for the RoomList component
*/
export interface RoomListProps {
/**
* The view model containing room list data and actions
* The view model containing all room list data and callbacks
*/
vm: RoomListViewModel;
@ -115,21 +88,12 @@ const EXTENDED_VIEWPORT_HEIGHT = 25 * ROOM_LIST_ITEM_HEIGHT;
*/
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 { roomListState } = snapshot;
const rooms = roomListState.rooms;
const activeRoomIndex = roomListState.activeRoomIndex;
const lastSpaceId = useRef<string | undefined>(undefined);
const lastFilterKeys = useRef<FilterKey[] | undefined>(undefined);
const roomCount = roomsResult.rooms.length;
const roomCount = rooms.length;
/**
* Get the item component for a specific index
@ -150,19 +114,17 @@ export function RoomList({ vm, renderAvatar }: RoomListProps): JSX.Element {
const isSelected = activeRoomIndex === index;
const callbacks = {
onOpenRoom: () => onOpenRoom(item.id),
onOpenRoom: () => vm.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),
onMarkAsRead: () => vm.onMarkAsRead(item.id),
onMarkAsUnread: () => vm.onMarkAsUnread(item.id),
onToggleFavorite: () => vm.onToggleFavorite(item.id),
onToggleLowPriority: () => vm.onToggleLowPriority(item.id),
onInvite: () => vm.onInvite(item.id),
onCopyRoomLink: () => vm.onCopyRoomLink(item.id),
onLeaveRoom: () => vm.onLeaveRoom(item.id),
},
onSetRoomNotifState: (state: RoomNotifState) => vm.onSetRoomNotifState(item.id, state),
};
return (
@ -216,30 +178,19 @@ export function RoomList({ vm, renderAvatar }: RoomListProps): JSX.Element {
[activeRoomIndex],
);
/**
* Handle keyboard events
*/
const keyDownCallback = useCallback(
(ev: React.KeyboardEvent): void => {
onKeyDown?.(ev);
},
[onKeyDown],
);
return (
<ListView
context={{ spaceId: roomsResult.spaceId, filterKeys: roomsResult.filterKeys }}
context={{ spaceId: roomListState.spaceId || "", filterKeys: roomListState.filterKeys }}
scrollIntoViewOnChange={scrollIntoViewOnChange}
initialTopMostItemIndex={activeRoomIndex}
data-testid="room-list"
role="listbox"
aria-label={_t("room_list|list_title")}
fixedItemHeight={ROOM_LIST_ITEM_HEIGHT}
items={roomsResult.rooms}
items={rooms}
getItemComponent={getItemComponent}
getItemKey={getItemKey}
isItemFocusable={() => true}
onKeyDown={keyDownCallback}
increaseViewportBy={{
bottom: EXTENDED_VIEWPORT_HEIGHT,
top: EXTENDED_VIEWPORT_HEIGHT,

View File

@ -8,9 +8,7 @@
export { RoomList } from "./RoomList";
export type {
RoomListProps,
RoomListViewModel,
RoomListViewSnapshot,
RoomListViewActions,
RoomListViewState,
RoomsResult,
FilterKey
} from "./RoomList";

View File

@ -12,14 +12,12 @@ import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room";
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { _t } from "../../utils/i18n";
/**
* Snapshot for ComposeMenu
* Props for ComposeMenu component
*/
export type ComposeMenuSnapshot = {
export interface ComposeMenuProps {
/** Whether the user can create rooms */
canCreateRoom: boolean;
/** Whether the user can create video rooms */
@ -30,22 +28,24 @@ export type ComposeMenuSnapshot = {
createRoom: () => void;
/** Create a video room */
createVideoRoom: () => void;
};
}
/**
* Props for ComposeMenu component
* @deprecated Use ComposeMenuProps instead
*/
export interface ComposeMenuProps {
/** The view model containing menu data and callbacks */
vm: ViewModel<ComposeMenuSnapshot>;
}
export type ComposeMenuSnapshot = ComposeMenuProps;
/**
* The compose menu for the room list header.
* Displays a dropdown menu with options to create new chats, rooms, and video rooms.
*/
export const ComposeMenu: React.FC<ComposeMenuProps> = ({ vm }): JSX.Element => {
const snapshot = useViewModel(vm);
export const ComposeMenu: React.FC<ComposeMenuProps> = ({
canCreateRoom,
canCreateVideoRoom,
createChatRoom,
createRoom,
createVideoRoom,
}): JSX.Element => {
const [open, setOpen] = useState(false);
return (
@ -62,25 +62,15 @@ export const ComposeMenu: React.FC<ComposeMenuProps> = ({ vm }): JSX.Element =>
</IconButton>
}
>
<MenuItem
Icon={ChatIcon}
label={_t("action|start_chat")}
onSelect={snapshot.createChatRoom}
hideChevron={true}
/>
{snapshot.canCreateRoom && (
<MenuItem
Icon={RoomIcon}
label={_t("action|new_room")}
onSelect={snapshot.createRoom}
hideChevron={true}
/>
<MenuItem Icon={ChatIcon} label={_t("action|start_chat")} onSelect={createChatRoom} hideChevron={true} />
{canCreateRoom && (
<MenuItem Icon={RoomIcon} label={_t("action|new_room")} onSelect={createRoom} hideChevron={true} />
)}
{snapshot.canCreateVideoRoom && (
{canCreateVideoRoom && (
<MenuItem
Icon={VideoCallIcon}
label={_t("action|new_video_room")}
onSelect={snapshot.createVideoRoom}
onSelect={createVideoRoom}
hideChevron={true}
/>
)}

View File

@ -8,11 +8,10 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RoomListHeader, type RoomListHeaderSnapshot } from "./RoomListHeader";
import { SortOption, type SortOptionsMenuSnapshot } from "./SortOptionsMenu";
import type { SpaceMenuSnapshot } from "./SpaceMenu";
import type { ComposeMenuSnapshot } from "./ComposeMenu";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { RoomListHeader } from "./RoomListHeader";
import { SortOption } from "./SortOptionsMenu";
import type { RoomListViewModel, RoomListSnapshot } from "../RoomListView";
import type { RoomListHeaderState } from "./RoomListHeader";
const meta: Meta<typeof RoomListHeader> = {
title: "Room List/RoomListHeader",
@ -23,119 +22,127 @@ const meta: Meta<typeof RoomListHeader> = {
export default meta;
type Story = StoryObj<typeof RoomListHeader>;
function createMockViewModel<T>(snapshot: T): ViewModel<T> {
const createMockViewModel = (headerState: RoomListHeaderState): RoomListViewModel => {
const snapshot: RoomListSnapshot = {
headerState,
isLoadingRooms: false,
isRoomListEmpty: false,
filters: [],
roomListState: {
rooms: [],
},
};
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
subscribe: (listener: () => void) => {
return () => {};
},
sort: (option: SortOption) => console.log("Sort by:", option),
onToggleFilter: () => {},
onSearchClick: () => {},
onDialPadClick: () => {},
onExploreClick: () => {},
showDialPad: false,
showExplore: false,
onComposeClick: () => console.log("Compose clicked"),
openSpaceHome: () => console.log("Open space home"),
inviteInSpace: () => console.log("Invite in space"),
openSpacePreferences: () => console.log("Open space preferences"),
openSpaceSettings: () => console.log("Open space settings"),
createChatRoom: () => console.log("Create chat room"),
createRoom: () => console.log("Create room"),
createVideoRoom: () => console.log("Create video room"),
onOpenRoom: () => {},
onMarkAsRead: () => {},
onMarkAsUnread: () => {},
onToggleFavorite: () => {},
onToggleLowPriority: () => {},
onInvite: () => {},
onCopyRoomLink: () => {},
onLeaveRoom: () => {},
onSetRoomNotifState: () => {},
};
}
const baseSortOptionsViewModel = createMockViewModel<SortOptionsMenuSnapshot>({
activeSortOption: SortOption.Activity,
sort: (option: SortOption) => console.log("Sort by:", option),
});
};
export const Default: Story = {
args: {
vm: createMockViewModel<RoomListHeaderSnapshot>({
vm: createMockViewModel({
title: "Home",
isSpace: false,
displayComposeMenu: false,
onComposeClick: () => console.log("Compose clicked"),
sortOptionsMenuVm: baseSortOptionsViewModel,
activeSortOption: SortOption.Activity,
}),
},
};
export const WithSpaceMenu: Story = {
args: {
vm: createMockViewModel<RoomListHeaderSnapshot>({
vm: createMockViewModel({
title: "My Space",
isSpace: true,
displayComposeMenu: false,
spaceMenuVm: createMockViewModel<SpaceMenuSnapshot>({
spaceMenuState: {
title: "My Space",
canInviteInSpace: true,
canAccessSpaceSettings: true,
openSpaceHome: () => console.log("Open space home"),
inviteInSpace: () => console.log("Invite in space"),
openSpacePreferences: () => console.log("Open space preferences"),
openSpaceSettings: () => console.log("Open space settings"),
}),
onComposeClick: () => console.log("Compose clicked"),
sortOptionsMenuVm: baseSortOptionsViewModel,
},
displayComposeMenu: false,
activeSortOption: SortOption.Activity,
}),
},
};
export const WithComposeMenu: Story = {
args: {
vm: createMockViewModel<RoomListHeaderSnapshot>({
vm: createMockViewModel({
title: "Home",
isSpace: false,
displayComposeMenu: true,
composeMenuVm: createMockViewModel<ComposeMenuSnapshot>({
composeMenuState: {
canCreateRoom: true,
canCreateVideoRoom: true,
createChatRoom: () => console.log("Create chat room"),
createRoom: () => console.log("Create room"),
createVideoRoom: () => console.log("Create video room"),
}),
sortOptionsMenuVm: baseSortOptionsViewModel,
},
activeSortOption: SortOption.Activity,
}),
},
};
export const FullHeader: Story = {
args: {
vm: createMockViewModel<RoomListHeaderSnapshot>({
vm: createMockViewModel({
title: "My Space",
isSpace: true,
displayComposeMenu: true,
spaceMenuVm: createMockViewModel<SpaceMenuSnapshot>({
spaceMenuState: {
title: "My Space",
canInviteInSpace: true,
canAccessSpaceSettings: true,
openSpaceHome: () => console.log("Open space home"),
inviteInSpace: () => console.log("Invite in space"),
openSpacePreferences: () => console.log("Open space preferences"),
openSpaceSettings: () => console.log("Open space settings"),
}),
composeMenuVm: createMockViewModel<ComposeMenuSnapshot>({
},
displayComposeMenu: true,
composeMenuState: {
canCreateRoom: true,
canCreateVideoRoom: true,
createChatRoom: () => console.log("Create chat room"),
createRoom: () => console.log("Create room"),
createVideoRoom: () => console.log("Create video room"),
}),
sortOptionsMenuVm: baseSortOptionsViewModel,
},
activeSortOption: SortOption.Activity,
}),
},
};
export const LongTitle: Story = {
args: {
vm: createMockViewModel<RoomListHeaderSnapshot>({
vm: createMockViewModel({
title: "This is a very long space name that should be truncated with ellipsis when it overflows",
isSpace: true,
displayComposeMenu: true,
spaceMenuVm: createMockViewModel<SpaceMenuSnapshot>({
spaceMenuState: {
title: "This is a very long space name that should be truncated with ellipsis when it overflows",
canInviteInSpace: true,
canAccessSpaceSettings: true,
openSpaceHome: () => console.log("Open space home"),
inviteInSpace: () => console.log("Invite in space"),
openSpacePreferences: () => console.log("Open space preferences"),
openSpaceSettings: () => console.log("Open space settings"),
}),
composeMenuVm: createMockViewModel<ComposeMenuSnapshot>({
},
displayComposeMenu: true,
composeMenuState: {
canCreateRoom: true,
canCreateVideoRoom: true,
createChatRoom: () => console.log("Create chat room"),
createRoom: () => console.log("Create room"),
createVideoRoom: () => console.log("Create video room"),
}),
sortOptionsMenuVm: baseSortOptionsViewModel,
},
activeSortOption: SortOption.Activity,
}),
},
decorators: [

View File

@ -8,62 +8,83 @@
import { render, screen } from "jest-matrix-react";
import React from "react";
import { RoomListHeader, type RoomListHeaderSnapshot } from "./RoomListHeader";
import type { SpaceMenuSnapshot } from "./SpaceMenu";
import type { ComposeMenuSnapshot } from "./ComposeMenu";
import type { SortOptionsMenuSnapshot } from "./SortOptionsMenu";
import { RoomListHeader } from "./RoomListHeader";
import { SortOption } from "./SortOptionsMenu";
import { type ViewModel } from "../../viewmodel/ViewModel";
function createMockViewModel<T>(snapshot: T): ViewModel<T> {
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
};
}
import type { RoomListViewModel, RoomListSnapshot } from "../RoomListView";
import type { RoomListHeaderState } from "./RoomListHeader";
describe("RoomListHeader", () => {
const mockSortOptionsSnapshot: SortOptionsMenuSnapshot = {
activeSortOption: SortOption.Activity,
sort: jest.fn(),
const createMockViewModel = (headerState: RoomListHeaderState): RoomListViewModel => {
const snapshot: RoomListSnapshot = {
headerState,
isLoadingRooms: false,
isRoomListEmpty: false,
filters: [],
roomListState: {
rooms: [],
},
};
return {
getSnapshot: () => snapshot,
subscribe: (listener: () => void) => {
return () => {};
},
sort: jest.fn(),
onToggleFilter: jest.fn(),
onSearchClick: jest.fn(),
onDialPadClick: jest.fn(),
onExploreClick: jest.fn(),
showDialPad: false,
showExplore: false,
onComposeClick: jest.fn(),
openSpaceHome: jest.fn(),
inviteInSpace: jest.fn(),
openSpacePreferences: jest.fn(),
openSpaceSettings: jest.fn(),
createChatRoom: jest.fn(),
createRoom: jest.fn(),
createVideoRoom: jest.fn(),
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(),
};
};
it("renders title", () => {
const snapshot: RoomListHeaderSnapshot = {
const vm = createMockViewModel({
title: "My Space",
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot),
};
activeSortOption: SortOption.Activity,
});
render(<RoomListHeader vm={createMockViewModel(snapshot)} />);
render(<RoomListHeader vm={vm} />);
expect(screen.getByText("My Space")).toBeInTheDocument();
expect(screen.getByRole("banner")).toBeInTheDocument();
});
it("renders space menu when isSpace is true", () => {
const mockSpaceMenuSnapshot: SpaceMenuSnapshot = {
title: "My Space",
canInviteInSpace: true,
canAccessSpaceSettings: true,
openSpaceHome: jest.fn(),
inviteInSpace: jest.fn(),
openSpacePreferences: jest.fn(),
openSpaceSettings: jest.fn(),
};
const snapshot: RoomListHeaderSnapshot = {
const vm = createMockViewModel({
title: "My Space",
isSpace: true,
spaceMenuVm: createMockViewModel(mockSpaceMenuSnapshot),
spaceMenuState: {
title: "My Space",
canInviteInSpace: true,
canAccessSpaceSettings: true,
},
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot),
};
activeSortOption: SortOption.Activity,
});
render(<RoomListHeader vm={createMockViewModel(snapshot)} />);
render(<RoomListHeader vm={vm} />);
expect(screen.getByText("My Space")).toBeInTheDocument();
// Space menu chevron button should be present
@ -71,53 +92,46 @@ describe("RoomListHeader", () => {
});
it("renders compose menu when displayComposeMenu is true", () => {
const mockComposeMenuSnapshot: ComposeMenuSnapshot = {
canCreateRoom: true,
canCreateVideoRoom: true,
createChatRoom: jest.fn(),
createRoom: jest.fn(),
createVideoRoom: jest.fn(),
};
const snapshot: RoomListHeaderSnapshot = {
const vm = createMockViewModel({
title: "My Space",
isSpace: false,
displayComposeMenu: true,
composeMenuVm: createMockViewModel(mockComposeMenuSnapshot),
sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot),
};
composeMenuState: {
canCreateRoom: true,
canCreateVideoRoom: true,
},
activeSortOption: SortOption.Activity,
});
render(<RoomListHeader vm={createMockViewModel(snapshot)} />);
render(<RoomListHeader vm={vm} />);
// Compose button should be present
expect(screen.getByLabelText("New conversation")).toBeInTheDocument();
});
it("renders compose icon button when displayComposeMenu is false", () => {
const snapshot: RoomListHeaderSnapshot = {
const vm = createMockViewModel({
title: "My Space",
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot),
};
activeSortOption: SortOption.Activity,
});
render(<RoomListHeader vm={createMockViewModel(snapshot)} />);
render(<RoomListHeader vm={vm} />);
// Compose icon button should be present
expect(screen.getByLabelText("New conversation")).toBeInTheDocument();
});
it("renders sort options menu", () => {
const snapshot: RoomListHeaderSnapshot = {
const vm = createMockViewModel({
title: "My Space",
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot),
};
activeSortOption: SortOption.Activity,
});
render(<RoomListHeader vm={createMockViewModel(snapshot)} />);
render(<RoomListHeader vm={vm} />);
// Sort options menu trigger should be present
expect(screen.getByLabelText("Room options")).toBeInTheDocument();
@ -125,15 +139,15 @@ describe("RoomListHeader", () => {
it("truncates long titles with title attribute", () => {
const longTitle = "This is a very long space name that should be truncated";
const snapshot: RoomListHeaderSnapshot = {
const vm = createMockViewModel({
title: longTitle,
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot),
};
activeSortOption: SortOption.Activity,
});
render(<RoomListHeader vm={createMockViewModel(snapshot)} />);
render(<RoomListHeader vm={vm} />);
const h1 = screen.getByRole("heading", { level: 1 });
expect(h1).toHaveAttribute("title", longTitle);
@ -141,15 +155,14 @@ describe("RoomListHeader", () => {
});
it("renders data-testid attribute", () => {
const snapshot: RoomListHeaderSnapshot = {
const vm = createMockViewModel({
title: "My Space",
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot),
};
activeSortOption: SortOption.Activity,
});
render(<RoomListHeader vm={createMockViewModel(snapshot)} />);
render(<RoomListHeader vm={vm} />);
expect(screen.getByTestId("room-list-header")).toBeInTheDocument();
});

View File

@ -9,49 +9,66 @@ import React, { type JSX } from "react";
import { IconButton } from "@vector-im/compound-web";
import ComposeIcon from "@vector-im/compound-design-tokens/assets/web/icons/compose";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { Flex } from "../../utils/Flex";
import { _t } from "../../utils/i18n";
import { SpaceMenu, type SpaceMenuSnapshot } from "./SpaceMenu";
import { ComposeMenu, type ComposeMenuSnapshot } from "./ComposeMenu";
import { SortOptionsMenu, type SortOptionsMenuSnapshot } from "./SortOptionsMenu";
import { SpaceMenu } from "./SpaceMenu";
import { ComposeMenu } from "./ComposeMenu";
import { SortOptionsMenu, SortOption } from "./SortOptionsMenu";
import styles from "./RoomListHeader.module.css";
import { RoomListViewModel } from "../RoomListView";
import { useViewModel } from "../../useViewModel";
/**
* Snapshot for RoomListHeader
* State for space menu - pure data, no callbacks
*/
export type RoomListHeaderSnapshot = {
/** The title to display in the header */
export type SpaceMenuState = {
/** The title of the space */
title: string;
/** Whether to display the space menu (true if there is an active space) */
isSpace: boolean;
/** Space menu view model (only used if isSpace is true) */
spaceMenuVm?: ViewModel<SpaceMenuSnapshot>;
/** Whether to display the compose menu */
displayComposeMenu: boolean;
/** Compose menu view model (only used if displayComposeMenu is true) */
composeMenuVm?: ViewModel<ComposeMenuSnapshot>;
/** Callback when compose button is clicked (only used if displayComposeMenu is false) */
onComposeClick?: () => void;
/** Sort options menu view model */
sortOptionsMenuVm: ViewModel<SortOptionsMenuSnapshot>;
/** Whether the user can invite in the space */
canInviteInSpace: boolean;
/** Whether the user can access space settings */
canAccessSpaceSettings: boolean;
};
/**
* Props for RoomListHeader component
* State for compose menu - pure data, no callbacks
*/
export interface RoomListHeaderProps {
/** The view model containing header data */
vm: ViewModel<RoomListHeaderSnapshot>;
}
export type ComposeMenuState = {
/** Whether the user can create rooms */
canCreateRoom: boolean;
/** Whether the user can create video rooms */
canCreateVideoRoom: boolean;
};
/**
* State for RoomListHeader - pure data
*/
export type RoomListHeaderState = {
/** Header title */
title: string;
/** Whether this is a space */
isSpace: boolean;
/** Space menu state (if this is a space) */
spaceMenuState?: SpaceMenuState;
/** Whether to display compose menu */
displayComposeMenu: boolean;
/** Compose menu state (if displayComposeMenu is true) */
composeMenuState?: ComposeMenuState;
/** Active sort option */
activeSortOption: SortOption;
};
export interface RoomListHeaderProps {
vm: RoomListViewModel;
}
/**
* A presentational header component for the room list.
* Displays a title with optional space menu, sort options, and compose actions.
*/
export const RoomListHeader: React.FC<RoomListHeaderProps> = ({ vm }): JSX.Element => {
const snapshot = useViewModel(vm);
const { title, isSpace, spaceMenuState, displayComposeMenu, composeMenuState, activeSortOption } =
snapshot.headerState;
return (
<Flex
@ -63,15 +80,31 @@ export const RoomListHeader: React.FC<RoomListHeaderProps> = ({ vm }): JSX.Eleme
data-testid="room-list-header"
>
<Flex className={styles.title} align="center" gap="var(--cpd-space-1x)">
<h1 title={snapshot.title}>{snapshot.title}</h1>
{snapshot.isSpace && snapshot.spaceMenuVm && <SpaceMenu vm={snapshot.spaceMenuVm} />}
<h1 title={title}>{title}</h1>
{isSpace && spaceMenuState && (
<SpaceMenu
title={spaceMenuState.title}
canInviteInSpace={spaceMenuState.canInviteInSpace}
canAccessSpaceSettings={spaceMenuState.canAccessSpaceSettings}
openSpaceHome={vm.openSpaceHome}
inviteInSpace={vm.inviteInSpace}
openSpacePreferences={vm.openSpacePreferences}
openSpaceSettings={vm.openSpaceSettings}
/>
)}
</Flex>
<Flex align="center" gap="var(--cpd-space-2x)">
<SortOptionsMenu vm={snapshot.sortOptionsMenuVm} />
{snapshot.displayComposeMenu && snapshot.composeMenuVm ? (
<ComposeMenu vm={snapshot.composeMenuVm} />
<SortOptionsMenu activeSortOption={activeSortOption} sort={vm.sort} />
{displayComposeMenu && composeMenuState ? (
<ComposeMenu
canCreateRoom={composeMenuState.canCreateRoom}
canCreateVideoRoom={composeMenuState.canCreateVideoRoom}
createChatRoom={vm.createChatRoom}
createRoom={vm.createRoom}
createVideoRoom={vm.createVideoRoom}
/>
) : (
<IconButton onClick={snapshot.onComposeClick} tooltip={_t("action|new_conversation")}>
<IconButton onClick={vm.onComposeClick} tooltip={_t("action|new_conversation")}>
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
</IconButton>
)}

View File

@ -9,8 +9,6 @@ import React, { useState, useCallback, type JSX } from "react";
import { IconButton, Menu, MenuTitle, Tooltip, RadioMenuItem } from "@vector-im/compound-web";
import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { _t } from "../../utils/i18n";
/**
@ -24,21 +22,13 @@ export enum SortOption {
/**
* Snapshot for SortOptionsMenu
*/
export type SortOptionsMenuSnapshot = {
export type SortOptionsMenuProps = {
/** The currently active sort option */
activeSortOption: SortOption;
/** Change the sort order of the room-list */
sort: (option: SortOption) => void;
};
/**
* Props for SortOptionsMenu component
*/
export interface SortOptionsMenuProps {
/** The view model containing menu data and callbacks */
vm: ViewModel<SortOptionsMenuSnapshot>;
}
const MenuTrigger = (props: React.ComponentProps<typeof IconButton>): JSX.Element => (
<Tooltip label={_t("room_list|room_options")}>
<IconButton aria-label={_t("room_list|room_options")} {...props}>
@ -51,17 +41,16 @@ const MenuTrigger = (props: React.ComponentProps<typeof IconButton>): JSX.Elemen
* The sort options menu for the room list header.
* Displays a dropdown menu with options to sort rooms by activity or alphabetically.
*/
export const SortOptionsMenu: React.FC<SortOptionsMenuProps> = ({ vm }): JSX.Element => {
const snapshot = useViewModel(vm);
export const SortOptionsMenu: React.FC<SortOptionsMenuProps> = ({ activeSortOption, sort }): JSX.Element => {
const [open, setOpen] = useState(false);
const onActivitySelected = useCallback(() => {
snapshot.sort(SortOption.Activity);
}, [snapshot]);
sort(SortOption.Activity);
}, [sort]);
const onAtoZSelected = useCallback(() => {
snapshot.sort(SortOption.AToZ);
}, [snapshot]);
sort(SortOption.AToZ);
}, [sort]);
return (
<Menu
@ -75,12 +64,12 @@ export const SortOptionsMenu: React.FC<SortOptionsMenuProps> = ({ vm }): JSX.Ele
<MenuTitle title={_t("room_list|sort")} />
<RadioMenuItem
label={_t("room_list|sort_type|activity")}
checked={snapshot.activeSortOption === SortOption.Activity}
checked={activeSortOption === SortOption.Activity}
onSelect={onActivitySelected}
/>
<RadioMenuItem
label={_t("room_list|sort_type|atoz")}
checked={snapshot.activeSortOption === SortOption.AToZ}
checked={activeSortOption === SortOption.AToZ}
onSelect={onAtoZSelected}
/>
</Menu>

View File

@ -13,14 +13,12 @@ import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user
import PreferencesIcon from "@vector-im/compound-design-tokens/assets/web/icons/preferences";
import SettingsIcon from "@vector-im/compound-design-tokens/assets/web/icons/settings";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { _t } from "../../utils/i18n";
/**
* Snapshot for SpaceMenu
* Props for SpaceMenu component
*/
export type SpaceMenuSnapshot = {
export interface SpaceMenuProps {
/** The title of the space */
title: string;
/** Whether the user can invite in the space */
@ -35,29 +33,33 @@ export type SpaceMenuSnapshot = {
openSpacePreferences: () => void;
/** Open the space settings */
openSpaceSettings: () => void;
};
}
/**
* Props for SpaceMenu component
* @deprecated Use SpaceMenuProps instead
*/
export interface SpaceMenuProps {
/** The view model containing menu data and callbacks */
vm: ViewModel<SpaceMenuSnapshot>;
}
export type SpaceMenuSnapshot = SpaceMenuProps;
/**
* The space menu for the room list header.
* Displays a dropdown menu with space-specific actions.
*/
export const SpaceMenu: React.FC<SpaceMenuProps> = ({ vm }): JSX.Element => {
const snapshot = useViewModel(vm);
export const SpaceMenu: React.FC<SpaceMenuProps> = ({
title,
canInviteInSpace,
canAccessSpaceSettings,
openSpaceHome,
inviteInSpace,
openSpacePreferences,
openSpaceSettings,
}): JSX.Element => {
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={setOpen}
title={snapshot.title}
title={title}
side="right"
align="start"
trigger={
@ -69,28 +71,23 @@ export const SpaceMenu: React.FC<SpaceMenuProps> = ({ vm }): JSX.Element => {
<MenuItem
Icon={HomeIcon}
label={_t("room_list|space_menu|home")}
onSelect={snapshot.openSpaceHome}
onSelect={openSpaceHome}
hideChevron={true}
/>
{snapshot.canInviteInSpace && (
<MenuItem
Icon={UserAddIcon}
label={_t("action|invite")}
onSelect={snapshot.inviteInSpace}
hideChevron={true}
/>
{canInviteInSpace && (
<MenuItem Icon={UserAddIcon} label={_t("action|invite")} onSelect={inviteInSpace} hideChevron={true} />
)}
<MenuItem
Icon={PreferencesIcon}
label={_t("common|preferences")}
onSelect={snapshot.openSpacePreferences}
onSelect={openSpacePreferences}
hideChevron={true}
/>
{snapshot.canAccessSpaceSettings && (
{canAccessSpaceSettings && (
<MenuItem
Icon={SettingsIcon}
label={_t("room_list|space_menu|space_settings")}
onSelect={snapshot.openSpaceSettings}
onSelect={openSpaceSettings}
hideChevron={true}
/>
)}

View File

@ -0,0 +1,11 @@
/*
* 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.
*/
export { RoomListHeader, type RoomListHeaderProps, type RoomListHeaderState, type SpaceMenuState, type ComposeMenuState } from "./RoomListHeader";
export { ComposeMenu, type ComposeMenuProps, type ComposeMenuSnapshot } from "./ComposeMenu";
export { SpaceMenu, type SpaceMenuProps, type SpaceMenuSnapshot } from "./SpaceMenu";
export { SortOptionsMenu, type SortOptionsMenuProps, SortOption } from "./SortOptionsMenu";

View File

@ -10,7 +10,7 @@ import React from "react";
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 { NotificationMenuState } from "./RoomListItemNotificationMenu";
import type { RoomNotifState } from "../../notifications/RoomNotifs";
import type { Meta, StoryObj } from "@storybook/react-vite";
@ -85,10 +85,6 @@ const mockMoreOptionsCallbacks: MoreOptionsMenuCallbacks = {
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",
@ -105,7 +101,7 @@ const baseItem: RoomListItem = {
const baseCallbacks: RoomListItemCallbacks = {
onOpenRoom: () => console.log("Opening room"),
moreOptionsCallbacks: mockMoreOptionsCallbacks,
notificationCallbacks: mockNotificationCallbacks,
onSetRoomNotifState: (state: RoomNotifState) => console.log("Set notification state:", state),
};
const meta = {

View File

@ -12,7 +12,7 @@ import React from "react";
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 { NotificationMenuState } from "./RoomListItemNotificationMenu";
describe("RoomListItem", () => {
const mockNotificationData: NotificationDecorationData = {
@ -51,9 +51,7 @@ describe("RoomListItem", () => {
onLeaveRoom: jest.fn(),
};
const mockNotificationCallbacks: NotificationMenuCallbacks = {
onSetRoomNotifState: jest.fn(),
};
const mockOnSetRoomNotifState = jest.fn();
const mockItem: RoomListItem = {
id: "!test:example.org",
@ -71,7 +69,7 @@ describe("RoomListItem", () => {
const mockCallbacks: RoomListItemCallbacks = {
onOpenRoom: jest.fn(),
moreOptionsCallbacks: mockMoreOptionsCallbacks,
notificationCallbacks: mockNotificationCallbacks,
onSetRoomNotifState: mockOnSetRoomNotifState,
};
const mockAvatar = <div data-testid="mock-avatar">Avatar</div>;

View File

@ -15,9 +15,9 @@ import {
type MoreOptionsMenuState,
type MoreOptionsMenuCallbacks,
type NotificationMenuState,
type NotificationMenuCallbacks,
} from "./RoomListItemHoverMenu";
import { RoomListItemContextMenu } from "./RoomListItemContextMenu";
import { type RoomNotifState } from "../../notifications/RoomNotifs";
import styles from "./RoomListItem.module.css";
/**
@ -55,8 +55,8 @@ export interface RoomListItemCallbacks {
onOpenRoom: () => void;
/** More options menu callbacks */
moreOptionsCallbacks: MoreOptionsMenuCallbacks;
/** Notification menu callbacks */
notificationCallbacks: NotificationMenuCallbacks;
/** Set the room notification state */
onSetRoomNotifState: (state: RoomNotifState) => void;
}
/**
@ -164,7 +164,7 @@ export const RoomListItemView = memo(function RoomListItemView({
moreOptionsState={item.moreOptionsState}
moreOptionsCallbacks={callbacks.moreOptionsCallbacks}
notificationState={item.notificationState}
notificationCallbacks={callbacks.notificationCallbacks}
onSetRoomNotifState={callbacks.onSetRoomNotifState}
onMenuOpenChange={(isOpen: boolean) => (isOpen ? setIsMenuOpen(true) : closeMenu())}
/>
) : (

View File

@ -16,8 +16,8 @@ import {
import {
RoomListItemNotificationMenu,
type NotificationMenuState,
type NotificationMenuCallbacks,
} from "./RoomListItemNotificationMenu";
import { type RoomNotifState } from "../../notifications/RoomNotifs";
/**
* Props for RoomListItemHoverMenu component
@ -33,8 +33,8 @@ export interface RoomListItemHoverMenuProps {
moreOptionsCallbacks: MoreOptionsMenuCallbacks;
/** Notification menu state */
notificationState: NotificationMenuState;
/** Notification menu callbacks */
notificationCallbacks: NotificationMenuCallbacks;
/** Callback to set room notification state */
onSetRoomNotifState: (state: RoomNotifState) => void;
/** Callback when menu open state changes */
onMenuOpenChange: (isOpen: boolean) => void;
}
@ -49,7 +49,7 @@ export const RoomListItemHoverMenu: React.FC<RoomListItemHoverMenuProps> = ({
moreOptionsState,
moreOptionsCallbacks,
notificationState,
notificationCallbacks,
onSetRoomNotifState,
onMenuOpenChange,
}): JSX.Element => {
return (
@ -64,7 +64,7 @@ export const RoomListItemHoverMenu: React.FC<RoomListItemHoverMenuProps> = ({
{showNotificationMenu && (
<RoomListItemNotificationMenu
state={notificationState}
callbacks={notificationCallbacks}
onSetRoomNotifState={onSetRoomNotifState}
onMenuOpenChange={onMenuOpenChange}
/>
)}
@ -74,4 +74,5 @@ export const RoomListItemHoverMenu: React.FC<RoomListItemHoverMenuProps> = ({
// Re-export types for convenience
export type { MoreOptionsMenuState, MoreOptionsMenuCallbacks } from "./RoomListItemMoreOptionsMenu";
export type { NotificationMenuState, NotificationMenuCallbacks } from "./RoomListItemNotificationMenu";
export type { NotificationMenuState } from "./RoomListItemNotificationMenu";
export type { RoomNotifState } from "../../notifications/RoomNotifs";

View File

@ -28,22 +28,14 @@ export interface NotificationMenuState {
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;
/** Set the room notification state */
onSetRoomNotifState: (state: RoomNotifState) => void;
/** Callback when menu open state changes */
onMenuOpenChange: (isOpen: boolean) => void;
}
@ -54,7 +46,7 @@ export interface RoomListItemNotificationMenuProps {
*/
export function RoomListItemNotificationMenu({
state,
callbacks,
onSetRoomNotifState,
onMenuOpenChange,
}: RoomListItemNotificationMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
@ -84,7 +76,7 @@ export function RoomListItemNotificationMenu({
aria-selected={state.isNotificationAllMessage}
hideChevron={true}
label={_t("notifications|default_settings")}
onSelect={() => callbacks.onSetRoomNotifState(RoomNotifState.AllMessages)}
onSelect={() => onSetRoomNotifState(RoomNotifState.AllMessages)}
onClick={(evt) => evt.stopPropagation()}
>
{state.isNotificationAllMessage && checkComponent}
@ -93,7 +85,7 @@ export function RoomListItemNotificationMenu({
aria-selected={state.isNotificationAllMessageLoud}
hideChevron={true}
label={_t("notifications|all_messages")}
onSelect={() => callbacks.onSetRoomNotifState(RoomNotifState.AllMessagesLoud)}
onSelect={() => onSetRoomNotifState(RoomNotifState.AllMessagesLoud)}
onClick={(evt) => evt.stopPropagation()}
>
{state.isNotificationAllMessageLoud && checkComponent}
@ -102,7 +94,7 @@ export function RoomListItemNotificationMenu({
aria-selected={state.isNotificationMentionOnly}
hideChevron={true}
label={_t("notifications|mentions_keywords")}
onSelect={() => callbacks.onSetRoomNotifState(RoomNotifState.MentionsOnly)}
onSelect={() => onSetRoomNotifState(RoomNotifState.MentionsOnly)}
onClick={(evt) => evt.stopPropagation()}
>
{state.isNotificationMentionOnly && checkComponent}
@ -111,7 +103,7 @@ export function RoomListItemNotificationMenu({
aria-selected={state.isNotificationMute}
hideChevron={true}
label={_t("notifications|mute_room")}
onSelect={() => callbacks.onSetRoomNotifState(RoomNotifState.Mute)}
onSelect={() => onSetRoomNotifState(RoomNotifState.Mute)}
onClick={(evt) => evt.stopPropagation()}
>
{state.isNotificationMute && checkComponent}

View File

@ -8,4 +8,4 @@
export { RoomListItemView } from "./RoomListItem";
export type { RoomListItem, RoomListItemViewProps, RoomListItemCallbacks } from "./RoomListItem";
export type { MoreOptionsMenuState, MoreOptionsMenuCallbacks } from "./RoomListItemMoreOptionsMenu";
export type { NotificationMenuState, NotificationMenuCallbacks } from "./RoomListItemNotificationMenu";
export type { NotificationMenuState } from "./RoomListItemNotificationMenu";

View File

@ -6,21 +6,16 @@
*/
import React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { NotificationDecorationData } from "../../notifications/NotificationDecoration";
import type { RoomsResult } from "../RoomList";
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 { RoomListViewWrapperSnapshot } from "../RoomListView";
import type { RoomListPrimaryFiltersSnapshot } from "../RoomListPrimaryFilters";
import { RoomListPanel } from "./RoomListPanel";
import type { Filter } from "../RoomListPrimaryFilters/useVisibleFilters";
import type { RoomListSnapshot, RoomListViewModel, RoomListHeaderState } from "../RoomListView";
// Mock avatar component
const mockAvatar = (roomItem: RoomListItem): React.ReactElement => (
@ -90,19 +85,12 @@ const generateMockRooms = (count: number): RoomListItem[] => {
});
};
const mockRoomsResult: RoomsResult = {
spaceId: "!space:server",
filterKeys: undefined,
rooms: generateMockRooms(20),
};
// Create mock filters
const createFilters = (): FilterViewModel[] => {
const createFilters = (): Filter[] => {
const filters = ["All", "People", "Rooms", "Favourites", "Unread"];
return filters.map((name, index) => ({
name,
active: index === 0,
toggle: () => console.log(`Filter: ${name}`),
}));
};
@ -118,67 +106,61 @@ type Story = StoryObj<typeof RoomListPanel>;
// Create stable unsubscribe function
const noop = (): void => {};
function createMockViewModel<T>(snapshot: T): ViewModel<T> {
// Create mock ViewModel with public methods
function createMockViewModel(snapshot: RoomListSnapshot): RoomListViewModel {
return {
getSnapshot: () => snapshot,
subscribe: () => noop,
// Public properties
showDialPad: false,
showExplore: false,
// Public callback methods
onToggleFilter: () => {},
onSearchClick: () => {},
onDialPadClick: () => {},
onExploreClick: () => {},
onComposeClick: () => {},
openSpaceHome: () => {},
inviteInSpace: () => {},
openSpacePreferences: () => {},
openSpaceSettings: () => {},
createChatRoom: () => {},
createRoom: () => {},
createVideoRoom: () => {},
sort: () => {},
onOpenRoom: () => {},
onMarkAsRead: () => {},
onMarkAsUnread: () => {},
onToggleFavorite: () => {},
onToggleLowPriority: () => {},
onInvite: () => {},
onCopyRoomLink: () => {},
onLeaveRoom: () => {},
onSetRoomNotifState: () => {},
};
}
// Create stable snapshot for RoomListViewModel
const mockRoomListSnapshot = {
roomsResult: mockRoomsResult,
activeRoomIndex: 0,
const baseHeaderState: RoomListHeaderState = {
title: "Home",
isSpace: false,
displayComposeMenu: false,
activeSortOption: SortOption.Activity,
};
// 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 baseSnapshot: RoomListSnapshot = {
headerState: baseHeaderState,
isLoadingRooms: false,
isRoomListEmpty: false,
filters: createFilters(),
roomListState: {
rooms: generateMockRooms(20),
},
emptyStateDescription: "Join a room to get started",
};
const baseViewModel: ViewModel<RoomListPanelSnapshot> = createMockViewModel({
ariaLabel: "Room list navigation",
searchVm: createMockViewModel<RoomListSearchSnapshot>({
onSearchClick: () => console.log("Open search"),
showDialPad: false,
showExplore: true,
onExploreClick: () => console.log("Explore rooms"),
}),
headerVm: createMockViewModel<RoomListHeaderSnapshot>({
title: "Home",
isSpace: false,
displayComposeMenu: false,
onComposeClick: () => console.log("Compose"),
sortOptionsMenuVm: createMockViewModel<SortOptionsMenuSnapshot>({
activeSortOption: SortOption.Activity,
sort: (option) => console.log(`Sort: ${option}`),
}),
}),
viewVm: createMockViewModel<RoomListViewWrapperSnapshot>({
isLoadingRooms: false,
isRoomListEmpty: false,
filtersVm: createMockViewModel<RoomListPrimaryFiltersSnapshot>({
filters: createFilters(),
}),
roomListVm: mockRoomListViewModel,
emptyStateTitle: "No rooms",
emptyStateDescription: "Join a room to get started",
}),
});
export const Default: Story = {
args: {
vm: baseViewModel,
vm: createMockViewModel(baseSnapshot),
renderAvatar: mockAvatar,
},
decorators: [
@ -192,11 +174,8 @@ export const Default: Story = {
export const WithoutSearch: Story = {
args: {
vm: createMockViewModel<RoomListPanelSnapshot>({
ariaLabel: "Room list navigation",
searchVm: undefined,
headerVm: baseViewModel.getSnapshot().headerVm,
viewVm: baseViewModel.getSnapshot().viewVm,
vm: createMockViewModel({
...baseSnapshot,
}),
renderAvatar: mockAvatar,
},
@ -211,14 +190,9 @@ export const WithoutSearch: Story = {
export const Loading: Story = {
args: {
vm: createMockViewModel<RoomListPanelSnapshot>({
ariaLabel: "Room list navigation",
searchVm: baseViewModel.getSnapshot().searchVm,
headerVm: baseViewModel.getSnapshot().headerVm,
viewVm: createMockViewModel<RoomListViewWrapperSnapshot>({
...baseViewModel.getSnapshot().viewVm.getSnapshot(),
isLoadingRooms: true,
}),
vm: createMockViewModel({
...baseSnapshot,
isLoadingRooms: true,
}),
renderAvatar: mockAvatar,
},
@ -233,16 +207,13 @@ export const Loading: Story = {
export const Empty: Story = {
args: {
vm: createMockViewModel<RoomListPanelSnapshot>({
ariaLabel: "Room list navigation",
searchVm: baseViewModel.getSnapshot().searchVm,
headerVm: baseViewModel.getSnapshot().headerVm,
viewVm: createMockViewModel<RoomListViewWrapperSnapshot>({
...baseViewModel.getSnapshot().viewVm.getSnapshot(),
isRoomListEmpty: true,
emptyStateTitle: "No rooms to display",
emptyStateDescription: "Join a room or start a conversation to get started",
}),
vm: createMockViewModel({
...baseSnapshot,
isRoomListEmpty: true,
roomListState: {
rooms: [],
},
emptyStateDescription: "Join a room or start a conversation to get started",
}),
renderAvatar: mockAvatar,
},

View File

@ -8,16 +8,13 @@
import { render, screen } from "jest-matrix-react";
import React from "react";
import { RoomListPanel, type RoomListPanelSnapshot } from "./RoomListPanel";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { RoomListPanel } from "./RoomListPanel";
import { type RoomListViewModel, type RoomListSnapshot } from "../RoomListView";
import { SortOption } from "../RoomListHeader";
import type { RoomListItem } from "../RoomListItem";
import type { RoomListSearchSnapshot } from "../RoomListSearch";
import type { RoomListHeaderSnapshot } from "../RoomListHeader";
import type { RoomListViewWrapperSnapshot } from "../RoomListView";
import type { RoomListPrimaryFiltersSnapshot } from "../RoomListPrimaryFilters";
import type { RoomListViewModel, RoomListViewSnapshot } from "../RoomList";
import type { RoomListSearchState } from "../RoomListSearch";
import type { SortOptionsMenuSnapshot } from "../RoomListHeader/SortOptionsMenu";
import type { Filter } from "../RoomListPrimaryFilters";
// Mock ResizeObserver which is used by RoomListPrimaryFilters
global.ResizeObserver = class ResizeObserver {
@ -27,10 +24,23 @@ global.ResizeObserver = class ResizeObserver {
};
describe("RoomListPanel", () => {
function createMockViewModel<T>(snapshot: T): ViewModel<T> {
function createMockViewModel(snapshot: RoomListSnapshot): RoomListViewModel {
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
onToggleFilter: jest.fn(),
onSearchClick: jest.fn(),
onDialPadClick: jest.fn(),
onExploreClick: jest.fn(),
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(),
};
}
@ -38,8 +48,7 @@ describe("RoomListPanel", () => {
<div data-testid={`avatar-${roomItem.id}`}>{roomItem.name[0]}</div>
));
const searchSnapshot: RoomListSearchSnapshot = {
onSearchClick: jest.fn(),
const searchState: RoomListSearchState = {
showDialPad: false,
showExplore: false,
};
@ -49,54 +58,26 @@ describe("RoomListPanel", () => {
sort: jest.fn(),
};
const headerSnapshot: RoomListHeaderSnapshot = {
title: "Test Header",
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuVm: createMockViewModel(sortOptionsMenuSnapshot),
};
const filters: Filter[] = [];
const filtersSnapshot: RoomListPrimaryFiltersSnapshot = {
filters: [],
};
const roomListSnapshot: RoomListViewSnapshot = {
roomsResult: {
spaceId: "!space:server",
filterKeys: undefined,
rooms: [],
const mockSnapshot: RoomListSnapshot = {
searchState: searchState,
headerState: {
title: "Test Header",
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuProps: sortOptionsMenuSnapshot,
},
activeRoomIndex: undefined,
};
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: roomListViewModel,
};
const mockSnapshot: RoomListPanelSnapshot = {
ariaLabel: "Room List",
searchVm: createMockViewModel(searchSnapshot),
headerVm: createMockViewModel(headerSnapshot),
viewVm: createMockViewModel(viewSnapshot),
filters: filters,
roomListState: {
rooms: [],
activeRoomIndex: undefined,
spaceId: "!space:server",
filterKeys: undefined,
},
};
const mockViewModel = createMockViewModel(mockSnapshot);
@ -105,13 +86,13 @@ describe("RoomListPanel", () => {
render(<RoomListPanel vm={mockViewModel} renderAvatar={mockRenderAvatar} />);
expect(screen.getByText("Test Header")).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "Room List" })).toBeInTheDocument();
expect(screen.getByRole("navigation")).toBeInTheDocument();
});
it("renders without search", () => {
const snapshotWithoutSearch: RoomListPanelSnapshot = {
const snapshotWithoutSearch: RoomListSnapshot = {
...mockSnapshot,
searchVm: undefined,
searchState: undefined,
};
const vmWithoutSearch = createMockViewModel(snapshotWithoutSearch);
@ -122,17 +103,12 @@ describe("RoomListPanel", () => {
});
it("renders loading state", () => {
const loadingViewSnapshot: RoomListViewWrapperSnapshot = {
...viewSnapshot,
const loadingSnapshot: RoomListSnapshot = {
...mockSnapshot,
isLoadingRooms: true,
isRoomListEmpty: false,
};
const loadingSnapshot: RoomListPanelSnapshot = {
...mockSnapshot,
viewVm: createMockViewModel(loadingViewSnapshot),
};
const vmLoading = createMockViewModel(loadingSnapshot);
render(<RoomListPanel vm={vmLoading} renderAvatar={mockRenderAvatar} />);
@ -142,17 +118,12 @@ describe("RoomListPanel", () => {
});
it("renders empty state", () => {
const emptyViewSnapshot: RoomListViewWrapperSnapshot = {
...viewSnapshot,
const emptySnapshot: RoomListSnapshot = {
...mockSnapshot,
isLoadingRooms: false,
isRoomListEmpty: true,
};
const emptySnapshot: RoomListPanelSnapshot = {
...mockSnapshot,
viewVm: createMockViewModel(emptyViewSnapshot),
};
const vmEmpty = createMockViewModel(emptySnapshot);
render(<RoomListPanel vm={vmEmpty} renderAvatar={mockRenderAvatar} />);

View File

@ -7,35 +7,19 @@
import React, { type JSX, type ReactNode } from "react";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { Flex } from "../../utils/Flex";
import { RoomListSearch, type RoomListSearchSnapshot } from "../RoomListSearch";
import { RoomListHeader, type RoomListHeaderSnapshot } from "../RoomListHeader";
import { RoomListView, type RoomListViewWrapperSnapshot } from "../RoomListView";
import { RoomListSearch } from "../RoomListSearch";
import { RoomListHeader } from "../RoomListHeader";
import { RoomListView, type RoomListViewModel } from "../RoomListView";
import { type RoomListItem } from "../RoomListItem";
import styles from "./RoomListPanel.module.css";
/**
* Snapshot for RoomListPanel
*/
export type RoomListPanelSnapshot = {
/** Accessibility label for the navigation landmark */
ariaLabel: string;
/** Optional search view model */
searchVm?: ViewModel<RoomListSearchSnapshot>;
/** Header view model */
headerVm: ViewModel<RoomListHeaderSnapshot>;
/** View model for the main content area */
viewVm: ViewModel<RoomListViewWrapperSnapshot>;
};
/**
* Props for RoomListPanel component
*/
export interface RoomListPanelProps extends React.HTMLAttributes<HTMLElement> {
/** The view model containing all data and callbacks */
vm: ViewModel<RoomListPanelSnapshot>;
vm: RoomListViewModel;
/** Render function for room avatar */
renderAvatar: (roomItem: RoomListItem) => ReactNode;
}
@ -45,20 +29,17 @@ export interface RoomListPanelProps extends React.HTMLAttributes<HTMLElement> {
* Composes search, header, and content areas with a ViewModel pattern.
*/
export const RoomListPanel: React.FC<RoomListPanelProps> = ({ vm, renderAvatar, ...props }): JSX.Element => {
const snapshot = useViewModel(vm);
return (
<Flex
as="nav"
className={styles.roomListPanel}
direction="column"
align="stretch"
aria-label={snapshot.ariaLabel}
{...props}
>
{snapshot.searchVm && <RoomListSearch vm={snapshot.searchVm} />}
<RoomListHeader vm={snapshot.headerVm} />
<RoomListView vm={snapshot.viewVm} renderAvatar={renderAvatar} />
<Flex as="nav" className={styles.roomListPanel} direction="column" align="stretch" {...props}>
<RoomListSearch
showDialPad={vm.showDialPad}
showExplore={vm.showExplore}
onSearchClick={vm.onSearchClick}
onDialPadClick={vm.onDialPadClick}
onExploreClick={vm.onExploreClick}
/>
<RoomListHeader vm={vm} />
<RoomListView vm={vm} renderAvatar={renderAvatar} />
</Flex>
);
};

View File

@ -0,0 +1,8 @@
/*
* 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.
*/
export { RoomListPanel, type RoomListPanelProps } from "./RoomListPanel";

View File

@ -6,68 +6,54 @@
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RoomListPrimaryFilters, type RoomListPrimaryFiltersSnapshot } from "./RoomListPrimaryFilters";
import type { FilterViewModel } from "./useVisibleFilters";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
import type { Filter } from "./useVisibleFilters";
const meta: Meta<typeof RoomListPrimaryFilters> = {
title: "Room List/RoomListPrimaryFilters",
component: RoomListPrimaryFilters,
tags: ["autodocs"],
args: {
onToggleFilter: () => {},
},
};
export default meta;
type Story = StoryObj<typeof RoomListPrimaryFilters>;
function createMockViewModel(snapshot: RoomListPrimaryFiltersSnapshot): ViewModel<RoomListPrimaryFiltersSnapshot> {
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
};
}
// Mock filter data - simple presentation data only
const createFilters = (selectedIndex: number = 0): FilterViewModel[] => {
const createFilters = (selectedIndex: number = 0): Filter[] => {
const filterNames = ["All", "People", "Rooms", "Favourites", "Unread"];
return filterNames.map((name, index) => ({
name,
active: index === selectedIndex,
toggle: () => console.log(`Filter toggled: ${name}`),
}));
};
export const Default: Story = {
args: {
vm: createMockViewModel({
filters: createFilters(0),
}),
filters: createFilters(0),
},
};
export const PeopleSelected: Story = {
args: {
vm: createMockViewModel({
filters: createFilters(1),
}),
filters: createFilters(1),
},
};
export const FewFilters: Story = {
args: {
vm: createMockViewModel({
filters: [
{
name: "All",
active: true,
toggle: () => console.log("All toggled"),
},
{
name: "Unread",
active: false,
toggle: () => console.log("Unread toggled"),
},
],
}),
filters: [
{
name: "All",
active: true,
},
{
name: "Unread",
active: false,
},
],
},
};

View File

@ -9,41 +9,35 @@ import React, { type JSX, useId, useState } from "react";
import { ChatFilter, IconButton } from "@vector-im/compound-web";
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { Flex } from "../../utils/Flex";
import { _t } from "../../utils/i18n";
import { useCollapseFilters } from "./useCollapseFilters";
import { useVisibleFilters, type FilterViewModel } from "./useVisibleFilters";
import { useVisibleFilters, type Filter } from "./useVisibleFilters";
import styles from "./RoomListPrimaryFilters.module.css";
/**
* Snapshot for RoomListPrimaryFilters
*/
export type RoomListPrimaryFiltersSnapshot = {
/** Array of filter data */
filters: FilterViewModel[];
};
/**
* Props for RoomListPrimaryFilters component
*/
export interface RoomListPrimaryFiltersProps {
/** The view model containing filter data */
vm: ViewModel<RoomListPrimaryFiltersSnapshot>;
/** Array of filters to display */
filters: Filter[];
/** Callback when a filter is toggled */
onToggleFilter: (filter: Filter) => void;
}
/**
* The primary filters component for the room list.
* Displays a collapsible list of filters with expand/collapse functionality.
*/
export const RoomListPrimaryFilters: React.FC<RoomListPrimaryFiltersProps> = ({ vm }): JSX.Element => {
const snapshot = useViewModel(vm);
export const RoomListPrimaryFilters: React.FC<RoomListPrimaryFiltersProps> = ({
filters,
onToggleFilter,
}): JSX.Element | null => {
const id = useId();
const [isExpanded, setIsExpanded] = useState(false);
const { ref, isWrapping: displayChevron, wrappingIndex } = useCollapseFilters<HTMLUListElement>(isExpanded);
const filters = useVisibleFilters(snapshot.filters, wrappingIndex);
const visibleFilters = useVisibleFilters(filters, wrappingIndex);
return (
<Flex
@ -77,8 +71,8 @@ export const RoomListPrimaryFilters: React.FC<RoomListPrimaryFiltersProps> = ({
className={styles.list}
ref={ref}
>
{filters.map((filter, i) => (
<ChatFilter key={i} role="option" selected={filter.active} onClick={() => filter.toggle()}>
{visibleFilters.map((filter, i) => (
<ChatFilter key={i} role="option" selected={filter.active} onClick={() => onToggleFilter(filter)}>
{filter.name}
</ChatFilter>
))}

View File

@ -6,7 +6,7 @@
*/
export { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
export type { RoomListPrimaryFiltersProps, RoomListPrimaryFiltersSnapshot } from "./RoomListPrimaryFilters";
export type { RoomListPrimaryFiltersProps } from "./RoomListPrimaryFilters";
export { useCollapseFilters } from "./useCollapseFilters";
export { useVisibleFilters } from "./useVisibleFilters";
export type { FilterViewModel } from "./useVisibleFilters";
export type { Filter } from "./useVisibleFilters";

View File

@ -7,13 +7,11 @@
import { useEffect, useState } from "react";
export interface FilterViewModel {
export interface Filter {
/** Filter name/label */
name: string;
/** Whether the filter is currently active */
active: boolean;
/** Callback when filter is clicked */
toggle: () => void;
}
/**
@ -24,7 +22,7 @@ export interface FilterViewModel {
* @param filters - the list of filters to sort.
* @param wrappingIndex - the index of the first filter that is wrapping.
*/
export function useVisibleFilters(filters: FilterViewModel[], wrappingIndex: number): FilterViewModel[] {
export function useVisibleFilters(filters: Filter[], wrappingIndex: number): Filter[] {
// By default, the filters are not sorted
const [sortedFilters, setSortedFilters] = useState(filters);

View File

@ -6,8 +6,7 @@
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RoomListSearch, type RoomListSearchSnapshot } from "./RoomListSearch";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { RoomListSearch } from "./RoomListSearch";
const meta: Meta<typeof RoomListSearch> = {
title: "Room List/RoomListSearch",
@ -18,53 +17,38 @@ const meta: Meta<typeof RoomListSearch> = {
export default meta;
type Story = StoryObj<typeof RoomListSearch>;
function createMockViewModel(snapshot: RoomListSearchSnapshot): ViewModel<RoomListSearchSnapshot> {
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
};
}
export const Default: Story = {
args: {
vm: createMockViewModel({
onSearchClick: () => console.log("Open search"),
showDialPad: false,
showExplore: false,
}),
onSearchClick: () => console.log("Open search"),
showDialPad: false,
showExplore: false,
},
};
export const WithDialPad: Story = {
args: {
vm: createMockViewModel({
onSearchClick: () => console.log("Open search"),
showDialPad: true,
onDialPadClick: () => console.log("Open dial pad"),
showExplore: false,
}),
onSearchClick: () => console.log("Open search"),
showDialPad: true,
onDialPadClick: () => console.log("Open dial pad"),
showExplore: false,
},
};
export const WithExplore: Story = {
args: {
vm: createMockViewModel({
onSearchClick: () => console.log("Open search"),
showDialPad: false,
showExplore: true,
onExploreClick: () => console.log("Explore rooms"),
}),
onSearchClick: () => console.log("Open search"),
showDialPad: false,
showExplore: true,
onExploreClick: () => console.log("Explore rooms"),
},
};
export const WithAllActions: Story = {
args: {
vm: createMockViewModel({
onSearchClick: () => console.log("Open search"),
showDialPad: true,
onDialPadClick: () => console.log("Open dial pad"),
showExplore: true,
onExploreClick: () => console.log("Explore rooms"),
}),
onSearchClick: () => console.log("Open search"),
showDialPad: true,
onDialPadClick: () => console.log("Open dial pad"),
showExplore: true,
onExploreClick: () => console.log("Explore rooms"),
},
};

View File

@ -9,26 +9,18 @@ import { render, screen } from "jest-matrix-react";
import React from "react";
import userEvent from "@testing-library/user-event";
import { RoomListSearch, type RoomListSearchSnapshot } from "./RoomListSearch";
import { type ViewModel } from "../../viewmodel/ViewModel";
function createMockViewModel(snapshot: RoomListSearchSnapshot): ViewModel<RoomListSearchSnapshot> {
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
};
}
import { RoomListSearch, type RoomListSearchProps } from "./RoomListSearch";
describe("RoomListSearch", () => {
it("renders search button with shortcut", () => {
const onSearchClick = jest.fn();
const vm = createMockViewModel({
const props: RoomListSearchProps = {
onSearchClick,
showDialPad: false,
showExplore: false,
});
};
render(<RoomListSearch vm={vm} />);
render(<RoomListSearch {...props} />);
expect(screen.getByRole("search")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument();
@ -38,13 +30,13 @@ describe("RoomListSearch", () => {
it("calls onSearchClick when search button is clicked", async () => {
const onSearchClick = jest.fn();
const vm = createMockViewModel({
const props: RoomListSearchProps = {
onSearchClick,
showDialPad: false,
showExplore: false,
});
};
render(<RoomListSearch vm={vm} />);
render(<RoomListSearch {...props} />);
await userEvent.click(screen.getByRole("button", { name: /search/i }));
expect(onSearchClick).toHaveBeenCalledTimes(1);
@ -52,28 +44,28 @@ describe("RoomListSearch", () => {
it("renders dial pad button when showDialPad is true", () => {
const onDialPadClick = jest.fn();
const vm = createMockViewModel({
const props: RoomListSearchProps = {
onSearchClick: jest.fn(),
showDialPad: true,
onDialPadClick,
showExplore: false,
});
};
render(<RoomListSearch vm={vm} />);
render(<RoomListSearch {...props} />);
expect(screen.getByRole("button", { name: /dial pad/i })).toBeInTheDocument();
});
it("calls onDialPadClick when dial pad button is clicked", async () => {
const onDialPadClick = jest.fn();
const vm = createMockViewModel({
const props: RoomListSearchProps = {
onSearchClick: jest.fn(),
showDialPad: true,
onDialPadClick,
showExplore: false,
});
};
render(<RoomListSearch vm={vm} />);
render(<RoomListSearch {...props} />);
await userEvent.click(screen.getByRole("button", { name: /dial pad/i }));
expect(onDialPadClick).toHaveBeenCalledTimes(1);
@ -81,43 +73,43 @@ describe("RoomListSearch", () => {
it("renders explore button when showExplore is true", () => {
const onExploreClick = jest.fn();
const vm = createMockViewModel({
const props: RoomListSearchProps = {
onSearchClick: jest.fn(),
showDialPad: false,
showExplore: true,
onExploreClick,
});
};
render(<RoomListSearch vm={vm} />);
render(<RoomListSearch {...props} />);
expect(screen.getByRole("button", { name: /explore/i })).toBeInTheDocument();
});
it("calls onExploreClick when explore button is clicked", async () => {
const onExploreClick = jest.fn();
const vm = createMockViewModel({
const props: RoomListSearchProps = {
onSearchClick: jest.fn(),
showDialPad: false,
showExplore: true,
onExploreClick,
});
};
render(<RoomListSearch vm={vm} />);
render(<RoomListSearch {...props} />);
await userEvent.click(screen.getByRole("button", { name: /explore/i }));
expect(onExploreClick).toHaveBeenCalledTimes(1);
});
it("renders all buttons when showDialPad and showExplore are true", () => {
const vm = createMockViewModel({
const props: RoomListSearchProps = {
onSearchClick: jest.fn(),
showDialPad: true,
onDialPadClick: jest.fn(),
showExplore: true,
onExploreClick: jest.fn(),
});
};
render(<RoomListSearch vm={vm} />);
render(<RoomListSearch {...props} />);
expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /dial pad/i })).toBeInTheDocument();
@ -125,13 +117,13 @@ describe("RoomListSearch", () => {
});
it("does not render dial pad or explore buttons when flags are false", () => {
const vm = createMockViewModel({
const props: RoomListSearchProps = {
onSearchClick: jest.fn(),
showDialPad: false,
showExplore: false,
});
};
render(<RoomListSearch vm={vm} />);
render(<RoomListSearch {...props} />);
expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /dial pad/i })).not.toBeInTheDocument();

View File

@ -11,43 +11,42 @@ import SearchIcon from "@vector-im/compound-design-tokens/assets/web/icons/searc
import DialPadIcon from "@vector-im/compound-design-tokens/assets/web/icons/dial-pad";
import ExploreIcon from "@vector-im/compound-design-tokens/assets/web/icons/explore";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { Flex } from "../../utils/Flex";
import { _t } from "../../utils/i18n";
import styles from "./RoomListSearch.module.css";
/**
* Snapshot for RoomListSearch
* State for RoomListSearch - pure data, no callbacks
*/
export type RoomListSearchSnapshot = {
/** Callback fired when search button is clicked */
onSearchClick: () => void;
/** Whether to show the dial pad button */
export interface RoomListSearchState {
showDialPad: boolean;
/** Callback fired when dial pad button is clicked */
onDialPadClick?: () => void;
/** Whether to show the explore rooms button */
showExplore: boolean;
/** Callback fired when explore button is clicked */
onExploreClick?: () => void;
};
}
/**
* Props for RoomListSearch component
* Props for RoomListSearch component - combines state with callbacks
*/
export interface RoomListSearchProps {
/** The view model containing search data */
vm: ViewModel<RoomListSearchSnapshot>;
export interface RoomListSearchProps extends RoomListSearchState {
/** Callback fired when search button is clicked */
onSearchClick: () => void;
/** Callback fired when dial pad button is clicked */
onDialPadClick: () => void;
/** Callback fired when explore button is clicked */
onExploreClick: () => void;
}
/**
* A presentational search bar component for the room list.
* Displays a search button and optional action buttons (dial pad, explore) in a horizontal layout.
*/
export const RoomListSearch: React.FC<RoomListSearchProps> = ({ vm }): JSX.Element => {
const snapshot = useViewModel(vm);
export const RoomListSearch: React.FC<RoomListSearchProps> = ({
onSearchClick,
showDialPad,
onDialPadClick,
showExplore,
onExploreClick,
}): JSX.Element => {
// Determine keyboard shortcut based on platform
const isMac = typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
const searchShortcut = isMac ? "⌘ K" : "Ctrl K";
@ -59,31 +58,31 @@ export const RoomListSearch: React.FC<RoomListSearchProps> = ({ vm }): JSX.Eleme
kind="secondary"
size="sm"
Icon={SearchIcon}
onClick={snapshot.onSearchClick}
onClick={onSearchClick}
>
<Flex as="span" justify="space-between">
<span className="mx_RoomListSearch_search_text">{_t("action|search")}</span>
<kbd>{searchShortcut}</kbd>
</Flex>
</Button>
{snapshot.showDialPad && (
{showDialPad && (
<Button
kind="secondary"
size="sm"
Icon={DialPadIcon}
iconOnly={true}
aria-label={_t("left_panel|open_dial_pad")}
onClick={snapshot.onDialPadClick}
onClick={onDialPadClick}
/>
)}
{snapshot.showExplore && (
{showExplore && (
<Button
kind="secondary"
size="sm"
Icon={ExploreIcon}
iconOnly={true}
aria-label={_t("action|explore_rooms")}
onClick={snapshot.onExploreClick}
onClick={onExploreClick}
/>
)}
</Flex>

View File

@ -0,0 +1,8 @@
/*
* 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.
*/
export { RoomListSearch, type RoomListSearchProps, type RoomListSearchState } from "./RoomListSearch";

View File

@ -6,4 +6,4 @@
*/
export { RoomListSearch } from "./RoomListSearch";
export type { RoomListSearchProps, RoomListSearchSnapshot } from "./RoomListSearch";
export type { RoomListSearchState, RoomListSearchProps, RoomListSearchSnapshot } from "./RoomListSearch";

View File

@ -9,38 +9,102 @@ import React, { type JSX, type ReactNode } from "react";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { RoomListPrimaryFilters, type RoomListPrimaryFiltersSnapshot } from "../RoomListPrimaryFilters";
import { _t } from "../../utils/i18n";
import { RoomListPrimaryFilters, type Filter } from "../RoomListPrimaryFilters";
import { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
import { RoomListEmptyState } from "./RoomListEmptyState";
import { RoomList, type RoomListViewModel } from "../RoomList";
import { RoomList, type RoomListViewState } from "../RoomList";
import { type RoomListItem } from "../RoomListItem";
import { type RoomNotifState } from "../../notifications/RoomNotifs";
import { type RoomListHeaderState } from "../RoomListHeader";
import { SortOption } from "../RoomListHeader/SortOptionsMenu";
/**
* Snapshot for RoomListView
* Snapshot for the complete room list, used across RoomListPanel, RoomListView, and RoomList
* Contains all data AND all callbacks needed by the room list components
*/
export type RoomListViewWrapperSnapshot = {
export type RoomListSnapshot = {
/** Header state for the room list */
headerState: RoomListHeaderState;
/** Whether the rooms are currently loading */
isLoadingRooms: boolean;
/** Whether the room list is empty */
isRoomListEmpty: boolean;
/** View model for the primary filters */
filtersVm: ViewModel<RoomListPrimaryFiltersSnapshot>;
/** View model for the room list */
roomListVm: RoomListViewModel;
/** Title for the empty state */
emptyStateTitle: string;
/** Array of filter data (required by RoomListPrimaryFilters) */
filters: Filter[];
/** Room list state */
roomListState: RoomListViewState;
/** Optional description for the empty state */
emptyStateDescription?: string;
/** Optional action element for the empty state */
emptyStateAction?: ReactNode;
};
/**
* Actions interface for room list operations
*/
export interface RoomListViewActions {
/** Whether to show the dial pad button */
showDialPad: boolean;
/** Whether to show the explore rooms button */
showExplore: boolean;
/** Called when a filter is toggled */
onToggleFilter: (filter: Filter) => void;
/** Called when search button is clicked */
onSearchClick: () => void;
/** Called when dial pad button is clicked */
onDialPadClick: () => void;
/** Called when explore button is clicked */
onExploreClick: () => void;
/** Called when compose button is clicked */
onComposeClick: () => void;
/** Open the space home */
openSpaceHome: () => void;
/** Display the space invite dialog */
inviteInSpace: () => void;
/** Open the space preferences */
openSpacePreferences: () => void;
/** Open the space settings */
openSpaceSettings: () => void;
/** Create a chat room */
createChatRoom: () => void;
/** Create a room */
createRoom: () => void;
/** Create a video room */
createVideoRoom: () => void;
/** Change the sort order of the room-list */
sort: (option: SortOption) => void;
/** Called when a room should be opened */
onOpenRoom: (roomId: string) => void;
/** Called when a room should be marked as read */
onMarkAsRead: (roomId: string) => void;
/** Called when a room should be marked as unread */
onMarkAsUnread: (roomId: string) => void;
/** Called when a room's favorite status should be toggled */
onToggleFavorite: (roomId: string) => void;
/** Called when a room's low priority status should be toggled */
onToggleLowPriority: (roomId: string) => void;
/** Called when inviting users to a room */
onInvite: (roomId: string) => void;
/** Called when copying a room link */
onCopyRoomLink: (roomId: string) => void;
/** Called when leaving a room */
onLeaveRoom: (roomId: string) => void;
/** Called when setting room notification state */
onSetRoomNotifState: (roomId: string, notifState: RoomNotifState) => void;
}
/**
* The view model type for the room list
*/
export type RoomListViewModel = ViewModel<RoomListSnapshot> & RoomListViewActions;
/**
* Props for RoomListView component
*/
export interface RoomListViewProps {
/** The view model containing list data */
vm: ViewModel<RoomListViewWrapperSnapshot>;
/** The view model containing all data and callbacks */
vm: RoomListViewModel;
/** Render function for room avatar */
renderAvatar: (roomItem: RoomListItem) => ReactNode;
}
@ -58,18 +122,18 @@ export const RoomListView: React.FC<RoomListViewProps> = ({ vm, renderAvatar }):
} else if (snapshot.isRoomListEmpty) {
listBody = (
<RoomListEmptyState
title={snapshot.emptyStateTitle}
title={_t("room_list|empty_no_rooms")}
description={snapshot.emptyStateDescription}
action={snapshot.emptyStateAction}
/>
);
} else {
listBody = <RoomList vm={snapshot.roomListVm} renderAvatar={renderAvatar} />;
listBody = <RoomList vm={vm} renderAvatar={renderAvatar} />;
}
return (
<>
<RoomListPrimaryFilters vm={snapshot.filtersVm} />
<RoomListPrimaryFilters filters={snapshot.filters} onToggleFilter={vm.onToggleFilter} />
{listBody}
</>
);

View File

@ -6,7 +6,10 @@
*/
export { RoomListView } from "./RoomListView";
export type { RoomListViewProps, RoomListViewWrapperSnapshot } from "./RoomListView";
export type { RoomListViewProps, RoomListSnapshot, RoomListViewActions, RoomListViewModel } from "./RoomListView";
export type { RoomListHeaderState } from "../RoomListHeader";
export type { RoomListSearchState } from "../RoomListSearch";
export type { RoomListViewState } from "../RoomList";
export { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
export { RoomListEmptyState } from "./RoomListEmptyState";
export type { RoomListEmptyStateProps } from "./RoomListEmptyState";

View File

@ -1,87 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { BaseViewModel, type ComposeMenuSnapshot } from "@element-hq/web-shared-components";
import { RoomType, type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
import { hasCreateRoomRights, createRoom as createRoomFunc } from "./utils";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import SettingsStore from "../../../settings/SettingsStore";
import { showCreateNewRoom } from "../../../utils/space";
interface ComposeMenuViewModelProps {
client: MatrixClient;
}
/**
* ViewModel for the ComposeMenu component.
* Manages room creation actions.
*/
export class ComposeMenuViewModel extends BaseViewModel<ComposeMenuSnapshot, ComposeMenuViewModelProps> {
private activeSpace: Room | null = null;
public constructor(props: ComposeMenuViewModelProps) {
super(props, ComposeMenuViewModel.createSnapshot(SpaceStore.instance.activeSpaceRoom, props.client));
this.activeSpace = SpaceStore.instance.activeSpaceRoom;
// Listen to space changes
this.disposables.trackListener(SpaceStore.instance, UPDATE_SELECTED_SPACE as any, this.onSpaceChanged);
}
private static createSnapshot(activeSpace: Room | null, client: MatrixClient): ComposeMenuSnapshot {
const canCreateRoom = hasCreateRoomRights(client, activeSpace);
const canCreateVideoRoom = SettingsStore.getValue("feature_video_rooms") && canCreateRoom;
return {
canCreateRoom,
canCreateVideoRoom,
createChatRoom: ComposeMenuViewModel.createChatRoom,
createRoom: () => ComposeMenuViewModel.createRoom(activeSpace),
createVideoRoom: () => ComposeMenuViewModel.createVideoRoom(activeSpace),
};
}
private onSpaceChanged = (): void => {
this.activeSpace = SpaceStore.instance.activeSpaceRoom;
const canCreateRoom = hasCreateRoomRights(this.props.client, this.activeSpace);
const canCreateVideoRoom = SettingsStore.getValue("feature_video_rooms") && canCreateRoom;
this.snapshot.merge({
canCreateRoom,
canCreateVideoRoom,
createRoom: () => ComposeMenuViewModel.createRoom(this.activeSpace),
createVideoRoom: () => ComposeMenuViewModel.createVideoRoom(this.activeSpace),
});
};
private static createChatRoom = (): void => {
defaultDispatcher.fire(Action.CreateChat);
};
private static createRoom = (activeSpace: Room | null): void => {
createRoomFunc(activeSpace);
};
private static createVideoRoom = (activeSpace: Room | null): void => {
const elementCallVideoRoomsEnabled = SettingsStore.getValue("feature_element_call_video_rooms");
const type = elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo;
if (activeSpace) {
showCreateNewRoom(activeSpace, type);
} else {
defaultDispatcher.dispatch({
action: Action.CreateRoom,
type,
});
}
};
}

View File

@ -1,149 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { BaseViewModel, type RoomListHeaderSnapshot } from "@element-hq/web-shared-components";
import { RoomEvent, type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { getMetaSpaceName, type MetaSpace, type SpaceKey, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
import { hasCreateRoomRights } from "./utils";
import { SortOptionsMenuViewModel } from "./SortOptionsMenuViewModel";
import { SpaceMenuViewModel } from "./SpaceMenuViewModel";
import { ComposeMenuViewModel } from "./ComposeMenuViewModel";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
interface RoomListHeaderViewModelProps {
client: MatrixClient;
}
/**
* ViewModel for the RoomListHeader component.
* Manages header display and actions.
*/
export class RoomListHeaderViewModel extends BaseViewModel<
RoomListHeaderSnapshot,
RoomListHeaderViewModelProps
> {
private activeSpace: Room | null = null;
private sortOptionsMenuVm: SortOptionsMenuViewModel;
private spaceMenuVm: SpaceMenuViewModel;
private composeMenuVm: ComposeMenuViewModel;
public constructor(props: RoomListHeaderViewModelProps) {
const activeSpace = SpaceStore.instance.activeSpaceRoom;
// Create child ViewModels
const sortOptionsMenuVm = new SortOptionsMenuViewModel({ client: props.client });
const spaceMenuVm = new SpaceMenuViewModel({ client: props.client });
const composeMenuVm = new ComposeMenuViewModel({ client: props.client });
super(props, RoomListHeaderViewModel.createSnapshot(
SpaceStore.instance.activeSpace,
activeSpace,
SpaceStore.instance.allRoomsInHome,
props.client,
sortOptionsMenuVm,
spaceMenuVm,
composeMenuVm,
));
this.activeSpace = activeSpace;
this.sortOptionsMenuVm = sortOptionsMenuVm;
this.spaceMenuVm = spaceMenuVm;
this.composeMenuVm = composeMenuVm;
// Listen to space changes
this.disposables.trackListener(SpaceStore.instance, UPDATE_SELECTED_SPACE as any, this.onSpaceChanged);
this.disposables.trackListener(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR as any, this.onHomeBehaviourChanged);
// Listen to room name changes if there's an active space
if (this.activeSpace) {
this.disposables.trackListener(this.activeSpace, RoomEvent.Name, this.onRoomNameChanged);
}
}
private static createSnapshot(
spaceKey: SpaceKey,
activeSpace: Room | null,
allRoomsInHome: boolean,
client: MatrixClient,
sortOptionsMenuVm: SortOptionsMenuViewModel,
spaceMenuVm: SpaceMenuViewModel,
composeMenuVm: ComposeMenuViewModel,
): RoomListHeaderSnapshot {
const spaceName = activeSpace?.name;
const title = spaceName ?? getMetaSpaceName(spaceKey as MetaSpace, allRoomsInHome);
const isSpace = Boolean(activeSpace);
const canCreateRoom = hasCreateRoomRights(client, activeSpace);
const displayComposeMenu = canCreateRoom;
return {
title,
isSpace,
spaceMenuVm: isSpace ? spaceMenuVm : undefined,
displayComposeMenu,
composeMenuVm: displayComposeMenu ? composeMenuVm : undefined,
onComposeClick: !displayComposeMenu ? RoomListHeaderViewModel.createChatRoom : undefined,
sortOptionsMenuVm,
};
}
private onSpaceChanged = (): void => {
// Remove listener from old space
if (this.activeSpace) {
this.activeSpace.off(RoomEvent.Name, this.onRoomNameChanged);
}
this.activeSpace = SpaceStore.instance.activeSpaceRoom;
// Add listener to new space
if (this.activeSpace) {
this.disposables.trackListener(this.activeSpace, RoomEvent.Name, this.onRoomNameChanged);
}
const spaceKey = SpaceStore.instance.activeSpace;
const spaceName = this.activeSpace?.name;
const title = spaceName ?? getMetaSpaceName(spaceKey as MetaSpace, SpaceStore.instance.allRoomsInHome);
const isSpace = Boolean(this.activeSpace);
const canCreateRoom = hasCreateRoomRights(this.props.client, this.activeSpace);
const displayComposeMenu = canCreateRoom;
this.snapshot.merge({
title,
isSpace,
spaceMenuVm: isSpace ? this.spaceMenuVm : undefined,
displayComposeMenu,
composeMenuVm: displayComposeMenu ? this.composeMenuVm : undefined,
onComposeClick: !displayComposeMenu ? RoomListHeaderViewModel.createChatRoom : undefined,
});
};
private onHomeBehaviourChanged = (): void => {
const spaceKey = SpaceStore.instance.activeSpace;
const spaceName = this.activeSpace?.name;
const title = spaceName ?? getMetaSpaceName(spaceKey as MetaSpace, SpaceStore.instance.allRoomsInHome);
this.snapshot.merge({ title });
};
private onRoomNameChanged = (): void => {
if (this.activeSpace) {
this.snapshot.merge({ title: this.activeSpace.name });
}
};
private static createChatRoom = (): void => {
defaultDispatcher.fire(Action.CreateChat);
};
public override dispose(): void {
this.sortOptionsMenuVm.dispose();
this.spaceMenuVm.dispose();
this.composeMenuVm.dispose();
super.dispose();
}
}

View File

@ -1,61 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { BaseViewModel, type RoomListPanelSnapshot } from "@element-hq/web-shared-components";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import { RoomListSearchViewModel } from "./RoomListSearchViewModel";
import { RoomListHeaderViewModel } from "./RoomListHeaderViewModel";
import { RoomListViewViewModel } from "./RoomListViewViewModel";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
interface RoomListPanelViewModelProps {
client: MatrixClient;
}
/**
* Top-level ViewModel for the RoomListPanel component.
* Composes search, header, and view ViewModels.
*/
export class RoomListPanelViewModel extends BaseViewModel<RoomListPanelSnapshot, RoomListPanelViewModelProps> {
private searchVm: RoomListSearchViewModel | undefined;
private headerVm: RoomListHeaderViewModel;
private viewVm: RoomListViewViewModel;
public constructor(props: RoomListPanelViewModelProps) {
// Initialize child ViewModels
const displayRoomSearch = shouldShowComponent(UIComponent.FilterContainer);
const searchVm = displayRoomSearch ? new RoomListSearchViewModel({ client: props.client }) : undefined;
const headerVm = new RoomListHeaderViewModel({ client: props.client });
const viewVm = new RoomListViewViewModel({ client: props.client });
super(props, {
ariaLabel: _t("room_list|list_title"),
searchVm,
headerVm,
viewVm,
});
this.searchVm = searchVm;
this.headerVm = headerVm;
this.viewVm = viewVm;
// Subscribe to child ViewModels to propagate updates
// Note: We don't need to update our snapshot when children update,
// because the child VM references stay the same and React will
// pick up changes from the child VMs directly via their own subscriptions
}
public override dispose(): void {
this.searchVm?.dispose();
this.headerVm.dispose();
this.viewVm.dispose();
super.dispose();
}
}

View File

@ -1,93 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { BaseViewModel, type RoomListPrimaryFiltersSnapshot } from "@element-hq/web-shared-components";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
import { _t, _td, type TranslationKey } from "../../../languageHandler";
import RoomListStoreV3, { RoomListStoreV3Event } from "../../../stores/room-list-v3/RoomListStoreV3";
interface RoomListPrimaryFiltersViewModelProps {
client: MatrixClient;
}
const filterKeyToNameMap: Map<FilterKey, TranslationKey> = new Map([
[FilterKey.UnreadFilter, _td("room_list|filters|unread")],
[FilterKey.PeopleFilter, _td("room_list|filters|people")],
[FilterKey.RoomsFilter, _td("room_list|filters|rooms")],
[FilterKey.FavouriteFilter, _td("room_list|filters|favourite")],
[FilterKey.MentionsFilter, _td("room_list|filters|mentions")],
[FilterKey.InvitesFilter, _td("room_list|filters|invites")],
[FilterKey.LowPriorityFilter, _td("room_list|filters|low_priority")],
]);
/**
* ViewModel for the RoomListPrimaryFilters component.
* Manages the primary filter pills above the room list.
*/
export class RoomListPrimaryFiltersViewModel extends BaseViewModel<
RoomListPrimaryFiltersSnapshot,
RoomListPrimaryFiltersViewModelProps
> {
private activeFilter: FilterKey | undefined = undefined;
private toggleCallback: ((key: FilterKey) => void) | undefined = undefined;
public constructor(props: RoomListPrimaryFiltersViewModelProps) {
super(props, RoomListPrimaryFiltersViewModel.createInitialSnapshot());
// Listen to room list updates
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.ListsUpdate as any,
this.onListsUpdate,
);
}
private static createInitialSnapshot(): RoomListPrimaryFiltersSnapshot {
const filters = [];
for (const [key, name] of filterKeyToNameMap.entries()) {
filters.push({
name: _t(name),
active: false,
toggle: () => {}, // Will be set by setToggleCallback
});
}
return { filters };
}
private createSnapshot(): RoomListPrimaryFiltersSnapshot {
const filters = [];
for (const [key, name] of filterKeyToNameMap.entries()) {
filters.push({
name: _t(name),
active: this.activeFilter === key,
toggle: () => this.toggleCallback?.(key),
});
}
return { filters };
}
private onListsUpdate = (): void => {
// Regenerate filters with current active state
this.snapshot.set(this.createSnapshot());
};
public setToggleCallback(callback: (key: FilterKey) => void): void {
this.toggleCallback = callback;
this.snapshot.set(this.createSnapshot());
}
public setActiveFilter(filter: FilterKey | undefined): void {
this.activeFilter = filter;
this.snapshot.set(this.createSnapshot());
}
}

View File

@ -1,86 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { BaseViewModel, type RoomListSearchSnapshot } from "@element-hq/web-shared-components";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { MetaSpace } from "../../../stores/spaces";
import { Action } from "../../../dispatcher/actions";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
interface RoomListSearchViewModelProps {
client: MatrixClient;
}
/**
* ViewModel for the RoomListSearch component.
* Manages search, explore, and dial pad buttons.
*/
export class RoomListSearchViewModel extends BaseViewModel<
RoomListSearchSnapshot,
RoomListSearchViewModelProps
> {
public constructor(props: RoomListSearchViewModelProps) {
super(props, RoomListSearchViewModel.createSnapshot());
// Listen to space changes
this.disposables.trackListener(SpaceStore.instance, UPDATE_SELECTED_SPACE as any, this.onSpaceChanged);
// Listen to protocol support changes
this.disposables.trackListener(LegacyCallHandler.instance, LegacyCallHandlerEvent.ProtocolSupport, this.onProtocolChanged);
}
private static createSnapshot(): RoomListSearchSnapshot {
const activeSpace = SpaceStore.instance.activeSpace;
const displayExploreButton = activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms);
const displayDialButton = LegacyCallHandler.instance.getSupportsPstnProtocol() ?? false;
return {
onSearchClick: RoomListSearchViewModel.onSearchClick,
showDialPad: displayDialButton,
onDialPadClick: displayDialButton ? RoomListSearchViewModel.onDialPadClick : undefined,
showExplore: displayExploreButton,
onExploreClick: displayExploreButton ? RoomListSearchViewModel.onExploreClick : undefined,
};
}
private onSpaceChanged = (): void => {
const activeSpace = SpaceStore.instance.activeSpace;
const displayExploreButton = activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms);
this.snapshot.merge({
showExplore: displayExploreButton,
onExploreClick: displayExploreButton ? RoomListSearchViewModel.onExploreClick : undefined,
});
};
private onProtocolChanged = (): void => {
const displayDialButton = LegacyCallHandler.instance.getSupportsPstnProtocol() ?? false;
this.snapshot.merge({
showDialPad: displayDialButton,
onDialPadClick: displayDialButton ? RoomListSearchViewModel.onDialPadClick : undefined,
});
};
private static onSearchClick = (): void => {
defaultDispatcher.fire(Action.OpenSpotlight);
};
private static onExploreClick = (): void => {
defaultDispatcher.fire(Action.ViewRoomDirectory);
};
private static onDialPadClick = (): void => {
defaultDispatcher.fire(Action.OpenDialPad);
};
}

View File

@ -5,97 +5,352 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { BaseViewModel, type RoomListViewModel as RoomListVMType, type RoomListItem, type RoomNotifState } from "@element-hq/web-shared-components";
import type { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import {
BaseViewModel,
type RoomListSnapshot,
type RoomListHeaderState,
type SpaceMenuState,
type ComposeMenuState,
type Filter,
type RoomListItem,
type RoomNotifState,
type RoomListViewActions,
SortOption,
type RoomListViewState,
} from "@element-hq/web-shared-components";
import {
type MatrixClient,
type Room,
RoomEvent,
JoinRule,
RoomType,
} from "matrix-js-sdk/src/matrix";
import RoomListStoreV3, { RoomListStoreV3Event, type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3";
import dispatcher from "../../../dispatcher/dispatcher";
import { _t, _td, type TranslationKey } from "../../../languageHandler";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { MetaSpace, getMetaSpaceName, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
import { Action } from "../../../dispatcher/actions";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import dispatcher from "../../../dispatcher/dispatcher";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { type ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import {
shouldShowSpaceSettings,
showSpaceInvite,
showSpacePreferences,
showSpaceSettings,
showCreateNewRoom,
} from "../../../utils/space";
import SettingsStore from "../../../settings/SettingsStore";
import RoomListStoreV3, { RoomListStoreV3Event, type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3";
import { SortingAlgorithm } from "../../../stores/room-list-v3/skip-list/sorters";
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
import { RoomNotificationStateStore, UPDATE_STATUS_INDICATOR } from "../../../stores/notifications/RoomNotificationStateStore";
import { DefaultTagID } from "../../../stores/room-list/models";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
import { DefaultTagID } from "../../../stores/room-list/models";
import { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications";
import { tagRoom } from "../../../utils/room/tagRoom";
import DMRoomMap from "../../../utils/DMRoomMap";
import { NotificationLevel } from "../../../stores/notifications/NotificationLevel";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils";
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu, hasCreateRoomRights, createRoom as createRoomFunc } from "./utils";
import { EchoChamber } from "../../../stores/local-echo/EchoChamber";
import { RoomNotifState as ElementRoomNotifState } from "../../../RoomNotifs";
import { SdkContextClass } from "../../../contexts/SDKContext";
interface RoomListViewModelProps {
client: MatrixClient;
activeFilter?: FilterKey;
}
const filterKeyToNameMap: Map<FilterKey, TranslationKey> = new Map([
[FilterKey.UnreadFilter, _td("room_list|filters|unread")],
[FilterKey.PeopleFilter, _td("room_list|filters|people")],
[FilterKey.RoomsFilter, _td("room_list|filters|rooms")],
[FilterKey.FavouriteFilter, _td("room_list|filters|favourite")],
[FilterKey.MentionsFilter, _td("room_list|filters|mentions")],
[FilterKey.InvitesFilter, _td("room_list|filters|invites")],
[FilterKey.LowPriorityFilter, _td("room_list|filters|low_priority")],
]);
/**
* ViewModel for the RoomList component.
* Manages the room list data and actions.
* Consolidated ViewModel for the entire RoomListPanel component.
* Manages search, header, filters, and room list state in a single class.
* Implements RoomListViewActions to provide room action callbacks.
*/
export class RoomListViewModel extends BaseViewModel<any, RoomListViewModelProps>
implements Omit<RoomListVMType, 'getSnapshot' | 'subscribe'> {
export class RoomListViewModel
extends BaseViewModel<RoomListSnapshot, RoomListViewModelProps>
implements RoomListViewActions
{
// State tracking
private activeSpace: Room | null = null;
private displayRoomSearch: boolean;
private activeFilter: FilterKey | undefined = undefined;
private roomsResult: RoomsResult;
private activeFilter: FilterKey | undefined;
public constructor(props: RoomListWrapperViewModelProps) {
const roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(
props.activeFilter ? [props.activeFilter] : undefined
);
// Search state properties (not in snapshot)
public showDialPad: boolean = false;
public showExplore: boolean = false;
super(props, RoomListViewModel.createSnapshot(roomsResult, props.client));
public constructor(props: RoomListViewModelProps) {
const displayRoomSearch = shouldShowComponent(UIComponent.FilterContainer);
const activeSpace = SpaceStore.instance.activeSpaceRoom;
// Get initial rooms
const roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(undefined);
super(props, {
headerState: RoomListViewModel.createHeaderState(
SpaceStore.instance.activeSpace,
activeSpace,
SpaceStore.instance.allRoomsInHome,
props.client,
),
// Initial view state
isLoadingRooms: RoomListStoreV3.instance.isLoadingRooms,
isRoomListEmpty: roomsResult.rooms.length === 0,
filters: RoomListViewModel.createFilters(undefined),
roomListState: RoomListViewModel.createRoomListState(roomsResult, props.client),
});
this.displayRoomSearch = displayRoomSearch;
this.activeSpace = activeSpace;
this.roomsResult = roomsResult;
this.activeFilter = props.activeFilter;
// Listen to room list updates
// Initialize search state
this.showDialPad = LegacyCallHandler.instance.getSupportsPstnProtocol() ?? false;
this.showExplore = SpaceStore.instance.activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms);
// Subscribe to search-related changes if search is enabled
if (this.displayRoomSearch) {
this.disposables.trackListener(
LegacyCallHandler.instance,
LegacyCallHandlerEvent.ProtocolSupport,
this.onProtocolChanged,
);
}
// Subscribe to space changes
this.disposables.trackListener(SpaceStore.instance, UPDATE_SELECTED_SPACE as any, this.onSpaceChanged);
this.disposables.trackListener(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR as any, this.onHomeBehaviourChanged);
// Subscribe to room name changes if there's an active space
if (this.activeSpace) {
this.disposables.trackListener(this.activeSpace, RoomEvent.Name, this.onRoomNameChanged);
}
// Subscribe to room list updates
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.ListsUpdate as any,
this.onListsUpdate,
);
// Listen to notification state changes
// Subscribe to room list loaded
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.ListsLoaded as any,
this.onListsLoaded,
);
// Subscribe to notification state changes
this.disposables.trackListener(
RoomNotificationStateStore.instance,
UPDATE_STATUS_INDICATOR as any,
this.onNotificationUpdate,
);
// Listen to message preview changes
// Subscribe to message preview changes
this.disposables.trackListener(
MessagePreviewStore.instance,
UPDATE_EVENT,
this.onMessagePreviewUpdate,
);
// Listen to ViewRoomDelta action for keyboard navigation
this.disposables.trackDispatcher(dispatcher, this.onDispatch);
// Subscribe to dispatcher for keyboard navigation
const dispatcherRef = dispatcher.register(this.onDispatch);
this.disposables.track(() => {
dispatcher.unregister(dispatcherRef);
});
} // ==================== Search Actions ====================
public onSearchClick = (): void => {
defaultDispatcher.fire(Action.OpenSpotlight);
};
public onExploreClick = (): void => {
defaultDispatcher.fire(Action.ViewRoomDirectory);
};
public onDialPadClick = (): void => {
defaultDispatcher.fire(Action.OpenDialPad);
};
public onComposeClick = (): void => {
this.createChatRoom();
};
private onProtocolChanged = (): void => {
this.showDialPad = LegacyCallHandler.instance.getSupportsPstnProtocol() ?? false;
};
// ==================== Header State ====================
private static createHeaderState(
spaceKey: string,
activeSpace: Room | null,
allRoomsInHome: boolean,
client: MatrixClient,
): RoomListHeaderState {
const spaceName = activeSpace?.name;
const title = spaceName ?? getMetaSpaceName(spaceKey as MetaSpace, allRoomsInHome);
const isSpace = Boolean(activeSpace);
const canCreateRoom = hasCreateRoomRights(client, activeSpace);
const displayComposeMenu = canCreateRoom;
// Create space menu state (data only, no callbacks)
const spaceMenuState: SpaceMenuState | undefined = isSpace
? {
title: activeSpace?.name ?? "",
canInviteInSpace: Boolean(
activeSpace?.getJoinRule() === JoinRule.Public ||
activeSpace?.canInvite(client.getSafeUserId()),
),
canAccessSpaceSettings: Boolean(activeSpace && shouldShowSpaceSettings(activeSpace)),
}
: undefined;
// Create compose menu state (data only, no callbacks)
const canCreateVideoRoom = SettingsStore.getValue("feature_video_rooms") && canCreateRoom;
const composeMenuState: ComposeMenuState | undefined = displayComposeMenu
? {
canCreateRoom,
canCreateVideoRoom,
}
: undefined;
// Get active sort option
const activeSortingAlgorithm = SettingsStore.getValue("RoomList.preferredSorting");
const activeSortOption =
activeSortingAlgorithm === SortingAlgorithm.Alphabetic ? SortOption.AToZ : SortOption.Activity;
return {
title,
isSpace,
spaceMenuState,
displayComposeMenu,
composeMenuState,
activeSortOption,
};
}
private static createSnapshot(
roomsResult: RoomsResult,
client: MatrixClient,
): any {
// Space menu actions
public openSpaceHome = (): void => {
if (!this.activeSpace) return;
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: this.activeSpace.roomId,
metricsTrigger: undefined,
});
};
public inviteInSpace = (): void => {
if (!this.activeSpace) return;
showSpaceInvite(this.activeSpace);
};
public openSpacePreferences = (): void => {
if (!this.activeSpace) return;
showSpacePreferences(this.activeSpace);
};
public openSpaceSettings = (): void => {
if (!this.activeSpace) return;
showSpaceSettings(this.activeSpace);
};
// Compose menu actions
public createChatRoom = (): void => {
defaultDispatcher.fire(Action.CreateChat);
};
public createRoom = (): void => {
createRoomFunc(this.activeSpace);
};
public createVideoRoom = (): void => {
const elementCallVideoRoomsEnabled = SettingsStore.getValue("feature_element_call_video_rooms");
const type = elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo;
if (this.activeSpace) {
showCreateNewRoom(this.activeSpace, type);
} else {
defaultDispatcher.dispatch({
action: Action.CreateRoom,
type,
});
}
};
// Sort options actions
public sort = (option: SortOption): void => {
const sortingAlgorithm =
option === SortOption.AToZ ? SortingAlgorithm.Alphabetic : SortingAlgorithm.Recency;
RoomListStoreV3.instance.resort(sortingAlgorithm);
};
// ==================== Filters ====================
private static createFilters(activeFilter: FilterKey | undefined): Filter[] {
const filters = [];
for (const [key, name] of filterKeyToNameMap.entries()) {
filters.push({
name: _t(name),
active: activeFilter === key,
});
}
return filters;
}
public onToggleFilter = (filter: Filter): void => {
// Find the FilterKey by matching the translated filter name
let filterKey: FilterKey | undefined = undefined;
for (const [key, name] of filterKeyToNameMap.entries()) {
if (_t(name) === filter.name) {
filterKey = key;
break;
}
}
if (filterKey === undefined) return;
// Toggle the filter - if it's already active, deactivate it
const newFilter = this.activeFilter === filterKey ? undefined : filterKey;
this.activeFilter = newFilter;
// Update rooms result with new filter
const filterKeys = this.activeFilter !== undefined ? [this.activeFilter] : undefined;
this.roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filterKeys);
this.updateRoomListData();
};
// ==================== Room List State ====================
private static createRoomListState(roomsResult: RoomsResult, client: MatrixClient): RoomListViewState {
// Transform rooms into RoomListItems
const roomListItems: RoomListItem[] = roomsResult.rooms.map((room) => {
return RoomListViewModel.roomToListItem(room, client);
});
return {
roomsResult: {
spaceId: roomsResult.spaceId,
filterKeys: roomsResult.filterKeys,
rooms: roomListItems,
},
rooms: roomListItems,
activeRoomIndex: undefined,
onKeyDown: undefined,
spaceId: roomsResult.spaceId,
filterKeys: roomsResult.filterKeys?.map(k => String(k)),
};
}
@ -167,39 +422,136 @@ export class RoomListViewModel extends BaseViewModel<any, RoomListViewModelProps
};
}
private onListsUpdate = (): void => {
// ==================== Event Handlers ====================
private onSpaceChanged = (): void => {
// Remove listener from old space
if (this.activeSpace) {
this.activeSpace.off(RoomEvent.Name, this.onRoomNameChanged);
}
this.activeSpace = SpaceStore.instance.activeSpaceRoom;
// Add listener to new space
if (this.activeSpace) {
this.disposables.trackListener(this.activeSpace, RoomEvent.Name, this.onRoomNameChanged);
}
// Update showExplore based on new space
const activeSpace = SpaceStore.instance.activeSpace;
this.showExplore = activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms);
// Update header state
const headerState = RoomListViewModel.createHeaderState(
SpaceStore.instance.activeSpace,
this.activeSpace,
SpaceStore.instance.allRoomsInHome,
this.props.client,
);
this.snapshot.merge({ headerState });
// Update rooms list
const filterKeys = this.activeFilter !== undefined ? [this.activeFilter] : undefined;
this.roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filterKeys);
this.updateRoomListData();
};
// Transform rooms into RoomListItems
const roomListItems: RoomListItem[] = this.roomsResult.rooms.map((room) => {
return RoomListViewModel.roomToListItem(room, this.props.client);
});
private onHomeBehaviourChanged = (): void => {
const spaceKey = SpaceStore.instance.activeSpace;
const spaceName = this.activeSpace?.name;
const title = spaceName ?? getMetaSpaceName(spaceKey as MetaSpace, SpaceStore.instance.allRoomsInHome);
const currentHeaderState = this.snapshot.current.headerState;
this.snapshot.merge({
roomsResult: {
spaceId: this.roomsResult.spaceId,
filterKeys: this.roomsResult.filterKeys,
rooms: roomListItems,
headerState: {
...currentHeaderState,
title,
},
});
};
public setActiveFilter(filter: FilterKey | undefined): void {
this.activeFilter = filter;
this.onListsUpdate();
}
private onRoomNameChanged = (): void => {
if (this.activeSpace) {
const title = this.activeSpace.name;
const isSpace = Boolean(this.activeSpace);
// Update space menu state with new name
const spaceMenuState: SpaceMenuState | undefined = isSpace
? {
title,
canInviteInSpace: Boolean(
this.activeSpace?.getJoinRule() === JoinRule.Public ||
this.activeSpace?.canInvite(this.props.client.getSafeUserId()),
),
canAccessSpaceSettings: Boolean(this.activeSpace && shouldShowSpaceSettings(this.activeSpace)),
}
: undefined;
const currentHeaderState = this.snapshot.current.headerState;
this.snapshot.merge({
headerState: {
...currentHeaderState,
title,
spaceMenuState,
},
});
}
};
private onListsUpdate = (): void => {
// Update sort options in header
const activeSortingAlgorithm = SettingsStore.getValue("RoomList.preferredSorting");
const activeSortOption =
activeSortingAlgorithm === SortingAlgorithm.Alphabetic ? SortOption.AToZ : SortOption.Activity;
const currentHeaderState = this.snapshot.current.headerState;
this.snapshot.merge({
headerState: {
...currentHeaderState,
activeSortOption,
},
});
// Update rooms list
const filterKeys = this.activeFilter !== undefined ? [this.activeFilter] : undefined;
this.roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filterKeys);
this.updateRoomListData();
};
private onListsLoaded = (): void => {
// Room lists have finished loading
this.snapshot.merge({
isLoadingRooms: false,
});
};
private onNotificationUpdate = (): void => {
// Notification states changed, update room list items
this.onListsUpdate();
this.updateRoomListData();
};
private onMessagePreviewUpdate = (): void => {
// Message previews changed, update room list items
this.onListsUpdate();
this.updateRoomListData();
};
private updateRoomListData(): void {
// Update the snapshot with fresh room list data
const filters = RoomListViewModel.createFilters(this.activeFilter);
const roomListState = RoomListViewModel.createRoomListState(this.roomsResult, this.props.client);
const isRoomListEmpty = this.roomsResult.rooms.length === 0;
const isLoadingRooms = RoomListStoreV3.instance.isLoadingRooms;
this.snapshot.merge({
isLoadingRooms,
isRoomListEmpty,
filters,
roomListState,
});
}
// ==================== Keyboard Navigation ====================
private onDispatch = (payload: any): void => {
if (payload.action !== Action.ViewRoomDelta) return;
@ -207,10 +559,10 @@ export class RoomListViewModel extends BaseViewModel<any, RoomListViewModelProps
if (!currentRoomId) return;
const { delta, unread } = payload as ViewRoomDeltaPayload;
// Get the rooms list to navigate through
const rooms = this.roomsResult.rooms;
// Filter rooms if unread navigation is requested
const filteredRooms = unread
? rooms.filter((room) => {
@ -237,7 +589,7 @@ export class RoomListViewModel extends BaseViewModel<any, RoomListViewModelProps
});
};
// Action implementations - using exact logic from RoomListItemMenuViewModel
// ==================== Room Action Handlers ====================
public onOpenRoom = (roomId: string): void => {
dispatcher.dispatch<ViewRoomPayload>({
@ -252,7 +604,7 @@ export class RoomListViewModel extends BaseViewModel<any, RoomListViewModelProps
if (!room) return;
await clearRoomNotification(room, this.props.client);
// Trigger immediate update for optimistic UI
this.onListsUpdate();
this.updateRoomListData();
};
public onMarkAsUnread = async (roomId: string): Promise<void> => {
@ -260,7 +612,7 @@ export class RoomListViewModel extends BaseViewModel<any, RoomListViewModelProps
if (!room) return;
await setMarkedUnreadState(room, this.props.client, true);
// Trigger immediate update for optimistic UI
this.onListsUpdate();
this.updateRoomListData();
};
public onToggleFavorite = (roomId: string): void => {
@ -268,7 +620,7 @@ export class RoomListViewModel extends BaseViewModel<any, RoomListViewModelProps
if (!room) return;
tagRoom(room, DefaultTagID.Favourite);
// Trigger immediate update for optimistic UI
this.onListsUpdate();
this.updateRoomListData();
};
public onToggleLowPriority = (roomId: string): void => {
@ -276,7 +628,7 @@ export class RoomListViewModel extends BaseViewModel<any, RoomListViewModelProps
if (!room) return;
tagRoom(room, DefaultTagID.LowPriority);
// Trigger immediate update for optimistic UI
this.onListsUpdate();
this.updateRoomListData();
};
public onInvite = (roomId: string): void => {
@ -332,6 +684,6 @@ export class RoomListViewModel extends BaseViewModel<any, RoomListViewModelProps
// Trigger immediate update for optimistic UI
// Use setTimeout to allow the echo chamber to update first
setTimeout(() => this.onListsUpdate(), 0);
setTimeout(() => this.updateRoomListData(), 0);
};
}

View File

@ -1,118 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { BaseViewModel, type RoomListViewWrapperSnapshot } from "@element-hq/web-shared-components";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import RoomListStoreV3, { RoomListStoreV3Event } from "../../../stores/room-list-v3/RoomListStoreV3";
import { RoomListPrimaryFiltersViewModel } from "./RoomListPrimaryFiltersViewModel";
import { RoomListViewModel } from "./RoomListViewModel";
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
import { _t } from "../../../languageHandler";
interface RoomListViewViewModelProps {
client: MatrixClient;
}
/**
* ViewModel for the RoomListView wrapper.
* Manages filters, loading state, empty state, and the room list.
*/
export class RoomListViewViewModel extends BaseViewModel<
RoomListViewWrapperSnapshot,
RoomListViewViewModelProps
> {
private filtersVm: RoomListPrimaryFiltersViewModel;
private roomListVm: RoomListViewModel;
private activeFilter: FilterKey | undefined = undefined;
public constructor(props: RoomListViewViewModelProps) {
const isLoadingRooms = RoomListStoreV3.instance.isLoadingRooms;
const filtersVm = new RoomListPrimaryFiltersViewModel({ client: props.client });
const roomListVm = new RoomListViewModel({ client: props.client, activeFilter: undefined });
super(props, RoomListViewViewModel.createSnapshot(
isLoadingRooms,
filtersVm,
roomListVm,
));
this.filtersVm = filtersVm;
this.roomListVm = roomListVm;
// Set up filter toggle callback
this.filtersVm.setToggleCallback(this.onToggleFilter);
// Listen to room list loaded event
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.ListsLoaded as any,
this.onListsLoaded,
);
// Listen to room list updates
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.ListsUpdate as any,
this.onListsUpdate,
);
}
private static createSnapshot(
isLoadingRooms: boolean,
filtersVm: RoomListPrimaryFiltersViewModel,
roomListVm: RoomListViewModel,
): RoomListViewWrapperSnapshot {
const roomsResult = roomListVm.getSnapshot().roomsResult;
const isRoomListEmpty = roomsResult.rooms.length === 0;
return {
isLoadingRooms,
isRoomListEmpty,
filtersVm,
roomListVm,
emptyStateTitle: "No rooms",
emptyStateDescription: "Start a chat or join a room to see it here",
emptyStateAction: undefined,
};
}
private onListsLoaded = (): void => {
this.snapshot.merge({ isLoadingRooms: false });
};
private onListsUpdate = (): void => {
// Child ViewModels will handle their own updates
// Just update empty state based on current room list
const roomsResult = this.roomListVm.getSnapshot().roomsResult;
const isRoomListEmpty = roomsResult.rooms.length === 0;
this.snapshot.merge({ isRoomListEmpty });
};
private onToggleFilter = (filterKey: FilterKey): void => {
// Toggle the filter - if it's already active, deactivate it
const newFilter = this.activeFilter === filterKey ? undefined : filterKey;
this.activeFilter = newFilter;
// Update the filters ViewModel to show which filter is active
this.filtersVm.setActiveFilter(newFilter);
// Update the room list ViewModel with the new filter
this.roomListVm.setActiveFilter(newFilter);
// Update empty state based on current room list
const roomsResult = this.roomListVm.getSnapshot().roomsResult;
const isRoomListEmpty = roomsResult.rooms.length === 0;
this.snapshot.merge({ isRoomListEmpty });
};
public override dispose(): void {
this.filtersVm.dispose();
this.roomListVm.dispose();
super.dispose();
}
}

View File

@ -1,62 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { BaseViewModel, type SortOptionsMenuSnapshot, SortOption } from "@element-hq/web-shared-components";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import RoomListStoreV3, { RoomListStoreV3Event } from "../../../stores/room-list-v3/RoomListStoreV3";
import { SortingAlgorithm } from "../../../stores/room-list-v3/skip-list/sorters";
import SettingsStore from "../../../settings/SettingsStore";
interface SortOptionsMenuViewModelProps {
client: MatrixClient;
}
/**
* ViewModel for the SortOptionsMenu component.
* Manages sort option selection.
*/
export class SortOptionsMenuViewModel extends BaseViewModel<
SortOptionsMenuSnapshot,
SortOptionsMenuViewModelProps
> {
public constructor(props: SortOptionsMenuViewModelProps) {
super(props, SortOptionsMenuViewModel.createSnapshot());
// Listen to room list updates that might include sort changes
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.ListsUpdate as any,
this.onListsUpdate,
);
}
private static createSnapshot(): SortOptionsMenuSnapshot {
const activeSortingAlgorithm = SettingsStore.getValue("RoomList.preferredSorting");
const activeSortOption =
activeSortingAlgorithm === SortingAlgorithm.Alphabetic ? SortOption.AToZ : SortOption.Activity;
return {
activeSortOption,
sort: SortOptionsMenuViewModel.sort,
};
}
private onListsUpdate = (): void => {
const activeSortingAlgorithm = SettingsStore.getValue("RoomList.preferredSorting");
const activeSortOption =
activeSortingAlgorithm === SortingAlgorithm.Alphabetic ? SortOption.AToZ : SortOption.Activity;
this.snapshot.merge({ activeSortOption });
};
private static sort = (option: SortOption): void => {
const sortingAlgorithm =
option === SortOption.AToZ ? SortingAlgorithm.Alphabetic : SortingAlgorithm.Recency;
RoomListStoreV3.instance.resort(sortingAlgorithm);
};
}

View File

@ -1,120 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { BaseViewModel, type SpaceMenuSnapshot } from "@element-hq/web-shared-components";
import { JoinRule, RoomEvent, type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
import { shouldShowSpaceSettings, showSpaceInvite, showSpacePreferences, showSpaceSettings } from "../../../utils/space";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
interface SpaceMenuViewModelProps {
client: MatrixClient;
}
/**
* ViewModel for the SpaceMenu component.
* Manages space-specific actions.
*/
export class SpaceMenuViewModel extends BaseViewModel<SpaceMenuSnapshot, SpaceMenuViewModelProps> {
private activeSpace: Room | null = null;
public constructor(props: SpaceMenuViewModelProps) {
super(props, SpaceMenuViewModel.createSnapshot(SpaceStore.instance.activeSpaceRoom, props.client));
this.activeSpace = SpaceStore.instance.activeSpaceRoom;
// Listen to space changes
this.disposables.trackListener(SpaceStore.instance, UPDATE_SELECTED_SPACE as any, this.onSpaceChanged);
// Listen to room name changes if there's an active space
if (this.activeSpace) {
this.disposables.trackListener(this.activeSpace, RoomEvent.Name, this.onRoomNameChanged);
}
}
private static createSnapshot(activeSpace: Room | null, client: MatrixClient): SpaceMenuSnapshot {
const title = activeSpace?.name ?? "";
const canInviteInSpace = Boolean(
activeSpace?.getJoinRule() === JoinRule.Public || activeSpace?.canInvite(client.getSafeUserId()),
);
const canAccessSpaceSettings = Boolean(activeSpace && shouldShowSpaceSettings(activeSpace));
return {
title,
canInviteInSpace,
canAccessSpaceSettings,
openSpaceHome: () => SpaceMenuViewModel.openSpaceHome(activeSpace),
inviteInSpace: () => SpaceMenuViewModel.inviteInSpace(activeSpace),
openSpacePreferences: () => SpaceMenuViewModel.openSpacePreferences(activeSpace),
openSpaceSettings: () => SpaceMenuViewModel.openSpaceSettings(activeSpace),
};
}
private onSpaceChanged = (): void => {
// Remove listener from old space
if (this.activeSpace) {
this.activeSpace.off(RoomEvent.Name, this.onRoomNameChanged);
}
this.activeSpace = SpaceStore.instance.activeSpaceRoom;
// Add listener to new space
if (this.activeSpace) {
this.disposables.trackListener(this.activeSpace, RoomEvent.Name, this.onRoomNameChanged);
}
const title = this.activeSpace?.name ?? "";
const canInviteInSpace = Boolean(
this.activeSpace?.getJoinRule() === JoinRule.Public || this.activeSpace?.canInvite(this.props.client.getSafeUserId()),
);
const canAccessSpaceSettings = Boolean(this.activeSpace && shouldShowSpaceSettings(this.activeSpace));
this.snapshot.merge({
title,
canInviteInSpace,
canAccessSpaceSettings,
openSpaceHome: () => SpaceMenuViewModel.openSpaceHome(this.activeSpace),
inviteInSpace: () => SpaceMenuViewModel.inviteInSpace(this.activeSpace),
openSpacePreferences: () => SpaceMenuViewModel.openSpacePreferences(this.activeSpace),
openSpaceSettings: () => SpaceMenuViewModel.openSpaceSettings(this.activeSpace),
});
};
private onRoomNameChanged = (): void => {
if (this.activeSpace) {
this.snapshot.merge({ title: this.activeSpace.name });
}
};
private static openSpaceHome = (activeSpace: Room | null): void => {
if (!activeSpace) return;
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: activeSpace.roomId,
metricsTrigger: undefined,
});
};
private static inviteInSpace = (activeSpace: Room | null): void => {
if (!activeSpace) return;
showSpaceInvite(activeSpace);
};
private static openSpacePreferences = (activeSpace: Room | null): void => {
if (!activeSpace) return;
showSpacePreferences(activeSpace);
};
private static openSpaceSettings = (activeSpace: Room | null): void => {
if (!activeSpace) return;
showSpaceSettings(activeSpace);
};
}

View File

@ -5,17 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { useState, useCallback, useMemo, useEffect, useRef } from "react";
import React, { useState, useCallback, useEffect, useRef } from "react";
import { RoomListPanel as SharedRoomListPanel } from "@element-hq/web-shared-components";
import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation";
import { type IState as IRovingTabIndexState } from "../../../../accessibility/RovingTabIndex";
import { RoomListPanelViewModel } from "../../../viewmodels/roomlist/RoomListPanelViewModel";
import { RoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewModel";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import RoomAvatar from "../../avatars/RoomAvatar";
import type { RoomListItem } from "@element-hq/web-shared-components";
import { _t } from "../../../../languageHandler";
type RoomListPanelProps = {
/**
@ -33,9 +34,9 @@ export const RoomListPanel: React.FC<RoomListPanelProps> = ({ activeSpace }) =>
const [focusedElement, setFocusedElement] = useState<Element | null>(null);
// Create ViewModel instance - use ref to survive strict mode double-mounting
const vmRef = useRef<RoomListPanelViewModel | null>(null);
const vmRef = useRef<RoomListViewModel | null>(null);
if (!vmRef.current) {
vmRef.current = new RoomListPanelViewModel({ client });
vmRef.current = new RoomListViewModel({ client });
}
const vm = vmRef.current;