mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-05 12:16:53 +02:00
Detangle state from callbacks
This commit is contained in:
parent
c3ecfd2083
commit
99e170f8b4
@ -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 'a few seconds ago' for <15s ago" duration="2"/>
|
||||
<testCase name="humanizeTime returns 'about a minute ago' for <75s ago" duration="4"/>
|
||||
<testCase name="humanizeTime returns '20 minutes ago' for <45min ago" duration="2"/>
|
||||
<testCase name="humanizeTime returns 'about an hour ago' for <75min ago" duration="0"/>
|
||||
<testCase name="humanizeTime returns '5 hours ago' for <23h ago" duration="1"/>
|
||||
<testCase name="humanizeTime returns 'about a day ago' for <26h ago" duration="1"/>
|
||||
<testCase name="humanizeTime returns '3 days ago' for >26h ago" duration="1"/>
|
||||
<testCase name="humanizeTime returns 'a few seconds from now' for <15s ahead" duration="2"/>
|
||||
<testCase name="humanizeTime returns 'about a minute from now' for <75s ahead" duration="1"/>
|
||||
<testCase name="humanizeTime returns '20 minutes from now' for <45min ahead" duration="0"/>
|
||||
<testCase name="humanizeTime returns 'about an hour from now' for <75min ahead" duration="0"/>
|
||||
<testCase name="humanizeTime returns '5 hours from now' for <23h ahead" duration="1"/>
|
||||
<testCase name="humanizeTime returns 'about a day from now' for <26h ahead" duration="2"/>
|
||||
<testCase name="humanizeTime returns '3 days from now' for >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 > 1" duration="1"/>
|
||||
<testCase name="numbers percentageWithin should work within 0-100 when pct < 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 > 1" duration="0"/>
|
||||
<testCase name="numbers percentageWithin should work with ranges other than 0-100 when pct < 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 > 100" duration="0"/>
|
||||
<testCase name="numbers percentageOf should work within 0-100 when val < 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 > 100" duration="0"/>
|
||||
<testCase name="numbers percentageOf should work with ranges other than 0-100 when val < 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>
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@ -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} />);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -8,9 +8,7 @@
|
||||
export { RoomList } from "./RoomList";
|
||||
export type {
|
||||
RoomListProps,
|
||||
RoomListViewModel,
|
||||
RoomListViewSnapshot,
|
||||
RoomListViewActions,
|
||||
RoomListViewState,
|
||||
RoomsResult,
|
||||
FilterKey
|
||||
} from "./RoomList";
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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";
|
||||
@ -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 = {
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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())}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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} />);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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";
|
||||
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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"),
|
||||
},
|
||||
};
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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";
|
||||
@ -6,4 +6,4 @@
|
||||
*/
|
||||
|
||||
export { RoomListSearch } from "./RoomListSearch";
|
||||
export type { RoomListSearchProps, RoomListSearchSnapshot } from "./RoomListSearch";
|
||||
export type { RoomListSearchState, RoomListSearchProps, RoomListSearchSnapshot } from "./RoomListSearch";
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user