Remove RoomListItemViewModel, just use dumb components .

This commit is contained in:
David Langley 2025-12-07 18:13:22 +00:00
parent 39fb3de400
commit 3023192ce7
31 changed files with 1010 additions and 653 deletions

View File

@ -1,163 +1,163 @@
<testExecutions version="1">
<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="205"/>
<testCase name="RoomList renders with correct aria-label" duration="26"/>
<testCase name="RoomList calls renderAvatar for each room" duration="16"/>
<testCase name="RoomList handles empty room list" duration="41"/>
<testCase name="RoomList passes activeRoomIndex correctly" duration="20"/>
<testCase name="RoomList accepts onKeyDown callback" duration="33"/>
<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="62"/>
<testCase name="Clock renders the clock with a lot of seconds" duration="6"/>
</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="111"/>
<testCase name="RichItem renders the list with isEmpty=true" duration="6"/>
</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="120"/>
<testCase name="Pill renders the pill without close button" duration="7"/>
</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="105"/>
<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="90"/>
<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="89"/>
</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="102"/>
<testCase name="PillInput renders only the input without children" duration="28"/>
<testCase name="PillInput calls onRemoveChildren when backspace is pressed and input is empty" duration="237"/>
<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="133"/>
<testCase name="RichItem renders the item in selected state" duration="33"/>
<testCase name="RichItem renders the item without timestamp" duration="13"/>
<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="307"/>
<testCase name="PlayPauseButton renders the button in playing state" duration="67"/>
<testCase name="PlayPauseButton calls togglePlay when clicked" duration="189"/>
<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/viewmodel/tests/Snapshot.test.ts">
<testCase name="Snapshot should accept an initial value" duration="3"/>
<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/viewmodel/tests/Disposables.test.ts">
<testCase name="Disposable isDisposed is true after dispose() is called" duration="9"/>
<testCase name="Disposable dispose() calls the correct disposing function" duration="2"/>
<testCase name="Disposable Throws error if acting on already disposed disposables" duration="34"/>
<testCase name="Disposable Removes tracked event listeners on dispose" duration="1"/>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/utils/humanize.test.ts">
<testCase name="humanizeTime returns &apos;a few seconds ago&apos; for &lt;15s ago" duration="2"/>
<testCase name="humanizeTime returns &apos;about a minute ago&apos; for &lt;75s ago" duration="4"/>
<testCase name="humanizeTime returns &apos;20 minutes ago&apos; for &lt;45min ago" duration="2"/>
<testCase name="humanizeTime returns &apos;about an hour ago&apos; for &lt;75min ago" duration="0"/>
<testCase name="humanizeTime returns &apos;5 hours ago&apos; for &lt;23h ago" duration="1"/>
<testCase name="humanizeTime returns &apos;about a day ago&apos; for &lt;26h ago" duration="1"/>
<testCase name="humanizeTime returns &apos;3 days ago&apos; for &gt;26h ago" duration="1"/>
<testCase name="humanizeTime returns &apos;a few seconds from now&apos; for &lt;15s ahead" duration="2"/>
<testCase name="humanizeTime returns &apos;about a minute from now&apos; for &lt;75s ahead" duration="1"/>
<testCase name="humanizeTime returns &apos;20 minutes from now&apos; for &lt;45min ahead" duration="0"/>
<testCase name="humanizeTime returns &apos;about an hour from now&apos; for &lt;75min ahead" duration="0"/>
<testCase name="humanizeTime returns &apos;5 hours from now&apos; for &lt;23h ahead" duration="1"/>
<testCase name="humanizeTime returns &apos;about a day from now&apos; for &lt;26h ahead" duration="2"/>
<testCase name="humanizeTime returns &apos;3 days from now&apos; for &gt;26h ahead" duration="2"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/utils/numbers.test.ts">
<testCase name="numbers defaultNumber should use the default when the input is not a number" duration="1"/>
<testCase name="numbers defaultNumber should use the number when it is a number" duration="1"/>
<testCase name="numbers clamp should clamp high numbers" duration="0"/>
<testCase name="numbers clamp should clamp low numbers" duration="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="13"/>
<testCase name="numbers sum should sum" duration="0"/>
<testCase name="numbers clamp should clamp floats" duration="1"/>
<testCase name="numbers sum should sum" duration="1"/>
<testCase name="numbers percentageWithin should work within 0-100" duration="0"/>
<testCase name="numbers percentageWithin should work within 0-100 when pct &gt; 1" duration="0"/>
<testCase name="numbers percentageWithin should work within 0-100 when pct &gt; 1" duration="1"/>
<testCase name="numbers percentageWithin should work within 0-100 when pct &lt; 0" duration="0"/>
<testCase name="numbers percentageWithin should work with ranges other than 0-100" duration="1"/>
<testCase name="numbers percentageWithin should work with ranges other than 0-100" duration="0"/>
<testCase name="numbers percentageWithin should work with ranges other than 0-100 when pct &gt; 1" duration="0"/>
<testCase name="numbers percentageWithin should work with ranges other than 0-100 when pct &lt; 0" duration="0"/>
<testCase name="numbers percentageWithin should work with floats" duration="0"/>
<testCase name="numbers percentageOf should work within 0-100" duration="0"/>
<testCase name="numbers percentageOf should work within 0-100" duration="1"/>
<testCase name="numbers percentageOf should work within 0-100 when val &gt; 100" duration="0"/>
<testCase name="numbers percentageOf should work within 0-100 when val &lt; 0" duration="0"/>
<testCase name="numbers percentageOf should work with ranges other than 0-100" duration="1"/>
<testCase name="numbers percentageOf should work with ranges other than 0-100 when val &gt; 100" duration="0"/>
<testCase name="numbers percentageOf should work with ranges other than 0-100 when val &lt; 0" duration="0"/>
<testCase name="numbers percentageOf should work with ranges other than 0-100 when val &lt; 0" duration="1"/>
<testCase name="numbers percentageOf should work with floats" duration="0"/>
<testCase name="numbers percentageOf should return 0 for values that cause a division by zero" duration="1"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/utils/humanize.test.ts">
<testCase name="humanizeTime returns &apos;a few seconds ago&apos; for &lt;15s ago" duration="9"/>
<testCase name="humanizeTime returns &apos;about a minute ago&apos; for &lt;75s ago" duration="1"/>
<testCase name="humanizeTime returns &apos;20 minutes ago&apos; for &lt;45min ago" duration="1"/>
<testCase name="humanizeTime returns &apos;about an hour ago&apos; for &lt;75min ago" duration="1"/>
<testCase name="humanizeTime returns &apos;5 hours ago&apos; for &lt;23h ago" duration="0"/>
<testCase name="humanizeTime returns &apos;about a day ago&apos; for &lt;26h ago" duration="1"/>
<testCase name="humanizeTime returns &apos;3 days ago&apos; for &gt;26h ago" duration="1"/>
<testCase name="humanizeTime returns &apos;a few seconds from now&apos; for &lt;15s ahead" duration="1"/>
<testCase name="humanizeTime returns &apos;about a minute from now&apos; for &lt;75s ahead" duration="2"/>
<testCase name="humanizeTime returns &apos;20 minutes from now&apos; for &lt;45min ahead" duration="1"/>
<testCase name="humanizeTime returns &apos;about an hour from now&apos; for &lt;75min ahead" duration="1"/>
<testCase name="humanizeTime returns &apos;5 hours from now&apos; for &lt;23h ahead" duration="0"/>
<testCase name="humanizeTime returns &apos;about a day from now&apos; for &lt;26h ahead" duration="0"/>
<testCase name="humanizeTime returns &apos;3 days from now&apos; for &gt;26h ahead" duration="1"/>
<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="397"/>
<testCase name="AudioPlayerView renders the audio player without media name" duration="58"/>
<testCase name="AudioPlayerView renders the audio player without size" duration="93"/>
<testCase name="AudioPlayerView renders the audio player in error state" duration="59"/>
<testCase name="AudioPlayerView should attach vm methods" duration="244"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/utils/i18n.test.ts">
<testCase name="i18n utils should wrap registerTranslations" duration="4"/>
<testCase name="i18n utils should wrap setMissingEntryGenerator" duration="0"/>
<testCase name="i18n utils should wrap getLocale" duration="1"/>
<testCase name="i18n utils should wrap setLocale" duration="1"/>
<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="7"/>
<testCase name="useListKeyDown should handle Space key to click active element" duration="7"/>
<testCase name="useListKeyDown should handle ArrowDown to focus the 1nth element" duration="7"/>
<testCase name="useListKeyDown should handle ArrowUp to focus the 1nth element" duration="4"/>
<testCase name="useListKeyDown should handle Home to focus the 0nth element" duration="4"/>
<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="3"/>
<testCase name="useListKeyDown should not handle ArrowUp when active element is not in list" duration="3"/>
<testCase name="useListKeyDown should not prevent default for unhandled keys" duration="24"/>
<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/audio/SeekBar/SeekBar.test.tsx">
<testCase name="Seekbar renders the clock" duration="12"/>
<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="101"/>
<testCase name="RoomListSearch calls onSearchClick when search button is clicked" duration="88"/>
<testCase name="RoomListSearch renders dial pad button when showDialPad is true" duration="12"/>
<testCase name="RoomListSearch calls onDialPadClick when dial pad button is clicked" duration="26"/>
<testCase name="RoomListSearch renders explore button when showExplore is true" duration="12"/>
<testCase name="RoomListSearch calls onExploreClick when explore button is clicked" duration="21"/>
<testCase name="RoomListSearch renders all buttons when showDialPad and showExplore are true" duration="18"/>
<testCase name="RoomListSearch does not render dial pad or explore buttons when flags are false" duration="13"/>
<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="58"/>
<testCase name="RoomListItem renders with message preview" duration="10"/>
<testCase name="RoomListItem applies selected styles when selected" duration="118"/>
<testCase name="RoomListItem applies bold styles when room has unread" duration="14"/>
<testCase name="RoomListItem calls openRoom when clicked" duration="148"/>
<testCase name="RoomListItem calls onFocus when focused" duration="12"/>
<testCase name="RoomListItem renders notification decoration when hasAnyNotificationOrActivity is true" duration="8"/>
<testCase name="RoomListItem sets correct ARIA attributes" duration="9"/>
<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="284"/>
<testCase name="RoomListHeader renders space menu when isSpace is true" duration="65"/>
<testCase name="RoomListHeader renders compose menu when displayComposeMenu is true" duration="60"/>
<testCase name="RoomListHeader renders compose icon button when displayComposeMenu is false" duration="44"/>
<testCase name="RoomListHeader renders sort options menu" duration="43"/>
<testCase name="RoomListHeader truncates long titles with title attribute" duration="72"/>
<testCase name="RoomListHeader renders data-testid attribute" duration="36"/>
<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="174"/>
<testCase name="RoomListPanel renders without search" duration="59"/>
<testCase name="RoomListPanel renders loading state" duration="55"/>
<testCase name="RoomListPanel renders empty state" duration="52"/>
<testCase name="RoomListPanel passes additional HTML attributes" duration="61"/>
<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"/>
</file>
</testExecutions>

View File

