diff --git a/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts index 53795773ed6..c3769e34e09 100644 --- a/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts +++ b/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts @@ -25,10 +25,17 @@ test.describe("Room list panel", () => { test.beforeEach(async ({ page, app, user }) => { // The notification toast is displayed above the search section await app.closeNotificationToast(); + + // Populate the room list + for (let i = 0; i < 20; i++) { + await app.client.createRoom({ name: `room${i}` }); + } }); test("should render the room list panel", { tag: "@screenshot" }, async ({ page, app, user }) => { const roomListView = getRoomListView(page); + // Wait for the last room list to be visible + await expect(roomListView.getByRole("gridcell", { name: "Open room room19" })).toBeVisible(); await expect(roomListView).toMatchScreenshot("room-list-panel.png"); }); }); diff --git a/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts new file mode 100644 index 00000000000..ff06eda0aa5 --- /dev/null +++ b/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts @@ -0,0 +1,50 @@ +/* + * 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 Page } from "@playwright/test"; + +import { test, expect } from "../../../element-web-test"; + +test.describe("Room list", () => { + test.use({ + labsFlags: ["feature_new_room_list"], + }); + + /** + * Get the room list + * @param page + */ + function getRoomList(page: Page) { + return page.getByTestId("room-list"); + } + + test.beforeEach(async ({ page, app, user }) => { + // The notification toast is displayed above the search section + await app.closeNotificationToast(); + for (let i = 0; i < 30; i++) { + await app.client.createRoom({ name: `room${i}` }); + } + }); + + test("should render the room list", { tag: "@screenshot" }, async ({ page, app, user }) => { + const roomListView = getRoomList(page); + await expect(roomListView.getByRole("gridcell", { name: "Open room room29" })).toBeVisible(); + await expect(roomListView).toMatchScreenshot("room-list.png"); + + await roomListView.hover(); + // Scroll to the end of the room list + await page.mouse.wheel(0, 1000); + await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible(); + await expect(roomListView).toMatchScreenshot("room-list-scrolled.png"); + }); + + test("should open the room when it is clicked", async ({ page, app, user }) => { + const roomListView = getRoomList(page); + await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); + await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible(); + }); +}); diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png index d03dbf992b0..1ebfde5ea70 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-linux.png new file mode 100644 index 00000000000..f9275dd2111 Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-scrolled-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-scrolled-linux.png new file mode 100644 index 00000000000..79c9254f0a7 Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-scrolled-linux.png differ diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 90129601958..298e13b09f7 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -269,6 +269,8 @@ @import "./views/right_panel/_VerificationPanel.pcss"; @import "./views/right_panel/_WidgetCard.pcss"; @import "./views/room_settings/_AliasSettings.pcss"; +@import "./views/rooms/RoomListPanel/_RoomList.pcss"; +@import "./views/rooms/RoomListPanel/_RoomListCell.pcss"; @import "./views/rooms/RoomListPanel/_RoomListHeaderView.pcss"; @import "./views/rooms/RoomListPanel/_RoomListPanel.pcss"; @import "./views/rooms/RoomListPanel/_RoomListSearch.pcss"; diff --git a/res/css/views/rooms/RoomListPanel/_RoomList.pcss b/res/css/views/rooms/RoomListPanel/_RoomList.pcss new file mode 100644 index 00000000000..2563c1b6752 --- /dev/null +++ b/res/css/views/rooms/RoomListPanel/_RoomList.pcss @@ -0,0 +1,15 @@ +/* + * 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%; + + .mx_RoomList_List { + /* Avoid when on hover, the background color to be on top of the right border */ + padding-right: 1px; + } +} diff --git a/res/css/views/rooms/RoomListPanel/_RoomListCell.pcss b/res/css/views/rooms/RoomListPanel/_RoomListCell.pcss new file mode 100644 index 00000000000..812145a73e3 --- /dev/null +++ b/res/css/views/rooms/RoomListPanel/_RoomListCell.pcss @@ -0,0 +1,44 @@ +/* + * 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 RoomCell has the following structure: + * button----------------------------------------| + * | <-12px-> container--------------------------| + * | | room avatar <-12px-> content-----| + * | | | room_name | + * | | | ----------| <-- border + * |---------------------------------------------| + */ +.mx_RoomListCell { + all: unset; + + &:hover { + background-color: var(--cpd-color-bg-action-secondary-hovered); + } + + .mx_RoomListCell_container { + padding-left: var(--cpd-space-3x); + font: var(--cpd-font-body-md-regular); + height: 100%; + + .mx_RoomListCell_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; + + span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } +} diff --git a/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss b/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss index 8ce4655e58f..595f47f9c83 100644 --- a/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss +++ b/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss @@ -6,7 +6,7 @@ */ .mx_RoomListHeaderView { - height: 60px; + flex: 0 0 60px; padding: 0 var(--cpd-space-3x); .mx_RoomListHeaderView_title { diff --git a/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss b/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss index f175ab3976d..8a97086df8e 100644 --- a/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss +++ b/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss @@ -7,7 +7,7 @@ .mx_RoomListSearch { /* From figma, this should be aligned with the room header */ - height: 64px; + flex: 0 0 64px; box-sizing: border-box; border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary); padding: 0 var(--cpd-space-3x); diff --git a/src/components/viewmodels/roomlist/RoomListViewModel.tsx b/src/components/viewmodels/roomlist/RoomListViewModel.tsx new file mode 100644 index 00000000000..9422676cf8a --- /dev/null +++ b/src/components/viewmodels/roomlist/RoomListViewModel.tsx @@ -0,0 +1,51 @@ +/* +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, useState } from "react"; + +import type { Room } from "matrix-js-sdk/src/matrix"; +import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3"; +import { useEventEmitter } from "../../../hooks/useEventEmitter"; +import { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; +import dispatcher from "../../../dispatcher/dispatcher"; +import { Action } from "../../../dispatcher/actions"; + +export interface RoomListViewState { + /** + * A list of rooms to be displayed in the left panel. + */ + rooms: Room[]; + + /** + * Open the room having given roomId. + */ + openRoom: (roomId: string) => void; +} + +/** + * View model for the new room list + * @see {@link RoomListViewState} for more information about what this view model returns. + */ +export function useRoomListViewModel(): RoomListViewState { + const [rooms, setRooms] = useState(RoomListStoreV3.instance.getSortedRoomInActiveSpace()); + + useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => { + const newRooms = RoomListStoreV3.instance.getSortedRoomInActiveSpace(); + setRooms(newRooms); + }); + + const openRoom = useCallback((roomId: string): void => { + dispatcher.dispatch({ + action: Action.ViewRoom, + room_id: roomId, + metricsTrigger: "RoomList", + }); + }, []); + + return { rooms, openRoom }; +} diff --git a/src/components/views/rooms/RoomListPanel/RoomList.tsx b/src/components/views/rooms/RoomListPanel/RoomList.tsx new file mode 100644 index 00000000000..3645a72bb91 --- /dev/null +++ b/src/components/views/rooms/RoomListPanel/RoomList.tsx @@ -0,0 +1,51 @@ +/* + * 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, type JSX } from "react"; +import { AutoSizer, List, type ListRowProps } from "react-virtualized"; + +import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel"; +import { _t } from "../../../../languageHandler"; +import { RoomListCell } from "./RoomListCell"; + +interface RoomListProps { + /** + * The view model state for the room list. + */ + vm: RoomListViewState; +} + +/** + * A virtualized list of rooms. + */ +export function RoomList({ vm: { rooms, openRoom } }: RoomListProps): JSX.Element { + const roomRendererMemoized = useCallback( + ({ key, index, style }: ListRowProps) => ( + openRoom(rooms[index].roomId)} /> + ), + [rooms, openRoom], + ); + + // The first div is needed to make the virtualized list take all the remaining space and scroll correctly + return ( +
+ + {({ height, width }) => ( + + )} + +
+ ); +} diff --git a/src/components/views/rooms/RoomListPanel/RoomListCell.tsx b/src/components/views/rooms/RoomListPanel/RoomListCell.tsx new file mode 100644 index 00000000000..a5e9cc5df23 --- /dev/null +++ b/src/components/views/rooms/RoomListPanel/RoomListCell.tsx @@ -0,0 +1,44 @@ +/* + * 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 } from "react"; +import { type Room } from "matrix-js-sdk/src/matrix"; + +import { _t } from "../../../../languageHandler"; +import { Flex } from "../../../utils/Flex"; +import DecoratedRoomAvatar from "../../avatars/DecoratedRoomAvatar"; + +interface RoomListCellProps extends React.HTMLAttributes { + /** + * The room to display + */ + room: Room; +} + +/** + * A cell in the room list + */ +export function RoomListCell({ room, ...props }: RoomListCellProps): JSX.Element { + return ( + + ); +} diff --git a/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx b/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx index e5c1cbfa307..291794399fb 100644 --- a/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx @@ -11,6 +11,8 @@ import { shouldShowComponent } from "../../../../customisations/helpers/UICompon import { UIComponent } from "../../../../settings/UIFeature"; import { RoomListSearch } from "./RoomListSearch"; import { RoomListHeaderView } from "./RoomListHeaderView"; +import { RoomListView } from "./RoomListView"; +import { Flex } from "../../../utils/Flex"; type RoomListPanelProps = { /** @@ -27,9 +29,16 @@ export const RoomListPanel: React.FC = ({ activeSpace }) => const displayRoomSearch = shouldShowComponent(UIComponent.FilterContainer); return ( -
+ {displayRoomSearch && } -
+ + ); }; diff --git a/src/components/views/rooms/RoomListPanel/RoomListView.tsx b/src/components/views/rooms/RoomListPanel/RoomListView.tsx new file mode 100644 index 00000000000..6e7734c91a9 --- /dev/null +++ b/src/components/views/rooms/RoomListPanel/RoomListView.tsx @@ -0,0 +1,17 @@ +/* + * 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 } from "react"; + +import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewModel"; +import { RoomList } from "./RoomList"; + +export function RoomListView(): JSX.Element { + const vm = useRoomListViewModel(); + // Room filters will be added soon + return ; +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 624beab0b88..c97a9ee3310 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2097,12 +2097,16 @@ "one": "Currently joining %(count)s room", "other": "Currently joining %(count)s rooms" }, + "list_title": "Room list", "notification_options": "Notification options", "open_space_menu": "Open space menu", "redacting_messages_status": { "one": "Currently removing messages in %(count)s room", "other": "Currently removing messages in %(count)s rooms" }, + "room": { + "open_room": "Open room %(roomName)s" + }, "show_less": "Show less", "show_n_more": { "one": "Show %(count)s more", diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts new file mode 100644 index 00000000000..369708b817d --- /dev/null +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -0,0 +1,203 @@ +/* +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 { logger } from "matrix-js-sdk/src/logger"; +import { EventType } from "matrix-js-sdk/src/matrix"; + +import type { EmptyObject, Room, RoomState } from "matrix-js-sdk/src/matrix"; +import type { MatrixDispatcher } from "../../dispatcher/dispatcher"; +import type { ActionPayload } from "../../dispatcher/payloads"; +import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; +import SettingsStore from "../../settings/SettingsStore"; +import { VisibilityProvider } from "../room-list/filters/VisibilityProvider"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { LISTS_UPDATE_EVENT } from "../room-list/RoomListStore"; +import { RoomSkipList } from "./skip-list/RoomSkipList"; +import { RecencySorter } from "./skip-list/sorters/RecencySorter"; +import { AlphabeticSorter } from "./skip-list/sorters/AlphabeticSorter"; +import { readReceiptChangeIsFor } from "../../utils/read-receipts"; +import { EffectiveMembership, getEffectiveMembership, getEffectiveMembershipTag } from "../../utils/membership"; +import SpaceStore from "../spaces/SpaceStore"; +import { UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces"; + +/** + * This store allows for fast retrieval of the room list in a sorted and filtered manner. + * This is the third such implementation hence the "V3". + * This store is being actively developed so expect the methods to change in future. + */ +export class RoomListStoreV3Class extends AsyncStoreWithClient { + private roomSkipList?: RoomSkipList; + private readonly msc3946ProcessDynamicPredecessor: boolean; + + public constructor(dispatcher: MatrixDispatcher) { + super(dispatcher); + this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors"); + SpaceStore.instance.on(UPDATE_SELECTED_SPACE, () => { + this.onActiveSpaceChanged(); + }); + SpaceStore.instance.on(UPDATE_HOME_BEHAVIOUR, () => this.onActiveSpaceChanged()); + } + + /** + * Get a list of unsorted, unfiltered rooms. + */ + public getRooms(): Room[] { + let rooms = this.matrixClient?.getVisibleRooms(this.msc3946ProcessDynamicPredecessor) ?? []; + rooms = rooms.filter((r) => VisibilityProvider.instance.isRoomVisible(r)); + return rooms; + } + + /** + * Get a list of sorted rooms. + */ + public getSortedRooms(): Room[] { + if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList); + else return []; + } + + /** + * Get a list of sorted rooms that belong to the currently active space. + */ + public getSortedRoomInActiveSpace(): Room[] { + if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList.getRoomsInActiveSpace()); + else return []; + } + + /** + * Re-sort the list of rooms by alphabetic order. + */ + public useAlphabeticSorting(): void { + if (this.roomSkipList) { + const sorter = new AlphabeticSorter(); + this.roomSkipList.useNewSorter(sorter, this.getRooms()); + } + } + + /** + * Re-sort the list of rooms by recency. + */ + public useRecencySorting(): void { + if (this.roomSkipList && this.matrixClient) { + const sorter = new RecencySorter(this.matrixClient?.getSafeUserId() ?? ""); + this.roomSkipList.useNewSorter(sorter, this.getRooms()); + } + } + + protected async onReady(): Promise { + if (this.roomSkipList?.initialized || !this.matrixClient) return; + const sorter = new RecencySorter(this.matrixClient.getSafeUserId()); + this.roomSkipList = new RoomSkipList(sorter); + const rooms = this.getRooms(); + await SpaceStore.instance.isReady; + this.roomSkipList.seed(rooms); + this.emit(LISTS_UPDATE_EVENT); + } + + protected async onAction(payload: ActionPayload): Promise { + if (!this.matrixClient || !this.roomSkipList?.initialized) return; + + switch (payload.action) { + case "MatrixActions.Room.receipt": { + if (readReceiptChangeIsFor(payload.event, this.matrixClient)) { + const room = payload.room; + if (!room) { + logger.warn(`Own read receipt was in unknown room ${room.roomId}`); + return; + } + this.addRoomAndEmit(room); + } + break; + } + + case "MatrixActions.Room.tags": { + const room = payload.room; + this.addRoomAndEmit(room); + break; + } + + case "MatrixActions.Event.decrypted": { + const roomId = payload.event.getRoomId(); + if (!roomId) return; + const room = this.matrixClient.getRoom(roomId); + if (!room) { + logger.warn(`Event ${payload.event.getId()} was decrypted in an unknown room ${roomId}`); + return; + } + this.addRoomAndEmit(room); + break; + } + + case "MatrixActions.accountData": { + if (payload.event_type !== EventType.Direct) return; + const dmMap = payload.event.getContent(); + for (const userId of Object.keys(dmMap)) { + const roomIds = dmMap[userId]; + for (const roomId of roomIds) { + const room = this.matrixClient.getRoom(roomId); + if (!room) { + logger.warn(`${roomId} was found in DMs but the room is not in the store`); + continue; + } + this.addRoomAndEmit(room); + } + } + break; + } + + case "MatrixActions.Room.timeline": { + // Ignore non-live events (backfill) and notification timeline set events (without a room) + if (!payload.isLiveEvent || !payload.isLiveUnfilteredRoomTimelineEvent || !payload.room) return; + this.addRoomAndEmit(payload.room); + break; + } + + case "MatrixActions.Room.myMembership": { + const oldMembership = getEffectiveMembership(payload.oldMembership); + const newMembership = getEffectiveMembershipTag(payload.room, payload.membership); + if (oldMembership !== EffectiveMembership.Join && newMembership === EffectiveMembership.Join) { + // If we're joining an upgraded room, we'll want to make sure we don't proliferate + // the dead room in the list. + const roomState: RoomState = payload.room.currentState; + const predecessor = roomState.findPredecessor(this.msc3946ProcessDynamicPredecessor); + if (predecessor) { + const prevRoom = this.matrixClient?.getRoom(predecessor.roomId); + if (prevRoom) this.roomSkipList.removeRoom(prevRoom); + else logger.warn(`Unable to find predecessor room with id ${predecessor.roomId}`); + } + } + this.addRoomAndEmit(payload.room); + break; + } + } + } + + private addRoomAndEmit(room: Room): void { + if (!this.roomSkipList) throw new Error("roomSkipList hasn't been created yet!"); + this.roomSkipList.addRoom(room); + this.emit(LISTS_UPDATE_EVENT); + } + + private onActiveSpaceChanged(): void { + if (!this.roomSkipList) return; + this.roomSkipList.calculateActiveSpaceForNodes(); + this.emit(LISTS_UPDATE_EVENT); + } +} + +export default class RoomListStoreV3 { + private static internalInstance: RoomListStoreV3Class; + + public static get instance(): RoomListStoreV3Class { + if (!RoomListStoreV3.internalInstance) { + const instance = new RoomListStoreV3Class(defaultDispatcher); + instance.start(); + RoomListStoreV3.internalInstance = instance; + } + + return this.internalInstance; + } +} diff --git a/src/stores/room-list-v3/skip-list/RoomNode.ts b/src/stores/room-list-v3/skip-list/RoomNode.ts index cbc2a3346f4..af792aa757c 100644 --- a/src/stores/room-list-v3/skip-list/RoomNode.ts +++ b/src/stores/room-list-v3/skip-list/RoomNode.ts @@ -6,6 +6,7 @@ Please see LICENSE files in the repository root for full details. */ import type { Room } from "matrix-js-sdk/src/matrix"; +import SpaceStore from "../../spaces/SpaceStore"; /** * Room skip list stores room nodes. @@ -13,6 +14,8 @@ import type { Room } from "matrix-js-sdk/src/matrix"; * in different levels. */ export class RoomNode { + private _isInActiveSpace: boolean = false; + public constructor(public readonly room: Room) {} /** @@ -26,4 +29,23 @@ export class RoomNode { * eg: previous[i] gives the previous room node from this room node in level i. */ public previous: RoomNode[] = []; + + /** + * Whether the room associated with this room node belongs to + * the currently active space. + * @see {@link SpaceStoreClass#activeSpace} to understand what active + * space means. + */ + public get isInActiveSpace(): boolean { + return this._isInActiveSpace; + } + + /** + * Check if this room belongs to the active space and store the result + * in {@link RoomNode#isInActiveSpace}. + */ + public checkIfRoomBelongsToActiveSpace(): void { + const activeSpace = SpaceStore.instance.activeSpace; + this._isInActiveSpace = SpaceStore.instance.isRoomInSpace(activeSpace, this.room.roomId); + } } diff --git a/src/stores/room-list-v3/skip-list/RoomSkipList.ts b/src/stores/room-list-v3/skip-list/RoomSkipList.ts index 260786594fc..323b4c8fcbf 100644 --- a/src/stores/room-list-v3/skip-list/RoomSkipList.ts +++ b/src/stores/room-list-v3/skip-list/RoomSkipList.ts @@ -44,9 +44,22 @@ export class RoomSkipList implements Iterable { this.levels[currentLevel.level] = currentLevel; currentLevel = currentLevel.generateNextLevel(); } while (currentLevel.size > 1); + + // 3. Go through the list of rooms and mark nodes in active space + this.calculateActiveSpaceForNodes(); + this.initialized = true; } + /** + * Go through all the room nodes and check if they belong to the active space. + */ + public calculateActiveSpaceForNodes(): void { + for (const node of this.roomNodeMap.values()) { + node.checkIfRoomBelongsToActiveSpace(); + } + } + /** * Change the sorting algorithm used by the skip list. * This will reset the list and will rebuild from scratch. @@ -81,6 +94,7 @@ export class RoomSkipList implements Iterable { this.removeRoom(room); const newNode = new RoomNode(room); + newNode.checkIfRoomBelongsToActiveSpace(); this.roomNodeMap.set(room.roomId, newNode); /** @@ -159,6 +173,10 @@ export class RoomSkipList implements Iterable { return new SortedRoomIterator(this.levels[0].head!); } + public getRoomsInActiveSpace(): SortedSpaceFilteredIterator { + return new SortedSpaceFilteredIterator(this.levels[0].head!); + } + /** * The number of rooms currently in the skip list. */ @@ -179,3 +197,23 @@ class SortedRoomIterator implements Iterator { }; } } + +class SortedSpaceFilteredIterator implements Iterator { + public constructor(private current: RoomNode) {} + + public [Symbol.iterator](): SortedSpaceFilteredIterator { + return this; + } + + public next(): IteratorResult { + let current = this.current; + while (current && !current.isInActiveSpace) { + current = current.next[0]; + } + if (!current) return { value: undefined, done: true }; + this.current = current.next[0]; + return { + value: current.room, + }; + } +} diff --git a/src/stores/spaces/SpaceStore.ts b/src/stores/spaces/SpaceStore.ts index 8e8b4cc273e..b47cc4d1049 100644 --- a/src/stores/spaces/SpaceStore.ts +++ b/src/stores/spaces/SpaceStore.ts @@ -21,6 +21,7 @@ import { } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; +import { defer } from "matrix-js-sdk/src/utils"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import defaultDispatcher from "../../dispatcher/dispatcher"; @@ -152,6 +153,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private _enabledMetaSpaces: MetaSpace[] = []; /** Whether the feature flag is set for MSC3946 */ private _msc3946ProcessDynamicPredecessor: boolean = SettingsStore.getValue("feature_dynamic_room_predecessors"); + private _isReady = defer(); public constructor() { super(defaultDispatcher, {}); @@ -162,6 +164,14 @@ export class SpaceStoreClass extends AsyncStoreWithClient { SettingsStore.monitorSetting("feature_dynamic_room_predecessors", null); } + /** + * Returns a promise that resolves when the space store is ready. + * This happens after an initial hierarchy of spaces and rooms has been computed. + */ + public get isReady(): Promise { + return this._isReady.promise; + } + /** * Get the order of meta spaces to display in the space panel. * @@ -1201,6 +1211,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } else { this.switchSpaceIfNeeded(); } + this._isReady.resolve(); } private sendUserProperties(): void { diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx new file mode 100644 index 00000000000..bbf0edbf5e8 --- /dev/null +++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx @@ -0,0 +1,52 @@ +/* + * 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, screen, waitFor } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; + +import { mkRoom, stubClient } from "../../../../../test-utils"; +import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel"; +import { RoomList } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomList"; +import DMRoomMap from "../../../../../../src/utils/DMRoomMap"; + +describe("", () => { + let matrixClient: MatrixClient; + let vm: RoomListViewState; + + beforeEach(() => { + // Needed to render the virtualized list in rtl tests + // https://github.com/bvaughn/react-virtualized/issues/493#issuecomment-640084107 + jest.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(1500); + jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(1500); + + matrixClient = stubClient(); + const rooms = Array.from({ length: 10 }, (_, i) => mkRoom(matrixClient, `room${i}`)); + vm = { rooms, openRoom: jest.fn() }; + + // Needed to render a room list cell + DMRoomMap.makeShared(matrixClient); + jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(null); + }); + + it("should render a room list", () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should open the room", async () => { + const user = userEvent.setup(); + + render(); + await waitFor(async () => { + expect(screen.getByRole("gridcell", { name: "Open room room9" })).toBeVisible(); + await user.click(screen.getByRole("gridcell", { name: "Open room room9" })); + }); + expect(vm.openRoom).toHaveBeenCalledWith(vm.rooms[9].roomId); + }); +}); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListCell-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListCell-test.tsx new file mode 100644 index 00000000000..3bbde9fb929 --- /dev/null +++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListCell-test.tsx @@ -0,0 +1,44 @@ +/* + * 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 } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; + +import { mkRoom, stubClient } from "../../../../../test-utils"; +import { RoomListCell } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListCell"; +import DMRoomMap from "../../../../../../src/utils/DMRoomMap"; + +describe("", () => { + let matrixClient: MatrixClient; + let room: Room; + + beforeEach(() => { + matrixClient = stubClient(); + room = mkRoom(matrixClient, "room1"); + + DMRoomMap.makeShared(matrixClient); + jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(null); + }); + + test("should render a room cell", () => { + const onClick = jest.fn(); + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + test("should call onClick when clicked", async () => { + const user = userEvent.setup(); + + const onClick = jest.fn(); + render(); + + await user.click(screen.getByRole("button", { name: `Open room ${room.name}` })); + expect(onClick).toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 00000000000..54919fb9804 --- /dev/null +++ b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomList-test.tsx.snap @@ -0,0 +1,504 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render a room list 1`] = ` + +
+
+
+
+ + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+ +`; diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListCell-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListCell-test.tsx.snap new file mode 100644 index 00000000000..cf7c8b854aa --- /dev/null +++ b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListCell-test.tsx.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render a room cell 1`] = ` + + + +`; diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap index 5125b5ed54a..cd1fafd2248 100644 --- a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap @@ -3,8 +3,9 @@ exports[` should not render the RoomListSearch component when UIComponent.FilterContainer is at false 1`] = `
should not render the RoomListSearch component when U
+
+
+
+
+
+
+
+
+
+
+
`; @@ -30,8 +62,9 @@ exports[` should not render the RoomListSearch component when U exports[` should render the RoomListSearch component when UIComponent.FilterContainer is at true 1`] = `
should render the RoomListSearch component when UICom
+
+
+
+
+
+
+
+
+
+
+
`; diff --git a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts new file mode 100644 index 00000000000..2d4d9783504 --- /dev/null +++ b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -0,0 +1,317 @@ +/* +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 { EventType, KnownMembership, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { RoomListStoreV3Class } from "../../../../src/stores/room-list-v3/RoomListStoreV3"; +import { AsyncStoreWithClient } from "../../../../src/stores/AsyncStoreWithClient"; +import { RecencySorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/RecencySorter"; +import { mkEvent, mkMessage, mkSpace, stubClient, upsertRoomStateEvents } from "../../../test-utils"; +import { getMockedRooms } from "./skip-list/getMockedRooms"; +import { AlphabeticSorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter"; +import { LISTS_UPDATE_EVENT } from "../../../../src/stores/room-list/RoomListStore"; +import dispatcher from "../../../../src/dispatcher/dispatcher"; +import SpaceStore from "../../../../src/stores/spaces/SpaceStore"; +import { MetaSpace, UPDATE_SELECTED_SPACE } from "../../../../src/stores/spaces"; + +describe("RoomListStoreV3", () => { + async function getRoomListStore() { + const client = stubClient(); + const rooms = getMockedRooms(client); + client.getVisibleRooms = jest.fn().mockReturnValue(rooms); + jest.spyOn(AsyncStoreWithClient.prototype, "matrixClient", "get").mockReturnValue(client); + const store = new RoomListStoreV3Class(dispatcher); + await store.start(); + return { client, rooms, store, dispatcher }; + } + + beforeEach(() => { + jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space) => space === MetaSpace.Home); + jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => MetaSpace.Home); + jest.spyOn(SpaceStore.instance, "isReady", "get").mockImplementation(() => Promise.resolve()); + }); + + it("Provides an unsorted list of rooms", async () => { + const { store, rooms } = await getRoomListStore(); + expect(store.getRooms()).toEqual(rooms); + }); + + it("Provides a sorted list of rooms", async () => { + const { store, rooms, client } = await getRoomListStore(); + const sorter = new RecencySorter(client.getSafeUserId()); + const sortedRooms = sorter.sort(rooms); + expect(store.getSortedRooms()).toEqual(sortedRooms); + }); + + it("Provides a way to resort", async () => { + const { store, rooms, client } = await getRoomListStore(); + + // List is sorted by recency, sort by alphabetical now + store.useAlphabeticSorting(); + let sortedRooms = new AlphabeticSorter().sort(rooms); + expect(store.getSortedRooms()).toEqual(sortedRooms); + + // Go back to recency sorting + store.useRecencySorting(); + sortedRooms = new RecencySorter(client.getSafeUserId()).sort(rooms); + expect(store.getSortedRooms()).toEqual(sortedRooms); + }); + + describe("Updates", () => { + it("Room is re-inserted on timeline event", async () => { + const { store, rooms, dispatcher } = await getRoomListStore(); + + // Let's pretend like a new timeline event came on the room in 37th index. + const room = rooms[37]; + const event = mkMessage({ room: room.roomId, user: `@foo${3}:matrix.org`, ts: 1000, event: true }); + room.timeline.push(event); + + const payload = { + action: "MatrixActions.Room.timeline", + event, + isLiveEvent: true, + isLiveUnfilteredRoomTimelineEvent: true, + room, + }; + + const fn = jest.fn(); + store.on(LISTS_UPDATE_EVENT, fn); + dispatcher.dispatch(payload, true); + + expect(fn).toHaveBeenCalled(); + expect(store.getSortedRooms()[0].roomId).toEqual(room.roomId); + }); + + it("Predecessor room is removed on room upgrade", async () => { + const { store, rooms, client, dispatcher } = await getRoomListStore(); + // Let's say that !foo32:matrix.org is being upgraded + const oldRoom = rooms[32]; + // Create a new room with a predecessor event that points to oldRoom + const newRoom = new Room("!foonew:matrix.org", client, client.getSafeUserId(), {}); + const createWithPredecessor = new MatrixEvent({ + type: EventType.RoomCreate, + sender: "@foo:foo.org", + room_id: newRoom.roomId, + content: { + predecessor: { room_id: oldRoom.roomId, event_id: "tombstone_event_id" }, + }, + event_id: "$create", + state_key: "", + }); + upsertRoomStateEvents(newRoom, [createWithPredecessor]); + + const fn = jest.fn(); + store.on(LISTS_UPDATE_EVENT, fn); + dispatcher.dispatch( + { + action: "MatrixActions.Room.myMembership", + oldMembership: KnownMembership.Invite, + membership: KnownMembership.Join, + room: newRoom, + }, + true, + ); + + expect(fn).toHaveBeenCalled(); + const roomIds = store.getSortedRooms().map((r) => r.roomId); + expect(roomIds).not.toContain(oldRoom.roomId); + expect(roomIds).toContain(newRoom.roomId); + }); + + it("Rooms are inserted on m.direct event", async () => { + const { store, dispatcher } = await getRoomListStore(); + + // Let's create a m.direct event that we can dispatch + const content = { + "@bar1:matrix.org": ["!newroom1:matrix.org", "!newroom2:matrix.org"], + "@bar2:matrix.org": ["!newroom3:matrix.org", "!newroom4:matrix.org"], + "@bar3:matrix.org": ["!newroom5:matrix.org"], + }; + const event = mkEvent({ + event: true, + content, + user: "@foo:matrix.org", + type: EventType.Direct, + }); + + const fn = jest.fn(); + store.on(LISTS_UPDATE_EVENT, fn); + dispatcher.dispatch( + { + action: "MatrixActions.accountData", + event_type: EventType.Direct, + event, + }, + true, + ); + + // Each of these rooms should now appear in the store + // We don't need to mock the rooms themselves since our mocked + // client will create the rooms on getRoom() call. + expect(fn).toHaveBeenCalledTimes(5); + const roomIds = store.getSortedRooms().map((r) => r.roomId); + [ + "!newroom1:matrix.org", + "!newroom2:matrix.org", + "!newroom3:matrix.org", + "!newroom4:matrix.org", + "!newroom5:matrix.org", + ].forEach((id) => expect(roomIds).toContain(id)); + }); + + it("Room is re-inserted on tag change", async () => { + const { store, rooms, dispatcher } = await getRoomListStore(); + const fn = jest.fn(); + store.on(LISTS_UPDATE_EVENT, fn); + dispatcher.dispatch( + { + action: "MatrixActions.Room.tags", + room: rooms[10], + }, + true, + ); + expect(fn).toHaveBeenCalled(); + }); + + it("Room is re-inserted on decryption", async () => { + const { store, rooms, client, dispatcher } = await getRoomListStore(); + jest.spyOn(client, "getRoom").mockImplementation(() => rooms[10]); + + const fn = jest.fn(); + store.on(LISTS_UPDATE_EVENT, fn); + dispatcher.dispatch( + { + action: "MatrixActions.Event.decrypted", + event: { getRoomId: () => rooms[10].roomId }, + }, + true, + ); + expect(fn).toHaveBeenCalled(); + }); + + it("Logs a warning if room couldn't be found from room-id on decryption action", async () => { + const { store, client, dispatcher } = await getRoomListStore(); + jest.spyOn(client, "getRoom").mockImplementation(() => null); + const warnSpy = jest.spyOn(logger, "warn"); + + const fn = jest.fn(); + store.on(LISTS_UPDATE_EVENT, fn); + + // Dispatch a decrypted action but the room does not exist. + dispatcher.dispatch( + { + action: "MatrixActions.Event.decrypted", + event: { + getRoomId: () => "!doesnotexist:matrix.org", + getId: () => "some-id", + }, + }, + true, + ); + + expect(warnSpy).toHaveBeenCalled(); + expect(fn).not.toHaveBeenCalled(); + }); + + describe("Update from read receipt", () => { + function getReadReceiptEvent(userId: string) { + const content = { + some_id: { + "m.read": { + [userId]: { + ts: 5000, + }, + }, + }, + }; + const event = mkEvent({ + event: true, + content, + user: "@foo:matrix.org", + type: EventType.Receipt, + }); + return event; + } + + it("Room is re-inserted on read receipt from our user", async () => { + const { store, rooms, client, dispatcher } = await getRoomListStore(); + const event = getReadReceiptEvent(client.getSafeUserId()); + const fn = jest.fn(); + store.on(LISTS_UPDATE_EVENT, fn); + dispatcher.dispatch( + { + action: "MatrixActions.Room.receipt", + room: rooms[10], + event, + }, + true, + ); + expect(fn).toHaveBeenCalled(); + }); + + it("Read receipt from other users do not cause room to be re-inserted", async () => { + const { store, rooms, dispatcher } = await getRoomListStore(); + const event = getReadReceiptEvent("@foobar:matrix.org"); + const fn = jest.fn(); + store.on(LISTS_UPDATE_EVENT, fn); + dispatcher.dispatch( + { + action: "MatrixActions.Room.receipt", + room: rooms[10], + event, + }, + true, + ); + expect(fn).not.toHaveBeenCalled(); + }); + }); + + describe("Spaces", () => { + it("Filtering by spaces work", async () => { + const client = stubClient(); + const rooms = getMockedRooms(client); + + // Let's choose 5 rooms to put in space + const indexes = [6, 8, 13, 27, 75]; + const roomIds = indexes.map((i) => rooms[i].roomId); + const spaceRoom = mkSpace(client, "!space1:matrix.org", [], roomIds); + rooms.push(spaceRoom); + + client.getVisibleRooms = jest.fn().mockReturnValue(rooms); + jest.spyOn(AsyncStoreWithClient.prototype, "matrixClient", "get").mockReturnValue(client); + + // Mock the space store + jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space, id) => { + if (space === MetaSpace.Home && !roomIds.includes(id)) return true; + if (space === spaceRoom.roomId && roomIds.includes(id)) return true; + return false; + }); + + const store = new RoomListStoreV3Class(dispatcher); + await store.start(); + const fn = jest.fn(); + store.on(LISTS_UPDATE_EVENT, fn); + + // The rooms which belong to the space should not be shown + const result = store.getSortedRoomInActiveSpace().map((r) => r.roomId); + for (const id of roomIds) { + expect(result).not.toContain(id); + } + + // Lets switch to the space + jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => spaceRoom.roomId); + SpaceStore.instance.emit(UPDATE_SELECTED_SPACE); + expect(fn).toHaveBeenCalled(); + const result2 = store.getSortedRoomInActiveSpace().map((r) => r.roomId); + for (const id of roomIds) { + expect(result2).toContain(id); + } + }); + }); + }); +}); diff --git a/test/unit-tests/stores/room-list-v3/skip-list/RoomSkipList-test.ts b/test/unit-tests/stores/room-list-v3/skip-list/RoomSkipList-test.ts index 3172307a818..54c7c69bdce 100644 --- a/test/unit-tests/stores/room-list-v3/skip-list/RoomSkipList-test.ts +++ b/test/unit-tests/stores/room-list-v3/skip-list/RoomSkipList-test.ts @@ -7,26 +7,17 @@ Please see LICENSE files in the repository root for full details. import { shuffle } from "lodash"; -import type { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import type { Room } from "matrix-js-sdk/src/matrix"; import type { Sorter } from "../../../../../src/stores/room-list-v3/skip-list/sorters"; -import { mkMessage, mkStubRoom, stubClient } from "../../../../test-utils"; +import { mkMessage, stubClient } from "../../../../test-utils"; import { RoomSkipList } from "../../../../../src/stores/room-list-v3/skip-list/RoomSkipList"; import { RecencySorter } from "../../../../../src/stores/room-list-v3/skip-list/sorters/RecencySorter"; import { AlphabeticSorter } from "../../../../../src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter"; +import { getMockedRooms } from "./getMockedRooms"; +import SpaceStore from "../../../../../src/stores/spaces/SpaceStore"; +import { MetaSpace } from "../../../../../src/stores/spaces"; describe("RoomSkipList", () => { - function getMockedRooms(client: MatrixClient, roomCount: number = 100): Room[] { - const rooms: Room[] = []; - for (let i = 0; i < roomCount; ++i) { - const roomId = `!foo${i}:matrix.org`; - const room = mkStubRoom(roomId, `Foo Room ${i}`, client); - const event = mkMessage({ room: roomId, user: `@foo${i}:matrix.org`, ts: i + 1, event: true }); - room.timeline.push(event); - rooms.push(room); - } - return rooms; - } - function generateSkipList(roomCount?: number): { skipList: RoomSkipList; rooms: Room[]; @@ -41,6 +32,12 @@ describe("RoomSkipList", () => { return { skipList, rooms, totalRooms: rooms.length, sorter }; } + beforeEach(() => { + jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space) => space === MetaSpace.Home); + jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => MetaSpace.Home); + jest.spyOn(SpaceStore.instance, "isReady", "get").mockImplementation(() => Promise.resolve()); + }); + it("Rooms are in sorted order after initial seed", () => { const { skipList, totalRooms } = generateSkipList(); expect(skipList.size).toEqual(totalRooms); diff --git a/test/unit-tests/stores/room-list-v3/skip-list/getMockedRooms.ts b/test/unit-tests/stores/room-list-v3/skip-list/getMockedRooms.ts new file mode 100644 index 00000000000..d895ba944bd --- /dev/null +++ b/test/unit-tests/stores/room-list-v3/skip-list/getMockedRooms.ts @@ -0,0 +1,21 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import type { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import { mkMessage, mkStubRoom } from "../../../../test-utils"; + +export function getMockedRooms(client: MatrixClient, roomCount: number = 100): Room[] { + const rooms: Room[] = []; + for (let i = 0; i < roomCount; ++i) { + const roomId = `!foo${i}:matrix.org`; + const room = mkStubRoom(roomId, `Foo Room ${i}`, client); + const event = mkMessage({ room: roomId, user: `@foo${i}:matrix.org`, ts: i + 1, event: true }); + room.timeline.push(event); + rooms.push(room); + } + return rooms; +}