From 935dbb1989b3116a3d7570d8adbe23611f8c3a02 Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 30 Jan 2026 09:46:28 +0000 Subject: [PATCH] Remove old room list implementation Remove old ViewModels, hooks, and view components that are now replaced by the shared-components implementation. --- .../rooms/RoomListPanel/_EmptyRoomList.pcss | 33 - .../views/rooms/RoomListPanel/_RoomList.pcss | 10 - .../RoomListPanel/_RoomListItemMenuView.pcss | 12 - .../RoomListPanel/_RoomListItemView.pcss | 102 -- .../_RoomListPrimaryFilters.pcss | 34 - .../_RoomListSecondaryFilters.pcss | 12 - .../RoomListPanel/_RoomListSkeleton.pcss | 24 - .../roomlist/MessagePreviewViewModel.tsx | 57 - .../roomlist/RoomListItemMenuViewModel.tsx | 226 --- .../roomlist/RoomListItemViewModel.tsx | 250 ---- .../viewmodels/roomlist/RoomListViewModel.tsx | 100 -- .../viewmodels/roomlist/useFilteredRooms.tsx | 131 -- .../roomlist/useMessagePreviewToggle.tsx | 32 - .../roomlist/useRoomListNavigation.ts | 56 - .../viewmodels/roomlist/useStickyRoomList.tsx | 138 -- .../rooms/RoomListPanel/EmptyRoomList.tsx | 182 --- .../views/rooms/RoomListPanel/RoomList.tsx | 144 -- .../RoomListItemContextMenuView.tsx | 44 - .../RoomListPanel/RoomListItemMenuView.tsx | 242 ---- .../rooms/RoomListPanel/RoomListItemView.tsx | 124 -- .../RoomListPanel/RoomListPrimaryFilters.tsx | 169 --- .../roomlist/MessagePreviewViewModel-test.tsx | 58 - .../RoomListItemMenuViewModel-test.tsx | 221 --- .../roomlist/RoomListViewModel-test.tsx | 341 ----- .../roomlist/useRoomListNavigation-test.ts | 152 -- .../RoomListPanel/EmptyRoomList-test.tsx | 92 -- .../rooms/RoomListPanel/RoomList-test.tsx | 78 - .../RoomListItemMenuView-test.tsx | 144 -- .../RoomListPanel/RoomListItemView-test.tsx | 162 --- .../RoomListPrimaryFilters-test.tsx | 155 -- .../rooms/RoomListPanel/RoomListView-test.tsx | 65 - .../__snapshots__/EmptyRoomList-test.tsx.snap | 279 ---- .../__snapshots__/RoomList-test.tsx.snap | 1255 ----------------- .../RoomListItemMenuView-test.tsx.snap | 155 -- .../RoomListItemView-test.tsx.snap | 234 --- .../RoomListPrimaryFilters-test.tsx.snap | 47 - 36 files changed, 5560 deletions(-) delete mode 100644 res/css/views/rooms/RoomListPanel/_EmptyRoomList.pcss delete mode 100644 res/css/views/rooms/RoomListPanel/_RoomList.pcss delete mode 100644 res/css/views/rooms/RoomListPanel/_RoomListItemMenuView.pcss delete mode 100644 res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss delete mode 100644 res/css/views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss delete mode 100644 res/css/views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss delete mode 100644 res/css/views/rooms/RoomListPanel/_RoomListSkeleton.pcss delete mode 100644 src/components/viewmodels/roomlist/MessagePreviewViewModel.tsx delete mode 100644 src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx delete mode 100644 src/components/viewmodels/roomlist/RoomListItemViewModel.tsx delete mode 100644 src/components/viewmodels/roomlist/RoomListViewModel.tsx delete mode 100644 src/components/viewmodels/roomlist/useFilteredRooms.tsx delete mode 100644 src/components/viewmodels/roomlist/useMessagePreviewToggle.tsx delete mode 100644 src/components/viewmodels/roomlist/useRoomListNavigation.ts delete mode 100644 src/components/viewmodels/roomlist/useStickyRoomList.tsx delete mode 100644 src/components/views/rooms/RoomListPanel/EmptyRoomList.tsx delete mode 100644 src/components/views/rooms/RoomListPanel/RoomList.tsx delete mode 100644 src/components/views/rooms/RoomListPanel/RoomListItemContextMenuView.tsx delete mode 100644 src/components/views/rooms/RoomListPanel/RoomListItemMenuView.tsx delete mode 100644 src/components/views/rooms/RoomListPanel/RoomListItemView.tsx delete mode 100644 src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters.tsx delete mode 100644 test/unit-tests/components/viewmodels/roomlist/MessagePreviewViewModel-test.tsx delete mode 100644 test/unit-tests/components/viewmodels/roomlist/RoomListItemMenuViewModel-test.tsx delete mode 100644 test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx delete mode 100644 test/unit-tests/components/viewmodels/roomlist/useRoomListNavigation-test.ts delete mode 100644 test/unit-tests/components/views/rooms/RoomListPanel/EmptyRoomList-test.tsx delete mode 100644 test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx delete mode 100644 test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemMenuView-test.tsx delete mode 100644 test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx delete mode 100644 test/unit-tests/components/views/rooms/RoomListPanel/RoomListPrimaryFilters-test.tsx delete mode 100644 test/unit-tests/components/views/rooms/RoomListPanel/RoomListView-test.tsx delete mode 100644 test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/EmptyRoomList-test.tsx.snap delete mode 100644 test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomList-test.tsx.snap delete mode 100644 test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListItemMenuView-test.tsx.snap delete mode 100644 test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListItemView-test.tsx.snap delete mode 100644 test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPrimaryFilters-test.tsx.snap 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/_RoomList.pcss b/res/css/views/rooms/RoomListPanel/_RoomList.pcss deleted file mode 100644 index 54798f1ea9..0000000000 --- a/res/css/views/rooms/RoomListPanel/_RoomList.pcss +++ /dev/null @@ -1,10 +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_RoomList { - height: 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 2e644cbba1..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/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/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`] = ` - -
-
- - - -
-
-
-`;