diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/activity-indicator-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/activity-indicator-auto.png new file mode 100644 index 0000000000..6ac546adbc Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/activity-indicator-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/invited-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/invited-auto.png new file mode 100644 index 0000000000..cfd3f0f556 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/invited-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/mention-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/mention-auto.png new file mode 100644 index 0000000000..bacce8176c Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/mention-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/mention-with-count-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/mention-with-count-auto.png new file mode 100644 index 0000000000..571b86c600 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/mention-with-count-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/muted-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/muted-auto.png new file mode 100644 index 0000000000..d4dbca6e39 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/muted-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/muted-without-activity-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/muted-without-activity-auto.png new file mode 100644 index 0000000000..d4dbca6e39 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/muted-without-activity-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/no-notification-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/no-notification-auto.png new file mode 100644 index 0000000000..9f58a62407 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/no-notification-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/notification-with-count-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/notification-with-count-auto.png new file mode 100644 index 0000000000..0c2c4bbe5a Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/notification-with-count-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/unsent-message-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/unsent-message-auto.png new file mode 100644 index 0000000000..c2bcf320c5 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/unsent-message-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/video-call-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/video-call-auto.png new file mode 100644 index 0000000000..9a6f8b5b35 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/video-call-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/video-call-without-activity-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/video-call-without-activity-auto.png new file mode 100644 index 0000000000..9a6f8b5b35 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/video-call-without-activity-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/voice-call-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/voice-call-auto.png new file mode 100644 index 0000000000..86f5cde837 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/voice-call-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/bold-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/bold-auto.png new file mode 100644 index 0000000000..7c5fa14a48 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/bold-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/default-auto.png new file mode 100644 index 0000000000..e045a22515 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/invitation-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/invitation-auto.png new file mode 100644 index 0000000000..b9613435e8 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/invitation-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/no-message-preview-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/no-message-preview-auto.png new file mode 100644 index 0000000000..c1caeadc05 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/no-message-preview-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/selected-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/selected-auto.png new file mode 100644 index 0000000000..c44d733d2f Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/selected-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/unsent-message-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/unsent-message-auto.png new file mode 100644 index 0000000000..30ad387147 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/unsent-message-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-hover-menu-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-hover-menu-auto.png new file mode 100644 index 0000000000..e045a22515 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-hover-menu-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-mention-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-mention-auto.png new file mode 100644 index 0000000000..345a8775f8 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-mention-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-notification-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-notification-auto.png new file mode 100644 index 0000000000..d6ff5c8493 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-notification-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/without-hover-menu-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/without-hover-menu-auto.png new file mode 100644 index 0000000000..e045a22515 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/without-hover-menu-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx/default-auto.png new file mode 100644 index 0000000000..95078643c6 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx/narrow-container-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx/narrow-container-auto.png new file mode 100644 index 0000000000..08f95e5684 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx/narrow-container-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx/narrow-with-active-wrapping-filter-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx/narrow-with-active-wrapping-filter-auto.png new file mode 100644 index 0000000000..a9d71aeb4f Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx/narrow-with-active-wrapping-filter-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx/no-filters-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx/no-filters-auto.png new file mode 100644 index 0000000000..9f58a62407 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx/no-filters-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx/people-selected-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx/people-selected-auto.png new file mode 100644 index 0000000000..44c69d4b65 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx/people-selected-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..980c20f8f5 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-auto.png new file mode 100644 index 0000000000..65f8c7b203 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-favourite-filter-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-favourite-filter-auto.png new file mode 100644 index 0000000000..6b342ecc84 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-favourite-filter-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-invites-filter-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-invites-filter-auto.png new file mode 100644 index 0000000000..f6e995e46b Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-invites-filter-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-low-priority-filter-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-low-priority-filter-auto.png new file mode 100644 index 0000000000..af7e857088 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-low-priority-filter-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-mentions-filter-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-mentions-filter-auto.png new file mode 100644 index 0000000000..a654ab1e94 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-mentions-filter-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-people-filter-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-people-filter-auto.png new file mode 100644 index 0000000000..1ff8fed3ea Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-people-filter-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-rooms-filter-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-rooms-filter-auto.png new file mode 100644 index 0000000000..5a6cbf6cae Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-rooms-filter-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-unread-filter-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-unread-filter-auto.png new file mode 100644 index 0000000000..2d829787b2 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-unread-filter-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-without-create-permission-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-without-create-permission-auto.png new file mode 100644 index 0000000000..fac07b40c4 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/empty-without-create-permission-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/large-list-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/large-list-auto.png new file mode 100644 index 0000000000..980c20f8f5 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/large-list-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/loading-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/loading-auto.png new file mode 100644 index 0000000000..bace5dba52 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/loading-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/small-list-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/small-list-auto.png new file mode 100644 index 0000000000..dd4d0a4bac Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/small-list-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/with-active-filter-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/with-active-filter-auto.png new file mode 100644 index 0000000000..c8087956e2 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/with-active-filter-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/with-selection-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/with-selection-auto.png new file mode 100644 index 0000000000..ba35806f60 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/with-selection-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..0447c2d348 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index 04bc3596ec..2ba72dd1c5 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -25,6 +25,12 @@ "left_panel": { "open_dial_pad": "Open dial pad" }, + "notifications": { + "all_messages": "All messages", + "default_settings": "Match default settings", + "mentions_keywords": "Mentions and keywords", + "mute_room": "Mute room" + }, "room": { "context_menu": { "title": "Room options" @@ -50,8 +56,63 @@ } }, "room_list": { + "a11y": { + "default": "Open room %(roomName)s", + "invitation": "Open room %(roomName)s invitation.", + "mention": { + "one": "Open room %(roomName)s with 1 unread mention.", + "other": "Open room %(roomName)s with %(count)s unread mentions." + }, + "unread": { + "one": "Open room %(roomName)s with 1 unread message.", + "other": "Open room %(roomName)s with %(count)s unread messages." + }, + "unsent_message": "Open room %(roomName)s with an unsent message." + }, "appearance": "Appearance", + "collapse_filters": "Collapse filter list", + "empty": { + "no_chats": "No chats yet", + "no_chats_description": "Get started by messaging someone or by creating a room", + "no_chats_description_no_room_rights": "Get started by messaging someone", + "no_favourites": "You don't have favourite chats yet", + "no_favourites_description": "You can add a chat to your favourites in the chat settings", + "no_invites": "You don't have any unread invites", + "no_lowpriority": "You don't have any low priority rooms", + "no_mentions": "You don't have any unread mentions", + "no_people": "You don’t have direct chats with anyone yet", + "no_people_description": "You can deselect filters in order to see your other chats", + "no_rooms": "You’re not in any room yet", + "no_rooms_description": "You can deselect filters in order to see your other chats", + "no_unread": "Congrats! You don’t have any unread messages", + "show_activity": "See all activity", + "show_chats": "Show all chats" + }, + "expand_filters": "Expand filter list", + "filters": { + "favourite": "Favourites", + "invites": "Invites", + "low_priority": "Low priority", + "mentions": "Mentions", + "people": "People", + "rooms": "Rooms", + "unread": "Unreads" + }, + "list_title": "Room list", + "more_options": { + "copy_link": "Copy room link", + "favourited": "Favourited", + "leave_room": "Leave room", + "low_priority": "Low priority", + "mark_read": "Mark as read", + "mark_unread": "Mark as unread" + }, + "notification_options": "Notification options", "open_space_menu": "Open space menu", + "primary_filters": "Room list filters", + "room": { + "more_options": "More Options" + }, "room_options": "Room Options", "show_message_previews": "Show message previews", "sort": "Sort", diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 0efa6698f6..96216b8ee2 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -27,6 +27,10 @@ export * from "./rich-list/RichItem"; export * from "./rich-list/RichList"; export * from "./room-list/RoomListHeaderView"; export * from "./room-list/RoomListSearchView"; +export * from "./room-list/RoomListView"; +export * from "./room-list/RoomListItem"; +export * from "./room-list/RoomListPrimaryFilters"; +export * from "./room-list/VirtualizedRoomListView"; export * from "./utils/Box"; export * from "./utils/Flex"; export * from "./right-panel/WidgetContextMenu"; diff --git a/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx b/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx new file mode 100644 index 0000000000..cca9e5eef3 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx @@ -0,0 +1,126 @@ +/* + * Copyright 2026 Element Creations 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 from "react"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { NotificationDecoration, type NotificationDecorationProps } from "./NotificationDecoration"; + +const defaultProps: NotificationDecorationProps = { + hasAnyNotificationOrActivity: false, + isUnsentMessage: false, + invited: false, + isMention: false, + isActivityNotification: false, + isNotification: false, + hasUnreadCount: false, + count: 0, + muted: false, +}; + +const meta = { + title: "Room List/NotificationDecoration", + component: NotificationDecoration, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: defaultProps, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel?node-id=101-13062", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const NoNotification: Story = {}; + +export const UnsentMessage: Story = { + args: { + hasAnyNotificationOrActivity: true, + isUnsentMessage: true, + }, +}; + +export const VideoCall: Story = { + args: { + hasAnyNotificationOrActivity: true, + callType: "video", + }, +}; + +export const VoiceCall: Story = { + args: { + hasAnyNotificationOrActivity: true, + callType: "voice", + }, +}; + +export const Invited: Story = { + args: { + hasAnyNotificationOrActivity: true, + invited: true, + }, +}; + +export const Mention: Story = { + args: { + hasAnyNotificationOrActivity: true, + isMention: true, + }, +}; + +export const MentionWithCount: Story = { + args: { + hasAnyNotificationOrActivity: true, + isMention: true, + count: 5, + }, +}; + +export const NotificationWithCount: Story = { + args: { + hasAnyNotificationOrActivity: true, + isNotification: true, + count: 3, + }, +}; + +export const ActivityIndicator: Story = { + args: { + hasAnyNotificationOrActivity: true, + isActivityNotification: true, + }, +}; + +export const Muted: Story = { + args: { + muted: true, + }, +}; + +export const MutedWithoutActivity: Story = { + args: { + hasAnyNotificationOrActivity: false, + muted: true, + }, +}; + +export const VideoCallWithoutActivity: Story = { + args: { + hasAnyNotificationOrActivity: false, + callType: "video", + }, +}; diff --git a/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.test.tsx b/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.test.tsx new file mode 100644 index 0000000000..f79e092f4d --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.test.tsx @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Element Creations 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 from "react"; +import { render } from "@test-utils"; +import { composeStories } from "@storybook/react-vite"; +import { describe, it, expect } from "vitest"; + +import * as stories from "./NotificationDecoration.stories"; + +const { + NoNotification, + UnsentMessage, + VideoCall, + VoiceCall, + Invited, + Mention, + MentionWithCount, + NotificationWithCount, + ActivityIndicator, + Muted, +} = composeStories(stories); + +describe("", () => { + describe("snapshots", () => { + it("renders NoNotification story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders UnsentMessage story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders VideoCall story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders VoiceCall story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders Invited story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders Mention story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders MentionWithCount story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders NotificationWithCount story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders ActivityIndicator story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders Muted story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.tsx b/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.tsx new file mode 100644 index 0000000000..03be962fbf --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.tsx @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Element Creations 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 from "react"; +import { + MentionIcon, + ErrorSolidIcon, + NotificationsOffSolidIcon, + VideoCallSolidIcon, + EmailSolidIcon, + VoiceCallSolidIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; +import { UnreadCounter, Unread } from "@vector-im/compound-web"; + +import { Flex } from "../../../utils/Flex"; + +/** + * Data representing the notification state for a room or item. + * Used in snapshots and passed to the NotificationDecoration component. + */ +export interface NotificationDecorationData { + /** Whether there is any notification or activity to display */ + hasAnyNotificationOrActivity: boolean; + /** Whether there's an unsent message */ + isUnsentMessage: boolean; + /** Whether the user is invited to the room */ + invited: boolean; + /** Whether the notification is a mention */ + isMention: boolean; + /** Whether there's activity (not a full notification) */ + isActivityNotification: boolean; + /** Whether there's a notification (not just activity) */ + isNotification: boolean; + /** Whether there are unread messages with a count */ + hasUnreadCount: boolean; + /** Notification count */ + count: number; + /** Whether notifications are muted */ + muted: boolean; + /** Optional call type indicator */ + callType?: "video" | "voice"; +} + +/** + * Props for the NotificationDecoration component. + */ +export interface NotificationDecorationProps extends NotificationDecorationData {} + +/** + * Renders notification badges and indicators for rooms/items + */ +export const NotificationDecoration: React.FC = ({ + hasAnyNotificationOrActivity, + muted, + callType, + isUnsentMessage, + invited, + isMention, + isNotification, + isActivityNotification, + count, +}) => { + // Don't render anything if there's nothing to show + if (!hasAnyNotificationOrActivity && !muted && !callType) { + return null; + } + + return ( + + {isUnsentMessage && ( + + )} + {callType === "video" && ( + + )} + {callType === "voice" && ( + + )} + {invited && } + {isMention && } + {(isMention || isNotification) && } + {isActivityNotification && } + {muted && } + + ); +}; diff --git a/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/__snapshots__/NotificationDecoration.test.tsx.snap b/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/__snapshots__/NotificationDecoration.test.tsx.snap new file mode 100644 index 0000000000..a7c7da94f4 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/__snapshots__/NotificationDecoration.test.tsx.snap @@ -0,0 +1,242 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > snapshots > renders ActivityIndicator story 1`] = ` +
+
+
+
+
+
+
+
+
+`; + +exports[` > snapshots > renders Invited story 1`] = ` +
+
+
+ + + +
+
+
+`; + +exports[` > snapshots > renders Mention story 1`] = ` +
+
+
+ + + +
+
+
+
+`; + +exports[` > snapshots > renders MentionWithCount story 1`] = ` +
+
+
+ + + + + 5 + +
+
+
+`; + +exports[` > snapshots > renders Muted story 1`] = ` +
+
+
+ + + + +
+
+
+`; + +exports[` > snapshots > renders NoNotification story 1`] = ` +
+
+
+`; + +exports[` > snapshots > renders NotificationWithCount story 1`] = ` +
+
+
+ + 3 + +
+
+
+`; + +exports[` > snapshots > renders UnsentMessage story 1`] = ` +
+
+
+ + + +
+
+
+`; + +exports[` > snapshots > renders VideoCall story 1`] = ` +
+
+
+ + + +
+
+
+`; + +exports[` > snapshots > renders VoiceCall story 1`] = ` +
+
+
+ + + +
+
+
+`; diff --git a/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/index.tsx b/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/index.tsx new file mode 100644 index 0000000000..42c7a3451f --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/index.tsx @@ -0,0 +1,9 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { NotificationDecoration } from "./NotificationDecoration"; +export type { NotificationDecorationProps, NotificationDecorationData } from "./NotificationDecoration"; diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.module.css b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.module.css new file mode 100644 index 0000000000..008a4462ac --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.module.css @@ -0,0 +1,106 @@ +/* + * Copyright 2025 Element Creations 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. + */ + +/** + * The RoomListItem has the following structure: + * button--------------------------------------------------| + * | <-12px-> container------------------------------------| + * | | room avatar <-8px-> content----------------| + * | | | room_name <- 20px ->| + * | | | --------------------| <-- border + * |-------------------------------------------------------| + */ +.roomListItem { + /* Remove button default style */ + background: unset; + border: none; + padding: 0; + text-align: unset; + + cursor: pointer; + height: 48px; + width: 100%; + + padding-left: var(--cpd-space-3x); + font: var(--cpd-font-body-md-regular); + color: var(--cpd-color-text-primary); + + /* Hide the menu by default */ + .hoverMenu { + display: none; + } +} + +/* Show hover menu and background on hover/focus/menu-open states */ +.roomListItem:hover, +.roomListItem:focus-visible, +/* When the context menu is opened */ +.roomListItem[data-state="open"], +/* When the options and notifications menu are opened */ +.roomListItem:has(.hoverMenu > button[data-state="open"]) { + background-color: var(--cpd-color-bg-action-secondary-hovered); + + .hoverMenu { + display: flex; + } + + /* When the menu is visible, hide the notification decoration to avoid clutter */ + .notificationDecoration { + display: none; + } + + /** + * The figma uses 16px padding (--cpd-space-4x) but due to https://github.com/element-hq/compound-web/issues/331 + * the icon size of the menu is 18px instead of 20px with a different internal padding + * We need to use 18px to align the icon with the others icons + * 18px is not available in compound spacing + */ + .content { + padding-right: 18px; + } +} + +.content { + height: 100%; + flex: 1; + /* The border is only under the room name and the future hover menu */ + border-bottom: var(--cpd-border-width-0-5) solid var(--cpd-color-bg-subtle-secondary); + box-sizing: border-box; + min-width: 0; + padding-right: var(--cpd-space-5x); +} + +.text { + min-width: 0; +} + +.roomName { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.messagePreview { + font: var(--cpd-font-body-sm-regular); + color: var(--cpd-color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.selected { + background-color: var(--cpd-color-bg-action-secondary-pressed); +} + +.bold .roomName { + font: var(--cpd-font-body-md-semibold); +} + +/* Set icon color for hover menu buttons */ +.hoverMenu svg { + fill: var(--cpd-color-icon-primary); +} diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx new file mode 100644 index 0000000000..fcc4017fb1 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx @@ -0,0 +1,213 @@ +/* + * Copyright 2026 Element Creations 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, { type JSX } from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Room } from "./RoomListItem"; +import { RoomListItemView, type RoomListItemSnapshot, type RoomListItemActions } from "./RoomListItem"; +import { useMockedViewModel } from "../../viewmodel"; +import { defaultSnapshot } from "./default-snapshot"; +import { renderAvatar } from "../story-mocks"; + +type RoomListItemProps = RoomListItemSnapshot & + RoomListItemActions & { + isSelected: boolean; + isFocused: boolean; + onFocus: (room: Room, e: React.FocusEvent) => void; + roomIndex: number; + roomCount: number; + renderAvatar: (room: Room) => React.ReactElement; + }; + +// Wrapper component that creates a mocked ViewModel +const RoomListItemWrapper = ({ + onOpenRoom, + onMarkAsRead, + onMarkAsUnread, + onToggleFavorite, + onToggleLowPriority, + onInvite, + onCopyRoomLink, + onLeaveRoom, + onSetRoomNotifState, + isSelected, + isFocused, + onFocus, + roomIndex, + roomCount, + renderAvatar: renderAvatarProp, + ...rest +}: RoomListItemProps): JSX.Element => { + const vm = useMockedViewModel(rest, { + onOpenRoom, + onMarkAsRead, + onMarkAsUnread, + onToggleFavorite, + onToggleLowPriority, + onInvite, + onCopyRoomLink, + onLeaveRoom, + onSetRoomNotifState, + }); + return ( + + ); +}; + +const meta = { + title: "Room List/RoomListItem", + component: RoomListItemWrapper, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+
+ +
+
+ ), + ], + args: { + ...defaultSnapshot, + isSelected: false, + isFocused: false, + roomIndex: 0, + roomCount: 10, + onOpenRoom: fn(), + onMarkAsRead: fn(), + onMarkAsUnread: fn(), + onToggleFavorite: fn(), + onToggleLowPriority: fn(), + onInvite: fn(), + onCopyRoomLink: fn(), + onLeaveRoom: fn(), + onSetRoomNotifState: fn(), + onFocus: fn(), + renderAvatar, + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel?node-id=101-13062", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Selected: Story = { + args: { + isSelected: true, + }, +}; + +export const Bold: Story = { + args: { + isBold: true, + name: "Team Updates", + }, +}; + +export const WithNotification: Story = { + args: { + isBold: true, + notification: { + hasAnyNotificationOrActivity: true, + isUnsentMessage: false, + invited: false, + isMention: false, + isActivityNotification: false, + isNotification: true, + hasUnreadCount: true, + count: 3, + muted: false, + }, + }, +}; + +export const WithMention: Story = { + args: { + isBold: true, + notification: { + hasAnyNotificationOrActivity: true, + isUnsentMessage: false, + invited: false, + isMention: true, + isActivityNotification: false, + isNotification: true, + hasUnreadCount: true, + count: 1, + muted: false, + }, + }, +}; + +export const Invitation: Story = { + args: { + name: "Secret Project", + messagePreview: "Bob invited you", + notification: { + hasAnyNotificationOrActivity: true, + isUnsentMessage: false, + invited: true, + isMention: false, + isActivityNotification: false, + isNotification: false, + hasUnreadCount: false, + count: 0, + muted: false, + }, + }, +}; + +export const UnsentMessage: Story = { + args: { + messagePreview: "Failed to send message", + notification: { + hasAnyNotificationOrActivity: true, + isUnsentMessage: true, + invited: false, + isMention: false, + isActivityNotification: false, + isNotification: false, + hasUnreadCount: false, + count: 0, + muted: false, + }, + }, +}; + +export const NoMessagePreview: Story = { + args: { + messagePreview: undefined, + }, +}; + +export const WithHoverMenu: Story = { + args: { + showMoreOptionsMenu: true, + }, +}; + +export const WithoutHoverMenu: Story = { + args: { + showMoreOptionsMenu: false, + }, +}; diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx new file mode 100644 index 0000000000..788c9f317f --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx @@ -0,0 +1,123 @@ +/* + * Copyright 2026 Element Creations 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 from "react"; +import { render, screen } from "@test-utils"; +import userEvent from "@testing-library/user-event"; +import { composeStories } from "@storybook/react-vite"; +import { describe, it, expect } from "vitest"; + +import * as stories from "./RoomListItem.stories"; + +const { + Default, + Selected, + Bold, + WithNotification, + WithMention, + Invitation, + UnsentMessage, + NoMessagePreview, + WithHoverMenu, + WithoutHoverMenu, +} = composeStories(stories); + +describe("", () => { + it("renders Default story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders Selected story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders Bold story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders WithNotification story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders WithMention story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders Invitation story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders UnsentMessage story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders NoMessagePreview story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders WithHoverMenu story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should call onOpenRoom when clicked", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("option")); + expect(Default.args.onOpenRoom).toHaveBeenCalled(); + }); + + it("should have aria-selected true when selected", () => { + render(); + expect(screen.getByRole("option")).toHaveAttribute("aria-selected", "true"); + }); + + it("should have aria-selected false when not selected", () => { + render(); + expect(screen.getByRole("option")).toHaveAttribute("aria-selected", "false"); + }); + + it("should have tabIndex -1 when not focused", () => { + render(); + expect(screen.getByRole("option")).toHaveAttribute("tabIndex", "-1"); + }); + + it("should call onFocus when focused", () => { + render(); + screen.getByRole("option").focus(); + expect(Default.args.onFocus).toHaveBeenCalled(); + }); + + it("should display notification decoration when present", () => { + render(); + expect(screen.getByTestId("notification-decoration")).toBeInTheDocument(); + }); + + it("should hide notification decoration when not present", () => { + render(); + expect(screen.queryByTestId("notification-decoration")).toBeNull(); + }); + + it("should show hover menu when showMoreOptionsMenu is true", () => { + const { container } = render(); + expect(container.querySelector('[aria-label="More Options"]')).not.toBeNull(); + }); + + it("should hide hover menu when showMoreOptionsMenu is false", () => { + const { container } = render(); + expect(container.querySelector('[aria-label="More Options"]')).toBeNull(); + }); +}); diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx new file mode 100644 index 0000000000..bc16d15cd3 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx @@ -0,0 +1,207 @@ +/* + * Copyright 2026 Element Creations 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, { type JSX, memo, useEffect, useRef, type ReactNode } from "react"; +import classNames from "classnames"; + +import { Flex } from "../../utils/Flex"; +import { NotificationDecoration, type NotificationDecorationData } from "./NotificationDecoration"; +import { RoomListItemHoverMenu } from "./RoomListItemHoverMenu"; +import { RoomListItemContextMenu } from "./RoomListItemContextMenu"; +import { type RoomNotifState } from "./RoomNotifs"; +import styles from "./RoomListItem.module.css"; +import { useViewModel, type ViewModel } from "../../viewmodel"; +import { _t } from "../../utils/i18n"; + +/** + * Opaque type representing a Room object from the parent application + */ +export type Room = unknown; + +/** + * Generate an accessible label for a room based on its notification state. + */ +function getA11yLabel(roomName: string, notification: NotificationDecorationData): string { + if (notification.isUnsentMessage) { + return _t("room_list|a11y|unsent_message", { roomName }); + } else if (notification.invited) { + return _t("room_list|a11y|invitation", { roomName }); + } else if (notification.isMention && notification.count) { + return _t("room_list|a11y|mention", { roomName, count: notification.count }); + } else if (notification.hasUnreadCount && notification.count) { + return _t("room_list|a11y|unread", { roomName, count: notification.count }); + } else { + return _t("room_list|a11y|default", { roomName }); + } +} + +/** + * Snapshot for a room list item. + * Contains all the data needed to render a room in the list. + */ +export interface RoomListItemSnapshot { + /** Unique identifier for the room (used for list keying) */ + id: string; + /** The opaque Room object from the client (e.g., matrix-js-sdk Room) */ + room: Room; + /** The name of the room */ + name: string; + /** Whether the room name should be bolded (has unread/activity) */ + isBold: boolean; + /** Optional message preview text */ + messagePreview?: string; + /** Notification decoration data */ + notification: NotificationDecorationData; + /** Whether the more options menu should be shown */ + showMoreOptionsMenu: boolean; + /** Whether the notification menu should be shown */ + showNotificationMenu: boolean; + /** 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; + /** The room's notification state */ + roomNotifState: RoomNotifState; +} + +/** + * Actions interface for room list item operations. + * Implemented by the room item view model. + */ +export interface RoomListItemActions { + /** Called when the room should be opened */ + onOpenRoom: () => void; + /** Called when the room should be marked as read */ + onMarkAsRead: () => void; + /** Called when the room should be marked as unread */ + onMarkAsUnread: () => void; + /** Called when the room's favorite status should be toggled */ + onToggleFavorite: () => void; + /** Called when the room's low priority status should be toggled */ + onToggleLowPriority: () => void; + /** Called when inviting users to the room */ + onInvite: () => void; + /** Called when copying the room link */ + onCopyRoomLink: () => void; + /** Called when leaving the room */ + onLeaveRoom: () => void; + /** Called when setting the room notification state */ + onSetRoomNotifState: (state: RoomNotifState) => void; +} + +/** + * The view model type for a room list item + */ +export type RoomItemViewModel = ViewModel & RoomListItemActions; + +/** + * Props for RoomListItemView component + */ +export interface RoomListItemViewProps extends Omit, "onFocus"> { + /** The room item view model */ + vm: RoomItemViewModel; + /** Whether the room is selected */ + isSelected: boolean; + /** Whether the room should be focused */ + isFocused: boolean; + /** Callback when item receives focus */ + onFocus: (roomId: string, e: React.FocusEvent) => void; + /** Index of this room in the list (for accessibility) */ + roomIndex: number; + /** Total number of rooms in the list (for accessibility) */ + roomCount: number; + /** Function to render the room avatar */ + renderAvatar: (room: Room) => ReactNode; +} + +/** + * A presentational room list item component. + * Displays room name, avatar, message preview, and notifications. + */ +export const RoomListItemView = memo(function RoomListItemView({ + vm, + isSelected, + isFocused, + onFocus, + roomIndex, + roomCount, + renderAvatar, + ...props +}: RoomListItemViewProps): JSX.Element { + const ref = useRef(null); + const item = useViewModel(vm); + + useEffect(() => { + if (isFocused) { + ref.current?.focus({ preventScroll: true, focusVisible: true } as FocusOptions); + } + }, [isFocused]); + + // Generate a11y label from notification state and room name + const a11yLabel = getA11yLabel(item.name, item.notification); + + const content = ( + ) => onFocus(item.id, e)} + tabIndex={isFocused ? 0 : -1} + {...props} + > + {renderAvatar(item.room)} + + {/* We truncate the room name when too long. Title here is to show the full name on hover */} +
+
+ {item.name} +
+ {item.messagePreview && ( +
+ {item.messagePreview} +
+ )} +
+ {(item.showMoreOptionsMenu || item.showNotificationMenu) && ( + + )} + + {/* aria-hidden because we summarise the unread count/notification status in a11yLabel */} +
+ +
+
+
+ ); + + return {content}; +}); diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemContextMenu.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItemContextMenu.tsx new file mode 100644 index 0000000000..0d202474f8 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemContextMenu.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Element Creations 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, { type JSX, type PropsWithChildren } from "react"; +import { ContextMenu } from "@vector-im/compound-web"; + +import { _t } from "../../utils/i18n"; +import { MoreOptionContent, type RoomItemViewModel } from "./RoomListItemMoreOptionsMenu"; + +/** + * Props for RoomListItemContextMenu component + */ +export interface RoomListItemContextMenuProps { + /** The room item view model */ + vm: RoomItemViewModel; +} + +/** + * The context menu for room list items. + * Wraps the trigger element with a right-click context menu displaying room options. + */ +export const RoomListItemContextMenu: React.FC> = ({ + vm, + children, +}): JSX.Element => { + return ( + + + + ); +}; diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx new file mode 100644 index 0000000000..9a453b2014 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2026 Element Creations 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, { type JSX } from "react"; + +import { Flex } from "../../utils/Flex"; +import { RoomListItemMoreOptionsMenu, type RoomItemViewModel } from "./RoomListItemMoreOptionsMenu"; +import { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu"; +import styles from "./RoomListItem.module.css"; + +/** + * Props for RoomListItemHoverMenu component + */ +export interface RoomListItemHoverMenuProps { + /** Whether the more options menu should be shown */ + showMoreOptionsMenu: boolean; + /** Whether the notification menu should be shown */ + showNotificationMenu: boolean; + /** The room item view model */ + vm: RoomItemViewModel; +} + +/** + * The hover menu for room list items. + * Displays more options and notification settings menus. + */ +export const RoomListItemHoverMenu: React.FC = ({ + showMoreOptionsMenu, + showNotificationMenu, + vm, +}): JSX.Element => { + return ( + + {showMoreOptionsMenu && } + {showNotificationMenu && } + + ); +}; diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.test.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.test.tsx new file mode 100644 index 0000000000..40b9917c5b --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.test.tsx @@ -0,0 +1,227 @@ +/* + * Copyright 2026 Element Creations 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, { type JSX } from "react"; +import { render, screen } from "@test-utils"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; + +import { RoomListItemMoreOptionsMenu } from "./RoomListItemMoreOptionsMenu"; +import { useMockedViewModel } from "../../viewmodel"; +import type { RoomListItemSnapshot } from "./RoomListItem"; +import { defaultSnapshot } from "./default-snapshot"; + +describe("", () => { + const mockCallbacks = { + onOpenRoom: vi.fn(), + onMarkAsRead: vi.fn(), + onMarkAsUnread: vi.fn(), + onToggleFavorite: vi.fn(), + onToggleLowPriority: vi.fn(), + onInvite: vi.fn(), + onCopyRoomLink: vi.fn(), + onLeaveRoom: vi.fn(), + onSetRoomNotifState: vi.fn(), + }; + + const renderMenu = (overrides: Partial = {}): ReturnType => { + const TestComponent = (): JSX.Element => { + const vm = useMockedViewModel( + { + ...defaultSnapshot, + showMoreOptionsMenu: true, + showNotificationMenu: false, + ...overrides, + } as RoomListItemSnapshot, + mockCallbacks, + ); + return ; + }; + return render(); + }; + + it("should render the more options button", () => { + renderMenu(); + expect(screen.getByRole("button", { name: "More Options" })).toBeInTheDocument(); + }); + + it("should open menu when clicked", async () => { + const user = userEvent.setup(); + renderMenu(); + + const button = screen.getByRole("button", { name: "More Options" }); + await user.click(button); + + expect(screen.getByRole("menu")).toBeInTheDocument(); + }); + + it("should show mark as read option when canMarkAsRead is true", async () => { + const user = userEvent.setup(); + renderMenu({ canMarkAsRead: true }); + + const button = screen.getByRole("button", { name: "More Options" }); + await user.click(button); + + expect(screen.getByRole("menuitem", { name: "Mark as read" })).toBeInTheDocument(); + }); + + it("should not show mark as read option when canMarkAsRead is false", async () => { + const user = userEvent.setup(); + renderMenu({ canMarkAsRead: false }); + + const button = screen.getByRole("button", { name: "More Options" }); + await user.click(button); + + expect(screen.queryByRole("menuitem", { name: "Mark as read" })).not.toBeInTheDocument(); + }); + + it("should call onMarkAsRead when mark as read clicked", async () => { + const user = userEvent.setup(); + renderMenu({ canMarkAsRead: true }); + + const button = screen.getByRole("button", { name: "More Options" }); + await user.click(button); + + const markAsReadOption = screen.getByRole("menuitem", { name: "Mark as read" }); + await user.click(markAsReadOption); + + expect(mockCallbacks.onMarkAsRead).toHaveBeenCalled(); + }); + + it("should show mark as unread option when canMarkAsUnread is true", async () => { + const user = userEvent.setup(); + renderMenu({ canMarkAsUnread: true }); + + const button = screen.getByRole("button", { name: "More Options" }); + await user.click(button); + + expect(screen.getByRole("menuitem", { name: "Mark as unread" })).toBeInTheDocument(); + }); + + it("should call onMarkAsUnread when mark as unread clicked", async () => { + const user = userEvent.setup(); + renderMenu({ canMarkAsUnread: true }); + + const button = screen.getByRole("button", { name: "More Options" }); + await user.click(button); + + const markAsUnreadOption = screen.getByRole("menuitem", { name: "Mark as unread" }); + await user.click(markAsUnreadOption); + + expect(mockCallbacks.onMarkAsUnread).toHaveBeenCalled(); + }); + + it("should show favorite option and call onToggleFavorite", async () => { + const user = userEvent.setup(); + renderMenu({ isFavourite: false }); + + const button = screen.getByRole("button", { name: "More Options" }); + await user.click(button); + + const favoriteOption = screen.getByRole("menuitemcheckbox", { name: "Favourited" }); + expect(favoriteOption).toBeInTheDocument(); + expect(favoriteOption).toHaveAttribute("aria-checked", "false"); + + await user.click(favoriteOption); + expect(mockCallbacks.onToggleFavorite).toHaveBeenCalled(); + }); + + it("should show favorite as checked when isFavourite is true", async () => { + const user = userEvent.setup(); + renderMenu({ isFavourite: true }); + + const button = screen.getByRole("button", { name: "More Options" }); + await user.click(button); + + const favoriteOption = screen.getByRole("menuitemcheckbox", { name: "Favourited" }); + expect(favoriteOption).toHaveAttribute("aria-checked", "true"); + }); + + it("should show low priority option and call onToggleLowPriority", async () => { + const user = userEvent.setup(); + renderMenu({ isLowPriority: false }); + + const button = screen.getByRole("button", { name: "More Options" }); + await user.click(button); + + const lowPriorityOption = screen.getByRole("menuitemcheckbox", { name: "Low priority" }); + expect(lowPriorityOption).toBeInTheDocument(); + expect(lowPriorityOption).toHaveAttribute("aria-checked", "false"); + + await user.click(lowPriorityOption); + expect(mockCallbacks.onToggleLowPriority).toHaveBeenCalled(); + }); + + it("should show invite option when canInvite is true", async () => { + const user = userEvent.setup(); + renderMenu({ canInvite: true }); + + const button = screen.getByRole("button", { name: "More Options" }); + await user.click(button); + + expect(screen.getByRole("menuitem", { name: "Invite" })).toBeInTheDocument(); + }); + + it("should call onInvite when invite clicked", async () => { + const user = userEvent.setup(); + renderMenu({ canInvite: true }); + + const button = screen.getByRole("button", { name: "More Options" }); + await user.click(button); + + const inviteOption = screen.getByRole("menuitem", { name: "Invite" }); + await user.click(inviteOption); + + expect(mockCallbacks.onInvite).toHaveBeenCalled(); + }); + + it("should show copy link option when canCopyRoomLink is true", async () => { + const user = userEvent.setup(); + renderMenu({ canCopyRoomLink: true }); + + const button = screen.getByRole("button", { name: "More Options" }); + await user.click(button); + + expect(screen.getByRole("menuitem", { name: "Copy room link" })).toBeInTheDocument(); + }); + + it("should call onCopyRoomLink when copy link clicked", async () => { + const user = userEvent.setup(); + renderMenu({ canCopyRoomLink: true }); + + const button = screen.getByRole("button", { name: "More Options" }); + await user.click(button); + + const copyLinkOption = screen.getByRole("menuitem", { name: "Copy room link" }); + await user.click(copyLinkOption); + + expect(mockCallbacks.onCopyRoomLink).toHaveBeenCalled(); + }); + + it("should show leave room option", async () => { + const user = userEvent.setup(); + renderMenu(); + + const button = screen.getByRole("button", { name: "More Options" }); + await user.click(button); + + expect(screen.getByRole("menuitem", { name: "Leave room" })).toBeInTheDocument(); + }); + + it("should call onLeaveRoom when leave room clicked", async () => { + const user = userEvent.setup(); + renderMenu(); + + const button = screen.getByRole("button", { name: "More Options" }); + await user.click(button); + + const leaveRoomOption = screen.getByRole("menuitem", { name: "Leave room" }); + await user.click(leaveRoomOption); + + expect(mockCallbacks.onLeaveRoom).toHaveBeenCalled(); + }); +}); diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.tsx new file mode 100644 index 0000000000..d10b5c32ec --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.tsx @@ -0,0 +1,137 @@ +/* + * Copyright 2026 Element Creations 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, type JSX } from "react"; +import { IconButton, Menu, MenuItem, Separator, ToggleMenuItem } from "@vector-im/compound-web"; +import { + MarkAsReadIcon, + MarkAsUnreadIcon, + FavouriteIcon, + ArrowDownIcon, + UserAddIcon, + LinkIcon, + LeaveIcon, + OverflowHorizontalIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { _t } from "../../utils/i18n"; +import { useViewModel, type ViewModel } from "../../viewmodel"; +import type { RoomListItemSnapshot, RoomListItemActions } from "./RoomListItem"; + +/** + * View model type for room list item + */ +export type RoomItemViewModel = ViewModel & RoomListItemActions; + +/** + * Props for RoomListItemMoreOptionsMenu component + */ +export interface RoomListItemMoreOptionsMenuProps { + /** The room item view model */ + vm: RoomItemViewModel; +} + +/** + * The more options menu for room list items. + * Displays additional room actions like mark as read/unread, favorite, invite, etc. + */ +export function RoomListItemMoreOptionsMenu({ vm }: RoomListItemMoreOptionsMenuProps): JSX.Element { + const [open, setOpen] = useState(false); + + return ( + + + + } + > + + + ); +} + +interface MoreOptionContentProps { + vm: RoomItemViewModel; +} + +export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element { + const snapshot = useViewModel(vm); + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
e.stopPropagation()}> + {snapshot.canMarkAsRead && ( + evt.stopPropagation()} + hideChevron={true} + /> + )} + {snapshot.canMarkAsUnread && ( + evt.stopPropagation()} + hideChevron={true} + /> + )} + evt.stopPropagation()} + /> + evt.stopPropagation()} + /> + {snapshot.canInvite && ( + evt.stopPropagation()} + hideChevron={true} + /> + )} + {snapshot.canCopyRoomLink && ( + evt.stopPropagation()} + hideChevron={true} + /> + )} + + evt.stopPropagation()} + hideChevron={true} + /> +
+ ); +} diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.test.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.test.tsx new file mode 100644 index 0000000000..3f88e2f8a1 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.test.tsx @@ -0,0 +1,164 @@ +/* + * Copyright 2026 Element Creations 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, { type JSX } from "react"; +import { render, screen } from "@test-utils"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; + +import { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu"; +import { RoomNotifState } from "./RoomNotifs"; +import { useMockedViewModel } from "../../viewmodel"; +import type { RoomListItemSnapshot } from "./RoomListItem"; +import { defaultSnapshot } from "./default-snapshot"; + +describe("", () => { + const mockCallbacks = { + onOpenRoom: vi.fn(), + onMarkAsRead: vi.fn(), + onMarkAsUnread: vi.fn(), + onToggleFavorite: vi.fn(), + onToggleLowPriority: vi.fn(), + onInvite: vi.fn(), + onCopyRoomLink: vi.fn(), + onLeaveRoom: vi.fn(), + onSetRoomNotifState: vi.fn(), + }; + + const renderMenu = (roomNotifState: RoomNotifState = RoomNotifState.AllMessages): ReturnType => { + const TestComponent = (): JSX.Element => { + const vm = useMockedViewModel( + { + ...defaultSnapshot, + showMoreOptionsMenu: false, + showNotificationMenu: true, + roomNotifState, + } as RoomListItemSnapshot, + mockCallbacks, + ); + return ; + }; + return render(); + }; + + it("should render the notification menu button", () => { + renderMenu(); + expect(screen.getByRole("button", { name: "Notification options" })).toBeInTheDocument(); + }); + + it("should show muted icon when notifications are muted", () => { + renderMenu(RoomNotifState.Mute); + const button = screen.getByRole("button", { name: "Notification options" }); + expect(button.querySelector("svg")).toBeInTheDocument(); + }); + + it("should open menu when clicked", async () => { + const user = userEvent.setup(); + renderMenu(); + + const button = screen.getByRole("button", { name: "Notification options" }); + await user.click(button); + + expect(screen.getByRole("menu")).toBeInTheDocument(); + }); + + it("should call onSetRoomNotifState with AllMessages when default settings selected", async () => { + const user = userEvent.setup(); + renderMenu(); + + const button = screen.getByRole("button", { name: "Notification options" }); + await user.click(button); + + const defaultOption = screen.getByRole("menuitem", { name: "Match default settings" }); + await user.click(defaultOption); + + expect(mockCallbacks.onSetRoomNotifState).toHaveBeenCalledWith(RoomNotifState.AllMessages); + }); + + it("should call onSetRoomNotifState with AllMessagesLoud when all messages selected", async () => { + const user = userEvent.setup(); + renderMenu(); + + const button = screen.getByRole("button", { name: "Notification options" }); + await user.click(button); + + const allMessagesOption = screen.getByRole("menuitem", { name: "All messages" }); + await user.click(allMessagesOption); + + expect(mockCallbacks.onSetRoomNotifState).toHaveBeenCalledWith(RoomNotifState.AllMessagesLoud); + }); + + it("should call onSetRoomNotifState with MentionsOnly when mentions and keywords selected", async () => { + const user = userEvent.setup(); + renderMenu(); + + const button = screen.getByRole("button", { name: "Notification options" }); + await user.click(button); + + const mentionsOption = screen.getByRole("menuitem", { name: "Mentions and keywords" }); + await user.click(mentionsOption); + + expect(mockCallbacks.onSetRoomNotifState).toHaveBeenCalledWith(RoomNotifState.MentionsOnly); + }); + + it("should call onSetRoomNotifState with Mute when mute selected", async () => { + const user = userEvent.setup(); + renderMenu(); + + const button = screen.getByRole("button", { name: "Notification options" }); + await user.click(button); + + const muteOption = screen.getByRole("menuitem", { name: "Mute room" }); + await user.click(muteOption); + + expect(mockCallbacks.onSetRoomNotifState).toHaveBeenCalledWith(RoomNotifState.Mute); + }); + + it("should show check mark next to selected option - AllMessage", async () => { + const user = userEvent.setup(); + renderMenu(RoomNotifState.AllMessages); + + const button = screen.getByRole("button", { name: "Notification options" }); + await user.click(button); + + const defaultOption = screen.getByRole("menuitem", { name: "Match default settings" }); + expect(defaultOption).toHaveAttribute("aria-selected", "true"); + }); + + it("should show check mark next to selected option - AllMessagesLoud", async () => { + const user = userEvent.setup(); + renderMenu(RoomNotifState.AllMessagesLoud); + + const button = screen.getByRole("button", { name: "Notification options" }); + await user.click(button); + + const allMessagesOption = screen.getByRole("menuitem", { name: "All messages" }); + expect(allMessagesOption).toHaveAttribute("aria-selected", "true"); + }); + + it("should show check mark next to selected option - MentionsOnly", async () => { + const user = userEvent.setup(); + renderMenu(RoomNotifState.MentionsOnly); + + const button = screen.getByRole("button", { name: "Notification options" }); + await user.click(button); + + const mentionsOption = screen.getByRole("menuitem", { name: "Mentions and keywords" }); + expect(mentionsOption).toHaveAttribute("aria-selected", "true"); + }); + + it("should show check mark next to selected option - Mute", async () => { + const user = userEvent.setup(); + renderMenu(RoomNotifState.Mute); + + const button = screen.getByRole("button", { name: "Notification options" }); + await user.click(button); + + const muteOption = screen.getByRole("menuitem", { name: "Mute room" }); + expect(muteOption).toHaveAttribute("aria-selected", "true"); + }); +}); diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.tsx new file mode 100644 index 0000000000..e4038fae6c --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.tsx @@ -0,0 +1,105 @@ +/* + * Copyright 2026 Element Creations 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, type JSX } from "react"; +import { IconButton, Menu, MenuItem } from "@vector-im/compound-web"; +import { + NotificationsSolidIcon, + NotificationsOffSolidIcon, + CheckIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { _t } from "../../utils/i18n"; +import { RoomNotifState } from "./RoomNotifs"; +import { useViewModel, type ViewModel } from "../../viewmodel"; +import type { RoomListItemSnapshot, RoomListItemActions } from "./RoomListItem"; + +/** + * View model type for room list item + */ +export type RoomItemViewModel = ViewModel & RoomListItemActions; + +/** + * Props for RoomListItemNotificationMenu component + */ +export interface RoomListItemNotificationMenuProps { + /** The room item view model */ + vm: RoomItemViewModel; +} + +/** + * The notification settings menu for room list items. + * Displays options to change notification settings. + */ +export function RoomListItemNotificationMenu({ vm }: RoomListItemNotificationMenuProps): JSX.Element { + const snapshot = useViewModel(vm); + const [open, setOpen] = useState(false); + const isMuted = snapshot.roomNotifState === RoomNotifState.Mute; + const checkComponent = ; + + return ( + + {isMuted ? : } + + } + > + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
e.stopPropagation()} + > + vm.onSetRoomNotifState(RoomNotifState.AllMessages)} + onClick={(evt) => evt.stopPropagation()} + > + {snapshot.roomNotifState === RoomNotifState.AllMessages && checkComponent} + + vm.onSetRoomNotifState(RoomNotifState.AllMessagesLoud)} + onClick={(evt) => evt.stopPropagation()} + > + {snapshot.roomNotifState === RoomNotifState.AllMessagesLoud && checkComponent} + + vm.onSetRoomNotifState(RoomNotifState.MentionsOnly)} + onClick={(evt) => evt.stopPropagation()} + > + {snapshot.roomNotifState === RoomNotifState.MentionsOnly && checkComponent} + + vm.onSetRoomNotifState(RoomNotifState.Mute)} + onClick={(evt) => evt.stopPropagation()} + > + {snapshot.roomNotifState === RoomNotifState.Mute && checkComponent} + +
+
+ ); +} diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomNotifs.ts b/packages/shared-components/src/room-list/RoomListItem/RoomNotifs.ts new file mode 100644 index 0000000000..06fc0fc23d --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomNotifs.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2026 Element Creations 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. + */ + +/** + * Notification state for a room. + */ +export enum RoomNotifState { + /** All messages (default) */ + AllMessages = "all_messages", + /** All messages with sound */ + AllMessagesLoud = "all_messages_loud", + /** Only mentions and keywords */ + MentionsOnly = "mentions_only", + /** Muted */ + Mute = "mute", +} diff --git a/packages/shared-components/src/room-list/RoomListItem/__snapshots__/RoomListItem.test.tsx.snap b/packages/shared-components/src/room-list/RoomListItem/__snapshots__/RoomListItem.test.tsx.snap new file mode 100644 index 0000000000..ff1ccad613 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/__snapshots__/RoomListItem.test.tsx.snap @@ -0,0 +1,1236 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders Bold story 1`] = ` +
+
+
+ + +
+ + +
+
+
+`; + +exports[` > renders Default story 1`] = ` +
+
+
+ + +
+ + +
+
+
+`; + +exports[` > renders Invitation story 1`] = ` +
+
+
+ + +
+ +
+ +
+
+ +`; + +exports[` > renders NoMessagePreview story 1`] = ` +
+
+
+ + +
+ + +
+
+ +`; + +exports[` > renders Selected story 1`] = ` +
+
+
+ + +
+ + +
+
+ +`; + +exports[` > renders UnsentMessage story 1`] = ` +
+
+
+ + +
+ +
+ +
+ + +`; + +exports[` > renders WithHoverMenu story 1`] = ` +
+
+
+ + +
+ + +
+
+ +`; + +exports[` > renders WithMention story 1`] = ` +
+
+
+ + +
+ +
+ +
+ + +`; + +exports[` > renders WithNotification story 1`] = ` +
+
+
+ + +
+ +
+ +
+ + +`; diff --git a/packages/shared-components/src/room-list/RoomListItem/default-snapshot.ts b/packages/shared-components/src/room-list/RoomListItem/default-snapshot.ts new file mode 100644 index 0000000000..b5e263567f --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/default-snapshot.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2026 Element Creations 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 { type RoomListItemSnapshot } from "./RoomListItem"; +import { RoomNotifState } from "./RoomNotifs"; + +export const mockRoom = { name: "General" }; + +export const defaultSnapshot: RoomListItemSnapshot = { + id: "!room:server", + room: mockRoom, + name: "General", + isBold: false, + messagePreview: "Alice: Hey everyone!", + notification: { + hasAnyNotificationOrActivity: false, + isUnsentMessage: false, + invited: false, + isMention: false, + isActivityNotification: false, + isNotification: false, + hasUnreadCount: false, + count: 0, + muted: false, + }, + showMoreOptionsMenu: true, + showNotificationMenu: true, + isFavourite: false, + isLowPriority: false, + canInvite: true, + canCopyRoomLink: true, + canMarkAsRead: false, + canMarkAsUnread: true, + roomNotifState: RoomNotifState.AllMessages, +}; diff --git a/packages/shared-components/src/room-list/RoomListItem/index.ts b/packages/shared-components/src/room-list/RoomListItem/index.ts new file mode 100644 index 0000000000..edf17066b8 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { RoomListItemView } from "./RoomListItem"; +export type { + Room, + RoomListItemSnapshot, + RoomItemViewModel, + RoomListItemActions, + RoomListItemViewProps, +} from "./RoomListItem"; +export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu"; +export type { RoomListItemNotificationMenuProps } from "./RoomListItemNotificationMenu"; +export { RoomListItemMoreOptionsMenu, MoreOptionContent } from "./RoomListItemMoreOptionsMenu"; +export type { RoomListItemMoreOptionsMenuProps } from "./RoomListItemMoreOptionsMenu"; +export { RoomListItemHoverMenu } from "./RoomListItemHoverMenu"; +export type { RoomListItemHoverMenuProps } from "./RoomListItemHoverMenu"; +export { RoomListItemContextMenu } from "./RoomListItemContextMenu"; +export type { RoomListItemContextMenuProps } from "./RoomListItemContextMenu"; +export { NotificationDecoration } from "./NotificationDecoration"; +export type { NotificationDecorationProps, NotificationDecorationData } from "./NotificationDecoration"; +export { RoomNotifState } from "./RoomNotifs"; diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.module.css b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.module.css new file mode 100644 index 0000000000..29db6d1bd6 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.module.css @@ -0,0 +1,32 @@ +/* + * Copyright 2025 Element Creations 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. + */ + +.roomListPrimaryFilters { + padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-3x); +} + +/* Hide filters that are wrapping when collapsed */ +.roomListPrimaryFilters :global(.wrapping) { + display: none; +} + +.list { + /** + * The InteractionObserver needs the height to be set to work properly. + */ + height: 100%; + flex: 1; +} + +/* IconButton styles for chevron */ +.iconButton svg { + transition: transform 0.1s linear; +} + +.iconButton[aria-expanded="true"] svg { + transform: rotate(180deg); +} diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx new file mode 100644 index 0000000000..a1a80334b9 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx @@ -0,0 +1,91 @@ +/* + * Copyright 2026 Element Creations 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 from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters"; +import type { FilterId } from "./useVisibleFilters"; + +const meta: Meta = { + title: "Room List/RoomListPrimaryFilters", + component: RoomListPrimaryFilters, + tags: ["autodocs"], + args: { + onToggleFilter: fn(), + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel-2025?node-id=98-1979&t=vafb4zoYMNLRuAbh-4", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// All available filter IDs +const allFilterIds: FilterId[] = ["unread", "people", "rooms", "favourite", "mentions", "invites", "low_priority"]; + +// Subset of filters for narrow container tests +const fewFilterIds: FilterId[] = ["people", "rooms", "unread"]; + +export const Default: Story = { + args: { + filterIds: allFilterIds, + }, +}; + +export const PeopleSelected: Story = { + args: { + filterIds: allFilterIds, + activeFilterId: "people", + }, +}; + +export const NoFilters: Story = { + args: { + filterIds: [], + }, +}; + +/** + * Narrow container that causes filters to wrap. + * The chevron button should appear to expand/collapse the filter list. + */ +export const NarrowContainer: Story = { + args: { + filterIds: fewFilterIds, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +/** + * Narrow container with active filter that would wrap. + * When collapsed, the active filter should move to the front. + */ +export const NarrowWithActiveWrappingFilter: Story = { + args: { + filterIds: fewFilterIds, + activeFilterId: "unread", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.test.tsx b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.test.tsx new file mode 100644 index 0000000000..a86181da15 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.test.tsx @@ -0,0 +1,140 @@ +/* + * Copyright 2026 Element Creations 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, { act } from "react"; +import { render, screen } from "@test-utils"; +import userEvent from "@testing-library/user-event"; +import { composeStories } from "@storybook/react-vite"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import * as stories from "./RoomListPrimaryFilters.stories"; + +const { Default, PeopleSelected, NoFilters, NarrowContainer, NarrowWithActiveWrappingFilter } = composeStories(stories); + +describe(" stories", () => { + describe("snapshots", () => { + it("renders Default story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders PeopleSelected story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders NoFilters story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders NarrowContainer story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders NarrowWithActiveWrappingFilter story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + }); + + describe("behavior", () => { + it("should call onToggleFilter when a filter is clicked", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("option", { name: "People" })); + + expect(Default.args.onToggleFilter).toHaveBeenCalled(); + }); + }); + + describe("resize behavior", () => { + let resizeCallback: ResizeObserverCallback; + + beforeEach(() => { + globalThis.ResizeObserver = class MockResizeObserver { + public constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + public observe = vi.fn(); + public unobserve = vi.fn(); + public disconnect = vi.fn(); + } as unknown as typeof ResizeObserver; + }); + + function mockFiltersNotWrapping(): void { + vi.spyOn(screen.getByText("People"), "offsetLeft", "get").mockReturnValue(0); + vi.spyOn(screen.getByText("Rooms"), "offsetLeft", "get").mockReturnValue(30); + vi.spyOn(screen.getByText("Unreads"), "offsetLeft", "get").mockReturnValue(60); + + const listbox = screen.getByRole("listbox", { name: "Room list filters" }); + act(() => resizeCallback([{ target: listbox } as any], {} as ResizeObserver)); + } + + function mockUnreadWrapping(): void { + vi.spyOn(screen.getByText("People"), "offsetLeft", "get").mockReturnValue(0); + vi.spyOn(screen.getByText("Rooms"), "offsetLeft", "get").mockReturnValue(30); + vi.spyOn(screen.getByText("Unreads"), "offsetLeft", "get").mockReturnValue(0); + + const listbox = screen.getByRole("listbox", { name: "Room list filters" }); + act(() => resizeCallback([{ target: listbox } as any], {} as ResizeObserver)); + } + + it("should hide wrapping filters and show chevron", () => { + render(); + mockUnreadWrapping(); + + expect(screen.queryByRole("option", { name: "Unreads" })).toBeNull(); + expect(screen.getByRole("button", { name: "Expand filter list" })).toBeInTheDocument(); + }); + + it("should expand and collapse filter list with chevron button", async () => { + const user = userEvent.setup(); + render(); + mockUnreadWrapping(); + + expect(screen.queryByRole("option", { name: "Unreads" })).toBeNull(); + + await user.click(screen.getByRole("button", { name: "Expand filter list" })); + expect(screen.getByRole("option", { name: "Unreads" })).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Collapse filter list" })); + expect(screen.queryByRole("option", { name: "Unreads" })).toBeNull(); + }); + + it("should move active filter to front when collapsed and wrapping", () => { + render(); + mockUnreadWrapping(); + + const listbox = screen.getByRole("listbox", { name: "Room list filters" }); + expect(listbox.children[0]).toBe(screen.getByRole("option", { name: "Unreads" })); + }); + + it("should restore original filter order when expanded", async () => { + const user = userEvent.setup(); + render(); + mockUnreadWrapping(); + + await user.click(screen.getByRole("button", { name: "Expand filter list" })); + + const listbox = screen.getByRole("listbox", { name: "Room list filters" }); + expect(listbox.children[0]).toBe(screen.getByRole("option", { name: "People" })); + }); + + it("should handle resize from non-wrapping to wrapping", () => { + render(); + mockFiltersNotWrapping(); + + expect(screen.queryByRole("button", { name: "Expand filter list" })).toBeNull(); + + mockUnreadWrapping(); + expect(screen.getByRole("button", { name: "Expand filter list" })).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.tsx b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.tsx new file mode 100644 index 0000000000..561544a3a5 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.tsx @@ -0,0 +1,116 @@ +/* + * Copyright 2026 Element Creations 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, { type JSX, useId, useState } from "react"; +import { ChatFilter, IconButton } from "@vector-im/compound-web"; +import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down"; + +import { Flex } from "../../utils/Flex"; +import { _t } from "../../utils/i18n"; +import { useCollapseFilters } from "./useCollapseFilters"; +import { useVisibleFilters, type FilterId } from "./useVisibleFilters"; +import styles from "./RoomListPrimaryFilters.module.css"; + +/** + * Maps filter IDs to translated labels + */ +const filterIdToLabel = (filterId: FilterId): string => { + switch (filterId) { + case "unread": + return _t("room_list|filters|unread"); + case "people": + return _t("room_list|filters|people"); + case "rooms": + return _t("room_list|filters|rooms"); + case "favourite": + return _t("room_list|filters|favourite"); + case "mentions": + return _t("room_list|filters|mentions"); + case "invites": + return _t("room_list|filters|invites"); + case "low_priority": + return _t("room_list|filters|low_priority"); + } +}; + +/** + * Props for RoomListPrimaryFilters component + */ +export interface RoomListPrimaryFiltersProps { + /** Array of filter IDs to display */ + filterIds: FilterId[]; + /** Currently active filter ID (if any) */ + activeFilterId?: FilterId; + /** Callback when a filter is toggled */ + onToggleFilter: (filterId: FilterId) => void; +} + +/** + * The primary filters component for the room list. + * Displays a collapsible list of filters with expand/collapse functionality. + */ +export const RoomListPrimaryFilters: React.FC = ({ + filterIds, + activeFilterId, + onToggleFilter, +}): JSX.Element | null => { + const id = useId(); + const [isExpanded, setIsExpanded] = useState(false); + + const { + ref, + isWrapping: displayChevron, + wrappingIndex, + } = useCollapseFilters(isExpanded, "wrapping"); + const visibleFilterIds = useVisibleFilters(filterIds, activeFilterId, wrappingIndex); + + return ( + + {displayChevron && ( + setIsExpanded((expanded) => !expanded)} + > + + + )} + + {visibleFilterIds.map((filterId, index) => ( + onToggleFilter(filterId)} + > + {filterIdToLabel(filterId)} + + ))} + + + ); +}; diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/__snapshots__/RoomListPrimaryFilters.test.tsx.snap b/packages/shared-components/src/room-list/RoomListPrimaryFilters/__snapshots__/RoomListPrimaryFilters.test.tsx.snap new file mode 100644 index 0000000000..74c281bde5 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/__snapshots__/RoomListPrimaryFilters.test.tsx.snap @@ -0,0 +1,388 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` stories > snapshots > renders Default story 1`] = ` +
+
+ +
+ + + + + + + +
+
+
+`; + +exports[` stories > snapshots > renders NarrowContainer story 1`] = ` +
+
+
+ +
+ + + +
+
+
+
+`; + +exports[` stories > snapshots > renders NarrowWithActiveWrappingFilter story 1`] = ` +
+
+
+ +
+ + + +
+
+
+
+`; + +exports[` stories > snapshots > renders NoFilters story 1`] = ` +
+
+
+
+
+`; + +exports[` stories > snapshots > renders PeopleSelected story 1`] = ` +
+
+ +
+ + + + + + + +
+
+
+`; diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/index.tsx b/packages/shared-components/src/room-list/RoomListPrimaryFilters/index.tsx new file mode 100644 index 0000000000..7697d4829c --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/index.tsx @@ -0,0 +1,12 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { RoomListPrimaryFilters } from "./RoomListPrimaryFilters"; +export type { RoomListPrimaryFiltersProps } from "./RoomListPrimaryFilters"; +export { useCollapseFilters } from "./useCollapseFilters"; +export { useVisibleFilters } from "./useVisibleFilters"; +export type { FilterId } from "./useVisibleFilters"; diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/useCollapseFilters.ts b/packages/shared-components/src/room-list/RoomListPrimaryFilters/useCollapseFilters.ts new file mode 100644 index 0000000000..e3fbf74e54 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/useCollapseFilters.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Element Creations 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 { useEffect, useRef, useState, type RefObject } from "react"; + +/** + * A hook to manage the wrapping of filters in the room list. + * It observes the filter list and hides filters that are wrapping when the list is not expanded. + * @param isExpanded + * @param wrappingClassName - the CSS class to apply to wrapping filters + * @returns an object containing: + * - `ref`: a ref to put on the filter list element + * - `isWrapping`: a boolean indicating if the filters are wrapping + * - `wrappingIndex`: the index of the first filter that is wrapping + */ +export function useCollapseFilters( + isExpanded: boolean, + wrappingClassName: string, +): { + ref: RefObject; + isWrapping: boolean; + wrappingIndex: number; +} { + const ref = useRef(null); + const [isWrapping, setIsWrapping] = useState(false); + const [wrappingIndex, setWrappingIndex] = useState(-1); + + useEffect(() => { + if (!ref.current) return; + + const hideFilters = (list: Element): void => { + let isWrapping = false; + Array.from(list.children).forEach((node, i): void => { + const child = node as HTMLElement; + child.setAttribute("aria-hidden", "false"); + child.classList.remove(wrappingClassName); + + // If the filter list is expanded, all filters are visible + if (isExpanded) return; + + // If the previous element is on the left element of the current one, it means that the filter is wrapping + const previousSibling = child.previousElementSibling as HTMLElement | null; + if (previousSibling && child.offsetLeft <= previousSibling.offsetLeft) { + if (!isWrapping) setWrappingIndex(i); + isWrapping = true; + } + + // If the filter is wrapping, we hide it + child.classList.toggle(wrappingClassName, isWrapping); + child.setAttribute("aria-hidden", isWrapping.toString()); + }); + + if (!isWrapping) setWrappingIndex(-1); + setIsWrapping(isExpanded || isWrapping); + }; + + hideFilters(ref.current); + const observer = new ResizeObserver((entries) => entries.forEach((entry) => hideFilters(entry.target))); + + observer.observe(ref.current); + return () => { + observer.disconnect(); + }; + }, [isExpanded, wrappingClassName]); + + return { ref, isWrapping, wrappingIndex }; +} diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/useVisibleFilters.ts b/packages/shared-components/src/room-list/RoomListPrimaryFilters/useVisibleFilters.ts new file mode 100644 index 0000000000..73a580b4d9 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/useVisibleFilters.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Element Creations 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 { useEffect, useState } from "react"; + +/** + * Standard filter identifiers that can be used across implementations. + * These are stable keys - the view layer maps them to translated labels. + */ +export type FilterId = "unread" | "people" | "rooms" | "favourite" | "mentions" | "invites" | "low_priority"; + +/** + * A hook to sort the filter IDs by active state. + * The list is sorted if the active filter index is greater than or equal to the wrapping index. + * If the wrapping index is -1, the filters are not sorted. + * + * @param filterIds - the list of filter IDs to sort. + * @param activeFilterId - the currently active filter ID (if any). + * @param wrappingIndex - the index of the first filter that is wrapping. + */ +export function useVisibleFilters( + filterIds: FilterId[], + activeFilterId: FilterId | undefined, + wrappingIndex: number, +): FilterId[] { + // By default, the filters are not sorted + const [sortedFilterIds, setSortedFilterIds] = useState(filterIds); + + useEffect(() => { + const activeIndex = activeFilterId ? filterIds.indexOf(activeFilterId) : -1; + const isActiveFilterWrapping = activeIndex >= wrappingIndex; + // If the active filter is not wrapping, we don't need to sort the filters + if (!isActiveFilterWrapping || wrappingIndex === -1) { + setSortedFilterIds(filterIds); + return; + } + + // Sort the filters with the active filter at first position + setSortedFilterIds( + filterIds.slice().sort((filterA, filterB) => { + // If the filter is active, it should be at the top of the list + if (filterA === activeFilterId && filterB !== activeFilterId) return -1; + if (filterA !== activeFilterId && filterB === activeFilterId) return 1; + // If both filters are active or not, keep their original order + return 0; + }), + ); + }, [filterIds, activeFilterId, wrappingIndex]); + + return sortedFilterIds; +} diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListEmptyStateView.module.css b/packages/shared-components/src/room-list/RoomListView/RoomListEmptyStateView.module.css new file mode 100644 index 0000000000..204e7615a4 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListView/RoomListEmptyStateView.module.css @@ -0,0 +1,33 @@ +/* + * Copyright 2025 Element Creations 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. + */ + +.genericPlaceholder { + align-self: center; + /** It should take 2/3 of the width **/ + width: 66%; + /** It should be positioned at 1/3 of the height **/ + padding-top: 33%; +} + +.title { + font: var(--cpd-font-body-lg-semibold); + text-align: center; +} + +.description { + font: var(--cpd-font-body-sm-regular); + color: var(--cpd-color-text-secondary); + text-align: center; +} + +.defaultPlaceholder { + margin-top: var(--cpd-space-4x); +} + +.genericPlaceholder button { + width: 100%; +} diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListEmptyStateView.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListEmptyStateView.tsx new file mode 100644 index 0000000000..12d26517b3 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListView/RoomListEmptyStateView.tsx @@ -0,0 +1,182 @@ +/* + * Copyright 2026 Element Creations 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, { type JSX, type PropsWithChildren, type ReactNode } from "react"; +import { Button } from "@vector-im/compound-web"; +import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat"; +import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room"; + +import { Flex } from "../../utils/Flex"; +import { _t } from "../../utils/i18n"; +import { useViewModel } from "../../viewmodel"; +import type { RoomListViewModel } from "./RoomListView"; +import styles from "./RoomListEmptyStateView.module.css"; + +/** + * Props for RoomListEmptyStateView component + */ +export interface RoomListEmptyStateViewProps { + /** The view model containing all data and callbacks */ + vm: RoomListViewModel; +} + +/** + * Empty state component for the room list. + * Displays appropriate message and actions based on the active filter. + */ +export const RoomListEmptyStateView: React.FC = ({ vm }): JSX.Element => { + const snapshot = useViewModel(vm); + + // If there is no active filter, show the default empty state + if (!snapshot.activeFilterId) { + return ( + + + + {snapshot.canCreateRoom && ( + + )} + + + ); + } + + // Handle different filter cases based on filter ID + switch (snapshot.activeFilterId) { + case "favourite": + return ( + + ); + case "people": + return ( + + ); + case "rooms": + return ( + + ); + case "unread": + return ( + vm.onToggleFilter(snapshot.activeFilterId!)} + /> + ); + case "invites": + return ( + vm.onToggleFilter(snapshot.activeFilterId!)} + /> + ); + case "mentions": + return ( + vm.onToggleFilter(snapshot.activeFilterId!)} + /> + ); + case "low_priority": + return ( + vm.onToggleFilter(snapshot.activeFilterId!)} + /> + ); + default: + return ( + + ); + } +}; + +interface GenericPlaceholderProps { + /** The title of the placeholder */ + title: string; + /** The description of the placeholder */ + description?: string; + /** Optional children (e.g., action buttons) */ + children?: ReactNode; +} + +/** + * A generic placeholder for the room list + */ +function GenericPlaceholder({ title, description, children }: PropsWithChildren): JSX.Element { + return ( + + {title} + {description && {description}} + {children} + + ); +} + +interface ActionPlaceholderProps { + /** The title to display */ + title: string; + /** The action button text */ + action: string; + /** Callback when the action button is clicked */ + onAction?: () => void; +} + +/** + * A placeholder for the room list when a filter is active + * The user can take action to toggle the filter + */ +function ActionPlaceholder({ title, action, onAction }: ActionPlaceholderProps): JSX.Element { + return ( + + {onAction && ( + + )} + + ); +} diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListLoadingSkeleton.module.css b/packages/shared-components/src/room-list/RoomListView/RoomListLoadingSkeleton.module.css new file mode 100644 index 0000000000..2f65f7969d --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListView/RoomListLoadingSkeleton.module.css @@ -0,0 +1,24 @@ +/* + * Copyright 2025 Element Creations 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. + */ + +.skeleton { + position: relative; + margin-left: 4px; + height: 100%; + flex: 1; +} + +.skeleton::before { + background-color: var(--cpd-color-bg-subtle-secondary); + width: 100%; + height: 100%; + content: ""; + position: absolute; + mask-repeat: repeat-y; + mask-size: auto 96px; + mask-image: url("./assets/skeleton.svg"); +} diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListLoadingSkeleton.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListLoadingSkeleton.tsx new file mode 100644 index 0000000000..6ab8b80de3 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListView/RoomListLoadingSkeleton.tsx @@ -0,0 +1,18 @@ +/* + * Copyright 2026 Element Creations 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, { type JSX } from "react"; + +import styles from "./RoomListLoadingSkeleton.module.css"; + +/** + * Loading skeleton component for the room list. + * Displays a repeating skeleton pattern while rooms are being fetched. + */ +export const RoomListLoadingSkeleton: React.FC = (): JSX.Element => { + return
; +}; diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx new file mode 100644 index 0000000000..206307262e --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx @@ -0,0 +1,221 @@ +/* + * Copyright 2026 Element Creations 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, { type JSX } from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Room } from "../RoomListItem/RoomListItem"; +import type { FilterId } from "../RoomListPrimaryFilters"; +import { RoomListView, type RoomListSnapshot, type RoomListViewActions } from "./RoomListView"; +import { useMockedViewModel } from "../../viewmodel"; +import { + renderAvatar, + createGetRoomItemViewModel, + mockRoomIds, + smallListRoomIds, + largeListRoomIds, +} from "../story-mocks"; + +type RoomListViewProps = RoomListSnapshot & RoomListViewActions & { renderAvatar: (room: Room) => React.ReactElement }; + +const mockFilterIds: FilterId[] = ["unread", "people", "rooms", "favourite"]; + +// Wrapper component that creates a mocked ViewModel +const RoomListViewWrapper = ({ + onToggleFilter, + createChatRoom, + createRoom, + getRoomItemViewModel, + updateVisibleRooms, + renderAvatar: renderAvatarProp, + ...rest +}: RoomListViewProps): JSX.Element => { + const vm = useMockedViewModel(rest, { + onToggleFilter, + createChatRoom, + createRoom, + getRoomItemViewModel, + updateVisibleRooms, + }); + return ; +}; + +const meta = { + title: "Room List/RoomListView", + component: RoomListViewWrapper, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + // Snapshot properties (state) + isLoadingRooms: false, + isRoomListEmpty: false, + filterIds: mockFilterIds, + activeFilterId: undefined, + roomListState: { + activeRoomIndex: undefined, + spaceId: "!space:server", + filterKeys: undefined, + }, + roomIds: mockRoomIds, + canCreateRoom: true, + // Action properties (callbacks) + onToggleFilter: fn(), + createChatRoom: fn(), + createRoom: fn(), + getRoomItemViewModel: createGetRoomItemViewModel(mockRoomIds), + updateVisibleRooms: fn(), + renderAvatar, + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel?node-id=2925-19126", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Loading: Story = { + args: { + isLoadingRooms: true, + }, +}; + +export const Empty: Story = { + args: { + isRoomListEmpty: true, + }, +}; + +export const EmptyWithoutCreatePermission: Story = { + args: { + isRoomListEmpty: true, + canCreateRoom: false, + }, +}; + +export const WithActiveFilter: Story = { + args: { + filterIds: ["unread", "people", "rooms", "favourite"], + activeFilterId: "favourite", + roomListState: { + activeRoomIndex: undefined, + spaceId: "!space:server", + filterKeys: ["favourites"], + }, + }, +}; + +export const WithSelection: Story = { + args: { + roomListState: { + activeRoomIndex: 0, + spaceId: "!space:server", + filterKeys: undefined, + }, + }, +}; + +export const EmptyFavouriteFilter: Story = { + args: { + isRoomListEmpty: true, + roomIds: [], + filterIds: ["favourite", "people"], + activeFilterId: "favourite", + }, +}; + +export const EmptyPeopleFilter: Story = { + args: { + isRoomListEmpty: true, + roomIds: [], + filterIds: ["people", "rooms"], + activeFilterId: "people", + }, +}; + +export const EmptyRoomsFilter: Story = { + args: { + isRoomListEmpty: true, + roomIds: [], + filterIds: ["rooms", "people"], + activeFilterId: "rooms", + }, +}; + +export const EmptyUnreadFilter: Story = { + args: { + isRoomListEmpty: true, + roomIds: [], + filterIds: ["unread", "people"], + activeFilterId: "unread", + }, +}; + +export const EmptyInvitesFilter: Story = { + args: { + isRoomListEmpty: true, + roomIds: [], + filterIds: ["invites", "people"], + activeFilterId: "invites", + }, +}; + +export const EmptyMentionsFilter: Story = { + args: { + isRoomListEmpty: true, + roomIds: [], + filterIds: ["mentions", "people"], + activeFilterId: "mentions", + }, +}; + +export const EmptyLowPriorityFilter: Story = { + args: { + isRoomListEmpty: true, + roomIds: [], + filterIds: ["low_priority", "people"], + activeFilterId: "low_priority", + }, +}; + +export const SmallList: Story = { + args: { + roomIds: smallListRoomIds, + getRoomItemViewModel: createGetRoomItemViewModel(smallListRoomIds), + }, +}; + +export const LargeList: Story = { + args: { + roomIds: largeListRoomIds, + getRoomItemViewModel: createGetRoomItemViewModel(largeListRoomIds), + }, +}; diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.test.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.test.tsx new file mode 100644 index 0000000000..15237eed7e --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.test.tsx @@ -0,0 +1,177 @@ +/* + * Copyright 2026 Element Creations 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 from "react"; +import { render, screen } from "@test-utils"; +import userEvent from "@testing-library/user-event"; +import { VirtuosoMockContext } from "react-virtuoso"; +import { composeStories } from "@storybook/react-vite"; +import { describe, it, expect } from "vitest"; + +import * as stories from "./RoomListView.stories"; + +const { + Default, + Loading, + Empty, + EmptyWithoutCreatePermission, + WithActiveFilter, + SmallList, + LargeList, + EmptyFavouriteFilter, + EmptyPeopleFilter, + EmptyRoomsFilter, + EmptyUnreadFilter, + EmptyInvitesFilter, + EmptyMentionsFilter, + EmptyLowPriorityFilter, +} = composeStories(stories); + +const renderWithMockContext = (component: React.ReactElement): ReturnType => { + return render(component, { + wrapper: ({ children }) => ( + + {children} + + ), + }); +}; + +describe("", () => { + it("renders Default story", () => { + const { container } = renderWithMockContext(); + expect(container).toMatchSnapshot(); + }); + + it("renders Loading story", () => { + const { container } = renderWithMockContext(); + expect(container).toMatchSnapshot(); + }); + + it("renders Empty story", () => { + const { container } = renderWithMockContext(); + expect(container).toMatchSnapshot(); + }); + + it("renders EmptyWithoutCreatePermission story", () => { + const { container } = renderWithMockContext(); + expect(container).toMatchSnapshot(); + }); + + it("renders WithActiveFilter story", () => { + const { container } = renderWithMockContext(); + expect(container).toMatchSnapshot(); + }); + + it("renders SmallList story", () => { + const { container } = renderWithMockContext(); + expect(container).toMatchSnapshot(); + }); + + it("renders LargeList story", () => { + const { container } = renderWithMockContext(); + expect(container).toMatchSnapshot(); + }); + + it("renders EmptyFavouriteFilter story", () => { + const { container } = renderWithMockContext(); + expect(container).toMatchSnapshot(); + }); + + it("renders EmptyPeopleFilter story", () => { + const { container } = renderWithMockContext(); + expect(container).toMatchSnapshot(); + }); + + it("renders EmptyRoomsFilter story", () => { + const { container } = renderWithMockContext(); + expect(container).toMatchSnapshot(); + }); + + it("renders EmptyUnreadFilter story", () => { + const { container } = renderWithMockContext(); + expect(container).toMatchSnapshot(); + }); + + it("renders EmptyInvitesFilter story", () => { + const { container } = renderWithMockContext(); + expect(container).toMatchSnapshot(); + }); + + it("renders EmptyMentionsFilter story", () => { + const { container } = renderWithMockContext(); + expect(container).toMatchSnapshot(); + }); + + it("renders EmptyLowPriorityFilter story", () => { + const { container } = renderWithMockContext(); + expect(container).toMatchSnapshot(); + }); + + it("should call onToggleFilter when filter is clicked", async () => { + const user = userEvent.setup(); + renderWithMockContext(); + + await user.click(screen.getByRole("option", { name: "People" })); + + expect(Default.args.onToggleFilter).toHaveBeenCalled(); + }); + + it("should call createRoom when New room button is clicked", async () => { + const user = userEvent.setup(); + renderWithMockContext(); + + await user.click(screen.getByRole("button", { name: "New room" })); + + expect(Empty.args.createRoom).toHaveBeenCalled(); + }); + + it("should call createChatRoom when Start chat button is clicked", async () => { + const user = userEvent.setup(); + renderWithMockContext(); + + await user.click(screen.getByRole("button", { name: "Start chat" })); + + expect(Empty.args.createChatRoom).toHaveBeenCalled(); + }); + + it("should call onToggleFilter when Show all chats is clicked in unread empty state", async () => { + const user = userEvent.setup(); + renderWithMockContext(); + + await user.click(screen.getByRole("button", { name: "Show all chats" })); + + expect(EmptyUnreadFilter.args.onToggleFilter).toHaveBeenCalled(); + }); + + it("should call onToggleFilter when See all activity is clicked in invites empty state", async () => { + const user = userEvent.setup(); + renderWithMockContext(); + + await user.click(screen.getByRole("button", { name: "See all activity" })); + + expect(EmptyInvitesFilter.args.onToggleFilter).toHaveBeenCalled(); + }); + + it("should call onToggleFilter when See all activity is clicked in mentions empty state", async () => { + const user = userEvent.setup(); + renderWithMockContext(); + + await user.click(screen.getByRole("button", { name: "See all activity" })); + + expect(EmptyMentionsFilter.args.onToggleFilter).toHaveBeenCalled(); + }); + + it("should call onToggleFilter when See all activity is clicked in low priority empty state", async () => { + const user = userEvent.setup(); + renderWithMockContext(); + + await user.click(screen.getByRole("button", { name: "See all activity" })); + + expect(EmptyLowPriorityFilter.args.onToggleFilter).toHaveBeenCalled(); + }); +}); diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx new file mode 100644 index 0000000000..491c28d7d1 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx @@ -0,0 +1,101 @@ +/* + * Copyright 2026 Element Creations 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, { type JSX, type ReactNode } from "react"; + +import { useViewModel, type ViewModel } from "../../viewmodel"; +import { RoomListPrimaryFilters, type FilterId } from "../RoomListPrimaryFilters"; +import { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton"; +import { RoomListEmptyStateView } from "./RoomListEmptyStateView"; +import { VirtualizedRoomListView, type RoomListViewState } from "../VirtualizedRoomListView"; +import { type Room } from "../RoomListItem"; + +/** + * Snapshot for the room list view + */ +export type RoomListSnapshot = { + /** Whether the rooms are currently loading */ + isLoadingRooms: boolean; + /** Whether the room list is empty */ + isRoomListEmpty: boolean; + /** Array of filter IDs */ + filterIds: FilterId[]; + /** Currently active filter ID (if any) */ + activeFilterId?: FilterId; + /** Room list state */ + roomListState: RoomListViewState; + /** Array of room IDs for virtualization */ + roomIds: string[]; + /** Optional description for the empty state */ + emptyStateDescription?: string; + /** Optional action element for the empty state */ + emptyStateAction?: ReactNode; + /** Whether the user can create rooms */ + canCreateRoom?: boolean; +}; + +/** + * Actions interface for room list operations + */ +export interface RoomListViewActions { + /** Called when a filter is toggled */ + onToggleFilter: (filterId: FilterId) => void; + /** Called to create a new chat room */ + createChatRoom: () => void; + /** Called to create a new room */ + createRoom: () => void; + /** Get view model for a specific room (virtualization API) */ + getRoomItemViewModel: (roomId: string) => any; + /** Called when the visible range changes (virtualization API) */ + updateVisibleRooms: (startIndex: number, endIndex: number) => void; +} + +/** + * The view model type for the room list view + */ +export type RoomListViewModel = ViewModel & RoomListViewActions; + +/** + * Props for RoomListView component + */ +export interface RoomListViewProps { + /** The view model containing all data and callbacks */ + vm: RoomListViewModel; + /** Render function for room avatar */ + renderAvatar: (room: Room) => ReactNode; + /** Optional callback for keyboard events on the room list */ + onKeyDown?: (e: React.KeyboardEvent) => void; +} + +/** + * Room list view component that manages filters, loading states, empty states, and the room list. + */ +export const RoomListView: React.FC = ({ vm, renderAvatar, onKeyDown }): JSX.Element => { + const snapshot = useViewModel(vm); + let listBody: ReactNode; + + if (snapshot.isLoadingRooms) { + listBody = ; + } else if (snapshot.isRoomListEmpty) { + listBody = ; + } else { + listBody = ; + } + + return ( + <> +
+ +
+ {listBody} + + ); +}; diff --git a/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap b/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap new file mode 100644 index 0000000000..c518632039 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap @@ -0,0 +1,11387 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders Default story 1`] = ` +
+
+
+
+
+ + + + +
+
+
+
+
+
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ +
+ +
+
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + + + + + +`; + +exports[` > renders Empty story 1`] = ` +
+
+
+
+
+ + + + +
+
+
+
+ + No chats yet + + + Get started by messaging someone or by creating a room + +
+ + +
+
+
+
+`; + +exports[` > renders EmptyFavouriteFilter story 1`] = ` +
+
+
+
+
+ + +
+
+
+
+ + You don't have favourite chats yet + + + You can add a chat to your favourites in the chat settings + +
+
+
+`; + +exports[` > renders EmptyInvitesFilter story 1`] = ` +
+
+
+
+
+ + +
+
+
+
+ + You don't have any unread invites + + +
+
+
+`; + +exports[` > renders EmptyLowPriorityFilter story 1`] = ` +
+
+
+
+
+ + +
+
+
+
+ + You don't have any low priority rooms + + +
+
+
+`; + +exports[` > renders EmptyMentionsFilter story 1`] = ` +
+
+
+
+
+ + +
+
+
+
+ + You don't have any unread mentions + + +
+
+
+`; + +exports[` > renders EmptyPeopleFilter story 1`] = ` +
+
+
+
+
+ + +
+
+
+
+ + You don’t have direct chats with anyone yet + + + You can deselect filters in order to see your other chats + +
+
+
+`; + +exports[` > renders EmptyRoomsFilter story 1`] = ` +
+
+
+
+
+ + +
+
+
+
+ + You’re not in any room yet + + + You can deselect filters in order to see your other chats + +
+
+
+`; + +exports[` > renders EmptyUnreadFilter story 1`] = ` +
+
+
+
+
+ + +
+
+
+
+ + Congrats! You don’t have any unread messages + + +
+
+
+`; + +exports[` > renders EmptyWithoutCreatePermission story 1`] = ` +
+
+
+
+
+ + + + +
+
+
+
+ + No chats yet + + + Get started by messaging someone + +
+ +
+
+
+
+`; + +exports[` > renders LargeList story 1`] = ` +
+
+
+
+
+ + + + +
+
+
+
+
+
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + +
+ + +
+ + + +
+ + +
+ + + + + + + + +`; + +exports[` > renders Loading story 1`] = ` +
+
+
+
+
+ + + + +
+
+
+
+
+
+`; + +exports[` > renders SmallList story 1`] = ` +
+
+
+
+
+ + + + +
+
+
+
+
+
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + + + +`; + +exports[` > renders WithActiveFilter story 1`] = ` +
+
+
+
+
+ + + + +
+
+
+
+
+
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + + + + + +`; diff --git a/packages/shared-components/src/room-list/RoomListView/assets/skeleton.svg b/packages/shared-components/src/room-list/RoomListView/assets/skeleton.svg new file mode 100644 index 0000000000..adf56e4ed8 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListView/assets/skeleton.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/shared-components/src/room-list/RoomListView/index.tsx b/packages/shared-components/src/room-list/RoomListView/index.tsx new file mode 100644 index 0000000000..405b94bf2f --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListView/index.tsx @@ -0,0 +1,12 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { RoomListView } from "./RoomListView"; +export type { RoomListViewProps, RoomListViewModel, RoomListSnapshot, RoomListViewActions } from "./RoomListView"; +export { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton"; +export { RoomListEmptyStateView } from "./RoomListEmptyStateView"; +export type { RoomListEmptyStateViewProps } from "./RoomListEmptyStateView"; diff --git a/res/css/views/rooms/RoomListPanel/_RoomList.pcss b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.module.css similarity index 63% rename from res/css/views/rooms/RoomListPanel/_RoomList.pcss rename to packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.module.css index 54798f1ea9..c444c8c1cd 100644 --- a/res/css/views/rooms/RoomListPanel/_RoomList.pcss +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.module.css @@ -1,10 +1,14 @@ /* - * Copyright 2025 New Vector Ltd. + * Copyright 2025 Element Creations 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. */ -.mx_RoomList { +/** + * Room list container styles + */ +.roomList { height: 100%; + width: 100%; } diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx new file mode 100644 index 0000000000..3ea908cb55 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx @@ -0,0 +1,94 @@ +/* + * Copyright 2026 Element Creations 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, { type JSX } from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Room } from "../RoomListItem/RoomListItem"; +import { VirtualizedRoomListView, type RoomListViewState } from "./VirtualizedRoomListView"; +import type { RoomListSnapshot, RoomListViewActions } from "../RoomListView"; +import { useMockedViewModel } from "../../viewmodel"; +import type { FilterId } from "../RoomListPrimaryFilters"; +import { renderAvatar, createGetRoomItemViewModel, mockRoomIds } from "../story-mocks"; + +type RoomListStoryProps = RoomListSnapshot & RoomListViewActions & { renderAvatar: (room: Room) => React.ReactElement }; + +// Use first 10 room IDs for this story +const storyRoomIds = mockRoomIds.slice(0, 10); + +// Wrapper component that creates a mocked ViewModel +const RoomListWrapper = ({ + onToggleFilter, + createChatRoom, + createRoom, + getRoomItemViewModel, + updateVisibleRooms, + renderAvatar: renderAvatarProp, + ...rest +}: RoomListStoryProps): JSX.Element => { + const vm = useMockedViewModel(rest, { + onToggleFilter, + createChatRoom, + createRoom, + getRoomItemViewModel, + updateVisibleRooms, + }); + + return ( +
+ +
+ ); +}; + +const mockFilterIds: FilterId[] = ["unread", "people"]; + +const defaultRoomListState: RoomListViewState = { + activeRoomIndex: 0, + spaceId: "!space:server", + filterKeys: undefined, +}; + +const meta: Meta = { + title: "Room List/VirtualizedRoomListView", + component: RoomListWrapper, + tags: ["autodocs"], + args: { + isLoadingRooms: false, + isRoomListEmpty: false, + filterIds: mockFilterIds, + activeFilterId: undefined, + roomIds: storyRoomIds, + roomListState: defaultRoomListState, + canCreateRoom: true, + onToggleFilter: fn(), + createChatRoom: fn(), + createRoom: fn(), + getRoomItemViewModel: createGetRoomItemViewModel(storyRoomIds), + updateVisibleRooms: fn(), + renderAvatar, + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel-2025?node-id=98-1979&t=vafb4zoYMNLRuAbh-4", + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx new file mode 100644 index 0000000000..23f384554d --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2026 Element Creations 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 from "react"; +import { render, screen, fireEvent } from "@test-utils"; +import { VirtuosoMockContext } from "react-virtuoso"; +import { composeStories } from "@storybook/react-vite"; +import { describe, it, expect } from "vitest"; + +import * as stories from "./VirtualizedRoomListView.stories"; + +const { Default } = composeStories(stories); + +const renderWithMockContext = (component: React.ReactElement): ReturnType => { + return render(component, { + wrapper: ({ children }) => ( + + {children} + + ), + }); +}; + +describe("", () => { + it("renders Default story", () => { + const { container } = renderWithMockContext(); + expect(container).toMatchSnapshot(); + }); + + it("should render the room list listbox", () => { + renderWithMockContext(); + expect(screen.getByRole("listbox", { name: "Room list" })).toBeInTheDocument(); + }); + + it("should render room items", () => { + renderWithMockContext(); + const items = screen.getAllByRole("option"); + expect(items.length).toBeGreaterThan(0); + }); + + it("should mark selected room with aria-selected true", () => { + renderWithMockContext(); + const items = screen.getAllByRole("option"); + // The first item (index 0) should be selected based on Default story (activeRoomIndex: 0) + expect(items[0]).toHaveAttribute("aria-selected", "true"); + }); + + it("should handle focus state correctly", () => { + renderWithMockContext(); + + const listbox = screen.getByRole("listbox", { name: "Room list" }); + fireEvent.focus(listbox); + + const items = screen.getAllByRole("option"); + // First item should have tabIndex 0 (focusable) when list is focused + expect(items[0]).toHaveAttribute("tabIndex", "0"); + }); + + it("should call updateVisibleRooms on render", () => { + renderWithMockContext(); + expect(Default.args.updateVisibleRooms).toHaveBeenCalled(); + }); +}); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx new file mode 100644 index 0000000000..7b27df0f28 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx @@ -0,0 +1,198 @@ +/* + * Copyright 2026 Element Creations 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, { useCallback, useMemo, useRef, type JSX, type ReactNode } from "react"; +import { type ScrollIntoViewLocation } from "react-virtuoso"; +import { isEqual } from "lodash"; + +import type { Room } from "../RoomListItem/RoomListItem"; +import { useViewModel } from "../../viewmodel"; +import { _t } from "../../utils/i18n"; +import { VirtualizedList, type VirtualizedListContext } from "../../utils/VirtualizedList"; +import { RoomListItemView } from "../RoomListItem"; +import type { RoomListViewModel } from "../RoomListView"; + +/** + * Filter key type - opaque string type for filter identifiers + */ +export type FilterKey = string; + +/** + * State for the room list data (nested within RoomListSnapshot) + */ +export interface RoomListViewState { + /** Optional active room index for keyboard navigation */ + activeRoomIndex?: number; + /** Space ID for context tracking */ + spaceId?: string; + /** Active filter keys for context tracking */ + filterKeys?: FilterKey[]; +} + +/** + * Props for the VirtualizedRoomListView component + */ +export interface VirtualizedRoomListViewProps { + /** + * The view model containing all room list data and callbacks + */ + vm: RoomListViewModel; + + /** + * Render function for room avatar + * @param room - The opaque Room object from the client + */ + renderAvatar: (room: Room) => ReactNode; + + /** + * Optional callback for keyboard key down events + */ + onKeyDown?: (e: React.KeyboardEvent) => void; +} + +/** Height of a single room list item in pixels */ +const ROOM_LIST_ITEM_HEIGHT = 48; + +/** + * Type for context used in ListView + */ +type Context = { spaceId: string; filterKeys: FilterKey[] | undefined }; + +/** + * Amount to extend the top and bottom of the viewport by. + * From manual testing and user feedback 25 items is reported to be enough to avoid blank space + * when using the mouse wheel, and the trackpad scrolling at a slow to moderate speed where you + * can still see/read the content. Using the trackpad to sling through a large percentage of the + * list quickly will still show blank space. We would likely need to simplify the item content to + * improve this case. + */ +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 RoomListItemView components for each room. + * + * @example + * ```tsx + * } /> + * ``` + */ +export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: VirtualizedRoomListViewProps): JSX.Element { + const snapshot = useViewModel(vm); + const { roomListState, roomIds } = snapshot; + const activeRoomIndex = roomListState.activeRoomIndex; + const lastSpaceId = useRef(undefined); + const lastFilterKeys = useRef(undefined); + const roomCount = roomIds.length; + + /** + * Callback when the visible range changes + * Notifies the view model which rooms are visible + */ + const rangeChanged = useCallback( + (range: { startIndex: number; endIndex: number }) => { + vm.updateVisibleRooms(range.startIndex, range.endIndex); + }, + [vm], + ); + + /** + * Get the item component for a specific index + * Gets the room's view model and passes it to RoomListItemView + */ + const getItemComponent = useCallback( + ( + index: number, + roomId: string, + context: VirtualizedListContext, + onFocus: (item: string, e: React.FocusEvent) => void, + ): JSX.Element => { + const isSelected = activeRoomIndex === index; + const roomItemVM = vm.getRoomItemViewModel(roomId); + + // Item is focused when the list has focus AND this item's key matches tabIndexKey + // This matches the old RoomList implementation's roving tabindex pattern + const isFocused = context.focused && context.tabIndexKey === roomId; + + return ( + + ); + }, + [activeRoomIndex, roomCount, renderAvatar, vm], + ); + + /** + * Get the key for a room item + * Since we're using virtualization, items are always room ID strings + */ + const getItemKey = useCallback((item: string): string => { + return item; + }, []); + + const context = useMemo( + () => ({ spaceId: roomListState.spaceId || "", filterKeys: roomListState.filterKeys }), + [roomListState.spaceId, roomListState.filterKeys], + ); + + /** + * Determine if we should scroll the active index into view + * This happens when the space or filters change + */ + const scrollIntoViewOnChange = useCallback( + (params: { + context: VirtualizedListContext<{ spaceId: string; filterKeys: FilterKey[] | undefined }>; + }): ScrollIntoViewLocation | null | undefined | false => { + const { spaceId, filterKeys } = params.context.context; + const shouldScrollIndexIntoView = + lastSpaceId.current !== spaceId || !isEqual(lastFilterKeys.current, filterKeys); + lastFilterKeys.current = filterKeys; + lastSpaceId.current = spaceId; + + if (shouldScrollIndexIntoView) { + return { + align: "start", + index: activeRoomIndex || 0, + behavior: "auto", + }; + } + return false; + }, + [activeRoomIndex], + ); + + return ( + true} + rangeChanged={rangeChanged} + onKeyDown={onKeyDown} + increaseViewportBy={{ + bottom: EXTENDED_VIEWPORT_HEIGHT, + top: EXTENDED_VIEWPORT_HEIGHT, + }} + /> + ); +} diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/__snapshots__/VirtualizedRoomListView.test.tsx.snap b/packages/shared-components/src/room-list/VirtualizedRoomListView/__snapshots__/VirtualizedRoomListView.test.tsx.snap new file mode 100644 index 0000000000..85cd5f6d08 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/__snapshots__/VirtualizedRoomListView.test.tsx.snap @@ -0,0 +1,1277 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders Default story 1`] = ` +
+
+
+
+
+
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ +
+ + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + + + + + + +`; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/index.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/index.ts new file mode 100644 index 0000000000..da5840ada5 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { VirtualizedRoomListView } from "./VirtualizedRoomListView"; +export type { VirtualizedRoomListViewProps, RoomListViewState, FilterKey } from "./VirtualizedRoomListView"; diff --git a/packages/shared-components/src/room-list/story-mocks.tsx b/packages/shared-components/src/room-list/story-mocks.tsx new file mode 100644 index 0000000000..83a8eb1b94 --- /dev/null +++ b/packages/shared-components/src/room-list/story-mocks.tsx @@ -0,0 +1,138 @@ +/* + * Copyright 2026 Element Creations 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 from "react"; +import { fn } from "storybook/test"; + +import type { Room } from "./RoomListItem/RoomListItem"; +import type { RoomListItemSnapshot } from "./RoomListItem"; +import { RoomNotifState } from "./RoomListItem/RoomNotifs"; + +/** + * Mock avatar component for stories + */ +export const mockAvatar = (name: string): React.ReactElement => ( +
+ {name.substring(0, 2).toUpperCase()} +
+); + +/** + * Render avatar function for stories + */ +export const renderAvatar = (room: Room): React.ReactElement => { + // Cast to any to access properties - in real usage, the room object from the SDK will have these + return mockAvatar((room as any)?.name || "Room"); +}; + +/** + * Room names used for mock data + */ +const roomNames = [ + "General", + "Random", + "Engineering", + "Design", + "Product", + "Marketing", + "Sales", + "Support", + "Announcements", + "Off-topic", + "Team Alpha", + "Team Beta", + "Project X", + "Project Y", + "Water Cooler", + "Feedback", + "Ideas", + "Bugs", + "Features", + "Releases", +]; + +/** + * Create a mock room item snapshot for stories + */ +export const createMockRoomSnapshot = (id: string, name: string, index: number): RoomListItemSnapshot => ({ + id, + room: { name }, + name, + isBold: index % 3 === 0, + messagePreview: index % 2 === 0 ? `Last message in ${name}` : undefined, + notification: { + hasAnyNotificationOrActivity: index % 5 === 0, + isUnsentMessage: false, + invited: false, + isMention: index % 5 === 0, + isActivityNotification: false, + isNotification: index % 5 === 0, + hasUnreadCount: index % 5 === 0, + count: index % 5 === 0 ? index : 0, + muted: false, + }, + showMoreOptionsMenu: true, + showNotificationMenu: true, + isFavourite: false, + isLowPriority: false, + canInvite: true, + canCopyRoomLink: true, + canMarkAsRead: false, + canMarkAsUnread: true, + roomNotifState: RoomNotifState.AllMessages, +}); + +/** + * Create a mock getRoomItemViewModel function for stories + */ +export const createGetRoomItemViewModel = (roomIds: string[]): ((roomId: string) => any) => { + const viewModels = new Map(); + roomIds.forEach((roomId, index) => { + const name = roomNames[index % roomNames.length]; + const snapshot = createMockRoomSnapshot(roomId, name, index); + + const mockViewModel = { + getSnapshot: () => snapshot, + subscribe: fn(), + unsubscribe: fn(), + onOpenRoom: fn(), + onMarkAsRead: fn(), + onMarkAsUnread: fn(), + onToggleFavorite: fn(), + onToggleLowPriority: fn(), + onInvite: fn(), + onCopyRoomLink: fn(), + onLeaveRoom: fn(), + onSetRoomNotifState: fn(), + }; + viewModels.set(roomId, mockViewModel); + }); + + return (roomId: string) => viewModels.get(roomId); +}; + +/** + * Mock room IDs for different list sizes + */ +export const mockRoomIds = Array.from({ length: 20 }, (_, i) => `!room${i}:server`); +export const smallListRoomIds = mockRoomIds.slice(0, 5); +export const largeListRoomIds = Array.from({ length: 100 }, (_, i) => `!room${i}:server`); diff --git a/packages/shared-components/src/utils/VirtualizedList/VirtualizedList.tsx b/packages/shared-components/src/utils/VirtualizedList/VirtualizedList.tsx index 20e191ba38..adea593d07 100644 --- a/packages/shared-components/src/utils/VirtualizedList/VirtualizedList.tsx +++ b/packages/shared-components/src/utils/VirtualizedList/VirtualizedList.tsx @@ -1,9 +1,9 @@ /* -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. -*/ + * Copyright 2026 Element Creations 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, { useRef, type JSX, useCallback, useEffect, useState, useMemo } from "react"; import { type VirtuosoHandle, type ListRange, Virtuoso, type VirtuosoProps } from "react-virtuoso"; @@ -95,6 +95,19 @@ export interface IVirtualizedListProps extends Omit< * @returns */ onKeyDown?: (e: React.KeyboardEvent) => void; + + /** + * Optional total count of items (for virtualization with partial data loading). + * If provided, this will be used instead of items.length for the total count. + */ + totalCount?: number; + + /** + * Optional callback when the visible range of items changes. + * Useful for loading data on-demand as the user scrolls. + * @param range - The new visible range with startIndex and endIndex + */ + rangeChanged?: (range: ListRange) => void; } /** @@ -113,7 +126,17 @@ export type ScrollIntoViewOnChange = NonNullable< */ export function VirtualizedList(props: IVirtualizedListProps): React.ReactElement { // Extract our custom props to avoid conflicts with Virtuoso props - const { items, getItemComponent, isItemFocusable, getItemKey, context, onKeyDown, ...virtuosoProps } = props; + const { + items, + getItemComponent, + isItemFocusable, + getItemKey, + context, + onKeyDown, + totalCount, + rangeChanged, + ...virtuosoProps + } = props; /** Reference to the Virtuoso component for programmatic scrolling */ const virtuosoHandleRef = useRef(null); /** Reference to the DOM element containing the virtualized list */ @@ -324,6 +347,15 @@ export function VirtualizedList(props: IVirtualizedListProp [tabIndexKey, isFocused, props.context], ); + // Combine internal range tracking with optional external callback + const handleRangeChanged = useCallback( + (range: ListRange) => { + setVisibleRange(range); + rangeChanged?.(range); + }, + [rangeChanged], + ); + return ( (props: IVirtualizedListProp scrollerRef={scrollerRef} onKeyDown={keyDownCallback} context={listContext} - rangeChanged={setVisibleRange} + rangeChanged={handleRangeChanged} // virtuoso errors internally if you pass undefined. overscan={props.overscan || 0} data={props.items} + totalCount={totalCount} onFocus={onFocus} onBlur={onBlur} itemContent={getItemComponentInternal} diff --git a/packages/shared-components/src/viewmodel/index.ts b/packages/shared-components/src/viewmodel/index.ts index 7936e535a2..25e5ec60b8 100644 --- a/packages/shared-components/src/viewmodel/index.ts +++ b/packages/shared-components/src/viewmodel/index.ts @@ -12,5 +12,5 @@ export * from "./ViewModelSubscriptions"; export type * from "./ViewModel"; export * from "./MockViewModel"; export * from "./useCreateAutoDisposedViewModel"; -export * from "./useViewModel"; export * from "./useMockedViewModel"; +export * from "./useViewModel"; diff --git a/playwright/e2e/read-receipts/room-list-order.spec.ts b/playwright/e2e/read-receipts/room-list-order.spec.ts index 405cdf22ed..c13730107c 100644 --- a/playwright/e2e/read-receipts/room-list-order.spec.ts +++ b/playwright/e2e/read-receipts/room-list-order.spec.ts @@ -18,19 +18,28 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { util, msg, page, + app, + bot, }) => { + // Create a third room to navigate to + const room3Id = await app.client.createRoom({ name: "Room Gamma", invite: [bot.credentials.userId] }); + await bot.awaitRoomMembership(room3Id); + const room3 = { name: "Room Gamma", roomId: room3Id }; + await util.goTo(room2); // Display the unread first room + await util.receiveMessages(room2, ["Msg2"]); await util.receiveMessages(room1, ["Msg1"]); await page.reload(); - // switch rooms so they can re-order in the list - await util.goTo(room1); + // Switch to room3 so neither room1 nor room2 is selected/sticky + // This allows them to reorder based on activity + await util.goTo(room3); // Room 1 has an unread message and should be displayed first // (as the default is to sort by activity) - await util.assertRoomListOrder([room1, room2]); + await util.assertRoomListOrder([room1, room2, room3]); }); test("Rooms with unread threads appear at the top of room list with default 'activity' order", async ({ @@ -38,18 +47,29 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { roomBeta: room2, util, msg, + app, + bot, }) => { + // Create a third room to navigate to + const room3Id = await app.client.createRoom({ name: "Room Gamma", invite: [bot.credentials.userId] }); + await bot.awaitRoomMembership(room3Id); + const room3 = { name: "Room Gamma", roomId: room3Id }; + await util.goTo(room2); await util.receiveMessages(room1, ["Msg1"]); + await util.receiveMessages(room2, ["Msg2"]); await util.markAsRead(room1); await util.assertRead(room1); - // Display the unread first room + // Display the unread first room (room1 moves above room2 as it has an unread thread) await util.receiveMessages(room1, [msg.threadedOff("Msg1", "Resp1")]); await util.saveAndReload(); + // Switch to room3 so neither room1 nor room2 is selected/sticky + await util.goTo(room3); + // Room 1 has an unread message and should be displayed first - await util.assertRoomListOrder([room1, room2]); + await util.assertRoomListOrder([room1, room2, room3]); }); }); }); diff --git a/playwright/e2e/sliding-sync/sliding-sync.spec.ts b/playwright/e2e/sliding-sync/sliding-sync.spec.ts index b31deadace..38aff5928d 100644 --- a/playwright/e2e/sliding-sync/sliding-sync.spec.ts +++ b/playwright/e2e/sliding-sync/sliding-sync.spec.ts @@ -38,7 +38,7 @@ const test = base.extend<{ test.describe("Sliding Sync", () => { const checkOrder = async (wantOrder: string[], page: Page) => { - await expect(page.getByTestId("room-list").locator(".mx_RoomListItemView_text")).toHaveText(wantOrder); + await expect(page.getByTestId("room-list").getByTestId("room-name")).toHaveText(wantOrder); }; const bumpRoom = async (roomId: string, app: ElementAppPage) => { diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 28458c899f..53f99f8a35 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -263,14 +263,7 @@ @import "./views/right_panel/_VerificationPanel.pcss"; @import "./views/right_panel/_WidgetCard.pcss"; @import "./views/room_settings/_AliasSettings.pcss"; -@import "./views/rooms/RoomListPanel/_EmptyRoomList.pcss"; -@import "./views/rooms/RoomListPanel/_RoomList.pcss"; -@import "./views/rooms/RoomListPanel/_RoomListItemMenuView.pcss"; -@import "./views/rooms/RoomListPanel/_RoomListItemView.pcss"; @import "./views/rooms/RoomListPanel/_RoomListPanel.pcss"; -@import "./views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss"; -@import "./views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss"; -@import "./views/rooms/RoomListPanel/_RoomListSkeleton.pcss"; @import "./views/rooms/_AppsDrawer.pcss"; @import "./views/rooms/_Autocomplete.pcss"; @import "./views/rooms/_AuxPanel.pcss"; diff --git a/res/css/views/rooms/RoomListPanel/_EmptyRoomList.pcss b/res/css/views/rooms/RoomListPanel/_EmptyRoomList.pcss deleted file mode 100644 index a0fbfdaea7..0000000000 --- a/res/css/views/rooms/RoomListPanel/_EmptyRoomList.pcss +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -.mx_EmptyRoomList_GenericPlaceholder { - align-self: center; - /** It should take 2/3 of the width **/ - width: 66%; - /** It should be positioned at 1/3 of the height **/ - padding-top: 33%; - - .mx_EmptyRoomList_GenericPlaceholder_title { - font: var(--cpd-font-body-lg-semibold); - text-align: center; - } - - .mx_EmptyRoomList_GenericPlaceholder_description { - font: var(--cpd-font-body-sm-regular); - color: var(--cpd-color-text-secondary); - text-align: center; - } - - .mx_EmptyRoomList_DefaultPlaceholder { - margin-top: var(--cpd-space-4x); - } - - button { - width: 100%; - } -} diff --git a/res/css/views/rooms/RoomListPanel/_RoomListItemMenuView.pcss b/res/css/views/rooms/RoomListPanel/_RoomListItemMenuView.pcss deleted file mode 100644 index cabd9b2d20..0000000000 --- a/res/css/views/rooms/RoomListPanel/_RoomListItemMenuView.pcss +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -.mx_RoomListItemMenuView { - svg { - fill: var(--cpd-color-icon-primary); - } -} diff --git a/res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss b/res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss deleted file mode 100644 index 4a7eb23b18..0000000000 --- a/res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -/** - * The RoomListItemView has the following structure: - * button--------------------------------------------------| - * | <-12px-> container------------------------------------| - * | | room avatar <-8px-> content----------------| - * | | | room_name <- 20px ->| - * | | | --------------------| <-- border - * |-------------------------------------------------------| - */ -.mx_RoomListItemView { - /* Remove button default style */ - color: inherit; - background: unset; - border: none; - padding: 0; - text-align: unset; - - cursor: pointer; - height: 48px; - width: 100%; - - padding-left: var(--cpd-space-3x); - font: var(--cpd-font-body-md-regular); - - /* Hide the menu by default */ - .mx_RoomListItemView_menu { - display: none; - } - - &:hover, - &:focus-visible, - /* When the context menu is opened */ - &[data-state="open"], - /* When the options and notifications menu are opened */ - &:has(.mx_RoomListItemMenuView > button[data-state="open"]) { - background-color: var(--cpd-color-bg-action-secondary-hovered); - - .mx_RoomListItemView_menu { - display: flex; - } - - &.mx_RoomListItemView_has_menu { - /** - * The figma uses 16px padding (--cpd-space-4x) but due to https://github.com/element-hq/compound-web/issues/331 - * the icon size of the menu is 18px instead of 20px with a different internal padding - * We need to use 18px to align the icon with the others icons - * 18px is not available in compound spacing - */ - .mx_RoomListItemView_content { - padding-right: 18px; - } - - /* When the menu is visible, hide the notification decoration to avoid clutter */ - .mx_RoomListItemView_notificationDecoration { - display: none; - } - } - } - - .mx_RoomListItemView_content { - height: 100%; - flex: 1; - /* The border is only under the room name and the future hover menu */ - border-bottom: var(--cpd-border-width-0-5) solid var(--cpd-color-bg-subtle-secondary); - box-sizing: border-box; - min-width: 0; - padding-right: var(--cpd-space-5x); - - .mx_RoomListItemView_text { - min-width: 0; - } - - .mx_RoomListItemView_roomName { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .mx_RoomListItemView_messagePreview { - font: var(--cpd-font-body-sm-regular); - color: var(--cpd-color-text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } -} - -.mx_RoomListItemView_selected { - background-color: var(--cpd-color-bg-action-secondary-pressed); -} - -.mx_RoomListItemView_bold .mx_RoomListItemView_roomName { - font: var(--cpd-font-body-md-semibold); -} diff --git a/res/css/views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss b/res/css/views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss deleted file mode 100644 index 378f2e75da..0000000000 --- a/res/css/views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -.mx_RoomListPrimaryFilters { - padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-3x); - - .mx_RoomListPrimaryFilters_wrapping { - display: none; - } - - .mx_RoomListPrimaryFilters_list { - /** - * The InteractionObserver needs the height to be set to work properly. - */ - height: 100%; - flex: 1; - } - - .mx_RoomListPrimaryFilters_IconButton { - svg { - transition: transform 0.1s linear; - } - } - - .mx_RoomListPrimaryFilters_IconButton[aria-expanded="true"] { - svg { - transform: rotate(180deg); - } - } -} diff --git a/res/css/views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss b/res/css/views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss deleted file mode 100644 index 0fa8dc12ae..0000000000 --- a/res/css/views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -.mx_RoomListSecondaryFilters { - font: var(--cpd-font-body-md-medium); - margin: var(--cpd-space-2x); - margin-left: var(--cpd-space-1x); -} diff --git a/res/css/views/rooms/RoomListPanel/_RoomListSkeleton.pcss b/res/css/views/rooms/RoomListPanel/_RoomListSkeleton.pcss deleted file mode 100644 index 248f260262..0000000000 --- a/res/css/views/rooms/RoomListPanel/_RoomListSkeleton.pcss +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -.mx_RoomListSkeleton { - position: relative; - margin-left: 4px; - height: 100%; - - &::before { - background-color: var(--cpd-color-bg-subtle-secondary); - width: 100%; - height: 100%; - - content: ""; - position: absolute; - mask-repeat: repeat-y; - mask-size: auto 96px; - mask-image: url("/res/img/element-icons/roomlist/room-list-item-skeleton.svg"); - } -} diff --git a/src/components/viewmodels/roomlist/MessagePreviewViewModel.tsx b/src/components/viewmodels/roomlist/MessagePreviewViewModel.tsx deleted file mode 100644 index 9e141c1379..0000000000 --- a/src/components/viewmodels/roomlist/MessagePreviewViewModel.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import { useCallback, useEffect, useState } from "react"; - -import type { Room } from "matrix-js-sdk/src/matrix"; -import { type MessagePreview, MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; -import { useEventEmitter } from "../../../hooks/useEventEmitter"; - -interface MessagePreviewViewState { - /** - * A string representation of the message preview if available. - */ - message?: string; -} - -/** - * View model for rendering a message preview for a given room list item. - * @param room The room for which we're rendering the message preview. - * @see {@link MessagePreviewViewState} for what this view model returns. - */ -export function useMessagePreviewViewModel(room: Room): MessagePreviewViewState { - const [messagePreview, setMessagePreview] = useState(null); - - const updatePreview = useCallback(async (): Promise => { - /** - * The second argument to getPreviewForRoom is a tag id which doesn't really make - * much sense within the context of the new room list. We can pass an empty string - * to match all tags for now but we should remember to actually change the implementation - * in the store once we remove the legacy room list. - */ - const newPreview = await MessagePreviewStore.instance.getPreviewForRoom(room, ""); - setMessagePreview(newPreview); - }, [room]); - - /** - * Update when the message preview has changed for this room. - */ - useEventEmitter(MessagePreviewStore.instance, MessagePreviewStore.getPreviewChangedEventName(room), () => { - updatePreview(); - }); - - /** - * Do an initial fetch of the message preview. - */ - useEffect(() => { - updatePreview(); - }, [updatePreview]); - - return { - message: messagePreview?.text, - }; -} diff --git a/src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx b/src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx deleted file mode 100644 index 738a05b8c3..0000000000 --- a/src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx +++ /dev/null @@ -1,226 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import { useCallback } from "react"; -import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; - -import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; -import { useEventEmitterState } from "../../../hooks/useEventEmitter"; -import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications"; -import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils"; -import DMRoomMap from "../../../utils/DMRoomMap"; -import { DefaultTagID } from "../../../stores/room-list/models"; -import { NotificationLevel } from "../../../stores/notifications/NotificationLevel"; -import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; -import { UIComponent } from "../../../settings/UIFeature"; -import dispatcher from "../../../dispatcher/dispatcher"; -import { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications"; -import PosthogTrackers from "../../../PosthogTrackers"; -import { tagRoom } from "../../../utils/room/tagRoom"; -import { RoomNotifState } from "../../../RoomNotifs"; -import { useNotificationState } from "../../../hooks/useRoomNotificationState"; - -export interface RoomListItemMenuViewState { - /** - * Whether the more options menu should be shown. - */ - showMoreOptionsMenu: boolean; - /** - * Whether the notification menu should be shown. - */ - showNotificationMenu: boolean; - /** - * Whether the room is a favourite room. - */ - isFavourite: boolean; - /** - * Whether the room is a low priority room. - */ - isLowPriority: boolean; - /** - * Can invite other user's 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; - /** - * 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; - /** - * Mark the room as read. - * @param evt - */ - markAsRead: (evt: Event) => void; - /** - * Mark the room as unread. - * @param evt - */ - markAsUnread: (evt: Event) => void; - /** - * Toggle the room as favourite. - * @param evt - */ - toggleFavorite: (evt: Event) => void; - /** - * Toggle the room as low priority. - */ - toggleLowPriority: () => void; - /** - * Invite other users in the room. - * @param evt - */ - invite: (evt: Event) => void; - /** - * Copy the room link in the clipboard. - * @param evt - */ - copyRoomLink: (evt: Event) => void; - /** - * Leave the room. - * @param evt - */ - leaveRoom: (evt: Event) => void; - /** - * Set the room notification state. - * @param state - */ - setRoomNotifState: (state: RoomNotifState) => void; -} - -export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewState { - const matrixClient = useMatrixClientContext(); - const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags); - const { level: notificationLevel } = useUnreadNotifications(room); - - const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId)); - const isFavourite = Boolean(roomTags[DefaultTagID.Favourite]); - const isLowPriority = Boolean(roomTags[DefaultTagID.LowPriority]); - const isArchived = Boolean(roomTags[DefaultTagID.Archived]); - - const showMoreOptionsMenu = hasAccessToOptionsMenu(room); - const showNotificationMenu = hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived); - - const canMarkAsRead = notificationLevel > NotificationLevel.None; - const canMarkAsUnread = !canMarkAsRead && !isArchived; - - const canInvite = - room.canInvite(matrixClient.getUserId()!) && !isDm && shouldShowComponent(UIComponent.InviteUsers); - const canCopyRoomLink = !isDm; - - const [roomNotifState, setRoomNotifState] = useNotificationState(room); - const isNotificationAllMessage = roomNotifState === RoomNotifState.AllMessages; - const isNotificationAllMessageLoud = roomNotifState === RoomNotifState.AllMessagesLoud; - const isNotificationMentionOnly = roomNotifState === RoomNotifState.MentionsOnly; - const isNotificationMute = roomNotifState === RoomNotifState.Mute; - - // Actions - - const markAsRead = useCallback( - async (evt: Event): Promise => { - await clearRoomNotification(room, matrixClient); - PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkRead", evt); - }, - [room, matrixClient], - ); - - const markAsUnread = useCallback( - async (evt: Event): Promise => { - await setMarkedUnreadState(room, matrixClient, true); - PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkUnread", evt); - }, - [room, matrixClient], - ); - - const toggleFavorite = useCallback( - (evt: Event): void => { - tagRoom(room, DefaultTagID.Favourite); - PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", evt); - }, - [room], - ); - - const toggleLowPriority = useCallback((): void => tagRoom(room, DefaultTagID.LowPriority), [room]); - - const invite = useCallback( - (evt: Event): void => { - dispatcher.dispatch({ - action: "view_invite", - roomId: room.roomId, - }); - PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuInviteItem", evt); - }, - [room], - ); - - const copyRoomLink = useCallback( - (evt: Event): void => { - dispatcher.dispatch({ - action: "copy_room", - room_id: room.roomId, - }); - PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", evt); - }, - [room], - ); - - const leaveRoom = useCallback( - (evt: Event): void => { - dispatcher.dispatch({ - action: isArchived ? "forget_room" : "leave_room", - room_id: room.roomId, - }); - PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem", evt); - }, - [room, isArchived], - ); - - return { - showMoreOptionsMenu, - showNotificationMenu, - isFavourite, - isLowPriority, - canInvite, - canCopyRoomLink, - canMarkAsRead, - canMarkAsUnread, - isNotificationAllMessage, - isNotificationAllMessageLoud, - isNotificationMentionOnly, - isNotificationMute, - markAsRead, - markAsUnread, - toggleFavorite, - toggleLowPriority, - invite, - copyRoomLink, - leaveRoom, - setRoomNotifState, - }; -} diff --git a/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx b/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx deleted file mode 100644 index 30576e2dc2..0000000000 --- a/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx +++ /dev/null @@ -1,250 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import { useCallback, useEffect, useMemo, useState } from "react"; -import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; -import { CallType } from "matrix-js-sdk/src/webrtc/call"; - -import dispatcher from "../../../dispatcher/dispatcher"; -import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import { Action } from "../../../dispatcher/actions"; -import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils"; -import { _t } from "../../../languageHandler"; -import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState"; -import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; -import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; -import { useEventEmitter, useEventEmitterState, useTypedEventEmitter } from "../../../hooks/useEventEmitter"; -import { DefaultTagID } from "../../../stores/room-list/models"; -import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall"; -import { CallEvent, type ConnectionState } from "../../../models/Call"; -import { NotificationStateEvents } from "../../../stores/notifications/NotificationState"; -import DMRoomMap from "../../../utils/DMRoomMap"; -import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; -import { useMessagePreviewToggle } from "./useMessagePreviewToggle"; - -export interface RoomListItemViewState { - /** - * The name of the room. - */ - name: string; - /** - * Whether the context menu should be shown. - */ - showContextMenu: boolean; - /** - * Whether the hover menu should be shown. - */ - showHoverMenu: boolean; - /** - * Open the room having given roomId. - */ - openRoom: () => void; - /** - * The a11y label for the room list item. - */ - a11yLabel: string; - /** - * The notification state of the room. - */ - notificationState: RoomNotificationState; - /** - * Whether the room should be bolded. - */ - isBold: boolean; - /** - * Whether the room is a video room - */ - isVideoRoom: boolean; - /** - * The connection state of the call. - * `null` if there is no call in the room. - */ - callConnectionState: ConnectionState | null; - /** - * Whether there are participants in the call. - */ - hasParticipantInCall: boolean; - /** - * Whether the call is a voice or video call. - */ - callType: CallType | undefined; - /** - * Pre-rendered and translated preview for the latest message in the room, or undefined - * if no preview should be shown. - */ - messagePreview: string | undefined; - /** - * Whether the notification decoration should be shown. - */ - showNotificationDecoration: boolean; -} - -/** - * View model for the room list item - * @see {@link RoomListItemViewState} for more information about what this view model returns. - */ -export function useRoomListItemViewModel(room: Room): RoomListItemViewState { - const matrixClient = useMatrixClientContext(); - const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags); - const isArchived = Boolean(roomTags[DefaultTagID.Archived]); - const name = useEventEmitterState(room, RoomEvent.Name, () => room.name); - - const notificationState = useMemo(() => RoomNotificationStateStore.instance.getRoomState(room), [room]); - - const [a11yLabel, setA11yLabel] = useState(getA11yLabel(name, notificationState)); - const [{ isBold, invited, hasVisibleNotification }, setNotificationValues] = useState( - getNotificationValues(notificationState), - ); - useEffect(() => { - setA11yLabel(getA11yLabel(name, notificationState)); - }, [name, notificationState]); - - // Listen to changes in the notification state and update the values - useTypedEventEmitter(notificationState, NotificationStateEvents.Update, () => { - setA11yLabel(getA11yLabel(name, notificationState)); - setNotificationValues(getNotificationValues(notificationState)); - }); - - // If the notification reference change due to room change, update the values - useEffect(() => { - setNotificationValues(getNotificationValues(notificationState)); - }, [notificationState]); - - // We don't want to show the menus if - // - there is an invitation for this room - // - the user doesn't have access to notification and more options menus - const showContextMenu = !invited && hasAccessToOptionsMenu(room); - const showHoverMenu = - !invited && (showContextMenu || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived)); - - const messagePreview = useRoomMessagePreview(room); - - // Video room - const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom(); - // EC video call or video room - const call = useCall(room.roomId); - const connectionState = useConnectionState(call); - const participantCount = useParticipantCount(call); - const callConnectionState = call ? connectionState : null; - - const showNotificationDecoration = hasVisibleNotification || participantCount > 0; - - // Actions - - const openRoom = useCallback((): void => { - dispatcher.dispatch({ - action: Action.ViewRoom, - room_id: room.roomId, - metricsTrigger: "RoomList", - }); - }, [room]); - - const [callType, setCallType] = useState(CallType.Video); - useTypedEventEmitter(call ?? undefined, CallEvent.CallTypeChanged, setCallType); - - return { - name, - notificationState, - showContextMenu, - showHoverMenu, - openRoom, - a11yLabel, - isBold, - isVideoRoom, - callConnectionState, - hasParticipantInCall: participantCount > 0, - messagePreview, - showNotificationDecoration, - callType: call ? callType : undefined, - }; -} - -/** - * Calculate the values from the notification state - * @param notificationState - */ -function getNotificationValues(notificationState: RoomNotificationState): { - computeA11yLabel: (name: string) => string; - isBold: boolean; - invited: boolean; - hasVisibleNotification: boolean; -} { - const invited = notificationState.invited; - const computeA11yLabel = (name: string): string => getA11yLabel(name, notificationState); - const isBold = notificationState.hasAnyNotificationOrActivity; - - const hasVisibleNotification = notificationState.hasAnyNotificationOrActivity || notificationState.muted; - - return { - computeA11yLabel, - isBold, - invited, - hasVisibleNotification, - }; -} - -/** - * Get the a11y label for the room list item - * @param roomName - * @param notificationState - */ -function getA11yLabel(roomName: string, notificationState: RoomNotificationState): string { - if (notificationState.isUnsentMessage) { - return _t("a11y|room_messsage_not_sent", { - roomName, - }); - } else if (notificationState.invited) { - return _t("a11y|room_n_unread_invite", { - roomName, - }); - } else if (notificationState.isMention) { - return _t("a11y|room_n_unread_messages_mentions", { - roomName, - count: notificationState.count, - }); - } else if (notificationState.hasUnreadCount) { - return _t("a11y|room_n_unread_messages", { - roomName, - count: notificationState.count, - }); - } else { - return _t("room_list|room|open_room", { roomName }); - } -} - -function useRoomMessagePreview(room: Room): string | undefined { - const { shouldShowMessagePreview } = useMessagePreviewToggle(); - const [previewText, setPreviewText] = useState(undefined); - - const updatePreview = useCallback(async () => { - if (!shouldShowMessagePreview) { - setPreviewText(undefined); - return; - } - - const roomIsDM = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId)); - // For the tag, we only care about whether the room is a DM or not as we don't show - // display names in previewsd for DMs, so anything else we just say is 'untagged' - // (even though it could actually be have other tags: we don't care about them). - const messagePreview = await MessagePreviewStore.instance.getPreviewForRoom( - room, - roomIsDM ? DefaultTagID.DM : DefaultTagID.Untagged, - ); - setPreviewText(messagePreview?.text); - }, [room, shouldShowMessagePreview]); - - // MessagePreviewStore and the other AsyncStores need to be converted to TypedEventEmitter - useEventEmitter(MessagePreviewStore.instance, MessagePreviewStore.getPreviewChangedEventName(room), () => { - updatePreview(); - }); - - useEffect(() => { - updatePreview(); - }, [updatePreview]); - - return previewText; -} diff --git a/src/components/viewmodels/roomlist/RoomListViewModel.tsx b/src/components/viewmodels/roomlist/RoomListViewModel.tsx deleted file mode 100644 index a48d973b23..0000000000 --- a/src/components/viewmodels/roomlist/RoomListViewModel.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/* -Copyright 2025 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { useCallback } from "react"; - -import type { Room } from "matrix-js-sdk/src/matrix"; -import { type PrimaryFilter, useFilteredRooms } from "./useFilteredRooms"; -import { createRoom as createRoomFunc, hasCreateRoomRights } from "./utils"; -import { useEventEmitterState } from "../../../hooks/useEventEmitter"; -import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces"; -import SpaceStore from "../../../stores/spaces/SpaceStore"; -import dispatcher from "../../../dispatcher/dispatcher"; -import { Action } from "../../../dispatcher/actions"; -import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; -import { useStickyRoomList } from "./useStickyRoomList"; -import { useRoomListNavigation } from "./useRoomListNavigation"; -import { type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3"; - -export interface RoomListViewState { - /** - * Whether the list of rooms is being loaded. - */ - isLoadingRooms: boolean; - - /** - * The room results to be displayed (along with the spaceId and filter keys at the time of query) - */ - roomsResult: RoomsResult; - - /** - * Create a chat room - * @param e - The click event - */ - createChatRoom: () => void; - - /** - * Whether the user can create a room in the current space - */ - canCreateRoom: boolean; - - /** - * Create a room - * @param e - The click event - */ - createRoom: () => void; - - /** - * A list of objects that provide the view enough information - * to render primary room filters. - */ - primaryFilters: PrimaryFilter[]; - - /** - * The currently active primary filter. - * If no primary filter is active, this will be undefined. - */ - activePrimaryFilter?: PrimaryFilter; - - /** - * The index of the active room in the room list. - */ - activeIndex: number | undefined; -} - -/** - * View model for the new room list - * @see {@link RoomListViewState} for more information about what this view model returns. - */ -export function useRoomListViewModel(): RoomListViewState { - const matrixClient = useMatrixClientContext(); - const { isLoadingRooms, primaryFilters, activePrimaryFilter, roomsResult: filteredRooms } = useFilteredRooms(); - const { activeIndex, roomsResult } = useStickyRoomList(filteredRooms); - - useRoomListNavigation(roomsResult.rooms); - - const currentSpace = useEventEmitterState( - SpaceStore.instance, - UPDATE_SELECTED_SPACE, - () => SpaceStore.instance.activeSpaceRoom, - ); - const canCreateRoom = hasCreateRoomRights(matrixClient, currentSpace); - - const createChatRoom = useCallback(() => dispatcher.fire(Action.CreateChat), []); - const createRoom = useCallback(() => createRoomFunc(currentSpace), [currentSpace]); - - return { - isLoadingRooms, - roomsResult, - canCreateRoom, - createRoom, - createChatRoom, - primaryFilters, - activePrimaryFilter, - activeIndex, - }; -} diff --git a/src/components/viewmodels/roomlist/useFilteredRooms.tsx b/src/components/viewmodels/roomlist/useFilteredRooms.tsx deleted file mode 100644 index a0e36dc668..0000000000 --- a/src/components/viewmodels/roomlist/useFilteredRooms.tsx +++ /dev/null @@ -1,131 +0,0 @@ -/* -Copyright 2025 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { useCallback, useEffect, useMemo, useState } from "react"; - -import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters"; -import { _t, _td } from "../../../languageHandler"; -import RoomListStoreV3, { - LISTS_LOADED_EVENT, - LISTS_UPDATE_EVENT, - type RoomsResult, -} from "../../../stores/room-list-v3/RoomListStoreV3"; -import { useEventEmitter } from "../../../hooks/useEventEmitter"; - -/** - * Provides information about a primary filter. - * A primary filter is a commonly used filter that is given - * more precedence in the UI. For eg, primary filters may be - * rendered as pills above the room list. - */ -export interface PrimaryFilter { - // A function to toggle this filter on and off. - toggle: () => void; - // Whether this filter is currently applied - active: boolean; - // Text that can be used in the UI to represent this filter. - name: string; - // The key of the filter - key: FilterKey; -} - -interface FilteredRooms { - primaryFilters: PrimaryFilter[]; - isLoadingRooms: boolean; - roomsResult: RoomsResult; - /** - * The currently active primary filter. - * If no primary filter is active, this will be undefined. - */ - activePrimaryFilter?: PrimaryFilter; -} - -const filterKeyToNameMap: Map = new Map([ - [FilterKey.UnreadFilter, _td("room_list|filters|unread")], - [FilterKey.PeopleFilter, _td("room_list|filters|people")], - [FilterKey.RoomsFilter, _td("room_list|filters|rooms")], - [FilterKey.FavouriteFilter, _td("room_list|filters|favourite")], - [FilterKey.MentionsFilter, _td("room_list|filters|mentions")], - [FilterKey.InvitesFilter, _td("room_list|filters|invites")], - [FilterKey.LowPriorityFilter, _td("room_list|filters|low_priority")], -]); - -/** - * Track available filters and provide a filtered list of rooms. - */ -export function useFilteredRooms(): FilteredRooms { - /** - * Primary filter refers to the pill based filters - * rendered above the room list. - */ - const [primaryFilter, setPrimaryFilter] = useState(); - - const [roomsResult, setRoomsResult] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace()); - const [isLoadingRooms, setIsLoadingRooms] = useState(() => RoomListStoreV3.instance.isLoadingRooms); - - const updateRoomsFromStore = useCallback((filters: FilterKey[] = []): void => { - const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters); - setRoomsResult(newRooms); - }, []); - - const filterUndefined = (array: (FilterKey | undefined)[]): FilterKey[] => - array.filter((f) => f !== undefined) as FilterKey[]; - - const getAppliedFilters = useCallback((): FilterKey[] => { - return filterUndefined([primaryFilter]); - }, [primaryFilter]); - - useEffect(() => { - // Update the rooms state when the primary filter changes - const filters = getAppliedFilters(); - updateRoomsFromStore(filters); - }, [getAppliedFilters, updateRoomsFromStore]); - - useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => { - const filters = getAppliedFilters(); - updateRoomsFromStore(filters); - }); - - useEventEmitter(RoomListStoreV3.instance, LISTS_LOADED_EVENT, () => { - setIsLoadingRooms(false); - }); - - /** - * This tells the view which primary filters are available, how to toggle them - * and whether a given primary filter is active. @see {@link PrimaryFilter} - */ - const primaryFilters = useMemo(() => { - const createPrimaryFilter = (key: FilterKey, name: string): PrimaryFilter => { - return { - toggle: () => { - setPrimaryFilter((currentFilter) => { - const filter = currentFilter === key ? undefined : key; - updateRoomsFromStore(filterUndefined([filter])); - return filter; - }); - }, - active: primaryFilter === key, - name, - key, - }; - }; - const filters: PrimaryFilter[] = []; - for (const [key, name] of filterKeyToNameMap.entries()) { - filters.push(createPrimaryFilter(key, _t(name))); - } - return filters; - }, [primaryFilter, updateRoomsFromStore]); - - const activePrimaryFilter = useMemo(() => primaryFilters.find((filter) => filter.active), [primaryFilters]); - - return { - isLoadingRooms, - primaryFilters, - activePrimaryFilter, - roomsResult, - }; -} diff --git a/src/components/viewmodels/roomlist/useMessagePreviewToggle.tsx b/src/components/viewmodels/roomlist/useMessagePreviewToggle.tsx deleted file mode 100644 index efb58b3e04..0000000000 --- a/src/components/viewmodels/roomlist/useMessagePreviewToggle.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ -import { useCallback } from "react"; - -import SettingsStore from "../../../settings/SettingsStore"; -import { SettingLevel } from "../../../settings/SettingLevel"; -import { useSettingValue } from "../../../hooks/useSettings"; - -interface MessagePreviewToggleState { - shouldShowMessagePreview: boolean; - toggleMessagePreview: () => void; -} - -/** - * This hook: - * - Provides a state that tracks whether message previews are turned on or off. - * - Provides a function to toggle message previews. - */ -export function useMessagePreviewToggle(): MessagePreviewToggleState { - const shouldShowMessagePreview = useSettingValue("RoomList.showMessagePreview"); - - const toggleMessagePreview = useCallback((): void => { - const toggled = !shouldShowMessagePreview; - SettingsStore.setValue("RoomList.showMessagePreview", null, SettingLevel.DEVICE, toggled); - }, [shouldShowMessagePreview]); - - return { toggleMessagePreview, shouldShowMessagePreview }; -} diff --git a/src/components/viewmodels/roomlist/useRoomListNavigation.ts b/src/components/viewmodels/roomlist/useRoomListNavigation.ts deleted file mode 100644 index 5ef979e79c..0000000000 --- a/src/components/viewmodels/roomlist/useRoomListNavigation.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import { type Room } from "matrix-js-sdk/src/matrix"; - -import dispatcher from "../../../dispatcher/dispatcher"; -import { useDispatcher } from "../../../hooks/useDispatcher"; -import { Action } from "../../../dispatcher/actions"; -import { type ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload"; -import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import { SdkContextClass } from "../../../contexts/SDKContext"; -import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; - -/** - * Hook to navigate the room list using keyboard shortcuts. - * It listens to the ViewRoomDelta action and updates the room list accordingly. - * @param rooms - */ -export function useRoomListNavigation(rooms: Room[]): void { - useDispatcher(dispatcher, (payload) => { - if (payload.action !== Action.ViewRoomDelta) return; - const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); - if (!roomId) return; - - const { delta, unread } = payload as ViewRoomDeltaPayload; - const filteredRooms = unread - ? // Filter the rooms to only include unread ones and the active room - rooms.filter((room) => { - const state = RoomNotificationStateStore.instance.getRoomState(room); - return room.roomId === roomId || state.isUnread; - }) - : rooms; - - const currentIndex = filteredRooms.findIndex((room) => room.roomId === roomId); - if (currentIndex === -1) return; - - // Get the next/previous new room according to the delta - // Use slice to loop on the list - // If delta is -1 at the start of the list, it will go to the end - // If delta is 1 at the end of the list, it will go to the start - const [newRoom] = filteredRooms.slice((currentIndex + delta) % filteredRooms.length); - if (!newRoom) return; - - dispatcher.dispatch({ - action: Action.ViewRoom, - room_id: newRoom.roomId, - show_room_tile: true, // to make sure the room gets scrolled into view - metricsTrigger: "WebKeyboardShortcut", - metricsViaKeyboard: true, - }); - }); -} diff --git a/src/components/viewmodels/roomlist/useStickyRoomList.tsx b/src/components/viewmodels/roomlist/useStickyRoomList.tsx deleted file mode 100644 index 355e09a292..0000000000 --- a/src/components/viewmodels/roomlist/useStickyRoomList.tsx +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import { useCallback, useEffect, useRef, useState } from "react"; - -import { SdkContextClass } from "../../../contexts/SDKContext"; -import { useDispatcher } from "../../../hooks/useDispatcher"; -import dispatcher from "../../../dispatcher/dispatcher"; -import { Action } from "../../../dispatcher/actions"; -import type { Room } from "matrix-js-sdk/src/matrix"; -import SpaceStore from "../../../stores/spaces/SpaceStore"; -import { type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3"; - -function getIndexByRoomId(rooms: Room[], roomId: string): number | undefined { - const index = rooms.findIndex((room) => room.roomId === roomId); - return index === -1 ? undefined : index; -} - -function getRoomsWithStickyRoom( - rooms: Room[], - oldIndex: number | undefined, - newIndex: number | undefined, - isRoomChange: boolean, -): { newRooms: Room[]; newIndex: number | undefined } { - const updated = { newIndex, newRooms: rooms }; - if (isRoomChange) { - /* - * When opening another room, the index should obviously change. - */ - return updated; - } - if (newIndex === undefined || oldIndex === undefined) { - /* - * If oldIndex is undefined, then there was no active room before. - * So nothing to do in regards to sticky room. - * Similarly, if newIndex is undefined, there's no active room anymore. - */ - return updated; - } - if (newIndex === oldIndex) { - /* - * If the index hasn't changed, we have nothing to do. - */ - return updated; - } - if (oldIndex > rooms.length - 1) { - /* - * If the old index falls out of the bounds of the rooms array - * (usually because rooms were removed), we can no longer place - * the active room in the same old index. - */ - return updated; - } - - /* - * Making the active room sticky is as simple as removing it from - * its new index and placing it in the old index. - */ - const newRooms = [...rooms]; - const [newRoom] = newRooms.splice(newIndex, 1); - newRooms.splice(oldIndex, 0, newRoom); - - return { newIndex: oldIndex, newRooms }; -} - -export interface StickyRoomListResult { - /** - * The rooms result with the active sticky room applied - */ - roomsResult: RoomsResult; - /** - * Index of the active room in the room list. - */ - activeIndex: number | undefined; -} - -/** - * - Provides a list of rooms such that the active room is sticky i.e the active room is kept - * in the same index even when the order of rooms in the list changes. - * - Provides the index of the active room. - * @param rooms list of rooms - * @see {@link StickyRoomListResult} details what this hook returns.. - */ -export function useStickyRoomList(roomsResult: RoomsResult): StickyRoomListResult { - const [listState, setListState] = useState({ - activeIndex: getIndexByRoomId(roomsResult.rooms, SdkContextClass.instance.roomViewStore.getRoomId()!), - roomsResult: roomsResult, - }); - - const currentSpaceRef = useRef(SpaceStore.instance.activeSpace); - - const updateRoomsAndIndex = useCallback( - (newRoomId: string | null, isRoomChange: boolean = false) => { - setListState((current) => { - const activeRoomId = newRoomId ?? SdkContextClass.instance.roomViewStore.getRoomId(); - const newActiveIndex = getIndexByRoomId(roomsResult.rooms, activeRoomId!); - const oldIndex = current.activeIndex; - const { newIndex, newRooms } = getRoomsWithStickyRoom( - roomsResult.rooms, - oldIndex, - newActiveIndex, - isRoomChange, - ); - return { activeIndex: newIndex, roomsResult: { ...roomsResult, rooms: newRooms } }; - }); - }, - [roomsResult], - ); - - // Re-calculate the index when the active room has changed. - useDispatcher(dispatcher, (payload) => { - if (payload.action === Action.ActiveRoomChanged) updateRoomsAndIndex(payload.newRoomId, true); - }); - - // Re-calculate the index when the list of rooms has changed. - useEffect(() => { - let newRoomId: string | null = null; - let isRoomChange = false; - if (currentSpaceRef.current !== roomsResult.spaceId) { - /* - If the space has changed, we check if we can immediately set the active - index to the last opened room in that space. Otherwise, we might see a - flicker because of the delay between the space change event and - active room change dispatch. - */ - newRoomId = SpaceStore.instance.getLastSelectedRoomIdForSpace(roomsResult.spaceId); - isRoomChange = true; - currentSpaceRef.current = roomsResult.spaceId; - } - updateRoomsAndIndex(newRoomId, isRoomChange); - }, [roomsResult, updateRoomsAndIndex]); - - return listState; -} diff --git a/src/components/views/rooms/RoomListPanel/EmptyRoomList.tsx b/src/components/views/rooms/RoomListPanel/EmptyRoomList.tsx deleted file mode 100644 index 8c1d04b8c5..0000000000 --- a/src/components/views/rooms/RoomListPanel/EmptyRoomList.tsx +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import React, { type JSX, type PropsWithChildren } from "react"; -import { Button } from "@vector-im/compound-web"; -import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat"; -import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room"; -import { Flex } from "@element-hq/web-shared-components"; - -import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel"; -import { _t } from "../../../../languageHandler"; -import { FilterKey } from "../../../../stores/room-list-v3/skip-list/filters"; -import { type PrimaryFilter } from "../../../viewmodels/roomlist/useFilteredRooms"; - -interface EmptyRoomListProps { - /** - * The view model for the room list - */ - vm: RoomListViewState; -} - -/** - * The empty state for the room list - */ -export function EmptyRoomList({ vm }: EmptyRoomListProps): JSX.Element | undefined { - // If there is no active primary filter, show the default empty state - if (!vm.activePrimaryFilter) return ; - - switch (vm.activePrimaryFilter.key) { - case FilterKey.FavouriteFilter: - return ( - - ); - case FilterKey.PeopleFilter: - return ( - - ); - case FilterKey.RoomsFilter: - return ( - - ); - case FilterKey.UnreadFilter: - return ( - - ); - case FilterKey.InvitesFilter: - return ( - - ); - case FilterKey.MentionsFilter: - return ( - - ); - case FilterKey.LowPriorityFilter: - return ( - - ); - default: - return undefined; - } -} - -interface GenericPlaceholderProps { - /** - * The title of the placeholder - */ - title: string; - /** - * The description of the placeholder - */ - description?: string; -} - -/** - * A generic placeholder for the room list - */ -function GenericPlaceholder({ title, description, children }: PropsWithChildren): JSX.Element { - return ( - - {title} - {description && {description}} - {children} - - ); -} - -interface DefaultPlaceholderProps { - /** - * The view model for the room list - */ - vm: RoomListViewState; -} - -/** - * The default empty state for the room list when no primary filter is active - * The user can create chat or room (if they have the permission) - */ -function DefaultPlaceholder({ vm }: DefaultPlaceholderProps): JSX.Element { - return ( - - - - {vm.canCreateRoom && ( - - )} - - - ); -} - -interface ActionPlaceholderProps { - filter: PrimaryFilter; - title: string; - action: string; -} - -/** - * A placeholder for the room list when a filter is active - * The user can take action to toggle the filter - */ -function ActionPlaceholder({ filter, title, action }: ActionPlaceholderProps): JSX.Element { - return ( - - - - ); -} diff --git a/src/components/views/rooms/RoomListPanel/RoomList.tsx b/src/components/views/rooms/RoomListPanel/RoomList.tsx deleted file mode 100644 index c946695b39..0000000000 --- a/src/components/views/rooms/RoomListPanel/RoomList.tsx +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import React, { useCallback, useRef, type JSX, useMemo } from "react"; -import { type Room } from "matrix-js-sdk/src/matrix"; -import { isEqual } from "lodash"; -import { - type VirtualizedListContext, - VirtualizedList, - type ScrollIntoViewOnChange, -} from "@element-hq/web-shared-components"; - -import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel"; -import { _t } from "../../../../languageHandler"; -import { RoomListItemView } from "./RoomListItemView"; -import { type FilterKey } from "../../../../stores/room-list-v3/skip-list/filters"; -import { getKeyBindingsManager } from "../../../../KeyBindingsManager"; -import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts"; -import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation"; - -interface RoomListProps { - /** - * The view model state for the room list. - */ - vm: RoomListViewState; -} - -type Context = { - spaceId: string; - filterKeys: FilterKey[] | undefined; -}; - -/** - * Height of a single room list item - */ -const ROOM_LIST_ITEM_HEIGHT = 48; -/** - * Amount to extend the top and bottom of the viewport by. - * From manual testing and user feedback 25 items is reported to be enough to avoid blank space when using the mouse wheel, - * and the trackpad scrolling at a slow to moderate speed where you can still see/read the content. - * Using the trackpad to sling through a large percentage of the list quickly will still show blank space. - * We would likely need to simplify the item content to improve this case. - */ -const EXTENDED_VIEWPORT_HEIGHT = 25 * ROOM_LIST_ITEM_HEIGHT; -/** - * A virtualized list of rooms. - */ -export function RoomList({ vm: { roomsResult, activeIndex } }: RoomListProps): JSX.Element { - const lastSpaceId = useRef(undefined); - const lastFilterKeys = useRef(undefined); - const roomCount = roomsResult.rooms.length; - const getItemComponent = useCallback( - ( - index: number, - item: Room, - context: VirtualizedListContext, - onFocus: (item: Room, e: React.FocusEvent) => void, - ): JSX.Element => { - const itemKey = item.roomId; - const isRovingItem = itemKey === context.tabIndexKey; - const isFocused = isRovingItem && context.focused; - const isSelected = activeIndex === index; - return ( - - ); - }, - [activeIndex, roomCount], - ); - - const getItemKey = useCallback((item: Room): string => { - return item.roomId; - }, []); - - const scrollIntoViewOnChange = useCallback>( - (params) => { - const { spaceId, filterKeys } = params.context.context; - const shouldScrollIndexIntoView = - lastSpaceId.current !== spaceId || !isEqual(lastFilterKeys.current, filterKeys); - lastFilterKeys.current = filterKeys; - lastSpaceId.current = spaceId; - - if (shouldScrollIndexIntoView) { - return { - align: `start`, - index: activeIndex || 0, - behavior: "auto", - }; - } - return false; - }, - [activeIndex], - ); - - const keyDownCallback = useCallback((ev: React.KeyboardEvent) => { - const navAction = getKeyBindingsManager().getNavigationAction(ev); - if (navAction === KeyBindingAction.NextLandmark || navAction === KeyBindingAction.PreviousLandmark) { - LandmarkNavigation.findAndFocusNextLandmark( - Landmark.ROOM_LIST, - navAction === KeyBindingAction.PreviousLandmark, - ); - ev.stopPropagation(); - ev.preventDefault(); - return; - } - }, []); - const context = useMemo( - () => ({ spaceId: roomsResult.spaceId, filterKeys: roomsResult.filterKeys }), - [roomsResult.spaceId, roomsResult.filterKeys], - ); - - return ( - true} - onKeyDown={keyDownCallback} - increaseViewportBy={{ - bottom: EXTENDED_VIEWPORT_HEIGHT, - top: EXTENDED_VIEWPORT_HEIGHT, - }} - /> - ); -} diff --git a/src/components/views/rooms/RoomListPanel/RoomListItemContextMenuView.tsx b/src/components/views/rooms/RoomListPanel/RoomListItemContextMenuView.tsx deleted file mode 100644 index f3ba4167e7..0000000000 --- a/src/components/views/rooms/RoomListPanel/RoomListItemContextMenuView.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import { type Room } from "matrix-js-sdk/src/matrix"; -import { type JSX, type PropsWithChildren } from "react"; -import { ContextMenu } from "@vector-im/compound-web"; -import React from "react"; - -import { _t } from "../../../../languageHandler"; -import { MoreOptionContent } from "./RoomListItemMenuView"; -import { useRoomListItemMenuViewModel } from "../../../viewmodels/roomlist/RoomListItemMenuViewModel"; - -interface RoomListItemContextMenuViewProps { - /** - * The room to display the menu for. - */ - room: Room; -} - -/** - * A view for the room list item context menu. - */ -export function RoomListItemContextMenuView({ - room, - children, -}: PropsWithChildren): JSX.Element { - const vm = useRoomListItemMenuViewModel(room); - - return ( - - - - ); -} diff --git a/src/components/views/rooms/RoomListPanel/RoomListItemMenuView.tsx b/src/components/views/rooms/RoomListPanel/RoomListItemMenuView.tsx deleted file mode 100644 index 7c5dd5ba1a..0000000000 --- a/src/components/views/rooms/RoomListPanel/RoomListItemMenuView.tsx +++ /dev/null @@ -1,242 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import React, { type JSX, useState } from "react"; -import { IconButton, Menu, MenuItem, Separator, ToggleMenuItem } from "@vector-im/compound-web"; -import { - MarkAsReadIcon, - MarkAsUnreadIcon, - FavouriteIcon, - ArrowDownIcon, - UserAddIcon, - LinkIcon, - LeaveIcon, - OverflowHorizontalIcon, - NotificationsSolidIcon, - NotificationsOffSolidIcon, - CheckIcon, -} from "@vector-im/compound-design-tokens/assets/web/icons"; -import { type Room } from "matrix-js-sdk/src/matrix"; -import { Flex } from "@element-hq/web-shared-components"; -import classNames from "classnames"; - -import { _t } from "../../../../languageHandler"; -import { - type RoomListItemMenuViewState, - useRoomListItemMenuViewModel, -} from "../../../viewmodels/roomlist/RoomListItemMenuViewModel"; -import { RoomNotifState } from "../../../../RoomNotifs"; - -interface RoomListItemMenuViewProps { - /** - * Additional class name for the root element. - */ - className?: string; - - /** - * The room to display the menu for. - */ - room: Room; -} - -/** - * A view for the room list item menu. - */ -export function RoomListItemMenuView({ room, className }: RoomListItemMenuViewProps): JSX.Element { - const vm = useRoomListItemMenuViewModel(room); - - return ( - - {vm.showMoreOptionsMenu && } - {vm.showNotificationMenu && } - - ); -} - -interface MoreOptionsMenuProps { - /** - * The view model state for the menu. - */ - vm: RoomListItemMenuViewState; -} - -/** - * The more options menu for the room list item. - */ -function MoreOptionsMenu({ vm }: MoreOptionsMenuProps): JSX.Element { - const [open, setOpen] = useState(false); - - return ( - - - - } - > - - - ); -} - -interface MoreOptionContentProps { - /** - * The view model state for the menu. - */ - vm: RoomListItemMenuViewState; -} - -export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element { - return ( -
e.stopPropagation()} - > - {vm.canMarkAsRead && ( - evt.stopPropagation()} - hideChevron={true} - /> - )} - {vm.canMarkAsUnread && ( - evt.stopPropagation()} - hideChevron={true} - /> - )} - evt.stopPropagation()} - /> - evt.stopPropagation()} - /> - {vm.canInvite && ( - evt.stopPropagation()} - hideChevron={true} - /> - )} - {vm.canCopyRoomLink && ( - evt.stopPropagation()} - hideChevron={true} - /> - )} - - evt.stopPropagation()} - hideChevron={true} - /> -
- ); -} - -interface NotificationMenuProps { - /** - * The view model state for the menu. - */ - vm: RoomListItemMenuViewState; -} - -function NotificationMenu({ vm }: NotificationMenuProps): JSX.Element { - const [open, setOpen] = useState(false); - const checkComponent = ; - - return ( - - {vm.isNotificationMute ? : } - - } - > -
e.stopPropagation()} - > - vm.setRoomNotifState(RoomNotifState.AllMessages)} - onClick={(evt) => evt.stopPropagation()} - > - {vm.isNotificationAllMessage && checkComponent} - - vm.setRoomNotifState(RoomNotifState.AllMessagesLoud)} - onClick={(evt) => evt.stopPropagation()} - > - {vm.isNotificationAllMessageLoud && checkComponent} - - vm.setRoomNotifState(RoomNotifState.MentionsOnly)} - onClick={(evt) => evt.stopPropagation()} - > - {vm.isNotificationMentionOnly && checkComponent} - - vm.setRoomNotifState(RoomNotifState.Mute)} - onClick={(evt) => evt.stopPropagation()} - > - {vm.isNotificationMute && checkComponent} - -
-
- ); -} diff --git a/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx b/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx deleted file mode 100644 index d87da9c034..0000000000 --- a/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import React, { type JSX, memo, useEffect, useRef } from "react"; -import { type Room } from "matrix-js-sdk/src/matrix"; -import classNames from "classnames"; -import { Flex } from "@element-hq/web-shared-components"; - -import { useRoomListItemViewModel } from "../../../viewmodels/roomlist/RoomListItemViewModel"; -import { RoomListItemMenuView } from "./RoomListItemMenuView"; -import { NotificationDecoration } from "../NotificationDecoration"; -import { RoomAvatarView } from "../../avatars/RoomAvatarView"; -import { RoomListItemContextMenuView } from "./RoomListItemContextMenuView"; - -interface RoomListItemViewProps extends Omit, "onFocus"> { - /** - * The room to display - */ - room: Room; - /** - * Whether the room is selected - */ - isSelected: boolean; - /** - * Whether the room is focused - */ - isFocused: boolean; - /** - * A callback that indicates the item has received focus - */ - onFocus: (room: Room, e: React.FocusEvent) => void; - /** - * The index of the room in the list - */ - roomIndex: number; - /** - * The total number of rooms in the list - */ - roomCount: number; -} - -/** - * An item in the room list - */ -export const RoomListItemView = memo(function RoomListItemView({ - room, - isSelected, - isFocused, - onFocus, - roomIndex: index, - roomCount: count, - ...props -}: RoomListItemViewProps): JSX.Element { - const ref = useRef(null); - const vm = useRoomListItemViewModel(room); - - useEffect(() => { - if (isFocused) { - ref.current?.focus({ preventScroll: true, focusVisible: true }); - } - }, [isFocused]); - - const content = ( - vm.openRoom()} - onFocus={(e: React.FocusEvent) => onFocus(room, e)} - tabIndex={isFocused ? 0 : -1} - {...props} - > - - - {/* We truncate the room name when too long. Title here is to show the full name on hover */} -
-
- {vm.name} -
- {vm.messagePreview && ( -
- {vm.messagePreview} -
- )} -
- {vm.showHoverMenu && } - - {/* aria-hidden because we summarise the unread count/notification status in a11yLabel variable */} - {vm.showNotificationDecoration && ( - - )} -
-
- ); - - if (!vm.showContextMenu) return content; - return {content}; -}); diff --git a/src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters.tsx b/src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters.tsx deleted file mode 100644 index 44f19a86da..0000000000 --- a/src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters.tsx +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import React, { type JSX, useEffect, useId, useRef, useState, type RefObject } from "react"; -import { ChatFilter, IconButton } from "@vector-im/compound-web"; -import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down"; -import { Flex } from "@element-hq/web-shared-components"; - -import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel"; -import { _t } from "../../../../languageHandler"; - -interface RoomListPrimaryFiltersProps { - /** - * The view model for the room list - */ - vm: RoomListViewState; -} - -/** - * The primary filters for the room list - */ -export function RoomListPrimaryFilters({ vm }: RoomListPrimaryFiltersProps): JSX.Element { - const id = useId(); - const [isExpanded, setIsExpanded] = useState(false); - - const { ref, isWrapping: displayChevron, wrappingIndex } = useCollapseFilters(isExpanded); - const filters = useVisibleFilters(vm.primaryFilters, wrappingIndex); - - return ( - - {displayChevron && ( - setIsExpanded((_expanded) => !_expanded)} - > - - - )} - - {filters.map((filter, i) => ( - filter.toggle()}> - {filter.name} - - ))} - - - ); -} - -/** - * A hook to manage the wrapping of filters in the room list. - * It observes the filter list and hides filters that are wrapping when the list is not expanded. - * @param isExpanded - * @returns an object containing: - * - `ref`: a ref to put on the filter list element - * - `isWrapping`: a boolean indicating if the filters are wrapping - * - `wrappingIndex`: the index of the first filter that is wrapping - */ -function useCollapseFilters( - isExpanded: boolean, -): { ref: RefObject; isWrapping: boolean; wrappingIndex: number } { - const ref = useRef(null); - const [isWrapping, setIsWrapping] = useState(false); - const [wrappingIndex, setWrappingIndex] = useState(-1); - - useEffect(() => { - if (!ref.current) return; - - const hideFilters = (list: Element): void => { - let isWrapping = false; - Array.from(list.children).forEach((node, i): void => { - const child = node as HTMLElement; - const wrappingClass = "mx_RoomListPrimaryFilters_wrapping"; - child.setAttribute("aria-hidden", "false"); - child.classList.remove(wrappingClass); - - // If the filter list is expanded, all filters are visible - if (isExpanded) return; - - // If the previous element is on the left element of the current one, it means that the filter is wrapping - const previousSibling = child.previousElementSibling as HTMLElement | null; - if (previousSibling && child.offsetLeft <= previousSibling.offsetLeft) { - if (!isWrapping) setWrappingIndex(i); - isWrapping = true; - } - - // If the filter is wrapping, we hide it - child.classList.toggle(wrappingClass, isWrapping); - child.setAttribute("aria-hidden", isWrapping.toString()); - }); - - if (!isWrapping) setWrappingIndex(-1); - setIsWrapping(isExpanded || isWrapping); - }; - - hideFilters(ref.current); - const observer = new ResizeObserver((entries) => entries.forEach((entry) => hideFilters(entry.target))); - - observer.observe(ref.current); - return () => { - observer.disconnect(); - }; - }, [isExpanded]); - - return { ref, isWrapping, wrappingIndex }; -} - -/** - * A hook to sort the filters by active state. - * The list is sorted if the current filter index is greater than or equal to the wrapping index. - * If the wrapping index is -1, the filters are not sorted. - * - * @param filters - the list of filters to sort. - * @param wrappingIndex - the index of the first filter that is wrapping. - */ -export function useVisibleFilters( - filters: RoomListViewState["primaryFilters"], - wrappingIndex: number, -): RoomListViewState["primaryFilters"] { - // By default, the filters are not sorted - const [sortedFilters, setSortedFilters] = useState(filters); - - useEffect(() => { - const isActiveFilterWrapping = filters.findIndex((f) => f.active) >= wrappingIndex; - // If the active filter is not wrapping, we don't need to sort the filters - if (!isActiveFilterWrapping || wrappingIndex === -1) { - setSortedFilters(filters); - return; - } - - // Sort the filters with the current filter at first position - setSortedFilters( - filters.slice().sort((filterA, filterB) => { - // If the filter is active, it should be at the top of the list - if (filterA.active && !filterB.active) return -1; - if (!filterA.active && filterB.active) return 1; - // If both filters are active or not, keep their original order - return 0; - }), - ); - }, [filters, wrappingIndex]); - - return sortedFilters; -} diff --git a/src/components/views/rooms/RoomListPanel/RoomListView.tsx b/src/components/views/rooms/RoomListPanel/RoomListView.tsx index b29affc0be..50dd83e505 100644 --- a/src/components/views/rooms/RoomListPanel/RoomListView.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListView.tsx @@ -5,33 +5,47 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { type JSX } from "react"; +import React, { useCallback, type JSX, type ReactNode } from "react"; +import { + RoomListView as SharedRoomListView, + useCreateAutoDisposedViewModel, + type Room as SharedRoom, +} from "@element-hq/web-shared-components"; +import { type Room } from "matrix-js-sdk/src/matrix"; -import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewModel"; -import { RoomList } from "./RoomList"; -import { EmptyRoomList } from "./EmptyRoomList"; -import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { RoomAvatarView } from "../../avatars/RoomAvatarView"; +import { getKeyBindingsManager } from "../../../../KeyBindingsManager"; +import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts"; +import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation"; +import { RoomListViewViewModel } from "../../../../viewmodels/room-list/RoomListViewViewModel"; /** - * Host the room list and the (future) room filters + * RoomListView component using shared components with proper MVVM pattern. */ export function RoomListView(): JSX.Element { - const vm = useRoomListViewModel(); - const isRoomListEmpty = vm.roomsResult.rooms.length === 0; - let listBody; - if (vm.isLoadingRooms) { - listBody =
; - } else if (isRoomListEmpty) { - listBody = ; - } else { - listBody = ; - } - return ( - <> -
- -
- {listBody} - - ); + const matrixClient = useMatrixClientContext(); + + // Create and auto-dispose ViewModel instance + const vm = useCreateAutoDisposedViewModel(() => new RoomListViewViewModel({ client: matrixClient })); + + // Render avatar for each room - memoized to prevent re-renders + const renderAvatar = useCallback((room: SharedRoom): ReactNode => { + return ; + }, []); + + // Handle keyboard navigation for landmarks + const onKeyDown = useCallback((ev: React.KeyboardEvent) => { + const navAction = getKeyBindingsManager().getNavigationAction(ev); + if (navAction === KeyBindingAction.NextLandmark || navAction === KeyBindingAction.PreviousLandmark) { + LandmarkNavigation.findAndFocusNextLandmark( + Landmark.ROOM_LIST, + navAction === KeyBindingAction.PreviousLandmark, + ); + ev.stopPropagation(); + ev.preventDefault(); + } + }, []); + + return ; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 4f6fe35959..b498e20d72 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -12,16 +12,6 @@ "other": "%(count)s unread messages including mentions." }, "recent_rooms": "Recent rooms", - "room_messsage_not_sent": "Open room %(roomName)s with an unsent message.", - "room_n_unread_invite": "Open room %(roomName)s invitation.", - "room_n_unread_messages": { - "one": "Open room %(roomName)s with 1 unread message.", - "other": "Open room %(roomName)s with %(count)s unread messages." - }, - "room_n_unread_messages_mentions": { - "one": "Open room %(roomName)s with 1 unread mention.", - "other": "Open room %(roomName)s with %(count)s unread messages including mentions." - }, "room_name": "Room %(name)s", "room_status_bar": "Room status bar", "seek_bar_label": "Audio seek bar", @@ -1717,7 +1707,6 @@ "class_global": "Global", "class_other": "Other", "default": "Default", - "default_settings": "Match default settings", "email_pusher_app_display_name": "Email Notifications", "enable_prompt_toast_description": "Enable desktop notifications", "enable_prompt_toast_title": "Notifications", @@ -1736,8 +1725,7 @@ "mentions_and_keywords_description": "Get notified only with mentions and keywords as set up in your settings", "mentions_keywords": "Mentions and keywords", "message_didnt_send": "Message didn't send. Click for info.", - "mute_description": "You won't get any notifications", - "mute_room": "Mute room" + "mute_description": "You won't get any notifications" }, "notifier": { "m.key.verification.request": "%(name)s is requesting verification" @@ -2154,37 +2142,9 @@ "add_space_label": "Add space", "breadcrumbs_empty": "No recently visited rooms", "breadcrumbs_label": "Recently visited rooms", - "collapse_filters": "Collapse filter list", - "empty": { - "no_chats": "No chats yet", - "no_chats_description": "Get started by messaging someone or by creating a room", - "no_chats_description_no_room_rights": "Get started by messaging someone", - "no_favourites": "You don't have favourite chats yet", - "no_favourites_description": "You can add a chat to your favourites in the chat settings", - "no_invites": "You don't have any unread invites", - "no_lowpriority": "You don't have any low priority rooms", - "no_mentions": "You don't have any unread mentions", - "no_people": "You don’t have direct chats with anyone yet", - "no_people_description": "You can deselect filters in order to see your other chats", - "no_rooms": "You’re not in any room yet", - "no_rooms_description": "You can deselect filters in order to see your other chats", - "no_unread": "Congrats! You don’t have any unread messages", - "show_activity": "See all activity", - "show_chats": "Show all chats" - }, - "expand_filters": "Expand filter list", "failed_add_tag": "Failed to add tag %(tagName)s to room", "failed_remove_tag": "Failed to remove tag %(tagName)s from room", "failed_set_dm_tag": "Failed to set direct message tag", - "filters": { - "favourite": "Favourites", - "invites": "Invites", - "low_priority": "Low priority", - "mentions": "Mentions", - "people": "People", - "rooms": "Rooms", - "unread": "Unreads" - }, "home_menu_label": "Home options", "join_public_room_label": "Join public room", "joining_rooms_status": { @@ -2193,23 +2153,13 @@ }, "list_title": "Room list", "more_options": { - "copy_link": "Copy room link", - "favourited": "Favourited", - "leave_room": "Leave room", - "low_priority": "Low priority", - "mark_read": "Mark as read", - "mark_unread": "Mark as unread" + "leave_room": "Leave room" }, "notification_options": "Notification options", - "primary_filters": "Room list filters", "redacting_messages_status": { "one": "Currently removing messages in %(count)s room", "other": "Currently removing messages in %(count)s rooms" }, - "room": { - "more_options": "More Options", - "open_room": "Open room %(roomName)s" - }, "show_less": "Show less", "show_n_more": { "one": "Show %(count)s more", diff --git a/src/viewmodels/room-list/RoomListHeaderViewModel.ts b/src/viewmodels/room-list/RoomListHeaderViewModel.ts index fee0c954c3..e99268190c 100644 --- a/src/viewmodels/room-list/RoomListHeaderViewModel.ts +++ b/src/viewmodels/room-list/RoomListHeaderViewModel.ts @@ -26,11 +26,11 @@ import { showSpaceSettings, } from "../../utils/space"; import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; -import { createRoom, hasCreateRoomRights } from "../../components/viewmodels/roomlist/utils"; import SettingsStore from "../../settings/SettingsStore"; import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3"; import { SortingAlgorithm } from "../../stores/room-list-v3/skip-list/sorters"; import { SettingLevel } from "../../settings/SettingLevel"; +import { createRoom, hasCreateRoomRights } from "./utils"; export interface Props { /** diff --git a/src/viewmodels/room-list/RoomListItemViewModel.ts b/src/viewmodels/room-list/RoomListItemViewModel.ts new file mode 100644 index 0000000000..d7ce4e6e7f --- /dev/null +++ b/src/viewmodels/room-list/RoomListItemViewModel.ts @@ -0,0 +1,327 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { + BaseViewModel, + RoomNotifState, + type RoomListItemSnapshot, + type RoomListItemActions, +} from "@element-hq/web-shared-components"; +import { RoomEvent } from "matrix-js-sdk/src/matrix"; +import { CallType } from "matrix-js-sdk/src/webrtc/call"; + +import type { Room, MatrixClient } from "matrix-js-sdk/src/matrix"; +import type { RoomNotificationState } from "../../stores/notifications/RoomNotificationState"; +import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; +import { NotificationStateEvents } from "../../stores/notifications/NotificationState"; +import { MessagePreviewStore } from "../../stores/room-list/MessagePreviewStore"; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import { DefaultTagID } from "../../stores/room-list/models"; +import DMRoomMap from "../../utils/DMRoomMap"; +import SettingsStore from "../../settings/SettingsStore"; +import { NotificationLevel } from "../../stores/notifications/NotificationLevel"; +import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils"; +import { EchoChamber } from "../../stores/local-echo/EchoChamber"; +import { RoomNotifState as ElementRoomNotifState } from "../../RoomNotifs"; +import { shouldShowComponent } from "../../customisations/helpers/UIComponents"; +import { UIComponent } from "../../settings/UIFeature"; +import { CallStore, CallStoreEvent } from "../../stores/CallStore"; +import { clearRoomNotification, setMarkedUnreadState } from "../../utils/notifications"; +import { tagRoom } from "../../utils/room/tagRoom"; +import dispatcher from "../../dispatcher/dispatcher"; +import { Action } from "../../dispatcher/actions"; +import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; +import PosthogTrackers from "../../PosthogTrackers"; + +interface RoomItemProps { + room: Room; + client: MatrixClient; +} + +/** + * View model for an individual room list item. + * Manages per-room subscriptions and updates only when this specific room's data changes. + * Implements RoomListItemActions to provide interaction callbacks. + */ +export class RoomListItemViewModel + extends BaseViewModel + implements RoomListItemActions +{ + private notifState: RoomNotificationState; + + public constructor(props: RoomItemProps) { + // Get notification state first so we can generate a complete initial snapshot + const notifState = RoomNotificationStateStore.instance.getRoomState(props.room); + const initialItem = RoomListItemViewModel.generateItemSync(props.room, props.client, notifState); + super(props, initialItem); + + this.notifState = notifState; + + // Subscribe to notification state changes for this room + this.disposables.trackListener(this.notifState, NotificationStateEvents.Update, this.onNotificationChanged); + + // Subscribe to message preview changes (will filter to this room) + this.disposables.trackListener(MessagePreviewStore.instance, UPDATE_EVENT, this.onMessagePreviewChanged); + + // Subscribe to settings changes for message preview toggle + const settingsWatchRef = SettingsStore.watchSetting( + "RoomList.showMessagePreview", + null, + this.onMessagePreviewSettingChanged, + ); + this.disposables.track(() => { + SettingsStore.unwatchSetting(settingsWatchRef); + }); + + // Subscribe to call state changes + this.disposables.trackListener(CallStore.instance, CallStoreEvent.ConnectedCalls, this.onCallStateChanged); + + // Subscribe to room-specific events + this.disposables.trackListener(props.room, RoomEvent.Name, this.onRoomChanged); + this.disposables.trackListener(props.room, RoomEvent.Tags, this.onRoomChanged); + + // Load message preview asynchronously (sync data is already complete) + void this.loadAndSetMessagePreview(); + } + + private onNotificationChanged = (): void => { + this.updateItem(); + }; + + private onMessagePreviewChanged = (): void => { + void this.loadAndSetMessagePreview(); + }; + + private onMessagePreviewSettingChanged = (): void => { + void this.loadAndSetMessagePreview(); + }; + + private onCallStateChanged = (): void => { + // Only update if call state for this room actually changed + const call = CallStore.instance.getCall(this.props.room.roomId); + const currentCallType = this.snapshot.current.notification.callType; + const newCallType = + call && call.participants.size > 0 ? (call.callType === CallType.Voice ? "voice" : "video") : undefined; + + if (currentCallType !== newCallType) { + this.updateItem(); + } + }; + + private onRoomChanged = (): void => { + this.updateItem(); + }; + + /** + * Update the item snapshot with current sync data. + * Preserves the message preview which is managed separately. + */ + private updateItem(): void { + const newItem = RoomListItemViewModel.generateItemSync(this.props.room, this.props.client, this.notifState); + // Preserve message preview - it's managed separately by loadAndSetMessagePreview + this.snapshot.set({ ...newItem, messagePreview: this.snapshot.current.messagePreview }); + } + + private getMessagePreviewTag(): string { + const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(this.props.room.roomId)); + return isDm ? DefaultTagID.DM : DefaultTagID.Untagged; + } + + /** + * Load the message preview for this room if enabled. + * Returns undefined if previews are disabled or couldn't be loaded. + */ + private async loadMessagePreview(): Promise { + const shouldShowMessagePreview = SettingsStore.getValue("RoomList.showMessagePreview"); + if (!shouldShowMessagePreview) { + return undefined; + } + + const messagePreviewTag = this.getMessagePreviewTag(); + const preview = await MessagePreviewStore.instance.getPreviewForRoom(this.props.room, messagePreviewTag); + return preview?.text; + } + + /** + * Load and set the message preview if it differs from current. + */ + private async loadAndSetMessagePreview(): Promise { + const messagePreview = await this.loadMessagePreview(); + if (messagePreview !== this.snapshot.current.messagePreview) { + this.snapshot.merge({ messagePreview }); + } + } + + /** + * Generate a complete RoomListItem with all synchronous data. + * Message preview is loaded separately to avoid blocking initial render. + */ + private static generateItemSync( + room: Room, + client: MatrixClient, + notifState: RoomNotificationState, + ): RoomListItemSnapshot { + // Get room tags for menu state + const roomTags = room.tags; + const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId)); + + // Message preview will be loaded asynchronously and updated separately + const messagePreview = undefined; + + const isFavourite = Boolean(roomTags[DefaultTagID.Favourite]); + const isLowPriority = Boolean(roomTags[DefaultTagID.LowPriority]); + const isArchived = Boolean(roomTags[DefaultTagID.Archived]); + + // More options menu state + const showMoreOptionsMenu = hasAccessToOptionsMenu(room); + const showNotificationMenu = hasAccessToNotificationMenu(room, client.isGuest(), isArchived); + + // Notification levels + const canMarkAsRead = notifState.level > NotificationLevel.None; + const canMarkAsUnread = !canMarkAsRead && !isArchived; + + const canInvite = room.canInvite(client.getUserId()!) && !isDm && shouldShowComponent(UIComponent.InviteUsers); + const canCopyRoomLink = !isDm; + + // Get the current room notification state from EchoChamber + const echoChamber = EchoChamber.forRoom(room); + const elementRoomNotifState = echoChamber.notificationVolume; + + // Convert element-web RoomNotifState to shared-components RoomNotifState + let roomNotifState: RoomNotifState; + switch (elementRoomNotifState) { + case ElementRoomNotifState.AllMessages: + roomNotifState = RoomNotifState.AllMessages; + break; + case ElementRoomNotifState.AllMessagesLoud: + roomNotifState = RoomNotifState.AllMessagesLoud; + break; + case ElementRoomNotifState.MentionsOnly: + roomNotifState = RoomNotifState.MentionsOnly; + break; + case ElementRoomNotifState.Mute: + roomNotifState = RoomNotifState.Mute; + break; + default: + roomNotifState = RoomNotifState.AllMessages; + } + + const isNotificationMute = elementRoomNotifState === ElementRoomNotifState.Mute; + + // Video room and call state tracking + const call = CallStore.instance.getCall(room.roomId); + const participantCount = call?.participants.size ?? 0; + const hasParticipantsInCall = participantCount > 0; + const callType = + call?.callType === CallType.Voice ? "voice" : call?.callType === CallType.Video ? "video" : undefined; + + return { + id: room.roomId, + room, + name: room.name, + isBold: notifState.hasAnyNotificationOrActivity, + messagePreview, + notification: { + hasAnyNotificationOrActivity: notifState.hasAnyNotificationOrActivity || hasParticipantsInCall, + isUnsentMessage: notifState.isUnsentMessage, + invited: notifState.invited, + isMention: notifState.isMention, + isActivityNotification: notifState.isActivityNotification, + isNotification: notifState.isNotification, + hasUnreadCount: notifState.hasUnreadCount, + count: notifState.count, + muted: isNotificationMute, + callType: hasParticipantsInCall ? callType : undefined, + }, + showMoreOptionsMenu, + showNotificationMenu, + isFavourite, + isLowPriority, + canInvite, + canCopyRoomLink, + canMarkAsRead, + canMarkAsUnread, + roomNotifState, + }; + } + + public onOpenRoom = (): void => { + dispatcher.dispatch({ + action: Action.ViewRoom, + room_id: this.props.room.roomId, + metricsTrigger: "RoomList", + }); + }; + + public onMarkAsRead = async (): Promise => { + await clearRoomNotification(this.props.room, this.props.client); + PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkRead"); + }; + + public onMarkAsUnread = async (): Promise => { + await setMarkedUnreadState(this.props.room, this.props.client, true); + PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkUnread"); + }; + + public onToggleFavorite = (): void => { + tagRoom(this.props.room, DefaultTagID.Favourite); + PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle"); + }; + + public onToggleLowPriority = (): void => { + tagRoom(this.props.room, DefaultTagID.LowPriority); + }; + + public onInvite = (): void => { + dispatcher.dispatch({ + action: "view_invite", + roomId: this.props.room.roomId, + }); + PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuInviteItem"); + }; + + public onCopyRoomLink = (): void => { + dispatcher.dispatch({ + action: "copy_room", + room_id: this.props.room.roomId, + }); + }; + + public onLeaveRoom = (): void => { + const isArchived = Boolean(this.props.room.tags[DefaultTagID.Archived]); + dispatcher.dispatch({ + action: isArchived ? "forget_room" : "leave_room", + room_id: this.props.room.roomId, + }); + PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem"); + }; + + public onSetRoomNotifState = (notifState: RoomNotifState): void => { + // Convert shared-components RoomNotifState to element-web RoomNotifState + let elementNotifState: ElementRoomNotifState; + switch (notifState) { + case "all_messages": + elementNotifState = ElementRoomNotifState.AllMessages; + break; + case "all_messages_loud": + elementNotifState = ElementRoomNotifState.AllMessagesLoud; + break; + case "mentions_only": + elementNotifState = ElementRoomNotifState.MentionsOnly; + break; + case "mute": + elementNotifState = ElementRoomNotifState.Mute; + break; + default: + elementNotifState = ElementRoomNotifState.AllMessages; + } + + // Set the notification state using EchoChamber + const echoChamber = EchoChamber.forRoom(this.props.room); + echoChamber.notificationVolume = elementNotifState; + }; +} diff --git a/src/viewmodels/room-list/RoomListViewViewModel.ts b/src/viewmodels/room-list/RoomListViewViewModel.ts new file mode 100644 index 0000000000..a3618b93af --- /dev/null +++ b/src/viewmodels/room-list/RoomListViewViewModel.ts @@ -0,0 +1,450 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { + BaseViewModel, + type RoomListSnapshot, + type FilterId, + type RoomListViewActions, + type RoomListViewState, +} from "@element-hq/web-shared-components"; +import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; + +import { Action } from "../../dispatcher/actions"; +import dispatcher from "../../dispatcher/dispatcher"; +import { type ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload"; +import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; +import SpaceStore from "../../stores/spaces/SpaceStore"; +import RoomListStoreV3, { RoomListStoreV3Event, type RoomsResult } from "../../stores/room-list-v3/RoomListStoreV3"; +import { FilterKey } from "../../stores/room-list-v3/skip-list/filters"; +import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; +import { RoomListItemViewModel } from "./RoomListItemViewModel"; +import { SdkContextClass } from "../../contexts/SDKContext"; +import { hasCreateRoomRights } from "./utils"; + +interface RoomListViewViewModelProps { + client: MatrixClient; +} + +const filterKeyToIdMap: Map = new Map([ + [FilterKey.UnreadFilter, "unread"], + [FilterKey.PeopleFilter, "people"], + [FilterKey.RoomsFilter, "rooms"], + [FilterKey.FavouriteFilter, "favourite"], + [FilterKey.MentionsFilter, "mentions"], + [FilterKey.InvitesFilter, "invites"], + [FilterKey.LowPriorityFilter, "low_priority"], +]); + +export class RoomListViewViewModel + extends BaseViewModel + implements RoomListViewActions +{ + // State tracking + private activeFilter: FilterKey | undefined = undefined; + private roomsResult: RoomsResult; + private lastActiveRoomIndex: number | undefined = undefined; + + // Child view model management + private roomItemViewModels = new Map(); + private roomsMap = new Map(); + + public constructor(props: RoomListViewViewModelProps) { + const activeSpace = SpaceStore.instance.activeSpaceRoom; + + // Get initial rooms + const roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(undefined); + const canCreateRoom = hasCreateRoomRights(props.client, activeSpace); + const filterIds = [...filterKeyToIdMap.values()]; + + super(props, { + // Initial view state - start with empty, will populate in async init + isLoadingRooms: RoomListStoreV3.instance.isLoadingRooms, + isRoomListEmpty: roomsResult.rooms.length === 0, + filterIds, + activeFilterId: undefined, + roomListState: { + activeRoomIndex: undefined, + spaceId: roomsResult.spaceId, + filterKeys: undefined, + }, + roomIds: roomsResult.rooms.map((room) => room.roomId), + canCreateRoom, + }); + + this.roomsResult = roomsResult; + + // Build initial roomsMap from roomsResult + this.updateRoomsMap(roomsResult); + + // Subscribe to room list updates + this.disposables.trackListener( + RoomListStoreV3.instance, + RoomListStoreV3Event.ListsUpdate as any, + this.onListsUpdate, + ); + + // Subscribe to room list loaded + this.disposables.trackListener( + RoomListStoreV3.instance, + RoomListStoreV3Event.ListsLoaded as any, + this.onListsLoaded, + ); + + // Subscribe to active room changes to update selected room + const dispatcherRef = dispatcher.register(this.onDispatch); + this.disposables.track(() => { + dispatcher.unregister(dispatcherRef); + }); + + // Track cleanup of all child view models + this.disposables.track(() => { + for (const viewModel of this.roomItemViewModels.values()) { + viewModel.dispose(); + } + this.roomItemViewModels.clear(); + }); + } + + public onToggleFilter = (filterId: FilterId): void => { + // Find the FilterKey by matching the filter ID + let filterKey: FilterKey | undefined = undefined; + for (const [key, id] of filterKeyToIdMap.entries()) { + if (id === filterId) { + filterKey = key; + break; + } + } + + if (filterKey === undefined) return; + + // Toggle the filter - if it's already active, deactivate it + const newFilter = this.activeFilter === filterKey ? undefined : filterKey; + this.activeFilter = newFilter; + + // Update rooms result with new filter + const filterKeys = this.activeFilter !== undefined ? [this.activeFilter] : undefined; + this.roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filterKeys); + + // Update roomsMap immediately before clearing VMs + this.updateRoomsMap(this.roomsResult); + + // Clear view models since room list changed + this.clearViewModels(); + + this.updateRoomListData(); + }; + + /** + * Rebuild roomsMap when roomsResult changes. + * This maintains a quick lookup for room objects. + */ + private updateRoomsMap(roomsResult: RoomsResult): void { + this.roomsMap.clear(); + for (const room of roomsResult.rooms) { + this.roomsMap.set(room.roomId, room); + } + } + + /** + * Clear all child view models. + * Called when the room list structure changes (space change, filter change, etc.) + */ + private clearViewModels(): void { + for (const viewModel of this.roomItemViewModels.values()) { + viewModel.dispose(); + } + this.roomItemViewModels.clear(); + } + + /** + * Get the ordered list of room IDs. + */ + public get roomIds(): string[] { + return this.roomsResult.rooms.map((room) => room.roomId); + } + + /** + * Get a RoomListItemViewModel for a specific room. + * Creates a RoomListItemViewModel if needed, which manages per-room subscriptions. + * The view should call this only for visible rooms from the roomIds list. + * @throws Error if room is not found in roomsMap (indicates a programming error) + */ + public getRoomItemViewModel(roomId: string): RoomListItemViewModel { + // Check if we have a view model for this room + let viewModel = this.roomItemViewModels.get(roomId); + + if (!viewModel) { + const room = this.roomsMap.get(roomId); + if (!room) { + throw new Error(`Room ${roomId} not found in roomsMap`); + } + + // Create new view model + viewModel = new RoomListItemViewModel({ + room, + client: this.props.client, + }); + + this.roomItemViewModels.set(roomId, viewModel); + } + + // Return the view model - the view will call useViewModel() on it + return viewModel; + } + + /** + * Update which rooms are currently visible. + * Called by the view when scroll position changes. + * Disposes of view models for rooms no longer visible. + */ + public updateVisibleRooms(startIndex: number, endIndex: number): void { + const allRoomIds = this.roomIds; + const newVisibleIds = allRoomIds.slice(startIndex, Math.min(endIndex, allRoomIds.length)); + + const newVisibleSet = new Set(newVisibleIds); + + // Dispose view models for rooms no longer visible + for (const [roomId, viewModel] of this.roomItemViewModels.entries()) { + if (!newVisibleSet.has(roomId)) { + viewModel.dispose(); + this.roomItemViewModels.delete(roomId); + } + } + } + + private onDispatch = (payload: any): void => { + if (payload.action === Action.ActiveRoomChanged) { + // When the active room changes, update the room list data to reflect the new selected room + // Pass isRoomChange=true so sticky logic doesn't prevent the index from updating + this.updateRoomListData(true); + } else if (payload.action === Action.ViewRoomDelta) { + // Handle keyboard navigation shortcuts (Alt+ArrowUp/Down) + // This was previously handled by useRoomListNavigation hook + this.handleViewRoomDelta(payload as ViewRoomDeltaPayload); + } + }; + + /** + * Handle keyboard navigation shortcuts (Alt+ArrowUp/Down) to move between rooms. + * Supports both regular navigation and unread-only navigation. + * Migrated from useRoomListNavigation hook. + */ + private handleViewRoomDelta(payload: ViewRoomDeltaPayload): void { + const currentRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); + if (!currentRoomId) return; + + const { delta, unread } = payload; + const rooms = this.roomsResult.rooms; + + const filteredRooms = unread + ? // Filter the rooms to only include unread ones and the active room + rooms.filter((room) => { + const state = RoomNotificationStateStore.instance.getRoomState(room); + return room.roomId === currentRoomId || state.isUnread; + }) + : rooms; + + const currentIndex = filteredRooms.findIndex((room) => room.roomId === currentRoomId); + if (currentIndex === -1) return; + + // Get the next/previous new room according to the delta + // Use slice to loop on the list + // If delta is -1 at the start of the list, it will go to the end + // If delta is 1 at the end of the list, it will go to the start + const [newRoom] = filteredRooms.slice((currentIndex + delta) % filteredRooms.length); + if (!newRoom) return; + + dispatcher.dispatch({ + action: Action.ViewRoom, + room_id: newRoom.roomId, + show_room_tile: true, // to make sure the room gets scrolled into view + metricsTrigger: "WebKeyboardShortcut", + metricsViaKeyboard: true, + }); + } + + /** + * Handle room list updates from RoomListStoreV3. + * + * This event fires when: + * - Room order changes (new messages, manual reordering) + * - Active space changes + * - Filters are applied + * - Rooms are added/removed + * + * Space changes are detected by comparing old vs new spaceId. + * This matches the old hook pattern where space changes were handled + * indirectly through room list updates. + */ + private onListsUpdate = (): void => { + const filterKeys = this.activeFilter !== undefined ? [this.activeFilter] : undefined; + const oldSpaceId = this.roomsResult.spaceId; + + // Refresh room data from store + this.roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filterKeys); + this.updateRoomsMap(this.roomsResult); + + const newSpaceId = this.roomsResult.spaceId; + + // Clear view models since room list structure changed + this.clearViewModels(); + + // Detect space change + if (oldSpaceId !== newSpaceId) { + // Space changed - get the last selected room for the new space to prevent flicker + const lastSelectedRoom = SpaceStore.instance.getLastSelectedRoomIdForSpace(newSpaceId); + + this.updateRoomListData(true, lastSelectedRoom); + return; + } + + // Normal room list update (not a space change) + this.updateRoomListData(); + }; + + private onListsLoaded = (): void => { + // Room lists have finished loading + this.snapshot.merge({ + isLoadingRooms: false, + }); + }; + + /** + * Calculate the active room index based on the currently viewed room. + * Returns undefined if no room is selected or if the selected room is not in the current list. + * + * @param roomId - The room ID to find the index for (can be null/undefined) + */ + private getActiveRoomIndex(roomId: string | null | undefined): number | undefined { + if (!roomId) { + return undefined; + } + + const index = this.roomsResult.rooms.findIndex((room) => room.roomId === roomId); + return index >= 0 ? index : undefined; + } + + /** + * Apply sticky room logic to keep the active room at the same index position. + * When the room list updates, this prevents the selected room from jumping around in the UI. + * + * @param isRoomChange - Whether this update is due to a room change (not a list update) + * @param roomId - The room ID to apply sticky logic for (can be null/undefined) + * @returns The modified rooms array with sticky positioning applied + */ + private applyStickyRoom(isRoomChange: boolean, roomId: string | null | undefined): Room[] { + const rooms = this.roomsResult.rooms; + + if (!roomId) { + return rooms; + } + + const newIndex = rooms.findIndex((room) => room.roomId === roomId); + const oldIndex = this.lastActiveRoomIndex; + + // When opening another room, the index should obviously change + if (isRoomChange) { + return rooms; + } + + // If oldIndex is undefined, then there was no active room before + // Similarly, if newIndex is -1, the active room is not in the current list + if (newIndex === -1 || oldIndex === undefined) { + return rooms; + } + + // If the index hasn't changed, we have nothing to do + if (newIndex === oldIndex) { + return rooms; + } + + // If the old index falls out of the bounds of the rooms array + // (usually because rooms were removed), we can no longer place + // the active room in the same old index + if (oldIndex > rooms.length - 1) { + return rooms; + } + + // Making the active room sticky is as simple as removing it from + // its new index and placing it in the old index + const newRooms = [...rooms]; + const [stickyRoom] = newRooms.splice(newIndex, 1); + newRooms.splice(oldIndex, 0, stickyRoom); + + return newRooms; + } + + private async updateRoomListData( + isRoomChange: boolean = false, + roomIdOverride: string | null = null, + ): Promise { + // Determine the room ID to use for calculations + // Use override if provided (e.g., during space changes), otherwise fall back to RoomViewStore + const roomId = roomIdOverride ?? SdkContextClass.instance.roomViewStore.getRoomId(); + + // Apply sticky room logic to keep selected room at same position + const stickyRooms = this.applyStickyRoom(isRoomChange, roomId); + + // Update roomsResult with sticky rooms + this.roomsResult = { + ...this.roomsResult, + rooms: stickyRooms, + }; + + // Rebuild roomsMap with the reordered rooms + this.updateRoomsMap(this.roomsResult); + + // Calculate the active room index after applying sticky logic + const activeRoomIndex = this.getActiveRoomIndex(roomId); + + // Track the current active room index for future sticky calculations + this.lastActiveRoomIndex = activeRoomIndex; + + // Build the complete state atomically to ensure consistency + // roomIds and roomListState must always be in sync + const roomIds = this.roomIds; + const roomListState: RoomListViewState = { + activeRoomIndex, + spaceId: this.roomsResult.spaceId, + filterKeys: this.roomsResult.filterKeys?.map((k) => String(k)), + }; + + const filterIds = [...filterKeyToIdMap.values()]; + const activeFilterId = this.activeFilter !== undefined ? filterKeyToIdMap.get(this.activeFilter) : undefined; + const isRoomListEmpty = roomIds.length === 0; + const isLoadingRooms = RoomListStoreV3.instance.isLoadingRooms; + + // Single atomic snapshot update + this.snapshot.merge({ + isLoadingRooms, + isRoomListEmpty, + filterIds, + activeFilterId, + roomListState, + roomIds, + }); + } + + public createChatRoom = (): void => { + dispatcher.fire(Action.CreateChat); + }; + + public createRoom = (): void => { + const activeSpace = SpaceStore.instance.activeSpaceRoom; + if (activeSpace) { + dispatcher.dispatch({ + action: Action.CreateRoom, + parent_space: activeSpace, + }); + } else { + dispatcher.dispatch({ + action: Action.CreateRoom, + }); + } + }; +} diff --git a/src/components/viewmodels/roomlist/utils.ts b/src/viewmodels/room-list/utils.ts similarity index 83% rename from src/components/viewmodels/roomlist/utils.ts rename to src/viewmodels/room-list/utils.ts index dfa20e0d1c..5cd2f58678 100644 --- a/src/components/viewmodels/roomlist/utils.ts +++ b/src/viewmodels/room-list/utils.ts @@ -7,12 +7,12 @@ import { type Room, KnownMembership, EventTimeline, EventType, type MatrixClient } from "matrix-js-sdk/src/matrix"; -import { isKnockDenied } from "../../../utils/membership"; -import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; -import { UIComponent } from "../../../settings/UIFeature"; -import { showCreateNewRoom } from "../../../utils/space"; -import dispatcher from "../../../dispatcher/dispatcher"; -import { Action } from "../../../dispatcher/actions"; +import { isKnockDenied } from "../../utils/membership"; +import { shouldShowComponent } from "../../customisations/helpers/UIComponents"; +import { UIComponent } from "../../settings/UIFeature"; +import { showCreateNewRoom } from "../../utils/space"; +import dispatcher from "../../dispatcher/dispatcher"; +import { Action } from "../../dispatcher/actions"; /** * Check if the user has access to the options menu. diff --git a/test/unit-tests/components/viewmodels/roomlist/MessagePreviewViewModel-test.tsx b/test/unit-tests/components/viewmodels/roomlist/MessagePreviewViewModel-test.tsx deleted file mode 100644 index 6c5b121022..0000000000 --- a/test/unit-tests/components/viewmodels/roomlist/MessagePreviewViewModel-test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import { renderHook, waitFor } from "jest-matrix-react"; -import { type Room } from "matrix-js-sdk/src/matrix"; - -import { createTestClient, mkStubRoom } from "../../../../test-utils"; -import { type MessagePreview, MessagePreviewStore } from "../../../../../src/stores/room-list/MessagePreviewStore"; -import { useMessagePreviewViewModel } from "../../../../../src/components/viewmodels/roomlist/MessagePreviewViewModel"; - -describe("MessagePreviewViewModel", () => { - let room: Room; - - beforeEach(() => { - const matrixClient = createTestClient(); - room = mkStubRoom("roomId", "roomName", matrixClient); - }); - - it("should do an initial fetch of the message preview", async () => { - // Mock the store to return some text. - jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockImplementation(async (room) => { - return { text: "Hello world!" } as MessagePreview; - }); - - const { result: vm } = renderHook(() => useMessagePreviewViewModel(room)); - - // Eventually, vm.message should have the text from the store. - await waitFor(() => { - expect(vm.current.message).toEqual("Hello world!"); - }); - }); - - it("should fetch message preview again on update from store", async () => { - // Mock the store to return the text in variable message. - let message = "Hello World!"; - jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockImplementation(async (room) => { - return { text: message } as MessagePreview; - }); - jest.spyOn(MessagePreviewStore, "getPreviewChangedEventName").mockImplementation((room) => { - return "UPDATE"; - }); - - const { result: vm } = renderHook(() => useMessagePreviewViewModel(room)); - - // Let's assume the message changed. - message = "New message!"; - MessagePreviewStore.instance.emit("UPDATE"); - - /// vm.message should be the updated message. - await waitFor(() => { - expect(vm.current.message).toEqual(message); - }); - }); -}); diff --git a/test/unit-tests/components/viewmodels/roomlist/RoomListItemMenuViewModel-test.tsx b/test/unit-tests/components/viewmodels/roomlist/RoomListItemMenuViewModel-test.tsx deleted file mode 100644 index d017084db5..0000000000 --- a/test/unit-tests/components/viewmodels/roomlist/RoomListItemMenuViewModel-test.tsx +++ /dev/null @@ -1,221 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import { renderHook } from "jest-matrix-react"; -import { mocked } from "jest-mock"; -import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; - -import { mkStubRoom, stubClient, withClientContextRenderOptions } from "../../../../test-utils"; -import { useRoomListItemMenuViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListItemMenuViewModel"; -import { - hasAccessToNotificationMenu, - hasAccessToOptionsMenu, -} from "../../../../../src/components/viewmodels/roomlist/utils"; -import DMRoomMap from "../../../../../src/utils/DMRoomMap"; -import { DefaultTagID } from "../../../../../src/stores/room-list/models"; -import { useUnreadNotifications } from "../../../../../src/hooks/useUnreadNotifications"; -import { NotificationLevel } from "../../../../../src/stores/notifications/NotificationLevel"; -import { clearRoomNotification, setMarkedUnreadState } from "../../../../../src/utils/notifications"; -import { tagRoom } from "../../../../../src/utils/room/tagRoom"; -import dispatcher from "../../../../../src/dispatcher/dispatcher"; -import { useNotificationState } from "../../../../../src/hooks/useRoomNotificationState"; -import { RoomNotifState } from "../../../../../src/RoomNotifs"; - -jest.mock("../../../../../src/components/viewmodels/roomlist/utils", () => ({ - hasAccessToOptionsMenu: jest.fn().mockReturnValue(false), - hasAccessToNotificationMenu: jest.fn().mockReturnValue(false), -})); - -jest.mock("../../../../../src/hooks/useUnreadNotifications", () => ({ - useUnreadNotifications: jest.fn(), -})); - -jest.mock("../../../../../src/hooks/useRoomNotificationState", () => ({ - useNotificationState: jest.fn(), -})); - -jest.mock("../../../../../src/utils/notifications", () => ({ - clearRoomNotification: jest.fn(), - setMarkedUnreadState: jest.fn(), -})); - -jest.mock("../../../../../src/utils/room/tagRoom", () => ({ - tagRoom: jest.fn(), -})); - -describe("RoomListItemMenuViewModel", () => { - let matrixClient: MatrixClient; - let room: Room; - - beforeEach(() => { - matrixClient = stubClient(); - room = mkStubRoom("roomId", "roomName", matrixClient); - - DMRoomMap.makeShared(matrixClient); - jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(undefined); - - mocked(useUnreadNotifications).mockReturnValue({ symbol: null, count: 0, level: NotificationLevel.None }); - mocked(useNotificationState).mockReturnValue([RoomNotifState.AllMessages, jest.fn()]); - jest.spyOn(dispatcher, "dispatch"); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - function render() { - return renderHook(() => useRoomListItemMenuViewModel(room), withClientContextRenderOptions(matrixClient)); - } - - it("default", () => { - const { result } = render(); - expect(result.current.showMoreOptionsMenu).toBe(false); - expect(result.current.canInvite).toBe(false); - expect(result.current.isFavourite).toBe(false); - expect(result.current.canCopyRoomLink).toBe(true); - expect(result.current.canMarkAsRead).toBe(false); - expect(result.current.canMarkAsUnread).toBe(true); - }); - - it("should has showMoreOptionsMenu to be true", () => { - mocked(hasAccessToOptionsMenu).mockReturnValue(true); - const { result } = render(); - expect(result.current.showMoreOptionsMenu).toBe(true); - }); - - it("should has showNotificationMenu to be true", () => { - mocked(hasAccessToNotificationMenu).mockReturnValue(true); - const { result } = render(); - expect(result.current.showNotificationMenu).toBe(true); - }); - - it("should be able to invite", () => { - jest.spyOn(room, "canInvite").mockReturnValue(true); - const { result } = render(); - expect(result.current.canInvite).toBe(true); - }); - - it("should be a favourite", () => { - room.tags = { [DefaultTagID.Favourite]: { order: 0 } }; - const { result } = render(); - expect(result.current.isFavourite).toBe(true); - }); - - it("should not be able to copy the room link", () => { - jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue("userId"); - const { result } = render(); - expect(result.current.canCopyRoomLink).toBe(false); - }); - - it("should be able to mark as read", () => { - // Add a notification - mocked(useUnreadNotifications).mockReturnValue({ - symbol: null, - count: 1, - level: NotificationLevel.Notification, - }); - const { result } = render(); - expect(result.current.canMarkAsRead).toBe(true); - expect(result.current.canMarkAsUnread).toBe(false); - }); - - it("should has isNotificationAllMessage to be true", () => { - const { result } = render(); - expect(result.current.isNotificationAllMessage).toBe(true); - }); - - it("should has isNotificationAllMessageLoud to be true", () => { - mocked(useNotificationState).mockReturnValue([RoomNotifState.AllMessagesLoud, jest.fn()]); - const { result } = render(); - expect(result.current.isNotificationAllMessageLoud).toBe(true); - }); - - it("should has isNotificationMentionOnly to be true", () => { - mocked(useNotificationState).mockReturnValue([RoomNotifState.MentionsOnly, jest.fn()]); - const { result } = render(); - expect(result.current.isNotificationMentionOnly).toBe(true); - }); - - it("should has isNotificationMute to be true", () => { - mocked(useNotificationState).mockReturnValue([RoomNotifState.Mute, jest.fn()]); - const { result } = render(); - expect(result.current.isNotificationMute).toBe(true); - }); - - // Actions - - it("should mark as read", () => { - const { result } = render(); - result.current.markAsRead(new Event("click")); - expect(mocked(clearRoomNotification)).toHaveBeenCalledWith(room, matrixClient); - }); - - it("should mark as unread", () => { - const { result } = render(); - result.current.markAsUnread(new Event("click")); - expect(mocked(setMarkedUnreadState)).toHaveBeenCalledWith(room, matrixClient, true); - }); - - it("should tag a room as favourite", () => { - const { result } = render(); - result.current.toggleFavorite(new Event("click")); - expect(mocked(tagRoom)).toHaveBeenCalledWith(room, DefaultTagID.Favourite); - }); - - it("should tag a room as low priority", () => { - const { result } = render(); - result.current.toggleLowPriority(); - expect(mocked(tagRoom)).toHaveBeenCalledWith(room, DefaultTagID.LowPriority); - }); - - it("should dispatch invite action", () => { - const { result } = render(); - result.current.invite(new Event("click")); - expect(dispatcher.dispatch).toHaveBeenCalledWith({ - action: "view_invite", - roomId: room.roomId, - }); - }); - - it("should dispatch a copy room action", () => { - const { result } = render(); - result.current.copyRoomLink(new Event("click")); - expect(dispatcher.dispatch).toHaveBeenCalledWith({ - action: "copy_room", - room_id: room.roomId, - }); - }); - - it("should dispatch forget room action", () => { - // forget room is only available for archived rooms - room.tags = { [DefaultTagID.Archived]: { order: 0 } }; - - const { result } = render(); - result.current.leaveRoom(new Event("click")); - expect(dispatcher.dispatch).toHaveBeenCalledWith({ - action: "forget_room", - room_id: room.roomId, - }); - }); - - it("should dispatch leave room action", () => { - const { result } = render(); - result.current.leaveRoom(new Event("click")); - expect(dispatcher.dispatch).toHaveBeenCalledWith({ - action: "leave_room", - room_id: room.roomId, - }); - }); - - it("should call setRoomNotifState", () => { - const setRoomNotifState = jest.fn(); - mocked(useNotificationState).mockReturnValue([RoomNotifState.AllMessages, setRoomNotifState]); - const { result } = render(); - result.current.setRoomNotifState(RoomNotifState.Mute); - expect(setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.Mute); - }); -}); diff --git a/test/unit-tests/components/viewmodels/roomlist/RoomListItemViewModel-test.tsx b/test/unit-tests/components/viewmodels/roomlist/RoomListItemViewModel-test.tsx deleted file mode 100644 index 96bc53016e..0000000000 --- a/test/unit-tests/components/viewmodels/roomlist/RoomListItemViewModel-test.tsx +++ /dev/null @@ -1,280 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import { renderHook, waitFor } from "jest-matrix-react"; -import { type Room } from "matrix-js-sdk/src/matrix"; -import { mocked } from "jest-mock"; - -import dispatcher from "../../../../../src/dispatcher/dispatcher"; -import { Action } from "../../../../../src/dispatcher/actions"; -import { useRoomListItemViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListItemViewModel"; -import { createTestClient, mkStubRoom, withClientContextRenderOptions } from "../../../../test-utils"; -import { - hasAccessToNotificationMenu, - hasAccessToOptionsMenu, -} from "../../../../../src/components/viewmodels/roomlist/utils"; -import { RoomNotificationState } from "../../../../../src/stores/notifications/RoomNotificationState"; -import { RoomNotificationStateStore } from "../../../../../src/stores/notifications/RoomNotificationStateStore"; -import * as UseCallModule from "../../../../../src/hooks/useCall"; -import { type MessagePreview, MessagePreviewStore } from "../../../../../src/stores/room-list/MessagePreviewStore"; -import DMRoomMap from "../../../../../src/utils/DMRoomMap"; -import { useMessagePreviewToggle } from "../../../../../src/components/viewmodels/roomlist/useMessagePreviewToggle"; - -jest.mock("../../../../../src/components/viewmodels/roomlist/utils", () => ({ - hasAccessToOptionsMenu: jest.fn().mockReturnValue(false), - hasAccessToNotificationMenu: jest.fn().mockReturnValue(false), -})); - -jest.mock("../../../../../src/components/viewmodels/roomlist/useMessagePreviewToggle", () => ({ - useMessagePreviewToggle: jest.fn().mockReturnValue({ shouldShowMessagePreview: true }), -})); - -describe("RoomListItemViewModel", () => { - let room: Room; - - beforeEach(() => { - const matrixClient = createTestClient(); - room = mkStubRoom("roomId", "roomName", matrixClient); - - const dmRoomMap = { - getUserIdForRoomId: jest.fn(), - getDMRoomsForUserId: jest.fn(), - } as unknown as DMRoomMap; - DMRoomMap.setShared(dmRoomMap); - - mocked(useMessagePreviewToggle).mockReturnValue({ - shouldShowMessagePreview: false, - toggleMessagePreview: jest.fn(), - }); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it("should dispatch view room action on openRoom", async () => { - const { result: vm } = renderHook( - () => useRoomListItemViewModel(room), - withClientContextRenderOptions(room.client), - ); - - const fn = jest.spyOn(dispatcher, "dispatch"); - vm.current.openRoom(); - expect(fn).toHaveBeenCalledWith( - expect.objectContaining({ - action: Action.ViewRoom, - room_id: room.roomId, - metricsTrigger: "RoomList", - }), - ); - }); - - it("should show context menu if user has access to options menu", async () => { - mocked(hasAccessToOptionsMenu).mockReturnValue(true); - const { result: vm } = renderHook( - () => useRoomListItemViewModel(room), - withClientContextRenderOptions(room.client), - ); - expect(vm.current.showContextMenu).toBe(true); - }); - - it("should show hover menu if user has access to options menu", async () => { - mocked(hasAccessToOptionsMenu).mockReturnValue(true); - const { result: vm } = renderHook( - () => useRoomListItemViewModel(room), - withClientContextRenderOptions(room.client), - ); - expect(vm.current.showHoverMenu).toBe(true); - }); - - it("should show hover menu if user has access to notification menu", async () => { - mocked(hasAccessToNotificationMenu).mockReturnValue(true); - const { result: vm } = renderHook( - () => useRoomListItemViewModel(room), - withClientContextRenderOptions(room.client), - ); - expect(vm.current.showHoverMenu).toBe(true); - }); - - it("should not show hover menu if user has an invitation notification", async () => { - mocked(hasAccessToOptionsMenu).mockReturnValue(true); - - const notificationState = new RoomNotificationState(room, false); - jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState); - jest.spyOn(notificationState, "invited", "get").mockReturnValue(false); - - const { result: vm } = renderHook( - () => useRoomListItemViewModel(room), - withClientContextRenderOptions(room.client), - ); - expect(vm.current.showHoverMenu).toBe(true); - }); - - it("should return a message preview if one is available and they are enabled", async () => { - jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({ - text: "Message look like this", - } as MessagePreview); - mocked(useMessagePreviewToggle).mockReturnValue({ - shouldShowMessagePreview: true, - toggleMessagePreview: jest.fn(), - }); - - const { result: vm } = renderHook( - () => useRoomListItemViewModel(room), - withClientContextRenderOptions(room.client), - ); - await waitFor(() => expect(vm.current.messagePreview).toBe("Message look like this")); - }); - - it("should hide message previews when disabled", async () => { - jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({ - text: "Message look like this", - } as MessagePreview); - - const { result: vm, rerender } = renderHook( - () => useRoomListItemViewModel(room), - withClientContextRenderOptions(room.client), - ); - - // This doesn't seem to test that the hook actually triggers an update, - // but I can't see how to test that. - rerender(); - - expect(vm.current.messagePreview).toBe(undefined); - }); - - it("should check message preview when room change", async () => { - const otherRoom = mkStubRoom("roomId2", "roomName2", room.client); - - jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({ - text: "Message look like this", - } as MessagePreview); - mocked(useMessagePreviewToggle).mockReturnValue({ - shouldShowMessagePreview: true, - toggleMessagePreview: jest.fn(), - }); - - const { result: vm, rerender } = renderHook((props) => useRoomListItemViewModel(props), { - initialProps: room, - ...withClientContextRenderOptions(room.client), - }); - await waitFor(() => expect(vm.current.messagePreview).toBe("Message look like this")); - - jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue(null); - rerender(otherRoom); - await waitFor(() => expect(vm.current.messagePreview).toBe(undefined)); - }); - - describe("notification", () => { - let notificationState: RoomNotificationState; - beforeEach(() => { - notificationState = new RoomNotificationState(room, false); - jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState); - }); - - it("should show notification decoration if there is call has participant", () => { - jest.spyOn(UseCallModule, "useParticipantCount").mockReturnValue(1); - - const { result: vm } = renderHook( - () => useRoomListItemViewModel(room), - withClientContextRenderOptions(room.client), - ); - expect(vm.current.showNotificationDecoration).toBe(true); - }); - - it.each([ - { - label: "hasAnyNotificationOrActivity", - mock: () => jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true), - }, - { label: "muted", mock: () => jest.spyOn(notificationState, "muted", "get").mockReturnValue(true) }, - ])("should show notification decoration if $label=true", ({ mock }) => { - mock(); - const { result: vm } = renderHook( - () => useRoomListItemViewModel(room), - withClientContextRenderOptions(room.client), - ); - expect(vm.current.showNotificationDecoration).toBe(true); - }); - - it("should be bold if there is a notification", () => { - jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true); - - const { result: vm } = renderHook( - () => useRoomListItemViewModel(room), - withClientContextRenderOptions(room.client), - ); - expect(vm.current.isBold).toBe(true); - }); - - it("should recompute notification state when room changes", () => { - const newRoom = mkStubRoom("room2", "Room 2", room.client); - const newNotificationState = new RoomNotificationState(newRoom, false); - - const { result, rerender } = renderHook((room) => useRoomListItemViewModel(room), { - ...withClientContextRenderOptions(room.client), - initialProps: room, - }); - - expect(result.current.showNotificationDecoration).toBe(false); - - jest.spyOn(newNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true); - jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(newNotificationState); - rerender(newRoom); - - expect(result.current.showNotificationDecoration).toBe(true); - }); - }); - - describe("a11yLabel", () => { - let notificationState: RoomNotificationState; - beforeEach(() => { - notificationState = new RoomNotificationState(room, false); - jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState); - }); - - it.each([ - { - label: "unsent message", - mock: () => jest.spyOn(notificationState, "isUnsentMessage", "get").mockReturnValue(true), - expected: "Open room roomName with an unsent message.", - }, - { - label: "invitation", - mock: () => jest.spyOn(notificationState, "invited", "get").mockReturnValue(true), - expected: "Open room roomName invitation.", - }, - { - label: "mention", - mock: () => { - jest.spyOn(notificationState, "isMention", "get").mockReturnValue(true); - jest.spyOn(notificationState, "count", "get").mockReturnValue(3); - }, - expected: "Open room roomName with 3 unread messages including mentions.", - }, - { - label: "unread", - mock: () => { - jest.spyOn(notificationState, "hasUnreadCount", "get").mockReturnValue(true); - jest.spyOn(notificationState, "count", "get").mockReturnValue(3); - }, - expected: "Open room roomName with 3 unread messages.", - }, - { - label: "default", - expected: "Open room roomName", - }, - ])("should return the $label label", ({ mock, expected }) => { - mock?.(); - const { result: vm } = renderHook( - () => useRoomListItemViewModel(room), - withClientContextRenderOptions(room.client), - ); - expect(vm.current.a11yLabel).toBe(expected); - }); - }); -}); diff --git a/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx b/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx deleted file mode 100644 index c8ede64320..0000000000 --- a/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx +++ /dev/null @@ -1,341 +0,0 @@ -/* -Copyright 2025 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { range } from "lodash"; -import { act, renderHook, waitFor } from "jest-matrix-react"; -import { mocked } from "jest-mock"; - -import RoomListStoreV3, { LISTS_UPDATE_EVENT } from "../../../../../src/stores/room-list-v3/RoomListStoreV3"; -import { mkStubRoom } from "../../../../test-utils"; -import { useRoomListViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListViewModel"; -import { FilterKey } from "../../../../../src/stores/room-list-v3/skip-list/filters"; -import { hasCreateRoomRights, createRoom } from "../../../../../src/components/viewmodels/roomlist/utils"; -import dispatcher from "../../../../../src/dispatcher/dispatcher"; -import { Action } from "../../../../../src/dispatcher/actions"; -import { SdkContextClass } from "../../../../../src/contexts/SDKContext"; -import SpaceStore from "../../../../../src/stores/spaces/SpaceStore"; -import { UPDATE_SELECTED_SPACE } from "../../../../../src/stores/spaces"; - -jest.mock("../../../../../src/components/viewmodels/roomlist/utils", () => ({ - hasCreateRoomRights: jest.fn().mockReturnValue(false), - createRoom: jest.fn(), -})); - -describe("RoomListViewModel", () => { - function mockAndCreateRooms() { - const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined)); - const fn = jest - .spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace") - .mockImplementation(() => ({ spaceId: "home", rooms: [...rooms] })); - return { rooms, fn }; - } - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it("should return a list of rooms", async () => { - const { rooms } = mockAndCreateRooms(); - const { result: vm } = renderHook(() => useRoomListViewModel()); - - expect(vm.current.roomsResult.rooms).toHaveLength(10); - for (const room of rooms) { - expect(vm.current.roomsResult.rooms).toContain(room); - } - }); - - it("should update list of rooms on event from room list store", async () => { - const { rooms } = mockAndCreateRooms(); - const { result: vm } = renderHook(() => useRoomListViewModel()); - - const newRoom = mkStubRoom("bar:matrix.org", "Bar", undefined); - rooms.push(newRoom); - await act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT)); - - await waitFor(() => { - expect(vm.current.roomsResult.rooms).toContain(newRoom); - }); - }); - - describe("Filters", () => { - it("should provide list of available filters", () => { - mockAndCreateRooms(); - const { result: vm } = renderHook(() => useRoomListViewModel()); - // should have 6 filters - expect(vm.current.primaryFilters).toHaveLength(7); - // check the order - for (const [i, name] of [ - "Unreads", - "People", - "Rooms", - "Favourites", - "Mentions", - "Invites", - "Low priority", - ].entries()) { - expect(vm.current.primaryFilters[i].name).toEqual(name); - expect(vm.current.primaryFilters[i].active).toEqual(false); - } - }); - - it("should get filtered rooms from RLS on toggle", () => { - const { fn } = mockAndCreateRooms(); - const { result: vm } = renderHook(() => useRoomListViewModel()); - // Let's say we toggle the People toggle - const i = vm.current.primaryFilters.findIndex((f) => f.name === "People"); - act(() => { - vm.current.primaryFilters[i].toggle(); - }); - expect(fn).toHaveBeenCalledWith([FilterKey.PeopleFilter]); - expect(vm.current.primaryFilters[i].active).toEqual(true); - }); - - it("should change active property on toggle", () => { - mockAndCreateRooms(); - const { result: vm } = renderHook(() => useRoomListViewModel()); - // Let's say we toggle the People filter - const i = vm.current.primaryFilters.findIndex((f) => f.name === "People"); - expect(vm.current.primaryFilters[i].active).toEqual(false); - act(() => { - vm.current.primaryFilters[i].toggle(); - }); - expect(vm.current.primaryFilters[i].active).toEqual(true); - - // Let's say that we toggle the Favourite filter - const j = vm.current.primaryFilters.findIndex((f) => f.name === "Favourites"); - act(() => { - vm.current.primaryFilters[j].toggle(); - }); - expect(vm.current.primaryFilters[i].active).toEqual(false); - expect(vm.current.primaryFilters[j].active).toEqual(true); - }); - - it("should return the current active primary filter", async () => { - // Let's say that the user's preferred sorting is alphabetic - mockAndCreateRooms(); - const { result: vm } = renderHook(() => useRoomListViewModel()); - // Toggle people filter - const i = vm.current.primaryFilters.findIndex((f) => f.name === "People"); - expect(vm.current.primaryFilters[i].active).toEqual(false); - act(() => vm.current.primaryFilters[i].toggle()); - - // The active primary filter should be the People filter - expect(vm.current.activePrimaryFilter).toEqual(vm.current.primaryFilters[i]); - }); - - it("should not remove all filters when active space is changed", async () => { - mockAndCreateRooms(); - const { result: vm } = renderHook(() => useRoomListViewModel()); - - // Let's first toggle the People filter - const i = vm.current.primaryFilters.findIndex((f) => f.name === "People"); - act(() => { - vm.current.primaryFilters[i].toggle(); - }); - expect(vm.current.primaryFilters[i].active).toEqual(true); - - // Simulate a space change - await act(() => SpaceStore.instance.emit(UPDATE_SELECTED_SPACE)); - - // Primary filter should remain unchanged - expect(vm.current.activePrimaryFilter?.name).toEqual("People"); - }); - }); - - describe("Create room and chat", () => { - it("should be canCreateRoom=false if hasCreateRoomRights=false", () => { - mocked(hasCreateRoomRights).mockReturnValue(false); - const { result } = renderHook(() => useRoomListViewModel()); - expect(result.current.canCreateRoom).toBe(false); - }); - - it("should be canCreateRoom=true if hasCreateRoomRights=true", () => { - mocked(hasCreateRoomRights).mockReturnValue(true); - const { result } = renderHook(() => useRoomListViewModel()); - expect(result.current.canCreateRoom).toBe(true); - }); - - it("should call createRoom", () => { - const { result } = renderHook(() => useRoomListViewModel()); - result.current.createRoom(); - expect(mocked(createRoom)).toHaveBeenCalled(); - }); - - it("should dispatch Action.CreateChat", () => { - const spy = jest.spyOn(dispatcher, "fire"); - const { result } = renderHook(() => useRoomListViewModel()); - result.current.createChatRoom(); - expect(spy).toHaveBeenCalledWith(Action.CreateChat); - }); - }); - - describe("Sticky room and active index", () => { - function expectActiveRoom(vm: ReturnType, i: number, roomId: string) { - expect(vm.activeIndex).toEqual(i); - expect(vm.roomsResult.rooms[i].roomId).toEqual(roomId); - } - - it("active index is calculated with the last opened room in a space", () => { - // Let's say there's two spaces: !space1:matrix.org and !space2:matrix.org - // Let's also say that the current active space is !space1:matrix.org - let currentSpace = "!space1:matrix.org"; - jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => currentSpace); - - const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined)); - // Let's say all the rooms are in space1 - const roomsInSpace1 = { spaceId: currentSpace, rooms: [...rooms] }; - // Let's say all rooms with even index are in space 2 - const roomsInSpace2 = { spaceId: "!space2:matrix.org", rooms: [...rooms].filter((_, i) => i % 2 === 0) }; - jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockImplementation(() => - currentSpace === "!space1:matrix.org" ? roomsInSpace1 : roomsInSpace2, - ); - - // Let's say that the room at index 4 is currently active - const roomId = rooms[4].roomId; - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId); - - const { result: vm } = renderHook(() => useRoomListViewModel()); - expect(vm.current.activeIndex).toEqual(4); - - // Let's say that space is changed to "!space2:matrix.org" - currentSpace = "!space2:matrix.org"; - // Let's say that room[6] is active in space 2 - const activeRoomIdInSpace2 = rooms[6].roomId; - jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockImplementation( - () => activeRoomIdInSpace2, - ); - act(() => { - RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT); - }); - - // Active index should be 3 even without the room change event. - expectActiveRoom(vm.current, 3, activeRoomIdInSpace2); - }); - - it("active room and active index are retained on order change", () => { - const { rooms } = mockAndCreateRooms(); - - // Let's say that the room at index 5 is active - const roomId = rooms[5].roomId; - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId); - - const { result: vm } = renderHook(() => useRoomListViewModel()); - expect(vm.current.activeIndex).toEqual(5); - - // Let's say that room at index 9 moves to index 5 - const room9 = rooms[9]; - rooms.splice(9, 1); - rooms.splice(5, 0, room9); - act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT)); - - // Active room index should still be 5 - expectActiveRoom(vm.current, 5, roomId); - - // Let's add 2 new rooms from index 0 - const newRoom1 = mkStubRoom("bar1:matrix.org", "Bar 1", undefined); - const newRoom2 = mkStubRoom("bar2:matrix.org", "Bar 2", undefined); - rooms.unshift(newRoom1, newRoom2); - act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT)); - - // Active room index should still be 5 - expectActiveRoom(vm.current, 5, roomId); - }); - - it("active room and active index are updated when another room is opened", () => { - const { rooms } = mockAndCreateRooms(); - const roomId = rooms[5].roomId; - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId); - - const { result: vm } = renderHook(() => useRoomListViewModel()); - expectActiveRoom(vm.current, 5, roomId); - - // Let's say that room at index 9 becomes active - const room = rooms[9]; - act(() => { - dispatcher.dispatch( - { - action: Action.ActiveRoomChanged, - oldRoomId: null, - newRoomId: room.roomId, - }, - true, - ); - }); - - // Active room index should change to reflect new room - expectActiveRoom(vm.current, 9, room.roomId); - }); - - it("active room and active index are updated when active index spills out of rooms array bounds", () => { - const { rooms } = mockAndCreateRooms(); - // Let's say that the room at index 5 is active - const roomId = rooms[5].roomId; - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId); - - const { result: vm } = renderHook(() => useRoomListViewModel()); - expectActiveRoom(vm.current, 5, roomId); - - // Let's say that we remove rooms from the start of the array - for (let i = 0; i < 4; ++i) { - // We should be able to do 4 deletions before we run out of rooms - rooms.splice(0, 1); - act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT)); - expectActiveRoom(vm.current, 5, roomId); - } - - // If we remove one more room from the start, there's not going to be enough rooms - // to maintain the active index. - rooms.splice(0, 1); - act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT)); - expectActiveRoom(vm.current, 0, roomId); - }); - - it("active room and active index are retained when rooms that appear after the active room are deleted", () => { - const { rooms } = mockAndCreateRooms(); - // Let's say that the room at index 5 is active - const roomId = rooms[5].roomId; - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId); - - const { result: vm } = renderHook(() => useRoomListViewModel()); - expectActiveRoom(vm.current, 5, roomId); - - // Let's say that we remove rooms from the start of the array - for (let i = 0; i < 4; ++i) { - // Deleting rooms after index 5 (active) should not update the active index - rooms.splice(6, 1); - act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT)); - expectActiveRoom(vm.current, 5, roomId); - } - }); - - it("active room index becomes undefined when active room is deleted", () => { - const { rooms } = mockAndCreateRooms(); - // Let's say that the room at index 5 is active - let roomId: string | null = rooms[5].roomId; - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId); - - const { result: vm } = renderHook(() => useRoomListViewModel()); - expectActiveRoom(vm.current, 5, roomId); - - // Let's remove the active room (i.e room at index 5) - rooms.splice(5, 1); - roomId = null; - act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT)); - expect(vm.current.activeIndex).toBeUndefined(); - }); - - it("active room index is initially undefined", () => { - mockAndCreateRooms(); - - // Let's say that there's no active room currently - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => null); - - const { result: vm } = renderHook(() => useRoomListViewModel()); - expect(vm.current.activeIndex).toEqual(undefined); - }); - }); -}); diff --git a/test/unit-tests/components/viewmodels/roomlist/useRoomListNavigation-test.ts b/test/unit-tests/components/viewmodels/roomlist/useRoomListNavigation-test.ts deleted file mode 100644 index 1ae8606697..0000000000 --- a/test/unit-tests/components/viewmodels/roomlist/useRoomListNavigation-test.ts +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import { renderHook } from "jest-matrix-react"; -import { type Room } from "matrix-js-sdk/src/matrix"; -import { waitFor } from "@testing-library/dom"; - -import { SdkContextClass } from "../../../../../src/contexts/SDKContext"; -import dispatcher from "../../../../../src/dispatcher/dispatcher"; -import { mkStubRoom, stubClient } from "../../../../test-utils"; -import { useRoomListNavigation } from "../../../../../src/components/viewmodels/roomlist/useRoomListNavigation"; -import { Action } from "../../../../../src/dispatcher/actions"; -import DMRoomMap from "../../../../../src/utils/DMRoomMap"; -import { RoomNotificationStateStore } from "../../../../../src/stores/notifications/RoomNotificationStateStore"; -import { type RoomNotificationState } from "../../../../../src/stores/notifications/RoomNotificationState"; - -describe("useRoomListNavigation", () => { - let rooms: Room[]; - - beforeEach(() => { - const matrixClient = stubClient(); - rooms = [ - mkStubRoom("room1", "Room 1", matrixClient), - mkStubRoom("room2", "Room 2", matrixClient), - mkStubRoom("room3", "Room 3", matrixClient), - ]; - - DMRoomMap.makeShared(matrixClient); - jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(undefined); - jest.spyOn(dispatcher, "dispatch"); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it("should navigate to the next room based on delta", async () => { - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("room1"); - - renderHook(() => useRoomListNavigation(rooms)); - dispatcher.dispatch({ - action: Action.ViewRoomDelta, - delta: 1, - unread: false, - }); - - await waitFor(() => - expect(dispatcher.dispatch).toHaveBeenCalledWith({ - action: Action.ViewRoom, - room_id: "room2", - show_room_tile: true, - metricsTrigger: "WebKeyboardShortcut", - metricsViaKeyboard: true, - }), - ); - }); - - it("should navigate to the previous room based on delta", async () => { - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("room2"); - - renderHook(() => useRoomListNavigation(rooms)); - dispatcher.dispatch({ - action: Action.ViewRoomDelta, - delta: -1, - unread: false, - }); - - await waitFor(() => - expect(dispatcher.dispatch).toHaveBeenCalledWith({ - action: Action.ViewRoom, - room_id: "room1", - show_room_tile: true, - metricsTrigger: "WebKeyboardShortcut", - metricsViaKeyboard: true, - }), - ); - }); - - it("should wrap around to the first room when navigating past the last room", async () => { - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("room3"); - - renderHook(() => useRoomListNavigation(rooms)); - dispatcher.dispatch({ - action: Action.ViewRoomDelta, - delta: 1, - unread: false, - }); - - await waitFor(() => - expect(dispatcher.dispatch).toHaveBeenCalledWith({ - action: Action.ViewRoom, - room_id: "room1", - show_room_tile: true, - metricsTrigger: "WebKeyboardShortcut", - metricsViaKeyboard: true, - }), - ); - }); - - it("should wrap around to the last room when navigating before the first room", async () => { - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("room1"); - - renderHook(() => useRoomListNavigation(rooms)); - dispatcher.dispatch({ - action: Action.ViewRoomDelta, - delta: -1, - unread: false, - }); - - await waitFor(() => - expect(dispatcher.dispatch).toHaveBeenCalledWith({ - action: Action.ViewRoom, - room_id: "room3", - show_room_tile: true, - metricsTrigger: "WebKeyboardShortcut", - metricsViaKeyboard: true, - }), - ); - }); - - it("should filter rooms to only unread when unread=true", async () => { - jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("room1"); - jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockImplementation( - (room) => - ({ - isUnread: room.roomId !== "room1", - }) as RoomNotificationState, - ); - - renderHook(() => useRoomListNavigation(rooms)); - - dispatcher.dispatch({ - action: Action.ViewRoomDelta, - delta: 1, - unread: true, - }); - - await waitFor(() => - expect(dispatcher.dispatch).toHaveBeenCalledWith({ - action: Action.ViewRoom, - room_id: "room2", - show_room_tile: true, - metricsTrigger: "WebKeyboardShortcut", - metricsViaKeyboard: true, - }), - ); - }); -}); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/EmptyRoomList-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/EmptyRoomList-test.tsx deleted file mode 100644 index 92466f685c..0000000000 --- a/test/unit-tests/components/views/rooms/RoomListPanel/EmptyRoomList-test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import React from "react"; -import { render, screen } from "jest-matrix-react"; -import userEvent from "@testing-library/user-event"; - -import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel"; -import { EmptyRoomList } from "../../../../../../src/components/views/rooms/RoomListPanel/EmptyRoomList"; -import { FilterKey } from "../../../../../../src/stores/room-list-v3/skip-list/filters"; - -describe("", () => { - let vm: RoomListViewState; - - beforeEach(() => { - vm = { - isLoadingRooms: false, - roomsResult: { spaceId: "home", rooms: [] }, - primaryFilters: [], - createRoom: jest.fn(), - createChatRoom: jest.fn(), - canCreateRoom: true, - activeIndex: undefined, - }; - }); - - test("should render the default placeholder when there is no filter", async () => { - const user = userEvent.setup(); - - const { asFragment } = render(); - expect(screen.getByText("No chats yet")).toBeInTheDocument(); - expect(asFragment()).toMatchSnapshot(); - - await user.click(screen.getByRole("button", { name: "Start chat" })); - expect(vm.createChatRoom).toHaveBeenCalled(); - - await user.click(screen.getByRole("button", { name: "New room" })); - expect(vm.createRoom).toHaveBeenCalled(); - }); - - test("should not render the new room button if the user doesn't have the rights to create a room", async () => { - const newState = { ...vm, canCreateRoom: false }; - - const { asFragment } = render(); - expect(screen.queryByRole("button", { name: "New room" })).toBeNull(); - expect(asFragment()).toMatchSnapshot(); - }); - - it.each([ - { key: FilterKey.UnreadFilter, name: "unread", action: "Show all chats" }, - { key: FilterKey.MentionsFilter, name: "mention", action: "See all activity" }, - { key: FilterKey.InvitesFilter, name: "invite", action: "See all activity" }, - { key: FilterKey.LowPriorityFilter, name: "low priority", action: "See all activity" }, - ])("should display the empty state for the $name filter", async ({ key, name, action }) => { - const user = userEvent.setup(); - const activePrimaryFilter = { - toggle: jest.fn(), - active: true, - name, - key, - }; - const newState = { - ...vm, - activePrimaryFilter, - }; - - const { asFragment } = render(); - await user.click(screen.getByRole("button", { name: action })); - expect(activePrimaryFilter.toggle).toHaveBeenCalled(); - expect(asFragment()).toMatchSnapshot(); - }); - - it.each([ - { key: FilterKey.FavouriteFilter, name: "favourite" }, - { key: FilterKey.PeopleFilter, name: "people" }, - { key: FilterKey.RoomsFilter, name: "rooms" }, - ])("should display empty state for filter $name", ({ name, key }) => { - const activePrimaryFilter = { - toggle: jest.fn(), - active: true, - name, - key, - }; - const newState = { ...vm, activePrimaryFilter }; - const { asFragment } = render(); - expect(asFragment()).toMatchSnapshot(); - }); -}); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx deleted file mode 100644 index fa7b351bea..0000000000 --- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import React from "react"; -import { type MatrixClient } from "matrix-js-sdk/src/matrix"; -import { render } from "jest-matrix-react"; -import { fireEvent } from "@testing-library/dom"; -import { VirtuosoMockContext } from "@element-hq/web-shared-components"; - -import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel"; -import { RoomList } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomList"; -import DMRoomMap from "../../../../../../src/utils/DMRoomMap"; -import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; -import { Landmark, LandmarkNavigation } from "../../../../../../src/accessibility/LandmarkNavigation"; -import { mkRoom, stubClient } from "../../../../../test-utils"; - -describe("", () => { - let matrixClient: MatrixClient; - let vm: RoomListViewState; - - beforeEach(() => { - matrixClient = stubClient(); - const rooms = Array.from({ length: 10 }, (_, i) => mkRoom(matrixClient, `room${i}`)); - vm = { - isLoadingRooms: false, - roomsResult: { spaceId: "home", rooms }, - primaryFilters: [], - createRoom: jest.fn(), - createChatRoom: jest.fn(), - canCreateRoom: true, - activeIndex: undefined, - }; - - // Needed to render a room list cell - DMRoomMap.makeShared(matrixClient); - jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(undefined); - }); - - it("should render a room list", () => { - const { asFragment } = render(, { - wrapper: ({ children }) => ( - - - <>{children} - - - ), - }); - // At the moment the context prop on Virtuoso gets rendered in the dom as "[object Object]". - // This is a general issue with the react-virtuoso library. - // TODO: Update the snapshot when the following issue is resolved: https://github.com/petyosi/react-virtuoso/issues/1281 - expect(asFragment()).toMatchSnapshot(); - }); - - it.each([ - { shortcut: { key: "F6", ctrlKey: true, shiftKey: true }, isPreviousLandmark: true, label: "PreviousLandmark" }, - { shortcut: { key: "F6", ctrlKey: true }, isPreviousLandmark: false, label: "NextLandmark" }, - ])("should navigate to the landmark on NextLandmark.$label action", ({ shortcut, isPreviousLandmark }) => { - const spyFindLandmark = jest.spyOn(LandmarkNavigation, "findAndFocusNextLandmark").mockReturnValue(); - const { getByTestId } = render(, { - wrapper: ({ children }) => ( - - - <>{children} - - - ), - }); - const roomList = getByTestId("room-list"); - fireEvent.keyDown(roomList, shortcut); - - expect(spyFindLandmark).toHaveBeenCalledWith(Landmark.ROOM_LIST, isPreviousLandmark); - }); -}); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemMenuView-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemMenuView-test.tsx deleted file mode 100644 index 58ab0c672b..0000000000 --- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemMenuView-test.tsx +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import React from "react"; -import { mocked } from "jest-mock"; -import { render, screen } from "jest-matrix-react"; -import userEvent from "@testing-library/user-event"; - -import { - type RoomListItemMenuViewState, - useRoomListItemMenuViewModel, -} from "../../../../../../src/components/viewmodels/roomlist/RoomListItemMenuViewModel"; -import type { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; -import { mkRoom, stubClient } from "../../../../../test-utils"; -import { RoomListItemMenuView } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListItemMenuView"; -import { RoomNotifState } from "../../../../../../src/RoomNotifs"; - -jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListItemMenuViewModel", () => ({ - useRoomListItemMenuViewModel: jest.fn(), -})); - -describe("", () => { - const defaultValue: RoomListItemMenuViewState = { - showMoreOptionsMenu: true, - showNotificationMenu: true, - isFavourite: true, - isLowPriority: true, - canInvite: true, - canMarkAsUnread: true, - canMarkAsRead: true, - canCopyRoomLink: true, - isNotificationAllMessage: true, - isNotificationMentionOnly: true, - isNotificationAllMessageLoud: true, - isNotificationMute: true, - copyRoomLink: jest.fn(), - markAsUnread: jest.fn(), - markAsRead: jest.fn(), - leaveRoom: jest.fn(), - toggleLowPriority: jest.fn(), - toggleFavorite: jest.fn(), - invite: jest.fn(), - setRoomNotifState: jest.fn(), - }; - - let matrixClient: MatrixClient; - let room: Room; - - beforeEach(() => { - mocked(useRoomListItemMenuViewModel).mockReturnValue(defaultValue); - matrixClient = stubClient(); - room = mkRoom(matrixClient, "room1"); - }); - - function renderMenu() { - return render(); - } - - it("should render the more options menu", () => { - const { asFragment } = renderMenu(); - expect(screen.getByRole("button", { name: "More Options" })).toBeInTheDocument(); - expect(asFragment()).toMatchSnapshot(); - }); - - it("should render the notification options menu", () => { - const { asFragment } = renderMenu(); - expect(screen.getByRole("button", { name: "Notification options" })).toBeInTheDocument(); - expect(asFragment()).toMatchSnapshot(); - }); - - it("should not render the more options menu when showMoreOptionsMenu is false", () => { - mocked(useRoomListItemMenuViewModel).mockReturnValue({ ...defaultValue, showMoreOptionsMenu: false }); - renderMenu(); - expect(screen.queryByRole("button", { name: "More Options" })).toBeNull(); - }); - - it("should not render the notification options menu when showNotificationMenu is false", () => { - mocked(useRoomListItemMenuViewModel).mockReturnValue({ ...defaultValue, showNotificationMenu: false }); - renderMenu(); - expect(screen.queryByRole("button", { name: "Notification options" })).toBeNull(); - }); - - it("should display all the buttons and have the actions linked for the more options menu", async () => { - const user = userEvent.setup(); - renderMenu(); - - const openMenu = screen.getByRole("button", { name: "More Options" }); - await user.click(openMenu); - - await user.click(screen.getByRole("menuitem", { name: "Mark as read" })); - expect(defaultValue.markAsRead).toHaveBeenCalled(); - - await user.click(openMenu); - await user.click(screen.getByRole("menuitem", { name: "Mark as unread" })); - expect(defaultValue.markAsUnread).toHaveBeenCalled(); - - await user.click(openMenu); - await user.click(screen.getByRole("menuitemcheckbox", { name: "Favourited" })); - expect(defaultValue.toggleFavorite).toHaveBeenCalled(); - - await user.click(openMenu); - await user.click(screen.getByRole("menuitemcheckbox", { name: "Low priority" })); - expect(defaultValue.toggleLowPriority).toHaveBeenCalled(); - - await user.click(openMenu); - await user.click(screen.getByRole("menuitem", { name: "Invite" })); - expect(defaultValue.invite).toHaveBeenCalled(); - - await user.click(openMenu); - await user.click(screen.getByRole("menuitem", { name: "Copy room link" })); - expect(defaultValue.copyRoomLink).toHaveBeenCalled(); - - await user.click(openMenu); - await user.click(screen.getByRole("menuitem", { name: "Leave room" })); - expect(defaultValue.leaveRoom).toHaveBeenCalled(); - }); - - it("should display all the buttons and have the actions linked for the notification options menu", async () => { - const user = userEvent.setup(); - renderMenu(); - - const openMenu = screen.getByRole("button", { name: "Notification options" }); - await user.click(openMenu); - - await user.click(screen.getByRole("menuitem", { name: "Match default settings" })); - expect(defaultValue.setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.AllMessages); - - await user.click(openMenu); - await user.click(screen.getByRole("menuitem", { name: "All messages" })); - expect(defaultValue.setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.AllMessagesLoud); - - await user.click(openMenu); - await user.click(screen.getByRole("menuitem", { name: "Mentions and keywords" })); - expect(defaultValue.setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.MentionsOnly); - - await user.click(openMenu); - await user.click(screen.getByRole("menuitem", { name: "Mute room" })); - expect(defaultValue.setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.Mute); - }); -}); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx deleted file mode 100644 index b6127e1189..0000000000 --- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import React from "react"; -import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; -import { render, screen, waitFor } from "jest-matrix-react"; -import userEvent from "@testing-library/user-event"; -import { mocked } from "jest-mock"; -import { CallType } from "matrix-js-sdk/src/webrtc/call"; - -import { mkRoom, stubClient, withClientContextRenderOptions } from "../../../../../test-utils"; -import { RoomListItemView } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListItemView"; -import DMRoomMap from "../../../../../../src/utils/DMRoomMap"; -import { - type RoomListItemViewState, - useRoomListItemViewModel, -} from "../../../../../../src/components/viewmodels/roomlist/RoomListItemViewModel"; -import { RoomNotificationState } from "../../../../../../src/stores/notifications/RoomNotificationState"; - -jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListItemViewModel", () => ({ - useRoomListItemViewModel: jest.fn(), -})); - -describe("", () => { - let defaultValue: RoomListItemViewState; - let matrixClient: MatrixClient; - let room: Room; - - const renderRoomListItem = (props: Partial> = {}) => { - const defaultProps = { - room, - isSelected: false, - isFocused: false, - onFocus: jest.fn(), - roomIndex: 0, - roomCount: 1, - listIsScrolling: false, - }; - - return render(, withClientContextRenderOptions(matrixClient)); - }; - - beforeEach(() => { - matrixClient = stubClient(); - room = mkRoom(matrixClient, "room1"); - - DMRoomMap.makeShared(matrixClient); - jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(undefined); - - const notificationState = new RoomNotificationState(room, false); - jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true); - jest.spyOn(notificationState, "isNotification", "get").mockReturnValue(true); - jest.spyOn(notificationState, "count", "get").mockReturnValue(1); - - defaultValue = { - openRoom: jest.fn(), - showContextMenu: false, - showHoverMenu: false, - notificationState, - a11yLabel: "Open room room1", - isBold: false, - isVideoRoom: false, - callConnectionState: null, - callType: CallType.Video, - hasParticipantInCall: false, - name: room.name, - showNotificationDecoration: false, - messagePreview: undefined, - }; - - mocked(useRoomListItemViewModel).mockReturnValue(defaultValue); - }); - - test("should render a room item", () => { - const onClick = jest.fn(); - const { asFragment } = renderRoomListItem({ - onClick, - roomCount: 0, - }); - expect(asFragment()).toMatchSnapshot(); - }); - - test("should render a room item with a message preview", () => { - defaultValue.messagePreview = "The message looks like this"; - - const onClick = jest.fn(); - const { asFragment } = renderRoomListItem({ - onClick, - }); - expect(asFragment()).toMatchSnapshot(); - }); - - test("should call openRoom when clicked", async () => { - const user = userEvent.setup(); - renderRoomListItem(); - - await user.click(screen.getByRole("option", { name: `Open room ${room.name}` })); - expect(defaultValue.openRoom).toHaveBeenCalled(); - }); - - test("should be selected if isSelected=true", async () => { - const { asFragment } = renderRoomListItem({ - isSelected: true, - }); - - expect(screen.queryByRole("option", { name: `Open room ${room.name}` })).toHaveAttribute( - "aria-selected", - "true", - ); - expect(asFragment()).toMatchSnapshot(); - }); - - test("should display notification decoration", async () => { - mocked(useRoomListItemViewModel).mockReturnValue({ - ...defaultValue, - showNotificationDecoration: true, - }); - - const { asFragment } = renderRoomListItem(); - - expect(screen.getByTestId("notification-decoration")).toBeInTheDocument(); - expect(asFragment()).toMatchSnapshot(); - }); - - test("should not display notification decoration when hovered", async () => { - const user = userEvent.setup(); - - mocked(useRoomListItemViewModel).mockReturnValue({ - ...defaultValue, - showNotificationDecoration: true, - }); - - renderRoomListItem(); - - const listItem = screen.getByRole("option", { name: `Open room ${room.name}` }); - await user.hover(listItem); - - expect(screen.queryByRole("notification-decoration")).toBeNull(); - }); - - test("should render the context menu", async () => { - const user = userEvent.setup(); - - mocked(useRoomListItemViewModel).mockReturnValue({ - ...defaultValue, - showContextMenu: true, - }); - - renderRoomListItem(); - - const button = screen.getByRole("option", { name: `Open room ${room.name}` }); - await user.pointer([{ target: button }, { keys: "[MouseRight]", target: button }]); - await waitFor(() => expect(screen.getByRole("menu")).toBeInTheDocument()); - // Menu should close - await user.keyboard("{Escape}"); - expect(screen.queryByRole("menu")).toBeNull(); - }); -}); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListPrimaryFilters-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListPrimaryFilters-test.tsx deleted file mode 100644 index 8276c7340f..0000000000 --- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListPrimaryFilters-test.tsx +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import React, { act } from "react"; -import { render, screen } from "jest-matrix-react"; -import userEvent from "@testing-library/user-event"; - -import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel"; -import { RoomListPrimaryFilters } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters"; -import { FilterKey } from "../../../../../../src/stores/room-list-v3/skip-list/filters"; - -describe("", () => { - let vm: RoomListViewState; - const filterToggleMocks = [jest.fn(), jest.fn(), jest.fn()]; - - let resizeCallback: ResizeObserverCallback; - - beforeEach(() => { - // Reset mocks between tests - filterToggleMocks.forEach((mock) => mock.mockClear()); - - // Mock ResizeObserver - global.ResizeObserver = jest.fn().mockImplementation((callback) => { - resizeCallback = callback; - return { - observe: jest.fn(), - unobserve: jest.fn(), - disconnect: jest.fn(), - }; - }); - - vm = { - primaryFilters: [ - { name: "People", active: true, toggle: filterToggleMocks[0], key: FilterKey.PeopleFilter }, - { name: "Rooms", active: false, toggle: filterToggleMocks[1], key: FilterKey.RoomsFilter }, - { name: "Unreads", active: false, toggle: filterToggleMocks[2], key: FilterKey.UnreadFilter }, - ], - } as unknown as RoomListViewState; - }); - - function mockFiltersOffsetLeft() { - // Use `getByText` instead of `getByRole` to bypass the aria-hidden - jest.spyOn(screen.getByText("People"), "offsetLeft", "get").mockReturnValue(0); - jest.spyOn(screen.getByText("Rooms"), "offsetLeft", "get").mockReturnValue(30); - jest.spyOn(screen.getByText("Unreads"), "offsetLeft", "get").mockReturnValue(60); - - // @ts-ignore - act(() => resizeCallback([{ target: screen.getByRole("listbox", { name: "Room list filters" }) }])); - } - - it("should renders all filters correctly", () => { - const { asFragment } = render(); - mockFiltersOffsetLeft(); - - // Check that all filters are rendered - expect(screen.getByRole("option", { name: "People" })).toBeInTheDocument(); - expect(screen.getByRole("option", { name: "Rooms" })).toBeInTheDocument(); - expect(screen.getByRole("option", { name: "Unreads" })).toBeInTheDocument(); - - // Check that the active filter is marked as selected - expect(screen.getByRole("option", { name: "People" })).toHaveAttribute("aria-selected", "true"); - expect(screen.getByRole("option", { name: "Rooms" })).toHaveAttribute("aria-selected", "false"); - expect(screen.getByRole("option", { name: "Unreads" })).toHaveAttribute("aria-selected", "false"); - - expect(asFragment()).toMatchSnapshot(); - }); - - it("should call toggle function when a filter is clicked", async () => { - const user = userEvent.setup(); - render(); - mockFiltersOffsetLeft(); - - // Click on an inactive filter - await user.click(screen.getByRole("option", { name: "People" })); - - // Check that the toggle function was called - expect(filterToggleMocks[0]).toHaveBeenCalledTimes(1); - }); - - function makeUnreadWrapping() { - // Use `getByText` instead of `getByRole` to bypass the aria-hidden - jest.spyOn(screen.getByText("People"), "offsetLeft", "get").mockReturnValue(0); - jest.spyOn(screen.getByText("Rooms"), "offsetLeft", "get").mockReturnValue(30); - // Unreads is wrapping - jest.spyOn(screen.getByText("Unreads"), "offsetLeft", "get").mockReturnValue(0); - - // @ts-ignore - act(() => resizeCallback([{ target: screen.getByRole("listbox", { name: "Room list filters" }) }])); - } - - it("should hide or display filters if they are wrapping", async () => { - const user = userEvent.setup(); - render(); - mockFiltersOffsetLeft(); - - // No filter is wrapping, so chevron shouldn't be visible - expect(screen.queryByRole("button", { name: "Expand filter list" })).toBeNull(); - expect(screen.queryByRole("option", { name: "Unreads" })).toBeVisible(); - - makeUnreadWrapping(); - - // The Unreads filter is wrapping, it should not be visible - expect(screen.queryByRole("option", { name: "Unreads" })).toBeNull(); - // Now filters are wrapping, so chevron should be visible - await user.click(screen.getByRole("button", { name: "Expand filter list" })); - // The list is expanded, so Unreads should be visible - expect(screen.getByRole("option", { name: "Unreads" })).toBeVisible(); - }); - - it("should move the active filter if the list is collapsed and the filter is wrapping", async () => { - vm = { - primaryFilters: [ - { name: "People", active: false, toggle: filterToggleMocks[0], key: FilterKey.PeopleFilter }, - { name: "Rooms", active: false, toggle: filterToggleMocks[1], key: FilterKey.RoomsFilter }, - { name: "Unreads", active: true, toggle: filterToggleMocks[2], key: FilterKey.UnreadFilter }, - ], - } as unknown as RoomListViewState; - - const user = userEvent.setup(); - render(); - makeUnreadWrapping(); - - // Unread filter should be moved to the first position - expect(screen.getByRole("listbox", { name: "Room list filters" }).children[0]).toBe( - screen.getByRole("option", { name: "Unreads" }), - ); - - // When the list is expanded, the Unreads filter should move to its original position - await user.click(screen.getByRole("button", { name: "Expand filter list" })); - expect(screen.getByRole("listbox", { name: "Room list filters" }).children[0]).not.toEqual( - screen.getByRole("option", { name: "Unreads" }), - ); - }); - - it("should hide the filter is the previous is on the same vertical position", async () => { - render(); - mockFiltersOffsetLeft(); - - jest.spyOn(screen.getByRole("option", { name: "People" }), "offsetLeft", "get").mockReturnValue(0); - // Rooms is wrapping - jest.spyOn(screen.getByRole("option", { name: "Rooms" }), "offsetLeft", "get").mockReturnValue(0); - - // @ts-ignore - act(() => resizeCallback([{ target: screen.getByRole("listbox", { name: "Room list filters" }) }])); - - // The Unreads filter is wrapping, it should not be visible - expect(screen.queryByRole("option", { name: "Rooms" })).toBeNull(); - // Now filters are wrapping, so chevron should be visible - expect(screen.getByRole("button", { name: "Expand filter list" })).toBeVisible(); - }); -}); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListView-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListView-test.tsx deleted file mode 100644 index 0081c6f350..0000000000 --- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListView-test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import { mocked } from "jest-mock"; -import { render, screen } from "jest-matrix-react"; -import React from "react"; - -import { - type RoomListViewState, - useRoomListViewModel, -} from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel"; -import { RoomListView } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListView"; -import { mkRoom, stubClient } from "../../../../../test-utils"; - -jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListViewModel", () => ({ - useRoomListViewModel: jest.fn(), -})); - -describe("", () => { - const defaultValue: RoomListViewState = { - isLoadingRooms: false, - roomsResult: { spaceId: "home", rooms: [] }, - primaryFilters: [], - createRoom: jest.fn(), - createChatRoom: jest.fn(), - canCreateRoom: true, - activeIndex: undefined, - }; - const matrixClient = stubClient(); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it("should render the loading room list", () => { - mocked(useRoomListViewModel).mockReturnValue({ - ...defaultValue, - isLoadingRooms: true, - }); - - const roomList = render(); - expect(roomList.container.querySelector(".mx_RoomListSkeleton")).not.toBeNull(); - }); - - it("should render an empty room list", () => { - mocked(useRoomListViewModel).mockReturnValue(defaultValue); - - render(); - expect(screen.getByText("No chats yet")).toBeInTheDocument(); - }); - - it("should render a room list", () => { - mocked(useRoomListViewModel).mockReturnValue({ - ...defaultValue, - roomsResult: { spaceId: "home", rooms: [mkRoom(matrixClient, "testing room")] }, - }); - - render(); - expect(screen.getByRole("listbox", { name: "Room list" })).toBeInTheDocument(); - }); -}); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/EmptyRoomList-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/EmptyRoomList-test.tsx.snap deleted file mode 100644 index 140e1f366b..0000000000 --- a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/EmptyRoomList-test.tsx.snap +++ /dev/null @@ -1,279 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[` should display empty state for filter favourite 1`] = ` - -
- - You don't have favourite chats yet - - - You can add a chat to your favourites in the chat settings - -
-
-`; - -exports[` should display empty state for filter people 1`] = ` - -
- - You don’t have direct chats with anyone yet - - - You can deselect filters in order to see your other chats - -
-
-`; - -exports[` should display empty state for filter rooms 1`] = ` - -
- - You’re not in any room yet - - - You can deselect filters in order to see your other chats - -
-
-`; - -exports[` should display the empty state for the invite filter 1`] = ` - -
- - You don't have any unread invites - - -
-
-`; - -exports[` should display the empty state for the low priority filter 1`] = ` - -
- - You don't have any low priority rooms - - -
-
-`; - -exports[` should display the empty state for the mention filter 1`] = ` - -
- - You don't have any unread mentions - - -
-
-`; - -exports[` should display the empty state for the unread filter 1`] = ` - -
- - Congrats! You don’t have any unread messages - - -
-
-`; - -exports[` should not render the new room button if the user doesn't have the rights to create a room 1`] = ` - -
- - No chats yet - - - Get started by messaging someone - -
- -
-
-
-`; - -exports[` should render the default placeholder when there is no filter 1`] = ` - -
- - No chats yet - - - Get started by messaging someone or by creating a room - -
- - -
-
-
-`; diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomList-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomList-test.tsx.snap deleted file mode 100644 index eb833e64fa..0000000000 --- a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomList-test.tsx.snap +++ /dev/null @@ -1,1255 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[` should render a room list 1`] = ` - -
-
-
-
- - - -
-
-
-
- - - -
-
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-`; diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListItemMenuView-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListItemMenuView-test.tsx.snap deleted file mode 100644 index 8842b91e6f..0000000000 --- a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListItemMenuView-test.tsx.snap +++ /dev/null @@ -1,155 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[` should render the more options menu 1`] = ` - -
- - -
-
-`; - -exports[` should render the notification options menu 1`] = ` - -
- - -
-
-`; diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListItemView-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListItemView-test.tsx.snap deleted file mode 100644 index f46588370f..0000000000 --- a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListItemView-test.tsx.snap +++ /dev/null @@ -1,234 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[` should be selected if isSelected=true 1`] = ` - - - -`; - -exports[` should display notification decoration 1`] = ` - - - -`; - -exports[` should render a room item 1`] = ` - - - -`; - -exports[` should render a room item with a message preview 1`] = ` - - - -`; diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPrimaryFilters-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPrimaryFilters-test.tsx.snap deleted file mode 100644 index ec71f70c95..0000000000 --- a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPrimaryFilters-test.tsx.snap +++ /dev/null @@ -1,47 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[` should renders all filters correctly 1`] = ` - -
-
- - - -
-
-
-`; diff --git a/test/viewmodels/room-list/RoomListHeaderViewModel-test.ts b/test/viewmodels/room-list/RoomListHeaderViewModel-test.ts index 5c73e6c8ef..7025abe7c1 100644 --- a/test/viewmodels/room-list/RoomListHeaderViewModel-test.ts +++ b/test/viewmodels/room-list/RoomListHeaderViewModel-test.ts @@ -23,8 +23,8 @@ import { showSpacePreferences, showSpaceSettings, } from "../../../src/utils/space"; -import { createRoom, hasCreateRoomRights } from "../../../src/components/viewmodels/roomlist/utils"; import { createTestClient, mkSpace } from "../../test-utils"; +import { createRoom, hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils"; jest.mock("../../../src/PosthogTrackers", () => ({ trackInteraction: jest.fn(), @@ -38,7 +38,7 @@ jest.mock("../../../src/utils/space", () => ({ showSpaceSettings: jest.fn(), })); -jest.mock("../../../src/components/viewmodels/roomlist/utils", () => ({ +jest.mock("../../../src/viewmodels/room-list/utils", () => ({ createRoom: jest.fn(), hasCreateRoomRights: jest.fn(), })); diff --git a/test/viewmodels/room-list/RoomListItemViewModel-test.tsx b/test/viewmodels/room-list/RoomListItemViewModel-test.tsx new file mode 100644 index 0000000000..873f786d1b --- /dev/null +++ b/test/viewmodels/room-list/RoomListItemViewModel-test.tsx @@ -0,0 +1,439 @@ +/* + * 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 { type MatrixClient, type MatrixEvent, Room, RoomEvent, PendingEventOrdering } from "matrix-js-sdk/src/matrix"; +import { CallType } from "matrix-js-sdk/src/webrtc/call"; + +import { createTestClient, flushPromises } from "../../test-utils"; +import { RoomNotificationState } from "../../../src/stores/notifications/RoomNotificationState"; +import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore"; +import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState"; +import { type MessagePreview, MessagePreviewStore } from "../../../src/stores/room-list/MessagePreviewStore"; +import { UPDATE_EVENT } from "../../../src/stores/AsyncStore"; +import SettingsStore from "../../../src/settings/SettingsStore"; +import DMRoomMap from "../../../src/utils/DMRoomMap"; +import { DefaultTagID } from "../../../src/stores/room-list/models"; +import dispatcher from "../../../src/dispatcher/dispatcher"; +import { Action } from "../../../src/dispatcher/actions"; +import { CallStore } from "../../../src/stores/CallStore"; +import type { Call } from "../../../src/models/Call"; +import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel"; + +jest.mock("../../../src/viewmodels/room-list/utils", () => ({ + hasAccessToOptionsMenu: jest.fn().mockReturnValue(true), + hasAccessToNotificationMenu: jest.fn().mockReturnValue(true), +})); + +jest.mock("../../../src/stores/CallStore", () => ({ + __esModule: true, + CallStore: { + instance: { + getCall: jest.fn(), + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + }, + }, + CallStoreEvent: { + ConnectedCalls: "connected_calls", + }, +})); + +describe("RoomListItemViewModel", () => { + let matrixClient: MatrixClient; + let room: Room; + let notificationState: RoomNotificationState; + let viewModel: RoomListItemViewModel; + + beforeEach(() => { + matrixClient = createTestClient(); + room = new Room("!room:server", matrixClient, matrixClient.getSafeUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + // Set room name + room.name = "Test Room"; + + notificationState = new RoomNotificationState(room, false); + jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState); + + const dmRoomMap = { + getUserIdForRoomId: jest.fn().mockReturnValue(undefined), + } as unknown as DMRoomMap; + DMRoomMap.setShared(dmRoomMap); + + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => { + if (setting === "RoomList.showMessagePreview") return false; + return false; + }); + jest.spyOn(SettingsStore, "watchSetting").mockImplementation(() => "watcher-id"); + + jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue(null); + jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null); + }); + + afterEach(() => { + viewModel?.dispose(); + jest.restoreAllMocks(); + }); + + describe("Initialization", () => { + it("should initialize with room data", async () => { + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + // Wait for async initialization + await flushPromises(); + + const snapshot = viewModel.getSnapshot(); + expect(snapshot.id).toBe("!room:server"); + expect(snapshot.name).toBe("Test Room"); + }); + + it("should load message preview when enabled", async () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({ + text: "Hello world!", + } as MessagePreview); + + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + // Wait for async message preview load + await flushPromises(); + + expect(viewModel.getSnapshot().messagePreview).toBe("Hello world!"); + }); + + it("should not load message preview when disabled", async () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); + + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + await flushPromises(); + + expect(viewModel.getSnapshot().messagePreview).toBeUndefined(); + }); + }); + + describe("Notification state", () => { + it("should reflect notification state", async () => { + jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true); + jest.spyOn(notificationState, "count", "get").mockReturnValue(5); + + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + await flushPromises(); + + const snapshot = viewModel.getSnapshot(); + expect(snapshot.notification.hasAnyNotificationOrActivity).toBe(true); + expect(snapshot.notification.count).toBe(5); + }); + + it("should update when notification state changes", async () => { + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + await flushPromises(); + expect(viewModel.getSnapshot().notification.count).toBe(0); + + jest.spyOn(notificationState, "count", "get").mockReturnValue(3); + notificationState.emit(NotificationStateEvents.Update); + + await flushPromises(); + expect(viewModel.getSnapshot().notification.count).toBe(3); + }); + + it("should show bold text when has notifications", async () => { + jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true); + + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + await flushPromises(); + + expect(viewModel.getSnapshot().isBold).toBe(true); + }); + + it("should show mention badge", async () => { + jest.spyOn(notificationState, "isMention", "get").mockReturnValue(true); + + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + await flushPromises(); + + expect(viewModel.getSnapshot().notification.isMention).toBe(true); + }); + + it("should show invitation state", async () => { + jest.spyOn(notificationState, "invited", "get").mockReturnValue(true); + + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + await flushPromises(); + + expect(viewModel.getSnapshot().notification.invited).toBe(true); + }); + }); + + describe("Message preview", () => { + it("should update message preview when store emits update", async () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({ + text: "Initial message", + } as MessagePreview); + + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + await flushPromises(); + expect(viewModel.getSnapshot().messagePreview).toBe("Initial message"); + + // Update preview + jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({ + text: "Updated message", + } as MessagePreview); + + MessagePreviewStore.instance.emit(UPDATE_EVENT); + + await flushPromises(); + expect(viewModel.getSnapshot().messagePreview).toBe("Updated message"); + }); + + it("should show/hide preview when setting changes", async () => { + let showPreview = false; + let watchCallback: any; + + jest.spyOn(SettingsStore, "getValue").mockImplementation(() => showPreview); + jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_setting, _room, callback) => { + watchCallback = callback; + return "watcher-id"; + }); + jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({ + text: "Test message", + } as MessagePreview); + + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + await flushPromises(); + expect(viewModel.getSnapshot().messagePreview).toBeUndefined(); + + // Enable previews + showPreview = true; + watchCallback(null, "device", true); + + await flushPromises(); + expect(viewModel.getSnapshot().messagePreview).toBe("Test message"); + }); + }); + + describe("Room tags", () => { + it("should reflect favorite tag", async () => { + room.tags = { [DefaultTagID.Favourite]: { order: 0 } }; + + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + await flushPromises(); + + expect(viewModel.getSnapshot().isFavourite).toBe(true); + }); + + it("should reflect low priority tag", async () => { + room.tags = { [DefaultTagID.LowPriority]: { order: 0 } }; + + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + await flushPromises(); + + expect(viewModel.getSnapshot().isLowPriority).toBe(true); + }); + + it("should update when room tags change", async () => { + room.tags = {}; + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + await flushPromises(); + expect(viewModel.getSnapshot().isFavourite).toBe(false); + + room.tags = { [DefaultTagID.Favourite]: { order: 0 } }; + const tagEvent = { + getContent: () => ({ tags: { [DefaultTagID.Favourite]: { order: 0 } } }), + } as MatrixEvent; + room.emit(RoomEvent.Tags, tagEvent, room); + + await flushPromises(); + expect(viewModel.getSnapshot().isFavourite).toBe(true); + }); + }); + + describe("Call state", () => { + it("should show voice call indicator", async () => { + const mockCall = { + callType: CallType.Voice, + participants: new Map([[matrixClient.getUserId()!, {}]]), + } as unknown as Call; + + jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall); + + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + await flushPromises(); + + expect(viewModel.getSnapshot().notification.callType).toBe("voice"); + }); + + it("should show video call indicator", async () => { + const mockCall = { + callType: CallType.Video, + participants: new Map([[matrixClient.getUserId()!, {}]]), + } as unknown as Call; + + jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall); + + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + await flushPromises(); + + expect(viewModel.getSnapshot().notification.callType).toBe("video"); + }); + + it("should not show call indicator when no participants", async () => { + const mockCall = { + callType: CallType.Voice, + participants: new Map(), + } as unknown as Call; + + jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall); + + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + await flushPromises(); + + expect(viewModel.getSnapshot().notification.callType).toBeUndefined(); + }); + }); + + describe("Room name updates", () => { + it("should update when room name changes", async () => { + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + await flushPromises(); + expect(viewModel.getSnapshot().name).toBe("Test Room"); + + room.name = "Updated Room"; + room.emit(RoomEvent.Name, room); + + await flushPromises(); + expect(viewModel.getSnapshot().name).toBe("Updated Room"); + }); + }); + + describe("DM detection", () => { + it("should detect DM rooms", async () => { + const dmRoomMap = DMRoomMap.shared(); + jest.spyOn(dmRoomMap, "getUserIdForRoomId").mockReturnValue("@user:server"); + + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + await flushPromises(); + + // DM rooms should not show copy room link option + expect(viewModel.getSnapshot().canCopyRoomLink).toBe(false); + }); + + it("should detect non-DM rooms", async () => { + const dmRoomMap = DMRoomMap.shared(); + jest.spyOn(dmRoomMap, "getUserIdForRoomId").mockReturnValue(undefined); + + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + await flushPromises(); + + expect(viewModel.getSnapshot().canCopyRoomLink).toBe(true); + }); + }); + + describe("Actions", () => { + it("should dispatch view room action on openRoom", () => { + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); + + viewModel.onOpenRoom(); + + expect(dispatchSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: "!room:server", + metricsTrigger: "RoomList", + }); + }); + + it("should return room object", () => { + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + expect(viewModel.getSnapshot().room).toBe(room); + }); + + it("should dispatch view_invite action when onInvite is called", () => { + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); + + viewModel.onInvite(); + + expect(dispatchSpy).toHaveBeenCalledWith({ + action: "view_invite", + roomId: "!room:server", + }); + }); + + it("should dispatch copy_room action when onCopyRoomLink is called", () => { + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); + + viewModel.onCopyRoomLink(); + + expect(dispatchSpy).toHaveBeenCalledWith({ + action: "copy_room", + room_id: "!room:server", + }); + }); + + it("should dispatch leave_room action when onLeaveRoom is called for normal room", () => { + room.tags = {}; + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); + + viewModel.onLeaveRoom(); + + expect(dispatchSpy).toHaveBeenCalledWith({ + action: "leave_room", + room_id: "!room:server", + }); + }); + + it("should dispatch forget_room action when onLeaveRoom is called for archived room", () => { + room.tags = { [DefaultTagID.Archived]: { order: 0 } }; + + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); + + viewModel.onLeaveRoom(); + + expect(dispatchSpy).toHaveBeenCalledWith({ + action: "forget_room", + room_id: "!room:server", + }); + }); + }); + + describe("Cleanup", () => { + it("should unsubscribe from all events on dispose", () => { + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + const offSpy = jest.spyOn(notificationState, "off"); + + viewModel.dispose(); + + expect(offSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/viewmodels/room-list/RoomListViewViewModel-test.tsx b/test/viewmodels/room-list/RoomListViewViewModel-test.tsx new file mode 100644 index 0000000000..c896d3111d --- /dev/null +++ b/test/viewmodels/room-list/RoomListViewViewModel-test.tsx @@ -0,0 +1,546 @@ +/* + * 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 { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; +import { mocked } from "jest-mock"; + +import { createTestClient, flushPromises, mkStubRoom, stubClient } from "../../test-utils"; +import RoomListStoreV3, { RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3"; +import SpaceStore from "../../../src/stores/spaces/SpaceStore"; +import { FilterKey } from "../../../src/stores/room-list-v3/skip-list/filters"; +import dispatcher from "../../../src/dispatcher/dispatcher"; +import { Action } from "../../../src/dispatcher/actions"; +import { SdkContextClass } from "../../../src/contexts/SDKContext"; +import DMRoomMap from "../../../src/utils/DMRoomMap"; +import { RoomListViewViewModel } from "../../../src/viewmodels/room-list/RoomListViewViewModel"; +import { hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils"; + +jest.mock("../../../src/viewmodels/room-list/utils", () => ({ + hasCreateRoomRights: jest.fn().mockReturnValue(false), + hasAccessToOptionsMenu: jest.fn().mockReturnValue(true), + hasAccessToNotificationMenu: jest.fn().mockReturnValue(true), +})); + +describe("RoomListViewViewModel", () => { + let matrixClient: MatrixClient; + let room1: Room; + let room2: Room; + let room3: Room; + let viewModel: RoomListViewViewModel; + + beforeEach(() => { + matrixClient = createTestClient(); + room1 = mkStubRoom("!room1:server", "Room 1", matrixClient); + room2 = mkStubRoom("!room2:server", "Room 2", matrixClient); + room3 = mkStubRoom("!room3:server", "Room 3", matrixClient); + + // Setup DMRoomMap + const dmRoomMap = { + getUserIdForRoomId: jest.fn().mockReturnValue(null), + } as unknown as DMRoomMap; + DMRoomMap.setShared(dmRoomMap); + + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: "home", + rooms: [room1, room2, room3], + }); + + jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(false); + jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(null); + jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null); + + mocked(hasCreateRoomRights).mockReturnValue(false); + }); + + afterEach(() => { + viewModel?.dispose(); + jest.restoreAllMocks(); + }); + + describe("Initialization", () => { + it("should initialize with correct snapshot", () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + const snapshot = viewModel.getSnapshot(); + expect(snapshot.roomIds).toEqual(["!room1:server", "!room2:server", "!room3:server"]); + expect(snapshot.isRoomListEmpty).toBe(false); + expect(snapshot.isLoadingRooms).toBe(false); + expect(snapshot.roomListState.spaceId).toBe("home"); + expect(snapshot.filterIds.length).toBeGreaterThan(0); + expect(snapshot.activeFilterId).toBeUndefined(); + }); + + it("should initialize with empty room list", () => { + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: "home", + rooms: [], + }); + + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + expect(viewModel.getSnapshot().roomIds).toEqual([]); + expect(viewModel.getSnapshot().isRoomListEmpty).toBe(true); + }); + + it("should set canCreateRoom based on user rights", () => { + mocked(hasCreateRoomRights).mockReturnValue(true); + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + expect(viewModel.getSnapshot().canCreateRoom).toBe(true); + }); + }); + + describe("Room list updates", () => { + it("should update room list when ListsUpdate event fires", () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + const newRoom = mkStubRoom("!room4:server", "Room 4", matrixClient); + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: "home", + rooms: [room1, room2, room3, newRoom], + }); + + RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate); + + expect(viewModel.getSnapshot().roomIds).toEqual([ + "!room1:server", + "!room2:server", + "!room3:server", + "!room4:server", + ]); + }); + + it("should update loading state when ListsLoaded event fires", () => { + jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(true); + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + expect(viewModel.getSnapshot().isLoadingRooms).toBe(true); + + RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsLoaded); + + expect(viewModel.getSnapshot().isLoadingRooms).toBe(false); + }); + }); + + describe("Space switching", () => { + it("should update room list when space changes", () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + const spaceRoomList = [room1, room2]; + + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: "!space:server", + rooms: spaceRoomList, + }); + + jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue("!room1:server"); + + RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate); + + expect(viewModel.getSnapshot().roomListState.spaceId).toBe("!space:server"); + expect(viewModel.getSnapshot().roomIds).toEqual(["!room1:server", "!room2:server"]); + }); + + it("should clear view models when space changes", () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + // Get view models for visible rooms + const vm1 = viewModel.getRoomItemViewModel("!room1:server"); + const vm2 = viewModel.getRoomItemViewModel("!room2:server"); + + const disposeSpy1 = jest.spyOn(vm1, "dispose"); + const disposeSpy2 = jest.spyOn(vm2, "dispose"); + + // Change space + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: "!space:server", + rooms: [room3], + }); + + RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate); + + expect(disposeSpy1).toHaveBeenCalled(); + expect(disposeSpy2).toHaveBeenCalled(); + }); + }); + + describe("Active room tracking", () => { + it("should update active room index when room is selected", async () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server"); + + dispatcher.dispatch({ + action: Action.ActiveRoomChanged, + oldRoomId: "!room1:server", + newRoomId: "!room2:server", + }); + + // Use setTimeout to allow the dispatcher callback to run + await flushPromises(); + expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(1); + }); + + it("should return undefined active room index when no room is selected", async () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null); + + dispatcher.dispatch({ + action: Action.ActiveRoomChanged, + oldRoomId: "!room1:server", + newRoomId: null, + }); + + // Use setTimeout to allow the dispatcher callback to run + await flushPromises(); + expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBeUndefined(); + }); + }); + + describe("Sticky room behavior", () => { + it("should keep selected room at same index when room list updates", async () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + // Select room at index 1 + jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server"); + dispatcher.dispatch({ + action: Action.ActiveRoomChanged, + newRoomId: "!room2:server", + }); + + await flushPromises(); + expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(1); + + // Simulate room list update that would move room2 to front + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: "home", + rooms: [room2, room1, room3], // room2 moved to front + }); + + RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate); + + // Active room should still be at index 1 (sticky behavior) + expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(1); + expect(viewModel.getSnapshot().roomIds[1]).toBe("!room2:server"); + }); + + it("should not apply sticky behavior when user changes rooms", async () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + // Select room at index 1 + jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server"); + dispatcher.dispatch({ + action: Action.ActiveRoomChanged, + newRoomId: "!room2:server", + }); + + await flushPromises(); + + // User switches to room3 + jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room3:server"); + dispatcher.dispatch({ + action: Action.ActiveRoomChanged, + oldRoomId: "!room2:server", + newRoomId: "!room3:server", + }); + + await flushPromises(); + expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(2); + }); + }); + + describe("Filters", () => { + it("should toggle filter on", () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + expect(viewModel.getSnapshot().activeFilterId).toBeUndefined(); + + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: "home", + rooms: [room1], + filterKeys: [FilterKey.UnreadFilter], + }); + + viewModel.onToggleFilter("unread"); + + expect(viewModel.getSnapshot().activeFilterId).toBe("unread"); + expect(viewModel.getSnapshot().roomIds).toEqual(["!room1:server"]); + }); + + it("should toggle filter off", () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + // Turn filter on + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: "home", + rooms: [room1], + filterKeys: [FilterKey.UnreadFilter], + }); + viewModel.onToggleFilter("unread"); + + expect(viewModel.getSnapshot().activeFilterId).toBe("unread"); + + // Turn filter off + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: "home", + rooms: [room1, room2, room3], + }); + viewModel.onToggleFilter("unread"); + + expect(viewModel.getSnapshot().activeFilterId).toBeUndefined(); + expect(viewModel.getSnapshot().roomIds).toEqual(["!room1:server", "!room2:server", "!room3:server"]); + }); + + it("should clear view models when filter changes", () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + // Get view models + const vm1 = viewModel.getRoomItemViewModel("!room1:server"); + const disposeSpy = jest.spyOn(vm1, "dispose"); + + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: "home", + rooms: [room2], + filterKeys: [FilterKey.UnreadFilter], + }); + + viewModel.onToggleFilter("unread"); + + expect(disposeSpy).toHaveBeenCalled(); + }); + }); + + describe("Room item view models", () => { + it("should create room item view model on demand", () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + const itemViewModel = viewModel.getRoomItemViewModel("!room1:server"); + + expect(itemViewModel).toBeDefined(); + expect(itemViewModel.getSnapshot().room).toBe(room1); + }); + + it("should reuse existing room item view model", () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + const itemViewModel1 = viewModel.getRoomItemViewModel("!room1:server"); + const itemViewModel2 = viewModel.getRoomItemViewModel("!room1:server"); + + expect(itemViewModel1).toBe(itemViewModel2); + }); + + it("should throw error when requesting view model for non-existent room", () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + expect(() => { + viewModel.getRoomItemViewModel("!nonexistent:server"); + }).toThrow(); + }); + + it("should dispose view models for rooms no longer visible", () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + const vm1 = viewModel.getRoomItemViewModel("!room1:server"); + const vm2 = viewModel.getRoomItemViewModel("!room2:server"); + const vm3 = viewModel.getRoomItemViewModel("!room3:server"); + + const disposeSpy1 = jest.spyOn(vm1, "dispose"); + const disposeSpy3 = jest.spyOn(vm3, "dispose"); + + // Update to show only middle room (index 1) + viewModel.updateVisibleRooms(1, 2); + + expect(disposeSpy1).toHaveBeenCalled(); + expect(disposeSpy3).toHaveBeenCalled(); + + // vm2 should still exist + const vm2Again = viewModel.getRoomItemViewModel("!room2:server"); + expect(vm2Again).toBe(vm2); + }); + }); + + describe("Room creation", () => { + it("should dispatch CreateChat action when createChatRoom is called", () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + const dispatchSpy = jest.spyOn(dispatcher, "fire"); + + viewModel.createChatRoom(); + + expect(dispatchSpy).toHaveBeenCalledWith(Action.CreateChat); + }); + + it("should dispatch CreateRoom action without parent space", () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); + + viewModel.createRoom(); + + expect(dispatchSpy).toHaveBeenCalledWith({ + action: Action.CreateRoom, + }); + }); + + it("should dispatch CreateRoom action with parent space", () => { + const spaceRoom = mkStubRoom("!space:server", "Space", matrixClient); + jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(spaceRoom); + + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); + + viewModel.createRoom(); + + expect(dispatchSpy).toHaveBeenCalledWith({ + action: Action.CreateRoom, + parent_space: spaceRoom, + }); + }); + }); + + describe("Keyboard navigation (ViewRoomDelta)", () => { + beforeEach(() => { + // stubClient sets up MatrixClientPeg which is needed when ViewRoom action is dispatched + stubClient(); + }); + + it("should navigate to next room when delta is 1", async () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room1:server"); + + const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); + + dispatcher.dispatch({ + action: Action.ViewRoomDelta, + delta: 1, + unread: false, + }); + + await flushPromises(); + + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + action: Action.ViewRoom, + room_id: "!room2:server", + }), + ); + }); + + it("should navigate to previous room when delta is -1", async () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server"); + + const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); + + dispatcher.dispatch({ + action: Action.ViewRoomDelta, + delta: -1, + unread: false, + }); + + await flushPromises(); + + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + action: Action.ViewRoom, + room_id: "!room1:server", + }), + ); + }); + + it("should wrap around to last room when navigating backwards from first room", async () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room1:server"); + + const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); + + dispatcher.dispatch({ + action: Action.ViewRoomDelta, + delta: -1, + unread: false, + }); + + await flushPromises(); + + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + action: Action.ViewRoom, + room_id: "!room3:server", + }), + ); + }); + + it("should not navigate when current room is not found", async () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!unknown:server"); + + const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); + dispatchSpy.mockClear(); + + dispatcher.dispatch({ + action: Action.ViewRoomDelta, + delta: 1, + unread: false, + }); + + await flushPromises(); + + // Should not dispatch ViewRoom since current room wasn't found + expect(dispatchSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ + action: Action.ViewRoom, + }), + ); + }); + + it("should not navigate when no room is selected", async () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null); + + const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); + dispatchSpy.mockClear(); + + dispatcher.dispatch({ + action: Action.ViewRoomDelta, + delta: 1, + unread: false, + }); + + await flushPromises(); + + expect(dispatchSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ + action: Action.ViewRoom, + }), + ); + }); + }); + + describe("Cleanup", () => { + it("should dispose all room item view models on dispose", () => { + viewModel = new RoomListViewViewModel({ client: matrixClient }); + + const vm1 = viewModel.getRoomItemViewModel("!room1:server"); + const vm2 = viewModel.getRoomItemViewModel("!room2:server"); + + const disposeSpy1 = jest.spyOn(vm1, "dispose"); + const disposeSpy2 = jest.spyOn(vm2, "dispose"); + + viewModel.dispose(); + + expect(disposeSpy1).toHaveBeenCalled(); + expect(disposeSpy2).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/unit-tests/components/viewmodels/roomlist/utils-test.ts b/test/viewmodels/room-list/utils-test.ts similarity index 79% rename from test/unit-tests/components/viewmodels/roomlist/utils-test.ts rename to test/viewmodels/room-list/utils-test.ts index 322d2a5cc6..e7e303aed5 100644 --- a/test/unit-tests/components/viewmodels/roomlist/utils-test.ts +++ b/test/viewmodels/room-list/utils-test.ts @@ -8,22 +8,18 @@ import { mocked } from "jest-mock"; import type { MatrixClient, Room, RoomState } from "matrix-js-sdk/src/matrix"; -import { createTestClient, mkStubRoom } from "../../../../test-utils"; -import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents"; -import { - hasCreateRoomRights, - createRoom, - hasAccessToNotificationMenu, -} from "../../../../../src/components/viewmodels/roomlist/utils"; -import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; -import { Action } from "../../../../../src/dispatcher/actions"; -import { showCreateNewRoom } from "../../../../../src/utils/space"; +import { createTestClient, mkStubRoom } from "../../test-utils"; +import { shouldShowComponent } from "../../../src/customisations/helpers/UIComponents"; +import defaultDispatcher from "../../../src/dispatcher/dispatcher"; +import { Action } from "../../../src/dispatcher/actions"; +import { showCreateNewRoom } from "../../../src/utils/space"; +import { hasCreateRoomRights, createRoom, hasAccessToNotificationMenu } from "../../../src/viewmodels/room-list/utils"; -jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({ +jest.mock("../../../src/customisations/helpers/UIComponents", () => ({ shouldShowComponent: jest.fn(), })); -jest.mock("../../../../../src/utils/space", () => ({ +jest.mock("../../../src/utils/space", () => ({ showCreateNewRoom: jest.fn(), }));