mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-05 20:26:19 +02:00
Remove RoomListItemViewModel, just use dumb components .
This commit is contained in:
parent
39fb3de400
commit
3023192ce7
@ -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 'a few seconds ago' for <15s ago" duration="2"/>
|
||||
<testCase name="humanizeTime returns 'about a minute ago' for <75s ago" duration="4"/>
|
||||
<testCase name="humanizeTime returns '20 minutes ago' for <45min ago" duration="2"/>
|
||||
<testCase name="humanizeTime returns 'about an hour ago' for <75min ago" duration="0"/>
|
||||
<testCase name="humanizeTime returns '5 hours ago' for <23h ago" duration="1"/>
|
||||
<testCase name="humanizeTime returns 'about a day ago' for <26h ago" duration="1"/>
|
||||
<testCase name="humanizeTime returns '3 days ago' for >26h ago" duration="1"/>
|
||||
<testCase name="humanizeTime returns 'a few seconds from now' for <15s ahead" duration="2"/>
|
||||
<testCase name="humanizeTime returns 'about a minute from now' for <75s ahead" duration="1"/>
|
||||
<testCase name="humanizeTime returns '20 minutes from now' for <45min ahead" duration="0"/>
|
||||
<testCase name="humanizeTime returns 'about an hour from now' for <75min ahead" duration="0"/>
|
||||
<testCase name="humanizeTime returns '5 hours from now' for <23h ahead" duration="1"/>
|
||||
<testCase name="humanizeTime returns 'about a day from now' for <26h ahead" duration="2"/>
|
||||
<testCase name="humanizeTime returns '3 days from now' for >26h ahead" duration="2"/>
|
||||
</file>
|
||||
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/utils/numbers.test.ts">
|
||||
<testCase name="numbers defaultNumber should use the default when the input is not a number" duration="1"/>
|
||||
<testCase name="numbers defaultNumber should use the number when it is a number" duration="1"/>
|
||||
<testCase name="numbers clamp should clamp high numbers" duration="0"/>
|
||||
<testCase name="numbers clamp should clamp low numbers" duration="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 > 1" duration="0"/>
|
||||
<testCase name="numbers percentageWithin should work within 0-100 when pct > 1" duration="1"/>
|
||||
<testCase name="numbers percentageWithin should work within 0-100 when pct < 0" duration="0"/>
|
||||
<testCase name="numbers percentageWithin should work with ranges other than 0-100" duration="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 > 1" duration="0"/>
|
||||
<testCase name="numbers percentageWithin should work with ranges other than 0-100 when pct < 0" duration="0"/>
|
||||
<testCase name="numbers percentageWithin should work with floats" duration="0"/>
|
||||
<testCase name="numbers percentageOf should work within 0-100" duration="0"/>
|
||||
<testCase name="numbers percentageOf should work within 0-100" duration="1"/>
|
||||
<testCase name="numbers percentageOf should work within 0-100 when val > 100" duration="0"/>
|
||||
<testCase name="numbers percentageOf should work within 0-100 when val < 0" duration="0"/>
|
||||
<testCase name="numbers percentageOf should work with ranges other than 0-100" duration="1"/>
|
||||
<testCase name="numbers percentageOf should work with ranges other than 0-100 when val > 100" duration="0"/>
|
||||
<testCase name="numbers percentageOf should work with ranges other than 0-100 when val < 0" duration="0"/>
|
||||
<testCase name="numbers percentageOf should work with ranges other than 0-100 when val < 0" duration="1"/>
|
||||
<testCase name="numbers percentageOf should work with floats" duration="0"/>
|
||||
<testCase name="numbers percentageOf should return 0 for values that cause a division by zero" duration="1"/>
|
||||
</file>
|
||||
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/utils/humanize.test.ts">
|
||||
<testCase name="humanizeTime returns 'a few seconds ago' for <15s ago" duration="9"/>
|
||||
<testCase name="humanizeTime returns 'about a minute ago' for <75s ago" duration="1"/>
|
||||
<testCase name="humanizeTime returns '20 minutes ago' for <45min ago" duration="1"/>
|
||||
<testCase name="humanizeTime returns 'about an hour ago' for <75min ago" duration="1"/>
|
||||
<testCase name="humanizeTime returns '5 hours ago' for <23h ago" duration="0"/>
|
||||
<testCase name="humanizeTime returns 'about a day ago' for <26h ago" duration="1"/>
|
||||
<testCase name="humanizeTime returns '3 days ago' for >26h ago" duration="1"/>
|
||||
<testCase name="humanizeTime returns 'a few seconds from now' for <15s ahead" duration="1"/>
|
||||
<testCase name="humanizeTime returns 'about a minute from now' for <75s ahead" duration="2"/>
|
||||
<testCase name="humanizeTime returns '20 minutes from now' for <45min ahead" duration="1"/>
|
||||
<testCase name="humanizeTime returns 'about an hour from now' for <75min ahead" duration="1"/>
|
||||
<testCase name="humanizeTime returns '5 hours from now' for <23h ahead" duration="0"/>
|
||||
<testCase name="humanizeTime returns 'about a day from now' for <26h ahead" duration="0"/>
|
||||
<testCase name="humanizeTime returns '3 days from now' for >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>
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -6,4 +6,4 @@
|
||||
*/
|
||||
|
||||
export { NotificationDecoration } from "./NotificationDecoration";
|
||||
export type { NotificationDecorationProps, NotificationDecorationViewModel } from "./NotificationDecoration";
|
||||
export type { NotificationDecorationProps, NotificationDecorationData } from "./NotificationDecoration";
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@ -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} />);
|
||||
|
||||
@ -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;
|
||||
}, []);
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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={() => {}}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -6,4 +6,4 @@
|
||||
*/
|
||||
|
||||
export { RoomListPanel } from "./RoomListPanel";
|
||||
export type { RoomListPanelProps } from "./RoomListPanel";
|
||||
export type { RoomListPanelProps, RoomListPanelSnapshot } from "./RoomListPanel";
|
||||
|
||||
@ -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} />;
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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 |
@ -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";
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"experimentalDecorators": false,
|
||||
"emitDecoratorMetadata": false,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
@ -6,8 +6,7 @@
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"module": "es2022",
|
||||
"allowImportingTsExtensions": true
|
||||
"module": "es2022"
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"experimentalDecorators": false,
|
||||
"emitDecoratorMetadata": false,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user