@ -23,7 +23,7 @@ exports[`AudioPlayerView renders the audio player in default state 1`] = `
tabindex="-1"
>
<div
class="_indicator-icon_zr2a0_17"
class="_indicator-icon_147l5_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_zr2a0_17"
class="_indicator-icon_147l5_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_zr2a0_17"
class="_indicator-icon_147l5_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_zr2a0_17"
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg

View File

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

View File

@ -14,6 +14,7 @@ export * from "./avatar/AvatarWithDetails";
export * from "./event-tiles/TextualEventView";
export * from "./message-body/MediaBody";
export * from "./notifications/NotificationDecoration";
export * from "./notifications/RoomNotifs";
export * from "./pill-input/Pill";
export * from "./pill-input/PillInput";
export * from "./rich-list/RichItem";

View File

@ -17,9 +17,9 @@ import { UnreadCounter, Unread } from "@vector-im/compound-web";
import { Flex } from "../../utils/Flex";
/**
* ViewModel representing the notification state for a room or item
* Data representing the notification state for a room or item
*/
export interface NotificationDecorationViewModel {
export interface NotificationDecorationData {
/** Whether there is any notification or activity to display */
hasAnyNotificationOrActivity: boolean;
/** Whether there's an unsent message */
@ -32,8 +32,8 @@ export interface NotificationDecorationViewModel {
isActivityNotification: boolean;
/** Whether there's a notification (not just activity) */
isNotification: boolean;
/** Notification count */
count: number;
/** Notification count (optional) */
count?: number;
/** Whether notifications are muted */
muted: boolean;
/** Optional call type indicator */
@ -41,37 +41,37 @@ export interface NotificationDecorationViewModel {
}
export interface NotificationDecorationProps {
/** ViewModel containing notification state */
viewModel: NotificationDecorationViewModel;
/** Data containing notification state */
data: NotificationDecorationData;
}
/**
* Renders notification badges and indicators for rooms/items
*/
export const NotificationDecoration: React.FC<NotificationDecorationProps> = ({ viewModel }) => {
export const NotificationDecoration: React.FC<NotificationDecorationProps> = ({ data }) => {
// Don't render anything if there's nothing to show
if (!viewModel.hasAnyNotificationOrActivity && !viewModel.muted && !viewModel.callType) {
if (!data.hasAnyNotificationOrActivity && !data.muted && !data.callType) {
return null;
}
return (
<Flex align="center" justify="center" gap="var(--cpd-space-1x)" data-testid="notification-decoration">
{viewModel.isUnsentMessage && (
{data.isUnsentMessage && (
<ErrorIcon width="20px" height="20px" fill="var(--cpd-color-icon-critical-primary)" />
)}
{viewModel.callType === "video" && (
{data.callType === "video" && (
<VideoCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
)}
{viewModel.callType === "voice" && (
{data.callType === "voice" && (
<VoiceCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
)}
{viewModel.invited && <EmailIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{viewModel.isMention && (
{data.invited && <EmailIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{data.isMention && (
<MentionIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
)}
{(viewModel.isMention || viewModel.isNotification) && <UnreadCounter count={viewModel.count || null} />}
{viewModel.isActivityNotification && <Unread />}
{viewModel.muted && (
{(data.isMention || data.isNotification) && <UnreadCounter count={data.count || null} />}
{data.isActivityNotification && <Unread />}
{data.muted && (
<NotificationOffIcon width="20px" height="20px" fill="var(--cpd-color-icon-tertiary)" />
)}
</Flex>

View File

@ -6,4 +6,4 @@
*/
export { NotificationDecoration } from "./NotificationDecoration";
export type { NotificationDecorationProps, NotificationDecorationViewModel } from "./NotificationDecoration";
export type { NotificationDecorationProps, NotificationDecorationData } from "./NotificationDecoration";

View File

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

View File

@ -7,12 +7,13 @@
import React from "react";
import { RoomList, type RoomListSnapshot, type RoomsResult } from "./RoomList";
import type { RoomListItemViewModel } from "../RoomListItem";
import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration";
import type { RoomListItemMenuViewModel } from "../RoomListItem/RoomListItemMenuViewModel";
import { RoomList, type RoomListViewModel, type RoomListViewSnapshot, type RoomsResult } from "./RoomList";
import type { RoomListItem } from "../RoomListItem";
import type { NotificationDecorationData } from "../../notifications/NotificationDecoration";
import type { MoreOptionsMenuState } from "../RoomListItem/RoomListItemMoreOptionsMenu";
import type { NotificationMenuState } from "../RoomListItem/RoomListItemNotificationMenu";
import { type RoomNotifState } from "../../notifications/RoomNotifs";
import { type ViewModel } from "../../viewmodel/ViewModel";
import type { Meta, StoryObj } from "@storybook/react-vite";
// Mock avatar component
@ -35,40 +36,32 @@ const mockAvatar = (name: string): React.ReactElement => (
</div>
);
// Generate mock rooms with ViewModels
const generateMockRooms = (count: number): RoomListItemViewModel[] => {
const mockNotificationViewModel: NotificationDecorationViewModel = {
// Generate mock rooms with data
const generateMockRooms = (count: number): RoomListItem[] => {
const mockNotificationData: NotificationDecorationData = {
hasAnyNotificationOrActivity: false,
isUnsentMessage: false,
invited: false,
isMention: false,
isActivityNotification: false,
isNotification: false,
count: 0,
muted: false,
};
const mockMenuViewModel: RoomListItemMenuViewModel = {
showMoreOptionsMenu: true,
showNotificationMenu: true,
const mockMoreOptionsState: MoreOptionsMenuState = {
isFavourite: false,
isLowPriority: false,
canInvite: true,
canCopyRoomLink: true,
canMarkAsRead: true,
canMarkAsUnread: true,
};
const mockNotificationState: NotificationMenuState = {
isNotificationAllMessage: true,
isNotificationAllMessageLoud: false,
isNotificationMentionOnly: false,
isNotificationMute: false,
markAsRead: () => console.log("Mark as read"),
markAsUnread: () => console.log("Mark as unread"),
toggleFavorite: () => console.log("Toggle favorite"),
toggleLowPriority: () => console.log("Toggle low priority"),
invite: () => console.log("Invite"),
copyRoomLink: () => console.log("Copy room link"),
leaveRoom: () => console.log("Leave room"),
setRoomNotifState: (state: RoomNotifState) => console.log("Set notification state:", state),
};
return Array.from({ length: count }, (_, i) => {
@ -77,7 +70,7 @@ const generateMockRooms = (count: number): RoomListItemViewModel[] => {
const hasNotification = Math.random() > 0.8;
const isMention = Math.random() > 0.9;
const notificationViewModel: NotificationDecorationViewModel = hasUnread
const notificationData: NotificationDecorationData = hasUnread
? {
hasAnyNotificationOrActivity: true,
isUnsentMessage: false,
@ -88,17 +81,19 @@ const generateMockRooms = (count: number): RoomListItemViewModel[] => {
count: unreadCount,
muted: false,
}
: mockNotificationViewModel;
: mockNotificationData;
return {
id: `!room${i}:server`,
name: `Room ${i + 1}`,
openRoom: () => console.log(`Opening room: Room ${i + 1}`),
a11yLabel: unreadCount > 0 ? `Room ${i + 1}, ${unreadCount} unread messages` : `Room ${i + 1}`,
isBold: unreadCount > 0,
messagePreview: undefined,
notificationViewModel,
menuViewModel: mockMenuViewModel,
notification: notificationData,
showMoreOptionsMenu: true,
showNotificationMenu: true,
moreOptionsState: mockMoreOptionsState,
notificationState: mockNotificationState,
};
});
};
@ -109,21 +104,33 @@ const mockRoomsResult: RoomsResult = {
rooms: generateMockRooms(50),
};
function createMockViewModel(snapshot: RoomListSnapshot): ViewModel<RoomListSnapshot> {
// Create stable unsubscribe function
const noop = (): void => {};
function createMockViewModel(snapshot: RoomListViewSnapshot): RoomListViewModel {
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
subscribe: () => noop,
onOpenRoom: (roomId: string) => console.log("Open room:", roomId),
onMarkAsRead: (roomId: string) => console.log("Mark as read:", roomId),
onMarkAsUnread: (roomId: string) => console.log("Mark as unread:", roomId),
onToggleFavorite: (roomId: string) => console.log("Toggle favorite:", roomId),
onToggleLowPriority: (roomId: string) => console.log("Toggle low priority:", roomId),
onInvite: (roomId: string) => console.log("Invite to room:", roomId),
onCopyRoomLink: (roomId: string) => console.log("Copy room link:", roomId),
onLeaveRoom: (roomId: string) => console.log("Leave room:", roomId),
onSetRoomNotifState: (roomId: string, state: RoomNotifState) =>
console.log("Set notification state:", roomId, state),
};
}
const mockViewModel: ViewModel<RoomListSnapshot> = createMockViewModel({
const mockViewModel: RoomListViewModel = createMockViewModel({
roomsResult: mockRoomsResult,
activeRoomIndex: undefined,
onKeyDown: undefined,
});
const renderAvatar = (roomViewModel: RoomListItemViewModel): React.ReactElement => {
return mockAvatar(roomViewModel.name);
const renderAvatar = (roomItem: RoomListItem): React.ReactElement => {
return mockAvatar(roomItem.name);
};
const meta = {
@ -146,14 +153,15 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Default: Story = {
args: {},
};
export const WithSelection: Story = {
args: {
vm: createMockViewModel({
roomsResult: mockRoomsResult,
activeRoomIndex: 5,
onKeyDown: undefined,
}),
},
};
@ -167,7 +175,6 @@ export const SmallList: Story = {
rooms: generateMockRooms(5),
},
activeRoomIndex: undefined,
onKeyDown: undefined,
}),
},
};
@ -181,7 +188,6 @@ export const LargeList: Story = {
rooms: generateMockRooms(200),
},
activeRoomIndex: undefined,
onKeyDown: undefined,
}),
},
};
@ -195,7 +201,6 @@ export const EmptyList: Story = {
rooms: [],
},
activeRoomIndex: undefined,
onKeyDown: undefined,
}),
},
};

View File

@ -5,84 +5,100 @@
* Please see LICENSE files in the repository root for full details.
*/
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import React from "react";
import { RoomList, type RoomListSnapshot, type RoomsResult } from "./RoomList";
import type { RoomListItemViewModel } from "../RoomListItem";
import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration";
import type { RoomListItemMenuViewModel } from "../RoomListItem/RoomListItemMenuViewModel";
import { type ViewModel } from "../../viewmodel/ViewModel";
import {
RoomList,
type RoomListViewModel,
type RoomListViewSnapshot,
type RoomListViewActions,
type RoomsResult,
} from "./RoomList";
import type { RoomListItem } from "../RoomListItem";
import type { NotificationDecorationData } from "../../notifications/NotificationDecoration";
import type { MoreOptionsMenuState } from "../RoomListItem/RoomListItemMoreOptionsMenu";
import type { NotificationMenuState } from "../RoomListItem/RoomListItemNotificationMenu";
function createMockViewModel(snapshot: RoomListSnapshot): ViewModel<RoomListSnapshot> {
function createMockViewModel(
snapshot: RoomListViewSnapshot,
actions: Partial<RoomListViewActions> = {},
): RoomListViewModel {
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
onOpenRoom: actions.onOpenRoom || jest.fn(),
onMarkAsRead: actions.onMarkAsRead || jest.fn(),
onMarkAsUnread: actions.onMarkAsUnread || jest.fn(),
onToggleFavorite: actions.onToggleFavorite || jest.fn(),
onToggleLowPriority: actions.onToggleLowPriority || jest.fn(),
onInvite: actions.onInvite || jest.fn(),
onCopyRoomLink: actions.onCopyRoomLink || jest.fn(),
onLeaveRoom: actions.onLeaveRoom || jest.fn(),
onSetRoomNotifState: actions.onSetRoomNotifState || jest.fn(),
};
}
describe("RoomList", () => {
const mockNotificationViewModel: NotificationDecorationViewModel = {
const mockNotificationData: NotificationDecorationData = {
hasAnyNotificationOrActivity: false,
isUnsentMessage: false,
invited: false,
isMention: false,
isActivityNotification: false,
isNotification: false,
count: 0,
muted: false,
};
const mockMenuViewModel: RoomListItemMenuViewModel = {
showMoreOptionsMenu: true,
showNotificationMenu: true,
const mockMoreOptionsState: MoreOptionsMenuState = {
isFavourite: false,
isLowPriority: false,
canInvite: true,
canCopyRoomLink: true,
canMarkAsRead: true,
canMarkAsUnread: true,
};
const mockNotificationState: NotificationMenuState = {
isNotificationAllMessage: true,
isNotificationAllMessageLoud: false,
isNotificationMentionOnly: false,
isNotificationMute: false,
markAsRead: jest.fn(),
markAsUnread: jest.fn(),
toggleFavorite: jest.fn(),
toggleLowPriority: jest.fn(),
invite: jest.fn(),
copyRoomLink: jest.fn(),
leaveRoom: jest.fn(),
setRoomNotifState: jest.fn(),
};
const mockRooms: RoomListItemViewModel[] = [
const mockRooms: RoomListItem[] = [
{
id: "!room1:server",
name: "Room 1",
openRoom: jest.fn(),
a11yLabel: "Room 1",
isBold: false,
notificationViewModel: mockNotificationViewModel,
menuViewModel: mockMenuViewModel,
notification: mockNotificationData,
showMoreOptionsMenu: true,
showNotificationMenu: true,
moreOptionsState: mockMoreOptionsState,
notificationState: mockNotificationState,
},
{
id: "!room2:server",
name: "Room 2",
openRoom: jest.fn(),
a11yLabel: "Room 2",
isBold: false,
notificationViewModel: mockNotificationViewModel,
menuViewModel: mockMenuViewModel,
notification: mockNotificationData,
showMoreOptionsMenu: true,
showNotificationMenu: true,
moreOptionsState: mockMoreOptionsState,
notificationState: mockNotificationState,
},
{
id: "!room3:server",
name: "Room 3",
openRoom: jest.fn(),
a11yLabel: "Room 3",
isBold: false,
notificationViewModel: mockNotificationViewModel,
menuViewModel: mockMenuViewModel,
notification: mockNotificationData,
showMoreOptionsMenu: true,
showNotificationMenu: true,
moreOptionsState: mockMoreOptionsState,
notificationState: mockNotificationState,
},
];
@ -92,14 +108,13 @@ describe("RoomList", () => {
rooms: mockRooms,
};
const mockRenderAvatar = jest.fn((roomViewModel: RoomListItemViewModel) => (
<div data-testid={`avatar-${roomViewModel.id}`}>{roomViewModel.name[0]}</div>
const mockRenderAvatar = jest.fn((roomItem: RoomListItem) => (
<div data-testid={`avatar-${roomItem.id}`}>{roomItem.name[0]}</div>
));
const mockViewModel = createMockViewModel({
roomsResult: mockRoomsResult,
activeRoomIndex: undefined,
onKeyDown: undefined,
});
beforeEach(() => {
@ -146,7 +161,6 @@ describe("RoomList", () => {
const emptyViewModel = createMockViewModel({
roomsResult: emptyResult,
activeRoomIndex: undefined,
onKeyDown: undefined,
});
render(<RoomList vm={emptyViewModel} renderAvatar={mockRenderAvatar} />);
@ -159,7 +173,6 @@ describe("RoomList", () => {
const vmWithActive = createMockViewModel({
roomsResult: mockRoomsResult,
activeRoomIndex: 1,
onKeyDown: undefined,
});
render(<RoomList vm={vmWithActive} renderAvatar={mockRenderAvatar} />);

View File

@ -13,7 +13,8 @@ import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { _t } from "../../utils/i18n";
import { ListView, type ListContext } from "../../utils/ListView";
import { RoomListItem, type RoomListItemViewModel } from "../RoomListItem";
import { RoomListItemView, type RoomListItem } from "../RoomListItem";
import { type RoomNotifState } from "../../notifications/RoomNotifs";
/**
* Filter key type - opaque string type for filter identifiers
@ -28,36 +29,65 @@ export interface RoomsResult {
spaceId: string;
/** Active filter keys */
filterKeys: FilterKey[] | undefined;
/** Array of room item view models */
rooms: RoomListItemViewModel[];
/** Array of room items */
rooms: RoomListItem[];
}
/**
* Snapshot for RoomList
* Snapshot for RoomList view state
*/
export type RoomListSnapshot = {
export interface RoomListViewSnapshot {
/** The rooms result containing the list of rooms */
roomsResult: RoomsResult;
/** Optional active room index */
activeRoomIndex?: number;
/** Optional keyboard event handler */
onKeyDown?: (ev: React.KeyboardEvent) => void;
};
}
/**
* Actions available for RoomList
*/
export interface RoomListViewActions {
/** Callback to open a room */
onOpenRoom: (roomId: string) => void;
/** Callback to mark a room as read */
onMarkAsRead: (roomId: string) => void;
/** Callback to mark a room as unread */
onMarkAsUnread: (roomId: string) => void;
/** Callback to toggle a room as favourite */
onToggleFavorite: (roomId: string) => void;
/** Callback to toggle a room as low priority */
onToggleLowPriority: (roomId: string) => void;
/** Callback to invite users to a room */
onInvite: (roomId: string) => void;
/** Callback to copy the room link */
onCopyRoomLink: (roomId: string) => void;
/** Callback to leave a room */
onLeaveRoom: (roomId: string) => void;
/** Callback to set the room notification state */
onSetRoomNotifState: (roomId: string, state: RoomNotifState) => void;
}
/**
* The view model for the room list.
*/
export type RoomListViewModel = ViewModel<RoomListViewSnapshot> & RoomListViewActions;
/**
* Props for the RoomList component
*/
export interface RoomListProps {
/**
* The view model containing room list data
* The view model containing room list data and actions
*/
vm: ViewModel<RoomListSnapshot>;
vm: RoomListViewModel;
/**
* Render function for room avatar
* @param roomViewModel - The room item view model
* @param roomItem - The room item data
*/
renderAvatar: (roomViewModel: RoomListItemViewModel) => ReactNode;
renderAvatar: (roomItem: RoomListItem) => ReactNode;
}
/** Height of a single room list item in pixels */
@ -76,11 +106,27 @@ const EXTENDED_VIEWPORT_HEIGHT = 25 * ROOM_LIST_ITEM_HEIGHT;
/**
* A virtualized list of rooms.
* This component provides efficient rendering of large room lists using virtualization,
* and renders RoomListItem components for each room.
* and renders RoomListItemView components for each room.
*
* @example
* ```tsx
* <RoomList vm={roomListViewModel} renderAvatar={(room) => <Avatar room={room} />} />
* ```
*/
export function RoomList({ vm, renderAvatar }: RoomListProps): JSX.Element {
const snapshot = useViewModel(vm);
const { roomsResult, activeRoomIndex, onKeyDown } = snapshot;
const {
onOpenRoom,
onMarkAsRead,
onMarkAsUnread,
onToggleFavorite,
onToggleLowPriority,
onInvite,
onCopyRoomLink,
onLeaveRoom,
onSetRoomNotifState,
} = vm;
const lastSpaceId = useRef<string | undefined>(undefined);
const lastFilterKeys = useRef<FilterKey[] | undefined>(undefined);
const roomCount = roomsResult.rooms.length;
@ -91,22 +137,39 @@ export function RoomList({ vm, renderAvatar }: RoomListProps): JSX.Element {
const getItemComponent = useCallback(
(
index: number,
item: RoomListItemViewModel,
item: RoomListItem,
context: ListContext<{
spaceId: string;
filterKeys: FilterKey[] | undefined;
}>,
onFocus: (item: RoomListItemViewModel, e: React.FocusEvent) => void,
onFocus: (item: RoomListItem, e: React.FocusEvent) => void,
): JSX.Element => {
const itemKey = item.id;
const isRovingItem = itemKey === context.tabIndexKey;
const isFocused = isRovingItem && context.focused;
const isSelected = activeRoomIndex === index;
const callbacks = {
onOpenRoom: () => onOpenRoom(item.id),
moreOptionsCallbacks: {
onMarkAsRead: () => onMarkAsRead(item.id),
onMarkAsUnread: () => onMarkAsUnread(item.id),
onToggleFavorite: () => onToggleFavorite(item.id),
onToggleLowPriority: () => onToggleLowPriority(item.id),
onInvite: () => onInvite(item.id),
onCopyRoomLink: () => onCopyRoomLink(item.id),
onLeaveRoom: () => onLeaveRoom(item.id),
},
notificationCallbacks: {
onSetRoomNotifState: (state: RoomNotifState) => onSetRoomNotifState(item.id, state),
},
};
return (
<div key={itemKey}>
<RoomListItem
viewModel={item}
<RoomListItemView
item={item}
callbacks={callbacks}
isSelected={isSelected}
isFocused={isFocused}
onFocus={(e) => onFocus(item, e)}
@ -117,13 +180,13 @@ export function RoomList({ vm, renderAvatar }: RoomListProps): JSX.Element {
</div>
);
},
[activeRoomIndex, roomCount, renderAvatar],
[activeRoomIndex, roomCount, renderAvatar, vm],
);
/**
* Get the key for a room item
*/
const getItemKey = useCallback((item: RoomListItemViewModel): string => {
const getItemKey = useCallback((item: RoomListItem): string => {
return item.id;
}, []);

View File

@ -6,4 +6,11 @@
*/
export { RoomList } from "./RoomList";
export type { RoomListProps, RoomListSnapshot, RoomsResult, FilterKey } from "./RoomList";
export type {
RoomListProps,
RoomListViewModel,
RoomListViewSnapshot,
RoomListViewActions,
RoomsResult,
FilterKey
} from "./RoomList";

View File

@ -7,9 +7,10 @@
import React from "react";
import { RoomListItem, type RoomListItemViewModel } from "./RoomListItem";
import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration";
import type { RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel";
import { RoomListItemView, type RoomListItem, type RoomListItemCallbacks } from "./RoomListItem";
import type { NotificationDecorationData } from "../../notifications/NotificationDecoration";
import type { MoreOptionsMenuState, MoreOptionsMenuCallbacks } from "./RoomListItemMoreOptionsMenu";
import type { NotificationMenuState, NotificationMenuCallbacks } from "./RoomListItemNotificationMenu";
import type { RoomNotifState } from "../../notifications/RoomNotifs";
import type { Meta, StoryObj } from "@storybook/react-vite";
@ -32,8 +33,8 @@ const mockAvatar = (
</div>
);
// Mock notification view model with notifications
const mockNotificationViewModel: NotificationDecorationViewModel = {
// Mock notification data with notifications
const mockNotificationData: NotificationDecorationData = {
hasAnyNotificationOrActivity: true,
isUnsentMessage: false,
invited: false,
@ -44,56 +45,72 @@ const mockNotificationViewModel: NotificationDecorationViewModel = {
muted: false,
};
// Mock notification view model without notifications
const mockEmptyNotificationViewModel: NotificationDecorationViewModel = {
// Mock notification data without notifications
const mockEmptyNotificationData: NotificationDecorationData = {
hasAnyNotificationOrActivity: false,
isUnsentMessage: false,
invited: false,
isMention: false,
isActivityNotification: false,
isNotification: false,
count: 0,
muted: false,
};
// Mock menu view model
const mockMenuViewModel: RoomListItemMenuViewModel = {
showMoreOptionsMenu: true,
showNotificationMenu: true,
// Mock more options menu state
const mockMoreOptionsState: MoreOptionsMenuState = {
isFavourite: false,
isLowPriority: false,
canInvite: true,
canCopyRoomLink: true,
canMarkAsRead: true,
canMarkAsUnread: true,
};
// Mock notification menu state
const mockNotificationState: NotificationMenuState = {
isNotificationAllMessage: true,
isNotificationAllMessageLoud: false,
isNotificationMentionOnly: false,
isNotificationMute: false,
markAsRead: () => console.log("Mark as read"),
markAsUnread: () => console.log("Mark as unread"),
toggleFavorite: () => console.log("Toggle favorite"),
toggleLowPriority: () => console.log("Toggle low priority"),
invite: () => console.log("Invite"),
copyRoomLink: () => console.log("Copy room link"),
leaveRoom: () => console.log("Leave room"),
setRoomNotifState: (state: RoomNotifState) => console.log("Set notification state:", state),
};
const baseViewModel: RoomListItemViewModel = {
// Mock callbacks
const mockMoreOptionsCallbacks: MoreOptionsMenuCallbacks = {
onMarkAsRead: () => console.log("Mark as read"),
onMarkAsUnread: () => console.log("Mark as unread"),
onToggleFavorite: () => console.log("Toggle favorite"),
onToggleLowPriority: () => console.log("Toggle low priority"),
onInvite: () => console.log("Invite"),
onCopyRoomLink: () => console.log("Copy room link"),
onLeaveRoom: () => console.log("Leave room"),
};
const mockNotificationCallbacks: NotificationMenuCallbacks = {
onSetRoomNotifState: (state: RoomNotifState) => console.log("Set notification state:", state),
};
const baseItem: RoomListItem = {
id: "!test:example.org",
name: "Test Room",
openRoom: () => console.log("Opening room"),
a11yLabel: "Test Room, no unread messages",
isBold: false,
messagePreview: undefined,
notificationViewModel: mockEmptyNotificationViewModel,
menuViewModel: mockMenuViewModel,
notification: mockEmptyNotificationData,
showMoreOptionsMenu: true,
showNotificationMenu: true,
moreOptionsState: mockMoreOptionsState,
notificationState: mockNotificationState,
};
const baseCallbacks: RoomListItemCallbacks = {
onOpenRoom: () => console.log("Opening room"),
moreOptionsCallbacks: mockMoreOptionsCallbacks,
notificationCallbacks: mockNotificationCallbacks,
};
const meta = {
title: "Room List/RoomListItem",
component: RoomListItem,
component: RoomListItemView,
tags: ["autodocs"],
decorators: [
(Story) => (
@ -103,7 +120,8 @@ const meta = {
),
],
args: {
viewModel: baseViewModel,
item: baseItem,
callbacks: baseCallbacks,
isSelected: false,
isFocused: false,
onFocus: () => {},
@ -111,7 +129,7 @@ const meta = {
roomCount: 10,
avatar: mockAvatar,
},
} satisfies Meta<typeof RoomListItem>;
} satisfies Meta<typeof RoomListItemView>;
export default meta;
type Story = StoryObj<typeof meta>;
@ -120,8 +138,8 @@ export const Default: Story = {};
export const WithMessagePreview: Story = {
args: {
viewModel: {
...baseViewModel,
item: {
...baseItem,
messagePreview: "Alice: Hey, are you coming to the meeting?",
},
},
@ -129,12 +147,12 @@ export const WithMessagePreview: Story = {
export const WithUnread: Story = {
args: {
viewModel: {
...baseViewModel,
item: {
...baseItem,
name: "Team Chat",
isBold: true,
a11yLabel: "Team Chat, 3 unread messages",
notificationViewModel: mockNotificationViewModel,
notification: mockNotificationData,
},
},
};
@ -153,8 +171,8 @@ export const Focused: Story = {
export const LongRoomName: Story = {
args: {
viewModel: {
...baseViewModel,
item: {
...baseItem,
name: "This is a very long room name that should be truncated with ellipsis when it exceeds the available width",
messagePreview: "And this is also a very long message preview that should also be truncated",
},
@ -163,12 +181,12 @@ export const LongRoomName: Story = {
export const BoldWithPreview: Story = {
args: {
viewModel: {
...baseViewModel,
item: {
...baseItem,
name: "Design Team",
isBold: true,
messagePreview: "Bob shared a new design file",
notificationViewModel: mockNotificationViewModel,
notification: mockNotificationData,
},
},
};
@ -176,8 +194,9 @@ export const BoldWithPreview: Story = {
export const AllStates: Story = {
render: (): React.ReactElement => (
<div style={{ width: "320px" }}>
<RoomListItem
viewModel={baseViewModel}
<RoomListItemView
item={baseItem}
callbacks={baseCallbacks}
isSelected={false}
isFocused={false}
onFocus={() => {}}
@ -185,8 +204,9 @@ export const AllStates: Story = {
roomCount={5}
avatar={mockAvatar}
/>
<RoomListItem
viewModel={{ ...baseViewModel, isBold: true, notificationViewModel: mockNotificationViewModel }}
<RoomListItemView
item={{ ...baseItem, isBold: true, notification: mockNotificationData }}
callbacks={baseCallbacks}
isSelected={false}
isFocused={false}
onFocus={() => {}}
@ -194,8 +214,9 @@ export const AllStates: Story = {
roomCount={5}
avatar={mockAvatar}
/>
<RoomListItem
viewModel={baseViewModel}
<RoomListItemView
item={baseItem}
callbacks={baseCallbacks}
isSelected={true}
isFocused={false}
onFocus={() => {}}
@ -203,8 +224,9 @@ export const AllStates: Story = {
roomCount={5}
avatar={mockAvatar}
/>
<RoomListItem
viewModel={{ ...baseViewModel, messagePreview: "Latest message" }}
<RoomListItemView
item={{ ...baseItem, messagePreview: "Latest message" }}
callbacks={baseCallbacks}
isSelected={false}
isFocused={false}
onFocus={() => {}}
@ -212,8 +234,9 @@ export const AllStates: Story = {
roomCount={5}
avatar={mockAvatar}
/>
<RoomListItem
viewModel={baseViewModel}
<RoomListItemView
item={baseItem}
callbacks={baseCallbacks}
isSelected={false}
isFocused={true}
onFocus={() => {}}

View File

@ -9,54 +9,69 @@ import { render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { RoomListItem, type RoomListItemViewModel } from "./RoomListItem";
import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration";
import type { RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel";
import { RoomListItemView, type RoomListItem, type RoomListItemCallbacks } from "./RoomListItem";
import type { NotificationDecorationData } from "../../notifications/NotificationDecoration";
import type { MoreOptionsMenuState, MoreOptionsMenuCallbacks } from "./RoomListItemMoreOptionsMenu";
import type { NotificationMenuState, NotificationMenuCallbacks } from "./RoomListItemNotificationMenu";
describe("RoomListItem", () => {
const mockNotificationViewModel: NotificationDecorationViewModel = {
const mockNotificationData: NotificationDecorationData = {
hasAnyNotificationOrActivity: false,
isUnsentMessage: false,
invited: false,
isMention: false,
isActivityNotification: false,
isNotification: false,
count: 0,
muted: false,
};
const mockMenuViewModel: RoomListItemMenuViewModel = {
showMoreOptionsMenu: true,
showNotificationMenu: true,
const mockMoreOptionsState: MoreOptionsMenuState = {
isFavourite: false,
isLowPriority: false,
canInvite: true,
canCopyRoomLink: true,
canMarkAsRead: true,
canMarkAsUnread: true,
};
const mockNotificationState: NotificationMenuState = {
isNotificationAllMessage: true,
isNotificationAllMessageLoud: false,
isNotificationMentionOnly: false,
isNotificationMute: false,
markAsRead: jest.fn(),
markAsUnread: jest.fn(),
toggleFavorite: jest.fn(),
toggleLowPriority: jest.fn(),
invite: jest.fn(),
copyRoomLink: jest.fn(),
leaveRoom: jest.fn(),
setRoomNotifState: jest.fn(),
};
const mockViewModel: RoomListItemViewModel = {
const mockMoreOptionsCallbacks: MoreOptionsMenuCallbacks = {
onMarkAsRead: jest.fn(),
onMarkAsUnread: jest.fn(),
onToggleFavorite: jest.fn(),
onToggleLowPriority: jest.fn(),
onInvite: jest.fn(),
onCopyRoomLink: jest.fn(),
onLeaveRoom: jest.fn(),
};
const mockNotificationCallbacks: NotificationMenuCallbacks = {
onSetRoomNotifState: jest.fn(),
};
const mockItem: RoomListItem = {
id: "!test:example.org",
name: "Test Room",
openRoom: jest.fn(),
a11yLabel: "Test Room, no unread messages",
isBold: false,
messagePreview: undefined,
notificationViewModel: mockNotificationViewModel,
menuViewModel: mockMenuViewModel,
notification: mockNotificationData,
showMoreOptionsMenu: true,
showNotificationMenu: true,
moreOptionsState: mockMoreOptionsState,
notificationState: mockNotificationState,
};
const mockCallbacks: RoomListItemCallbacks = {
onOpenRoom: jest.fn(),
moreOptionsCallbacks: mockMoreOptionsCallbacks,
notificationCallbacks: mockNotificationCallbacks,
};
const mockAvatar = <div data-testid="mock-avatar">Avatar</div>;
@ -67,8 +82,9 @@ describe("RoomListItem", () => {
it("renders room name and avatar", () => {
render(
<RoomListItem
viewModel={mockViewModel}
<RoomListItemView
item={mockItem}
callbacks={mockCallbacks}
isSelected={false}
isFocused={false}
onFocus={jest.fn()}
@ -83,10 +99,11 @@ describe("RoomListItem", () => {
});
it("renders with message preview", () => {
const vmWithPreview = { ...mockViewModel, messagePreview: "Latest message preview" };
const itemWithPreview = { ...mockItem, messagePreview: "Latest message preview" };
render(
<RoomListItem
viewModel={vmWithPreview}
<RoomListItemView
item={itemWithPreview}
callbacks={mockCallbacks}
isSelected={false}
isFocused={false}
onFocus={jest.fn()}
@ -101,8 +118,9 @@ describe("RoomListItem", () => {
it("applies selected styles when selected", () => {
render(
<RoomListItem
viewModel={mockViewModel}
<RoomListItemView
item={mockItem}
callbacks={mockCallbacks}
isSelected={true}
isFocused={false}
onFocus={jest.fn()}
@ -117,10 +135,11 @@ describe("RoomListItem", () => {
});
it("applies bold styles when room has unread", () => {
const vmWithUnread = { ...mockViewModel, isBold: true };
const itemWithUnread = { ...mockItem, isBold: true };
render(
<RoomListItem
viewModel={vmWithUnread}
<RoomListItemView
item={itemWithUnread}
callbacks={mockCallbacks}
isSelected={false}
isFocused={false}
onFocus={jest.fn()}
@ -138,8 +157,9 @@ describe("RoomListItem", () => {
it("calls openRoom when clicked", async () => {
const user = userEvent.setup();
render(
<RoomListItem
viewModel={mockViewModel}
<RoomListItemView
item={mockItem}
callbacks={mockCallbacks}
isSelected={false}
isFocused={false}
onFocus={jest.fn()}
@ -150,14 +170,15 @@ describe("RoomListItem", () => {
);
await user.click(screen.getByRole("option"));
expect(mockViewModel.openRoom).toHaveBeenCalledTimes(1);
expect(mockCallbacks.onOpenRoom).toHaveBeenCalledTimes(1);
});
it("calls onFocus when focused", async () => {
const onFocus = jest.fn();
render(
<RoomListItem
viewModel={mockViewModel}
<RoomListItemView
item={mockItem}
callbacks={mockCallbacks}
isSelected={false}
isFocused={false}
onFocus={onFocus}
@ -173,21 +194,21 @@ describe("RoomListItem", () => {
});
it("renders notification decoration when hasAnyNotificationOrActivity is true", () => {
const notificationVM: NotificationDecorationViewModel = {
const notificationData: NotificationDecorationData = {
hasAnyNotificationOrActivity: true,
isUnsentMessage: false,
invited: false,
isMention: false,
isActivityNotification: true,
isNotification: false,
count: 0,
muted: false,
};
const vmWithNotification = { ...mockViewModel, notificationViewModel: notificationVM };
const itemWithNotification = { ...mockItem, notification: notificationData };
render(
<RoomListItem
viewModel={vmWithNotification}
<RoomListItemView
item={itemWithNotification}
callbacks={mockCallbacks}
isSelected={false}
isFocused={false}
onFocus={jest.fn()}
@ -202,8 +223,9 @@ describe("RoomListItem", () => {
it("sets correct ARIA attributes", () => {
render(
<RoomListItem
viewModel={mockViewModel}
<RoomListItemView
item={mockItem}
callbacks={mockCallbacks}
isSelected={false}
isFocused={false}
onFocus={jest.fn()}
@ -216,6 +238,6 @@ describe("RoomListItem", () => {
const button = screen.getByRole("option");
expect(button).toHaveAttribute("aria-posinset", "6"); // index + 1
expect(button).toHaveAttribute("aria-setsize", "20");
expect(button).toHaveAttribute("aria-label", mockViewModel.a11yLabel);
expect(button).toHaveAttribute("aria-label", mockItem.a11yLabel);
});
});

View File

@ -9,44 +9,64 @@ import React, { type JSX, memo, useCallback, useEffect, useRef, useState, type R
import classNames from "classnames";
import { Flex } from "../../utils/Flex";
import { NotificationDecoration, type NotificationDecorationData } from "../../notifications/NotificationDecoration";
import {
NotificationDecoration,
type NotificationDecorationViewModel,
} from "../../notifications/NotificationDecoration";
import { type RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel";
import { RoomListItemHoverMenu } from "./RoomListItemHoverMenu";
RoomListItemHoverMenu,
type MoreOptionsMenuState,
type MoreOptionsMenuCallbacks,
type NotificationMenuState,
type NotificationMenuCallbacks,
} from "./RoomListItemHoverMenu";
import { RoomListItemContextMenu } from "./RoomListItemContextMenu";
import styles from "./RoomListItem.module.css";
/**
* ViewModel interface for RoomListItem
* Element-web will provide implementations that connect to Matrix SDK
* Data interface for a room list item.
* Contains all the data needed to render a room in the list.
*/
export interface RoomListItemViewModel {
export interface RoomListItem {
/** Unique identifier for the room (used for list keying) */
id: string;
/** The name of the room */
name: string;
/** Callback to open the room */
openRoom: () => void;
/** Accessibility label for the room list item */
a11yLabel: string;
/** Whether the room name should be bolded (has unread/activity) */
isBold: boolean;
/** Optional message preview text */
messagePreview?: string;
/** Notification decoration view model */
notificationViewModel: NotificationDecorationViewModel;
/** Menu view model (for hover and context menus) */
menuViewModel: RoomListItemMenuViewModel;
/** Notification decoration data */
notification: NotificationDecorationData;
/** Whether the more options menu should be shown */
showMoreOptionsMenu: boolean;
/** Whether the notification menu should be shown */
showNotificationMenu: boolean;
/** More options menu state */
moreOptionsState: MoreOptionsMenuState;
/** Notification menu state */
notificationState: NotificationMenuState;
}
/**
* Props for RoomListItem component
* Callbacks for room list item interactions
*/
export interface RoomListItemProps extends Omit<React.HTMLAttributes<HTMLButtonElement>, "onFocus"> {
/** The view model containing room data and actions */
viewModel: RoomListItemViewModel;
export interface RoomListItemCallbacks {
/** Callback to open the room */
onOpenRoom: () => void;
/** More options menu callbacks */
moreOptionsCallbacks: MoreOptionsMenuCallbacks;
/** Notification menu callbacks */
notificationCallbacks: NotificationMenuCallbacks;
}
/**
* Props for RoomListItemView component
*/
export interface RoomListItemViewProps extends Omit<React.HTMLAttributes<HTMLButtonElement>, "onFocus"> {
/** The room data to display */
item: RoomListItem;
/** The room callbacks */
callbacks: RoomListItemCallbacks;
/** Whether the room is currently selected */
isSelected: boolean;
/** Whether the room is currently focused */
@ -64,10 +84,11 @@ export interface RoomListItemProps extends Omit<React.HTMLAttributes<HTMLButtonE
/**
* A presentational room list item component.
* Displays room name, avatar, message preview, and notifications.
* Delegates all business logic to the viewModel and render functions.
* All business logic is handled through callbacks to parent components.
*/
export const RoomListItem = memo(function RoomListItem({
viewModel,
export const RoomListItemView = memo(function RoomListItemView({
item,
callbacks,
isSelected,
isFocused,
onFocus,
@ -75,7 +96,7 @@ export const RoomListItem = memo(function RoomListItem({
roomCount,
avatar,
...props
}: RoomListItemProps): JSX.Element {
}: RoomListItemViewProps): JSX.Element {
const ref = useRef<HTMLButtonElement>(null);
const [isHover, setHover] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
@ -105,7 +126,7 @@ export const RoomListItem = memo(function RoomListItem({
[styles.hover]: showHoverDecoration,
[styles.menuOpen]: showHoverMenu,
[styles.selected]: isSelected,
[styles.bold]: viewModel.isBold,
[styles.bold]: item.isBold,
})}
gap="var(--cpd-space-3x)"
align="center"
@ -114,8 +135,8 @@ export const RoomListItem = memo(function RoomListItem({
aria-posinset={roomIndex + 1}
aria-setsize={roomCount}
aria-selected={isSelected}
aria-label={viewModel.a11yLabel}
onClick={() => viewModel.openRoom()}
aria-label={item.a11yLabel}
onClick={callbacks.onOpenRoom}
onFocus={onFocus}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
@ -127,25 +148,30 @@ export const RoomListItem = memo(function RoomListItem({
<Flex className={styles.content} gap="var(--cpd-space-2x)" align="center" justify="space-between">
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
<div className={styles.text}>
<div className={styles.roomName} title={viewModel.name}>
{viewModel.name}
<div className={styles.roomName} title={item.name}>
{item.name}
</div>
{viewModel.messagePreview && (
<div className={styles.messagePreview} title={viewModel.messagePreview}>
{viewModel.messagePreview}
{item.messagePreview && (
<div className={styles.messagePreview} title={item.messagePreview}>
{item.messagePreview}
</div>
)}
</div>
{showHoverMenu ? (
<RoomListItemHoverMenu
viewModel={viewModel.menuViewModel}
showMoreOptionsMenu={item.showMoreOptionsMenu}
showNotificationMenu={item.showNotificationMenu}
moreOptionsState={item.moreOptionsState}
moreOptionsCallbacks={callbacks.moreOptionsCallbacks}
notificationState={item.notificationState}
notificationCallbacks={callbacks.notificationCallbacks}
onMenuOpenChange={(isOpen: boolean) => (isOpen ? setIsMenuOpen(true) : closeMenu())}
/>
) : (
<>
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel */}
<div aria-hidden={true}>
<NotificationDecoration viewModel={viewModel.notificationViewModel} />
<NotificationDecoration data={item.notification} />
</div>
</>
)}
@ -154,7 +180,11 @@ export const RoomListItem = memo(function RoomListItem({
);
return (
<RoomListItemContextMenu viewModel={viewModel.menuViewModel} onMenuOpenChange={setIsMenuOpen}>
<RoomListItemContextMenu
state={item.moreOptionsState}
callbacks={callbacks.moreOptionsCallbacks}
onMenuOpenChange={setIsMenuOpen}
>
{content}
</RoomListItemContextMenu>
);

View File

@ -9,15 +9,20 @@ import React, { type JSX, type PropsWithChildren } from "react";
import { ContextMenu } from "@vector-im/compound-web";
import { _t } from "../../utils/i18n";
import { MoreOptionContent } from "./RoomListItemHoverMenu";
import { type RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel";
import {
MoreOptionContent,
type MoreOptionsMenuState,
type MoreOptionsMenuCallbacks,
} from "./RoomListItemMoreOptionsMenu";
/**
* Props for RoomListItemContextMenu component
*/
export interface RoomListItemContextMenuProps {
/** The view model containing menu data and callbacks */
viewModel: RoomListItemMenuViewModel;
/** More options menu state */
state: MoreOptionsMenuState;
/** More options menu callbacks */
callbacks: MoreOptionsMenuCallbacks;
/** Callback when menu open state changes */
onMenuOpenChange: (isOpen: boolean) => void;
}
@ -27,7 +32,8 @@ export interface RoomListItemContextMenuProps {
* Wraps the trigger element with a right-click context menu displaying room options.
*/
export const RoomListItemContextMenu: React.FC<PropsWithChildren<RoomListItemContextMenuProps>> = ({
viewModel,
state,
callbacks,
onMenuOpenChange,
children,
}): JSX.Element => {
@ -39,7 +45,7 @@ export const RoomListItemContextMenu: React.FC<PropsWithChildren<RoomListItemCon
trigger={children}
onOpenChange={onMenuOpenChange}
>
<MoreOptionContent viewModel={viewModel} />
<MoreOptionContent state={state} callbacks={callbacks} />
</ContextMenu>
);
};

View File

@ -5,31 +5,36 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { useState, useCallback, type JSX, type ComponentProps } from "react";
import { IconButton, Menu, MenuItem, Separator, ToggleMenuItem, Tooltip } from "@vector-im/compound-web";
import MarkAsReadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-read";
import MarkAsUnreadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-unread";
import FavouriteIcon from "@vector-im/compound-design-tokens/assets/web/icons/favourite";
import ArrowDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/arrow-down";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
import LinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link";
import LeaveIcon from "@vector-im/compound-design-tokens/assets/web/icons/leave";
import OverflowIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import NotificationIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid";
import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-off-solid";
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
import React, { type JSX } from "react";
import { Flex } from "../../utils/Flex";
import { _t } from "../../utils/i18n";
import { RoomNotifState } from "../../notifications/RoomNotifs";
import { type RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel";
import {
RoomListItemMoreOptionsMenu,
type MoreOptionsMenuState,
type MoreOptionsMenuCallbacks,
} from "./RoomListItemMoreOptionsMenu";
import {
RoomListItemNotificationMenu,
type NotificationMenuState,
type NotificationMenuCallbacks,
} from "./RoomListItemNotificationMenu";
/**
* Props for RoomListItemHoverMenu component
*/
export interface RoomListItemHoverMenuProps {
/** The view model containing menu data and callbacks */
viewModel: RoomListItemMenuViewModel;
/** Whether the more options menu should be shown */
showMoreOptionsMenu: boolean;
/** Whether the notification menu should be shown */
showNotificationMenu: boolean;
/** More options menu state */
moreOptionsState: MoreOptionsMenuState;
/** More options menu callbacks */
moreOptionsCallbacks: MoreOptionsMenuCallbacks;
/** Notification menu state */
notificationState: NotificationMenuState;
/** Notification menu callbacks */
notificationCallbacks: NotificationMenuCallbacks;
/** Callback when menu open state changes */
onMenuOpenChange: (isOpen: boolean) => void;
}
@ -39,215 +44,34 @@ export interface RoomListItemHoverMenuProps {
* Displays more options and notification settings menus.
*/
export const RoomListItemHoverMenu: React.FC<RoomListItemHoverMenuProps> = ({
viewModel,
showMoreOptionsMenu,
showNotificationMenu,
moreOptionsState,
moreOptionsCallbacks,
notificationState,
notificationCallbacks,
onMenuOpenChange,
}): JSX.Element => {
return (
<Flex className="mx_RoomListItemHoverMenu" align="center" gap="var(--cpd-space-1x)">
{viewModel.showMoreOptionsMenu && (
<MoreOptionsMenu viewModel={viewModel} onMenuOpenChange={onMenuOpenChange} />
{showMoreOptionsMenu && (
<RoomListItemMoreOptionsMenu
state={moreOptionsState}
callbacks={moreOptionsCallbacks}
onMenuOpenChange={onMenuOpenChange}
/>
)}
{viewModel.showNotificationMenu && (
<NotificationMenu viewModel={viewModel} onMenuOpenChange={onMenuOpenChange} />
{showNotificationMenu && (
<RoomListItemNotificationMenu
state={notificationState}
callbacks={notificationCallbacks}
onMenuOpenChange={onMenuOpenChange}
/>
)}
</Flex>
);
};
interface MoreOptionsMenuProps {
viewModel: RoomListItemMenuViewModel;
onMenuOpenChange: (isOpen: boolean) => void;
}
function MoreOptionsMenu({ viewModel, onMenuOpenChange }: MoreOptionsMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
const handleOpenChange = useCallback(
(isOpen: boolean) => {
setOpen(isOpen);
onMenuOpenChange(isOpen);
},
[onMenuOpenChange],
);
return (
<Menu
open={open}
onOpenChange={handleOpenChange}
title={_t("room_list|room|more_options")}
showTitle={false}
align="start"
trigger={<MoreOptionsButton size="24px" />}
>
<MoreOptionContent viewModel={viewModel} />
</Menu>
);
}
interface MoreOptionContentProps {
viewModel: RoomListItemMenuViewModel;
}
export function MoreOptionContent({ viewModel }: MoreOptionContentProps): JSX.Element {
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div onKeyDown={(e) => e.stopPropagation()}>
{viewModel.canMarkAsRead && (
<MenuItem
Icon={MarkAsReadIcon}
label={_t("room_list|more_options|mark_read")}
onSelect={viewModel.markAsRead}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
{viewModel.canMarkAsUnread && (
<MenuItem
Icon={MarkAsUnreadIcon}
label={_t("room_list|more_options|mark_unread")}
onSelect={viewModel.markAsUnread}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
<ToggleMenuItem
checked={viewModel.isFavourite}
Icon={FavouriteIcon}
label={_t("room_list|more_options|favourited")}
onSelect={viewModel.toggleFavorite}
onClick={(evt) => evt.stopPropagation()}
/>
<ToggleMenuItem
checked={viewModel.isLowPriority}
Icon={ArrowDownIcon}
label={_t("room_list|more_options|low_priority")}
onSelect={viewModel.toggleLowPriority}
onClick={(evt) => evt.stopPropagation()}
/>
{viewModel.canInvite && (
<MenuItem
Icon={UserAddIcon}
label={_t("action|invite")}
onSelect={viewModel.invite}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
{viewModel.canCopyRoomLink && (
<MenuItem
Icon={LinkIcon}
label={_t("room_list|more_options|copy_link")}
onSelect={viewModel.copyRoomLink}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
<Separator />
<MenuItem
kind="critical"
Icon={LeaveIcon}
label={_t("room_list|more_options|leave_room")}
onSelect={viewModel.leaveRoom}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
</div>
);
}
const MoreOptionsButton = function MoreOptionsButton(props: ComponentProps<typeof IconButton>): JSX.Element {
return (
<Tooltip label={_t("room_list|room|more_options")}>
<IconButton aria-label={_t("room_list|room|more_options")} {...props}>
<OverflowIcon />
</IconButton>
</Tooltip>
);
};
interface NotificationMenuProps {
viewModel: RoomListItemMenuViewModel;
onMenuOpenChange: (isOpen: boolean) => void;
}
function NotificationMenu({ viewModel, onMenuOpenChange }: NotificationMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
const handleOpenChange = useCallback(
(isOpen: boolean) => {
setOpen(isOpen);
onMenuOpenChange(isOpen);
},
[onMenuOpenChange],
);
const checkComponent = <CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-primary)" />;
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div onKeyDown={(e) => e.stopPropagation()}>
<Menu
open={open}
onOpenChange={handleOpenChange}
title={_t("room_list|notification_options")}
showTitle={false}
align="start"
trigger={<NotificationButton isRoomMuted={viewModel.isNotificationMute} size="24px" />}
>
<MenuItem
aria-selected={viewModel.isNotificationAllMessage}
hideChevron={true}
label={_t("notifications|default_settings")}
onSelect={() => viewModel.setRoomNotifState(RoomNotifState.AllMessages)}
onClick={(evt) => evt.stopPropagation()}
>
{viewModel.isNotificationAllMessage && checkComponent}
</MenuItem>
<MenuItem
aria-selected={viewModel.isNotificationAllMessageLoud}
hideChevron={true}
label={_t("notifications|all_messages")}
onSelect={() => viewModel.setRoomNotifState(RoomNotifState.AllMessagesLoud)}
onClick={(evt) => evt.stopPropagation()}
>
{viewModel.isNotificationAllMessageLoud && checkComponent}
</MenuItem>
<MenuItem
aria-selected={viewModel.isNotificationMentionOnly}
hideChevron={true}
label={_t("notifications|mentions_keywords")}
onSelect={() => viewModel.setRoomNotifState(RoomNotifState.MentionsOnly)}
onClick={(evt) => evt.stopPropagation()}
>
{viewModel.isNotificationMentionOnly && checkComponent}
</MenuItem>
<MenuItem
aria-selected={viewModel.isNotificationMute}
hideChevron={true}
label={_t("notifications|mute_room")}
onSelect={() => viewModel.setRoomNotifState(RoomNotifState.Mute)}
onClick={(evt) => evt.stopPropagation()}
>
{viewModel.isNotificationMute && checkComponent}
</MenuItem>
</Menu>
</div>
);
}
interface NotificationButtonProps extends ComponentProps<typeof IconButton> {
isRoomMuted: boolean;
}
const NotificationButton = function NotificationButton({
isRoomMuted,
...props
}: NotificationButtonProps): JSX.Element {
return (
<Tooltip label={_t("room_list|notification_options")}>
<IconButton aria-label={_t("room_list|notification_options")} {...props}>
{isRoomMuted ? <NotificationOffIcon /> : <NotificationIcon />}
</IconButton>
</Tooltip>
);
};
// Re-export types for convenience
export type { MoreOptionsMenuState, MoreOptionsMenuCallbacks } from "./RoomListItemMoreOptionsMenu";
export type { NotificationMenuState, NotificationMenuCallbacks } from "./RoomListItemNotificationMenu";

View File

@ -0,0 +1,184 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { useState, useCallback, type JSX, type ComponentProps } from "react";
import { IconButton, Menu, MenuItem, Separator, ToggleMenuItem, Tooltip } from "@vector-im/compound-web";
import MarkAsReadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-read";
import MarkAsUnreadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-unread";
import FavouriteIcon from "@vector-im/compound-design-tokens/assets/web/icons/favourite";
import ArrowDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/arrow-down";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
import LinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link";
import LeaveIcon from "@vector-im/compound-design-tokens/assets/web/icons/leave";
import OverflowIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import { _t } from "../../utils/i18n";
/**
* State for the more options menu
*/
export interface MoreOptionsMenuState {
/** Whether the room is a favourite room */
isFavourite: boolean;
/** Whether the room is a low priority room */
isLowPriority: boolean;
/** Can invite other users in the room */
canInvite: boolean;
/** Can copy the room link */
canCopyRoomLink: boolean;
/** Can mark the room as read */
canMarkAsRead: boolean;
/** Can mark the room as unread */
canMarkAsUnread: boolean;
}
/**
* Callbacks for the more options menu
*/
export interface MoreOptionsMenuCallbacks {
/** Mark the room as read */
onMarkAsRead: () => void;
/** Mark the room as unread */
onMarkAsUnread: () => void;
/** Toggle the room as favourite */
onToggleFavorite: () => void;
/** Toggle the room as low priority */
onToggleLowPriority: () => void;
/** Invite other users in the room */
onInvite: () => void;
/** Copy the room link to clipboard */
onCopyRoomLink: () => void;
/** Leave the room */
onLeaveRoom: () => void;
}
/**
* Props for RoomListItemMoreOptionsMenu component
*/
export interface RoomListItemMoreOptionsMenuProps {
/** More options menu state */
state: MoreOptionsMenuState;
/** More options menu callbacks */
callbacks: MoreOptionsMenuCallbacks;
/** Callback when menu open state changes */
onMenuOpenChange: (isOpen: boolean) => void;
}
/**
* The more options menu for room list items.
* Displays additional room actions like mark as read/unread, favorite, invite, etc.
*/
export function RoomListItemMoreOptionsMenu({
state,
callbacks,
onMenuOpenChange,
}: RoomListItemMoreOptionsMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
const handleOpenChange = useCallback(
(isOpen: boolean) => {
setOpen(isOpen);
onMenuOpenChange(isOpen);
},
[onMenuOpenChange],
);
return (
<Menu
open={open}
onOpenChange={handleOpenChange}
title={_t("room_list|room|more_options")}
showTitle={false}
align="start"
trigger={<MoreOptionsButton size="24px" />}
>
<MoreOptionContent state={state} callbacks={callbacks} />
</Menu>
);
}
interface MoreOptionContentProps {
state: MoreOptionsMenuState;
callbacks: MoreOptionsMenuCallbacks;
}
export function MoreOptionContent({ state, callbacks }: MoreOptionContentProps): JSX.Element {
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div onKeyDown={(e) => e.stopPropagation()}>
{state.canMarkAsRead && (
<MenuItem
Icon={MarkAsReadIcon}
label={_t("room_list|more_options|mark_read")}
onSelect={callbacks.onMarkAsRead}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
{state.canMarkAsUnread && (
<MenuItem
Icon={MarkAsUnreadIcon}
label={_t("room_list|more_options|mark_unread")}
onSelect={callbacks.onMarkAsUnread}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
<ToggleMenuItem
checked={state.isFavourite}
Icon={FavouriteIcon}
label={_t("room_list|more_options|favourited")}
onSelect={callbacks.onToggleFavorite}
onClick={(evt) => evt.stopPropagation()}
/>
<ToggleMenuItem
checked={state.isLowPriority}
Icon={ArrowDownIcon}
label={_t("room_list|more_options|low_priority")}
onSelect={callbacks.onToggleLowPriority}
onClick={(evt) => evt.stopPropagation()}
/>
{state.canInvite && (
<MenuItem
Icon={UserAddIcon}
label={_t("action|invite")}
onSelect={callbacks.onInvite}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
{state.canCopyRoomLink && (
<MenuItem
Icon={LinkIcon}
label={_t("room_list|more_options|copy_link")}
onSelect={callbacks.onCopyRoomLink}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
<Separator />
<MenuItem
kind="critical"
Icon={LeaveIcon}
label={_t("room_list|more_options|leave_room")}
onSelect={callbacks.onLeaveRoom}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
</div>
);
}
const MoreOptionsButton = function MoreOptionsButton(props: ComponentProps<typeof IconButton>): JSX.Element {
return (
<Tooltip label={_t("room_list|room|more_options")}>
<IconButton aria-label={_t("room_list|room|more_options")} {...props}>
<OverflowIcon />
</IconButton>
</Tooltip>
);
};

View File

@ -0,0 +1,139 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { useState, useCallback, type JSX, type ComponentProps } from "react";
import { IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
import NotificationIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid";
import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-off-solid";
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
import { _t } from "../../utils/i18n";
import { RoomNotifState } from "../../notifications/RoomNotifs";
/**
* State for the notification menu
*/
export interface NotificationMenuState {
/** Whether the notification is set to all messages */
isNotificationAllMessage: boolean;
/** Whether the notification is set to all messages loud */
isNotificationAllMessageLoud: boolean;
/** Whether the notification is set to mentions and keywords only */
isNotificationMentionOnly: boolean;
/** Whether the notification is muted */
isNotificationMute: boolean;
}
/**
* Callbacks for the notification menu
*/
export interface NotificationMenuCallbacks {
/** Set the room notification state */
onSetRoomNotifState: (state: RoomNotifState) => void;
}
/**
* Props for RoomListItemNotificationMenu component
*/
export interface RoomListItemNotificationMenuProps {
/** Notification menu state */
state: NotificationMenuState;
/** Notification menu callbacks */
callbacks: NotificationMenuCallbacks;
/** Callback when menu open state changes */
onMenuOpenChange: (isOpen: boolean) => void;
}
/**
* The notification settings menu for room list items.
* Displays options to change notification settings.
*/
export function RoomListItemNotificationMenu({
state,
callbacks,
onMenuOpenChange,
}: RoomListItemNotificationMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
const handleOpenChange = useCallback(
(isOpen: boolean) => {
setOpen(isOpen);
onMenuOpenChange(isOpen);
},
[onMenuOpenChange],
);
const checkComponent = <CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-primary)" />;
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div onKeyDown={(e) => e.stopPropagation()}>
<Menu
open={open}
onOpenChange={handleOpenChange}
title={_t("room_list|notification_options")}
showTitle={false}
align="start"
trigger={<NotificationButton isRoomMuted={state.isNotificationMute} size="24px" />}
>
<MenuItem
aria-selected={state.isNotificationAllMessage}
hideChevron={true}
label={_t("notifications|default_settings")}
onSelect={() => callbacks.onSetRoomNotifState(RoomNotifState.AllMessages)}
onClick={(evt) => evt.stopPropagation()}
>
{state.isNotificationAllMessage && checkComponent}
</MenuItem>
<MenuItem
aria-selected={state.isNotificationAllMessageLoud}
hideChevron={true}
label={_t("notifications|all_messages")}
onSelect={() => callbacks.onSetRoomNotifState(RoomNotifState.AllMessagesLoud)}
onClick={(evt) => evt.stopPropagation()}
>
{state.isNotificationAllMessageLoud && checkComponent}
</MenuItem>
<MenuItem
aria-selected={state.isNotificationMentionOnly}
hideChevron={true}
label={_t("notifications|mentions_keywords")}
onSelect={() => callbacks.onSetRoomNotifState(RoomNotifState.MentionsOnly)}
onClick={(evt) => evt.stopPropagation()}
>
{state.isNotificationMentionOnly && checkComponent}
</MenuItem>
<MenuItem
aria-selected={state.isNotificationMute}
hideChevron={true}
label={_t("notifications|mute_room")}
onSelect={() => callbacks.onSetRoomNotifState(RoomNotifState.Mute)}
onClick={(evt) => evt.stopPropagation()}
>
{state.isNotificationMute && checkComponent}
</MenuItem>
</Menu>
</div>
);
}
interface NotificationButtonProps extends ComponentProps<typeof IconButton> {
isRoomMuted: boolean;
}
const NotificationButton = function NotificationButton({
isRoomMuted,
...props
}: NotificationButtonProps): JSX.Element {
return (
<Tooltip label={_t("room_list|notification_options")}>
<IconButton aria-label={_t("room_list|notification_options")} {...props}>
{isRoomMuted ? <NotificationOffIcon /> : <NotificationIcon />}
</IconButton>
</Tooltip>
);
};

View File

@ -5,5 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
export { RoomListItem } from "./RoomListItem";
export type { RoomListItemProps, RoomListItemViewModel } from "./RoomListItem";
export { RoomListItemView } from "./RoomListItem";
export type { RoomListItem, RoomListItemViewProps, RoomListItemCallbacks } from "./RoomListItem";
export type { MoreOptionsMenuState, MoreOptionsMenuCallbacks } from "./RoomListItemMoreOptionsMenu";
export type { NotificationMenuState, NotificationMenuCallbacks } from "./RoomListItemNotificationMenu";

View File

@ -8,21 +8,22 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration";
import type { NotificationDecorationData } from "../../notifications/NotificationDecoration";
import type { RoomsResult } from "../RoomList";
import type { RoomListItemViewModel } from "../RoomListItem";
import type { RoomListItem } from "../RoomListItem";
import type { MoreOptionsMenuState } from "../RoomListItem/RoomListItemMoreOptionsMenu";
import type { NotificationMenuState } from "../RoomListItem/RoomListItemNotificationMenu";
import { SortOption } from "../RoomListHeader/SortOptionsMenu";
import { RoomListPanel, type RoomListPanelSnapshot } from "./RoomListPanel";
import type { FilterViewModel } from "../RoomListPrimaryFilters/useVisibleFilters";
import { type ViewModel } from "../../viewmodel/ViewModel";
import type { RoomListSearchSnapshot } from "../RoomListSearch";
import type { RoomListHeaderSnapshot, SortOptionsMenuSnapshot } from "../RoomListHeader";
import type { RoomListViewSnapshot } from "../RoomListView";
import type { RoomListViewWrapperSnapshot } from "../RoomListView";
import type { RoomListPrimaryFiltersSnapshot } from "../RoomListPrimaryFilters";
import type { RoomListSnapshot } from "../RoomList";
// Mock avatar component
const mockAvatar = (roomViewModel: RoomListItemViewModel): React.ReactElement => (
const mockAvatar = (roomItem: RoomListItem): React.ReactElement => (
<div
style={{
width: "32px",
@ -37,17 +38,17 @@ const mockAvatar = (roomViewModel: RoomListItemViewModel): React.ReactElement =>
fontSize: "12px",
}}
>
{roomViewModel.name.substring(0, 2).toUpperCase()}
{roomItem.name.substring(0, 2).toUpperCase()}
</div>
);
// Generate mock rooms
const generateMockRooms = (count: number): RoomListItemViewModel[] => {
const generateMockRooms = (count: number): RoomListItem[] => {
return Array.from({ length: count }, (_, i) => {
const unreadCount = Math.random() > 0.7 ? Math.floor(Math.random() * 10) : 0;
const hasNotification = Math.random() > 0.8;
const notificationViewModel: NotificationDecorationViewModel = {
const notificationData: NotificationDecorationData = {
hasAnyNotificationOrActivity: unreadCount > 0,
isUnsentMessage: false,
invited: false,
@ -58,36 +59,33 @@ const generateMockRooms = (count: number): RoomListItemViewModel[] => {
muted: false,
};
const moreOptionsState: MoreOptionsMenuState = {
isFavourite: false,
isLowPriority: false,
canInvite: true,
canCopyRoomLink: true,
canMarkAsRead: unreadCount > 0,
canMarkAsUnread: unreadCount === 0,
};
const notificationState: NotificationMenuState = {
isNotificationAllMessage: true,
isNotificationAllMessageLoud: false,
isNotificationMentionOnly: false,
isNotificationMute: false,
};
return {
id: `!room${i}:server`,
name: `Room ${i + 1}`,
openRoom: () => console.log(`Opening room: Room ${i + 1}`),
a11yLabel: unreadCount ? `Room ${i + 1}, ${unreadCount} unread messages` : `Room ${i + 1}`,
isBold: unreadCount > 0,
messagePreview: undefined,
notificationViewModel,
menuViewModel: {
showMoreOptionsMenu: true,
showNotificationMenu: true,
canMarkAsRead: unreadCount > 0,
canMarkAsUnread: unreadCount === 0,
isFavourite: false,
isLowPriority: false,
canInvite: true,
canCopyRoomLink: true,
isNotificationAllMessage: true,
isNotificationAllMessageLoud: false,
isNotificationMentionOnly: false,
isNotificationMute: false,
markAsRead: () => console.log(`Mark read: Room ${i + 1}`),
markAsUnread: () => console.log(`Mark unread: Room ${i + 1}`),
toggleFavorite: () => console.log(`Toggle favorite: Room ${i + 1}`),
toggleLowPriority: () => console.log(`Toggle low priority: Room ${i + 1}`),
invite: () => console.log(`Invite: Room ${i + 1}`),
copyRoomLink: () => console.log(`Copy link: Room ${i + 1}`),
leaveRoom: () => console.log(`Leave: Room ${i + 1}`),
setRoomNotifState: (state) => console.log(`Set notif state: ${state}`),
},
notification: notificationData,
showMoreOptionsMenu: true,
showNotificationMenu: true,
moreOptionsState,
notificationState,
};
});
};
@ -117,13 +115,37 @@ const meta: Meta<typeof RoomListPanel> = {
export default meta;
type Story = StoryObj<typeof RoomListPanel>;
// Create stable unsubscribe function
const noop = (): void => {};
function createMockViewModel<T>(snapshot: T): ViewModel<T> {
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
subscribe: () => noop,
};
}
// Create stable snapshot for RoomListViewModel
const mockRoomListSnapshot = {
roomsResult: mockRoomsResult,
activeRoomIndex: 0,
};
// Create stable RoomListViewModel
const mockRoomListViewModel = {
getSnapshot: () => mockRoomListSnapshot,
subscribe: () => noop,
onOpenRoom: (roomId: string) => console.log("Open room:", roomId),
onMarkAsRead: (roomId: string) => console.log("Mark as read:", roomId),
onMarkAsUnread: (roomId: string) => console.log("Mark as unread:", roomId),
onToggleFavorite: (roomId: string) => console.log("Toggle favorite:", roomId),
onToggleLowPriority: (roomId: string) => console.log("Toggle low priority:", roomId),
onInvite: (roomId: string) => console.log("Invite:", roomId),
onCopyRoomLink: (roomId: string) => console.log("Copy room link:", roomId),
onLeaveRoom: (roomId: string) => console.log("Leave room:", roomId),
onSetRoomNotifState: (roomId: string, state: any) => console.log("Set notification:", roomId, state),
};
const baseViewModel: ViewModel<RoomListPanelSnapshot> = createMockViewModel({
ariaLabel: "Room list navigation",
searchVm: createMockViewModel<RoomListSearchSnapshot>({
@ -142,16 +164,13 @@ const baseViewModel: ViewModel<RoomListPanelSnapshot> = createMockViewModel({
sort: (option) => console.log(`Sort: ${option}`),
}),
}),
viewVm: createMockViewModel<RoomListViewSnapshot>({
viewVm: createMockViewModel<RoomListViewWrapperSnapshot>({
isLoadingRooms: false,
isRoomListEmpty: false,
filtersVm: createMockViewModel<RoomListPrimaryFiltersSnapshot>({
filters: createFilters(),
}),
roomListVm: createMockViewModel<RoomListSnapshot>({
roomsResult: mockRoomsResult,
activeRoomIndex: 0,
}),
roomListVm: mockRoomListViewModel,
emptyStateTitle: "No rooms",
emptyStateDescription: "Join a room to get started",
}),
@ -196,7 +215,7 @@ export const Loading: Story = {
ariaLabel: "Room list navigation",
searchVm: baseViewModel.getSnapshot().searchVm,
headerVm: baseViewModel.getSnapshot().headerVm,
viewVm: createMockViewModel<RoomListViewSnapshot>({
viewVm: createMockViewModel<RoomListViewWrapperSnapshot>({
...baseViewModel.getSnapshot().viewVm.getSnapshot(),
isLoadingRooms: true,
}),
@ -218,7 +237,7 @@ export const Empty: Story = {
ariaLabel: "Room list navigation",
searchVm: baseViewModel.getSnapshot().searchVm,
headerVm: baseViewModel.getSnapshot().headerVm,
viewVm: createMockViewModel<RoomListViewSnapshot>({
viewVm: createMockViewModel<RoomListViewWrapperSnapshot>({
...baseViewModel.getSnapshot().viewVm.getSnapshot(),
isRoomListEmpty: true,
emptyStateTitle: "No rooms to display",

View File

@ -11,12 +11,12 @@ import React from "react";
import { RoomListPanel, type RoomListPanelSnapshot } from "./RoomListPanel";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { SortOption } from "../RoomListHeader";
import type { RoomListItemViewModel } from "../RoomListItem";
import type { RoomListItem } from "../RoomListItem";
import type { RoomListSearchSnapshot } from "../RoomListSearch";
import type { RoomListHeaderSnapshot } from "../RoomListHeader";
import type { RoomListViewSnapshot } from "../RoomListView";
import type { RoomListViewWrapperSnapshot } from "../RoomListView";
import type { RoomListPrimaryFiltersSnapshot } from "../RoomListPrimaryFilters";
import type { RoomListSnapshot } from "../RoomList";
import type { RoomListViewModel, RoomListViewSnapshot } from "../RoomList";
import type { SortOptionsMenuSnapshot } from "../RoomListHeader/SortOptionsMenu";
// Mock ResizeObserver which is used by RoomListPrimaryFilters
@ -34,8 +34,8 @@ describe("RoomListPanel", () => {
};
}
const mockRenderAvatar = jest.fn((roomViewModel: RoomListItemViewModel) => (
<div data-testid={`avatar-${roomViewModel.id}`}>{roomViewModel.name[0]}</div>
const mockRenderAvatar = jest.fn((roomItem: RoomListItem) => (
<div data-testid={`avatar-${roomItem.id}`}>{roomItem.name[0]}</div>
));
const searchSnapshot: RoomListSearchSnapshot = {
@ -61,22 +61,35 @@ describe("RoomListPanel", () => {
filters: [],
};
const roomListSnapshot: RoomListSnapshot = {
const roomListSnapshot: RoomListViewSnapshot = {
roomsResult: {
spaceId: "!space:server",
filterKeys: undefined,
rooms: [],
},
activeRoomIndex: undefined,
onKeyDown: undefined,
};
const viewSnapshot: RoomListViewSnapshot = {
const roomListViewModel: RoomListViewModel = {
getSnapshot: () => roomListSnapshot,
subscribe: () => () => {},
onOpenRoom: jest.fn(),
onMarkAsRead: jest.fn(),
onMarkAsUnread: jest.fn(),
onToggleFavorite: jest.fn(),
onToggleLowPriority: jest.fn(),
onInvite: jest.fn(),
onCopyRoomLink: jest.fn(),
onLeaveRoom: jest.fn(),
onSetRoomNotifState: jest.fn(),
};
const viewSnapshot: RoomListViewWrapperSnapshot = {
isLoadingRooms: false,
isRoomListEmpty: false,
emptyStateTitle: "No rooms",
filtersVm: createMockViewModel(filtersSnapshot),
roomListVm: createMockViewModel(roomListSnapshot),
roomListVm: roomListViewModel,
};
const mockSnapshot: RoomListPanelSnapshot = {
@ -109,7 +122,7 @@ describe("RoomListPanel", () => {
});
it("renders loading state", () => {
const loadingViewSnapshot: RoomListViewSnapshot = {
const loadingViewSnapshot: RoomListViewWrapperSnapshot = {
...viewSnapshot,
isLoadingRooms: true,
isRoomListEmpty: false,
@ -129,7 +142,7 @@ describe("RoomListPanel", () => {
});
it("renders empty state", () => {
const emptyViewSnapshot: RoomListViewSnapshot = {
const emptyViewSnapshot: RoomListViewWrapperSnapshot = {
...viewSnapshot,
isLoadingRooms: false,
isRoomListEmpty: true,

View File

@ -12,8 +12,8 @@ import { useViewModel } from "../../useViewModel";
import { Flex } from "../../utils/Flex";
import { RoomListSearch, type RoomListSearchSnapshot } from "../RoomListSearch";
import { RoomListHeader, type RoomListHeaderSnapshot } from "../RoomListHeader";
import { RoomListView, type RoomListViewSnapshot } from "../RoomListView";
import { type RoomListItemViewModel } from "../RoomListItem";
import { RoomListView, type RoomListViewWrapperSnapshot } from "../RoomListView";
import { type RoomListItem } from "../RoomListItem";
import styles from "./RoomListPanel.module.css";
/**
@ -27,7 +27,7 @@ export type RoomListPanelSnapshot = {
/** Header view model */
headerVm: ViewModel<RoomListHeaderSnapshot>;
/** View model for the main content area */
viewVm: ViewModel<RoomListViewSnapshot>;
viewVm: ViewModel<RoomListViewWrapperSnapshot>;
};
/**
@ -37,7 +37,7 @@ export interface RoomListPanelProps extends React.HTMLAttributes<HTMLElement> {
/** The view model containing all data and callbacks */
vm: ViewModel<RoomListPanelSnapshot>;
/** Render function for room avatar */
renderAvatar: (roomViewModel: RoomListItemViewModel) => ReactNode;
renderAvatar: (roomItem: RoomListItem) => ReactNode;
}
/**

View File

@ -6,4 +6,4 @@
*/
export { RoomListPanel } from "./RoomListPanel";
export type { RoomListPanelProps } from "./RoomListPanel";
export type { RoomListPanelProps, RoomListPanelSnapshot } from "./RoomListPanel";

View File

@ -11,7 +11,7 @@ import styles from "./RoomListView.module.css";
/**
* Loading skeleton component for the room list.
* Displays a simple loading indicator while rooms are being fetched.
* Displays a repeating skeleton pattern while rooms are being fetched.
*/
export const RoomListLoadingSkeleton: React.FC = (): JSX.Element => {
return <div className={styles.skeleton} />;

View File

@ -12,7 +12,6 @@
flex: 1;
}
/* Skeleton animation - note: mask-image requires SVG from element-web */
.skeleton::before {
background-color: var(--cpd-color-bg-subtle-secondary);
width: 100%;
@ -21,9 +20,5 @@
position: absolute;
mask-repeat: repeat-y;
mask-size: auto 96px;
}
/* Element-web provides the actual mask-image */
:global(.mx_RoomListSkeleton)::before {
mask-image: url("$(res)/img/element-icons/roomlist/room-list-item-skeleton.svg");
mask-image: url("./assets/skeleton.svg");
}

View File

@ -12,13 +12,13 @@ import { useViewModel } from "../../useViewModel";
import { RoomListPrimaryFilters, type RoomListPrimaryFiltersSnapshot } from "../RoomListPrimaryFilters";
import { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
import { RoomListEmptyState } from "./RoomListEmptyState";
import { RoomList, type RoomListSnapshot } from "../RoomList";
import { type RoomListItemViewModel } from "../RoomListItem";
import { RoomList, type RoomListViewModel } from "../RoomList";
import { type RoomListItem } from "../RoomListItem";
/**
* Snapshot for RoomListView
*/
export type RoomListViewSnapshot = {
export type RoomListViewWrapperSnapshot = {
/** Whether the rooms are currently loading */
isLoadingRooms: boolean;
/** Whether the room list is empty */
@ -26,7 +26,7 @@ export type RoomListViewSnapshot = {
/** View model for the primary filters */
filtersVm: ViewModel<RoomListPrimaryFiltersSnapshot>;
/** View model for the room list */
roomListVm: ViewModel<RoomListSnapshot>;
roomListVm: RoomListViewModel;
/** Title for the empty state */
emptyStateTitle: string;
/** Optional description for the empty state */
@ -40,9 +40,9 @@ export type RoomListViewSnapshot = {
*/
export interface RoomListViewProps {
/** The view model containing list data */
vm: ViewModel<RoomListViewSnapshot>;
vm: ViewModel<RoomListViewWrapperSnapshot>;
/** Render function for room avatar */
renderAvatar: (roomViewModel: RoomListItemViewModel) => ReactNode;
renderAvatar: (roomItem: RoomListItem) => ReactNode;
}
/**

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -6,7 +6,7 @@
*/
export { RoomListView } from "./RoomListView";
export type { RoomListViewProps, RoomListViewSnapshot } from "./RoomListView";
export type { RoomListViewProps, RoomListViewWrapperSnapshot } from "./RoomListView";
export { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
export { RoomListEmptyState } from "./RoomListEmptyState";
export type { RoomListEmptyStateProps } from "./RoomListEmptyState";

View File

@ -1,6 +1,5 @@
{
"compilerOptions": {
"allowImportingTsExtensions": true,
"experimentalDecorators": false,
"emitDecoratorMetadata": false,
"resolveJsonModule": true,

View File

@ -6,8 +6,7 @@
"resolveJsonModule": true,
"esModuleInterop": true,
"moduleResolution": "node",
"module": "es2022",
"allowImportingTsExtensions": true
"module": "es2022"
},
"include": [
"**/*.ts",

View File

@ -1,6 +1,5 @@
{
"compilerOptions": {
"allowImportingTsExtensions": true,
"experimentalDecorators": false,
"emitDecoratorMetadata": false,
"resolveJsonModule": true,