From 199ab71fce4b57c57e8c01a9dcaaadfc4485b350 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Mon, 24 Feb 2025 15:42:58 +0530 Subject: [PATCH 01/32] Implement enough of the new store to get a list of rooms --- src/@types/global.d.ts | 2 + src/stores/room-list-v3/RoomListStoreV3.ts | 69 ++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/stores/room-list-v3/RoomListStoreV3.ts diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 1df84ad344b..154a6504c67 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -47,6 +47,7 @@ import { type DeepReadonly } from "./common"; import type MatrixChat from "../components/structures/MatrixChat"; import { type InitialCryptoSetupStore } from "../stores/InitialCryptoSetupStore"; import { type ModuleApiType } from "../modules/Api.ts"; +import type RoomListStoreV3 from "../stores/room-list-v3/RoomListStoreV3.ts"; /* eslint-disable @typescript-eslint/naming-convention */ @@ -99,6 +100,7 @@ declare global { mxToastStore: ToastStore; mxDeviceListener: DeviceListener; mxRoomListStore: RoomListStore; + mxRoomListStoreV3: RoomListStoreV3; mxRoomListLayoutStore: RoomListLayoutStore; mxPlatformPeg: PlatformPeg; mxIntegrationManagers: typeof IntegrationManagers; diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts new file mode 100644 index 00000000000..10791dd9f46 --- /dev/null +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -0,0 +1,69 @@ +/* +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 { EmptyObject, Room } 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"; + +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"); + } + + public getSortedRooms(): Room[] { + if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList); + else return []; + } + + 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.fetchRoomsFromSdk(); + if (!rooms) return; + this.roomSkipList.seed(rooms); + this.emit(LISTS_UPDATE_EVENT); + } + + protected async onAction(payload: ActionPayload): Promise { + return; + } + + private fetchRoomsFromSdk(): Room[] | null { + if (!this.matrixClient) return null; + let rooms = this.matrixClient.getVisibleRooms(this.msc3946ProcessDynamicPredecessor); + rooms = rooms.filter((r) => VisibilityProvider.instance.isRoomVisible(r)); + return rooms; + } +} + +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; + } +} + +window.mxRoomListStoreV3 = RoomListStoreV3.instance; From 257cd9e4eab4980d2cb58744de641b4e950d2c1d Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Mon, 24 Feb 2025 21:44:05 +0530 Subject: [PATCH 02/32] Make it possible to swap sorting algorithm --- src/stores/room-list-v3/RoomListStoreV3.ts | 33 +++++++++++++++------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index 10791dd9f46..4b661c97029 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -15,6 +15,7 @@ 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"; export class RoomListStoreV3Class extends AsyncStoreWithClient { private roomSkipList?: RoomSkipList; @@ -25,17 +26,36 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors"); } + public getRooms(): Room[] { + let rooms = this.matrixClient?.getVisibleRooms(this.msc3946ProcessDynamicPredecessor) ?? []; + rooms = rooms.filter((r) => VisibilityProvider.instance.isRoomVisible(r)); + return rooms; + } + public getSortedRooms(): Room[] { if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList); else return []; } + public useAlphabeticSorting(): void { + if (this.roomSkipList) { + const sorter = new AlphabeticSorter(); + this.roomSkipList.useNewSorter(sorter, this.getRooms()); + } + } + + 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() ?? ""); + const sorter = new RecencySorter(this.matrixClient.getSafeUserId()); this.roomSkipList = new RoomSkipList(sorter); - const rooms = this.fetchRoomsFromSdk(); - if (!rooms) return; + const rooms = this.getRooms(); this.roomSkipList.seed(rooms); this.emit(LISTS_UPDATE_EVENT); } @@ -43,13 +63,6 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { protected async onAction(payload: ActionPayload): Promise { return; } - - private fetchRoomsFromSdk(): Room[] | null { - if (!this.matrixClient) return null; - let rooms = this.matrixClient.getVisibleRooms(this.msc3946ProcessDynamicPredecessor); - rooms = rooms.filter((r) => VisibilityProvider.instance.isRoomVisible(r)); - return rooms; - } } export default class RoomListStoreV3 { From 757cd88bb77897bf4d95b243c84715d5bef1aa92 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Tue, 25 Feb 2025 09:37:52 +0530 Subject: [PATCH 03/32] Don't attach to window object We don't want the store to be created if the labs flag is off --- src/@types/global.d.ts | 2 -- src/stores/room-list-v3/RoomListStoreV3.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 154a6504c67..1df84ad344b 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -47,7 +47,6 @@ import { type DeepReadonly } from "./common"; import type MatrixChat from "../components/structures/MatrixChat"; import { type InitialCryptoSetupStore } from "../stores/InitialCryptoSetupStore"; import { type ModuleApiType } from "../modules/Api.ts"; -import type RoomListStoreV3 from "../stores/room-list-v3/RoomListStoreV3.ts"; /* eslint-disable @typescript-eslint/naming-convention */ @@ -100,7 +99,6 @@ declare global { mxToastStore: ToastStore; mxDeviceListener: DeviceListener; mxRoomListStore: RoomListStore; - mxRoomListStoreV3: RoomListStoreV3; mxRoomListLayoutStore: RoomListLayoutStore; mxPlatformPeg: PlatformPeg; mxIntegrationManagers: typeof IntegrationManagers; diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index 4b661c97029..d6ce63713c8 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -78,5 +78,3 @@ export default class RoomListStoreV3 { return this.internalInstance; } } - -window.mxRoomListStoreV3 = RoomListStoreV3.instance; From 933fa086f2083deb4c912c8a3fb394b949741891 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Tue, 25 Feb 2025 09:42:46 +0530 Subject: [PATCH 04/32] Remove the store class Probably best to include this PR with the minimal vm implmentation --- src/stores/room-list-v3/RoomListStoreV3.ts | 80 ---------------------- 1 file changed, 80 deletions(-) delete mode 100644 src/stores/room-list-v3/RoomListStoreV3.ts diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts deleted file mode 100644 index d6ce63713c8..00000000000 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ /dev/null @@ -1,80 +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 { EmptyObject, Room } 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"; - -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"); - } - - public getRooms(): Room[] { - let rooms = this.matrixClient?.getVisibleRooms(this.msc3946ProcessDynamicPredecessor) ?? []; - rooms = rooms.filter((r) => VisibilityProvider.instance.isRoomVisible(r)); - return rooms; - } - - public getSortedRooms(): Room[] { - if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList); - else return []; - } - - public useAlphabeticSorting(): void { - if (this.roomSkipList) { - const sorter = new AlphabeticSorter(); - this.roomSkipList.useNewSorter(sorter, this.getRooms()); - } - } - - 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(); - this.roomSkipList.seed(rooms); - this.emit(LISTS_UPDATE_EVENT); - } - - protected async onAction(payload: ActionPayload): Promise { - return; - } -} - -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; - } -} From 80604111b6ff6ac20ebfbcd28a0b8f7263b667d1 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Tue, 25 Feb 2025 09:45:55 +0530 Subject: [PATCH 05/32] Create a new room list store that wraps around the skip list --- src/stores/room-list-v3/RoomListStoreV3.ts | 81 ++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/stores/room-list-v3/RoomListStoreV3.ts diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts new file mode 100644 index 00000000000..ec7c6926e1d --- /dev/null +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -0,0 +1,81 @@ +/* +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 { EmptyObject, Room } 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"; + +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"); + } + + public getRooms(): Room[] { + let rooms = this.matrixClient?.getVisibleRooms(this.msc3946ProcessDynamicPredecessor) ?? []; + rooms = rooms.filter((r) => VisibilityProvider.instance.isRoomVisible(r)); + return rooms; + } + + public getSortedRooms(): Room[] { + if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList); + else return []; + } + + public useAlphabeticSorting(): void { + if (this.roomSkipList) { + const sorter = new AlphabeticSorter(); + this.roomSkipList.useNewSorter(sorter, this.getRooms()); + } + } + + 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(); + this.roomSkipList.seed(rooms); + this.emit(LISTS_UPDATE_EVENT); + } + + protected async onAction(payload: ActionPayload): Promise { + return; + } +} + +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; + } +} + From d1152351c7769a683cc15a24304108587ce04d4b Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Tue, 25 Feb 2025 11:53:30 +0530 Subject: [PATCH 06/32] Create a minimal view model --- .../viewmodels/roomlist/RoomListViewModel.tsx | 18 +++++++++++++++ .../rooms/RoomListPanel/RoomListPanel.tsx | 23 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 src/components/viewmodels/roomlist/RoomListViewModel.tsx diff --git a/src/components/viewmodels/roomlist/RoomListViewModel.tsx b/src/components/viewmodels/roomlist/RoomListViewModel.tsx new file mode 100644 index 00000000000..97312d05049 --- /dev/null +++ b/src/components/viewmodels/roomlist/RoomListViewModel.tsx @@ -0,0 +1,18 @@ +/* +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 RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3"; + +export interface RoomListViewState { + rooms: Room[]; +} + +export function useRoomListViewModel(): RoomListViewState { + const rooms = RoomListStoreV3.instance.getSortedRooms(); + return { rooms }; +} diff --git a/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx b/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx index e5c1cbfa307..a52b6196510 100644 --- a/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx @@ -6,11 +6,14 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; +import { AutoSizer, List } from "react-virtualized"; +import type { ListRowProps } from "react-virtualized"; import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../../settings/UIFeature"; import { RoomListSearch } from "./RoomListSearch"; import { RoomListHeaderView } from "./RoomListHeaderView"; +import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewModel"; type RoomListPanelProps = { /** @@ -25,11 +28,31 @@ type RoomListPanelProps = { */ export const RoomListPanel: React.FC = ({ activeSpace }) => { const displayRoomSearch = shouldShowComponent(UIComponent.FilterContainer); + const { rooms } = useRoomListViewModel(); + + const rowRenderer = ({ key, index, style }: ListRowProps): React.JSX.Element => { + return ( +
+ {rooms[index].name} +
+ ); + }; return (
{displayRoomSearch && } + + {({ height, width }) => ( + + )} +
); }; From db25c0d8246af4baae0b1841cbd09452d10a2b7f Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Tue, 25 Feb 2025 15:05:56 +0530 Subject: [PATCH 07/32] Fix CI --- src/stores/room-list-v3/RoomListStoreV3.ts | 1 - .../__snapshots__/RoomListPanel-test.tsx.snap | 52 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index ec7c6926e1d..d6ce63713c8 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -78,4 +78,3 @@ export default class RoomListStoreV3 { return this.internalInstance; } } - 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..35643e394fb 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 @@ -23,6 +23,32 @@ exports[` should not render the RoomListSearch component when U +
+
+
+
+
+
+
+
+
`; @@ -141,6 +167,32 @@ exports[` should render the RoomListSearch component when UICom
+
+
+
+
+
+
+
+
+
`; From 06d9852f505f6bf3e8cf33638e547dbccd0da9a5 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Fri, 28 Feb 2025 15:07:11 +0530 Subject: [PATCH 08/32] Add some basic tests for the store --- .../room-list-v3/RoomListStoreV3-test.ts | 38 +++++++++++++++++++ .../skip-list/RoomSkipList-test.ts | 17 ++------- .../room-list-v3/skip-list/getMockedRooms.ts | 21 ++++++++++ 3 files changed, 62 insertions(+), 14 deletions(-) create mode 100644 test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts create mode 100644 test/unit-tests/stores/room-list-v3/skip-list/getMockedRooms.ts 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..cd58c428e6a --- /dev/null +++ b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -0,0 +1,38 @@ +/* +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 { MatrixDispatcher } from "../../../../src/dispatcher/dispatcher"; +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 { stubClient } from "../../../test-utils"; +import { getMockedRooms } from "./skip-list/getMockedRooms"; + +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 fakeDispatcher = { register: jest.fn() } as unknown as MatrixDispatcher; + const store = new RoomListStoreV3Class(fakeDispatcher); + store.start(); + return { client, rooms, store }; + } + + 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); + }); +}); 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..b644aa30e9d 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,15 @@ 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"; 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[]; 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; +} From 3da4576e10f9da1485719e5758fabbd68cda5ad6 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Fri, 28 Feb 2025 15:25:59 +0530 Subject: [PATCH 09/32] Write more tests --- .../stores/room-list-v3/RoomListStoreV3-test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts index cd58c428e6a..cd37b04e346 100644 --- a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -11,6 +11,7 @@ import { AsyncStoreWithClient } from "../../../../src/stores/AsyncStoreWithClien import { RecencySorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/RecencySorter"; import { stubClient } from "../../../test-utils"; import { getMockedRooms } from "./skip-list/getMockedRooms"; +import { AlphabeticSorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter"; describe("RoomListStoreV3", () => { async function getRoomListStore() { @@ -35,4 +36,18 @@ describe("RoomListStoreV3", () => { 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); + }); }); From 67ed03fad82fc4c4d2b72e9ca98bf53899b7987f Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Fri, 28 Feb 2025 15:48:14 +0530 Subject: [PATCH 10/32] Add some jsdoc comments --- src/stores/room-list-v3/RoomListStoreV3.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index d6ce63713c8..c37d158f41f 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -26,17 +26,26 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors"); } + /** + * 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 []; } + /** + * Re-sort the list of rooms by alphabetic order. + */ public useAlphabeticSorting(): void { if (this.roomSkipList) { const sorter = new AlphabeticSorter(); @@ -44,6 +53,9 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { } } + /** + * Re-sort the list of rooms by recency. + */ public useRecencySorting(): void { if (this.roomSkipList && this.matrixClient) { const sorter = new RecencySorter(this.matrixClient?.getSafeUserId() ?? ""); From cf50965e9e5702cd9f97138db0f56fbb8a36828f Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Fri, 28 Feb 2025 17:54:06 +0530 Subject: [PATCH 11/32] Add more documentation --- src/stores/room-list-v3/RoomListStoreV3.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index c37d158f41f..2904680ea8a 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -17,6 +17,11 @@ import { RoomSkipList } from "./skip-list/RoomSkipList"; import { RecencySorter } from "./skip-list/sorters/RecencySorter"; import { AlphabeticSorter } from "./skip-list/sorters/AlphabeticSorter"; +/** + * 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; From a7817e5e98c49d7cdaaa80b67da9bdc084289a4b Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Sun, 2 Mar 2025 16:35:25 +0530 Subject: [PATCH 12/32] Add more docs --- src/components/viewmodels/roomlist/RoomListViewModel.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/viewmodels/roomlist/RoomListViewModel.tsx b/src/components/viewmodels/roomlist/RoomListViewModel.tsx index 97312d05049..1dacd030e2d 100644 --- a/src/components/viewmodels/roomlist/RoomListViewModel.tsx +++ b/src/components/viewmodels/roomlist/RoomListViewModel.tsx @@ -9,9 +9,16 @@ import type { Room } from "matrix-js-sdk/src/matrix"; import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3"; export interface RoomListViewState { + /** + * A list of rooms to be displayed in the left panel. + */ rooms: Room[]; } +/** + * 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 = RoomListStoreV3.instance.getSortedRooms(); return { rooms }; From 87d8ef7037d07d8ac8d06f6a5b9e340e90ea2391 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Sun, 2 Mar 2025 15:05:22 +0530 Subject: [PATCH 13/32] Update the store on action --- src/stores/room-list-v3/RoomListStoreV3.ts | 94 +++++++++++++- .../room-list-v3/RoomListStoreV3-test.ts | 115 +++++++++++++++++- 2 files changed, 202 insertions(+), 7 deletions(-) diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index 2904680ea8a..566ee7361f5 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -5,7 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import type { EmptyObject, Room } from "matrix-js-sdk/src/matrix"; +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"; @@ -16,6 +19,8 @@ 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"; /** * This store allows for fast retrieval of the room list in a sorted and filtered manner. @@ -78,7 +83,92 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { } protected async onAction(payload: ActionPayload): Promise { - return; + 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; + + const roomId = payload.event.getRoomId(); + const tryAdd = (): boolean => { + const room = this.matrixClient?.getRoom(roomId); + if (room) this.addRoomAndEmit(room); + return !!room; + }; + if (!tryAdd()) setTimeout(tryAdd, 100); + 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); } } diff --git a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts index cd37b04e346..25401bb56a4 100644 --- a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -5,13 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import type { MatrixDispatcher } from "../../../../src/dispatcher/dispatcher"; +import { EventType, KnownMembership, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; + 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 { stubClient } from "../../../test-utils"; +import { mkEvent, mkMessage, stubClient, upsertRoomStateEvents } from "../../../test-utils"; import { getMockedRooms } from "./skip-list/getMockedRooms"; import { AlphabeticSorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter"; +import dispatcher from "../../../../src/dispatcher/dispatcher"; +import { LISTS_UPDATE_EVENT } from "../../../../src/stores/room-list/RoomListStore"; describe("RoomListStoreV3", () => { async function getRoomListStore() { @@ -19,10 +22,9 @@ describe("RoomListStoreV3", () => { const rooms = getMockedRooms(client); client.getVisibleRooms = jest.fn().mockReturnValue(rooms); jest.spyOn(AsyncStoreWithClient.prototype, "matrixClient", "get").mockReturnValue(client); - const fakeDispatcher = { register: jest.fn() } as unknown as MatrixDispatcher; - const store = new RoomListStoreV3Class(fakeDispatcher); + const store = new RoomListStoreV3Class(dispatcher); store.start(); - return { client, rooms, store }; + return { client, rooms, store, dispatcher }; } it("Provides an unsorted list of rooms", async () => { @@ -50,4 +52,107 @@ describe("RoomListStoreV3", () => { 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)); + }); + }); }); From 0fe02600ad391f232a4cc38e7590b10469abe7b7 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Sun, 2 Mar 2025 15:58:30 +0530 Subject: [PATCH 14/32] Add more tests --- .../room-list-v3/RoomListStoreV3-test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts index 25401bb56a4..cc98a48eb0d 100644 --- a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -154,5 +154,35 @@ describe("RoomListStoreV3", () => { "!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(); + }); }); }); From 35a9cbe3ff79ad9dfd394e4e142e01975ec93ecd Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Sun, 2 Mar 2025 16:08:40 +0530 Subject: [PATCH 15/32] Add newlines between case blocks --- src/stores/room-list-v3/RoomListStoreV3.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index 566ee7361f5..171c7c805e4 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -97,11 +97,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { } 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; @@ -113,6 +115,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { this.addRoomAndEmit(room); break; } + case "MatrixActions.accountData": { if (payload.event_type !== EventType.Direct) return; const dmMap = payload.event.getContent(); @@ -129,6 +132,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { } 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; @@ -142,6 +146,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { if (!tryAdd()) setTimeout(tryAdd, 100); break; } + case "MatrixActions.Room.myMembership": { const oldMembership = getEffectiveMembership(payload.oldMembership); const newMembership = getEffectiveMembershipTag(payload.room, payload.membership); From 44a867ffc064a0a7c520c4a86092b10e9c697117 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Sun, 2 Mar 2025 17:18:17 +0530 Subject: [PATCH 16/32] Make code more readable - Make if/else more consistent - Add comment on findAndAddRoom() --- src/stores/room-list-v3/RoomListStoreV3.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index 171c7c805e4..de3502b7d8a 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -138,12 +138,18 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { if (!payload.isLiveEvent || !payload.isLiveUnfilteredRoomTimelineEvent || !payload.room) return; const roomId = payload.event.getRoomId(); - const tryAdd = (): boolean => { + const findAndAddRoom = (): boolean => { const room = this.matrixClient?.getRoom(roomId); if (room) this.addRoomAndEmit(room); return !!room; }; - if (!tryAdd()) setTimeout(tryAdd, 100); + // We'll try finding the room associated with this event. + // If we can't find the room, we'll try again after 100ms. + if (!findAndAddRoom()) { + logger.warn(`Live timeline event ${payload.event.getId()} received without associated room`); + logger.warn(`Queuing failed room update for retry as a result.`); + setTimeout(findAndAddRoom, 100); + } break; } @@ -157,11 +163,9 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { 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}`); - } + if (prevRoom) this.roomSkipList.removeRoom(prevRoom); + else logger.warn(`Unable to find predecessor room with id ${predecessor.roomId}`); + } } this.addRoomAndEmit(payload.room); From 9f0f7826a09692c09d668f3ae684d4e55a1cbcc8 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Sun, 2 Mar 2025 17:20:27 +0530 Subject: [PATCH 17/32] Add more tests --- .../room-list-v3/RoomListStoreV3-test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts index cc98a48eb0d..4908609b5ef 100644 --- a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -184,5 +184,59 @@ describe("RoomListStoreV3", () => { ); expect(fn).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(); + }); + }); + }); }); From 9fae0012c8c454d8cd1c3db4ebd6a5c67ffa4e16 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Sun, 2 Mar 2025 18:16:43 +0530 Subject: [PATCH 18/32] Remove redundant code On a timeline action, we return early if payload.room is falsy. So then why do we need to retry fetching the room? I think this can be removed but will ask others if there's some conext I'm missing. --- src/stores/room-list-v3/RoomListStoreV3.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index de3502b7d8a..5aad4712ef3 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -138,18 +138,9 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { if (!payload.isLiveEvent || !payload.isLiveUnfilteredRoomTimelineEvent || !payload.room) return; const roomId = payload.event.getRoomId(); - const findAndAddRoom = (): boolean => { - const room = this.matrixClient?.getRoom(roomId); - if (room) this.addRoomAndEmit(room); - return !!room; - }; - // We'll try finding the room associated with this event. - // If we can't find the room, we'll try again after 100ms. - if (!findAndAddRoom()) { - logger.warn(`Live timeline event ${payload.event.getId()} received without associated room`); - logger.warn(`Queuing failed room update for retry as a result.`); - setTimeout(findAndAddRoom, 100); - } + const room = this.matrixClient?.getRoom(roomId); + if (room) this.addRoomAndEmit(room); + else logger.warn(`Live timeline event ${payload.event.getId()} received without associated room`); break; } @@ -165,7 +156,6 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { 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); From c989bbfcd671747862461f1c19a48cc80599e722 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Sun, 2 Mar 2025 18:18:24 +0530 Subject: [PATCH 19/32] Fix test --- test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts index 4908609b5ef..2fcb9c67cad 100644 --- a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -237,6 +237,5 @@ describe("RoomListStoreV3", () => { expect(fn).not.toHaveBeenCalled(); }); }); - }); }); From 6b61ba97ea293002ff9432a73a76235845356a2f Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Sun, 2 Mar 2025 18:51:53 +0530 Subject: [PATCH 20/32] Remove more redundant code --- src/stores/room-list-v3/RoomListStoreV3.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index 5aad4712ef3..417f4df413b 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -136,11 +136,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { 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; - - const roomId = payload.event.getRoomId(); - const room = this.matrixClient?.getRoom(roomId); - if (room) this.addRoomAndEmit(room); - else logger.warn(`Live timeline event ${payload.event.getId()} received without associated room`); + this.addRoomAndEmit(payload.room); break; } From 2c41ade8cfd2d01626ac482d1eec68dd966914b0 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Sun, 2 Mar 2025 18:52:06 +0530 Subject: [PATCH 21/32] Add more tests --- .../room-list-v3/RoomListStoreV3-test.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts index 2fcb9c67cad..dae9a3d58e5 100644 --- a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -6,6 +6,7 @@ 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"; @@ -13,8 +14,8 @@ import { RecencySorter } from "../../../../src/stores/room-list-v3/skip-list/sor import { mkEvent, mkMessage, stubClient, upsertRoomStateEvents } from "../../../test-utils"; import { getMockedRooms } from "./skip-list/getMockedRooms"; import { AlphabeticSorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter"; -import dispatcher from "../../../../src/dispatcher/dispatcher"; import { LISTS_UPDATE_EVENT } from "../../../../src/stores/room-list/RoomListStore"; +import dispatcher from "../../../../src/dispatcher/dispatcher"; describe("RoomListStoreV3", () => { async function getRoomListStore() { @@ -185,6 +186,30 @@ describe("RoomListStoreV3", () => { 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 = { From b0937897ab3741bfb1f0af547e7602a93b4e2f0b Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Mon, 3 Mar 2025 10:54:44 +0530 Subject: [PATCH 22/32] Add method to await space store setup Otherwise, the room list store will get incorrect information about spaces and thus will produce an incorrect roomlist. --- src/stores/spaces/SpaceStore.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 { From c254ae050e9c4327e0fada9cdbfc12c1ebb20e66 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Mon, 3 Mar 2025 14:33:22 +0530 Subject: [PATCH 23/32] Implement a way to filter by active space Implement a way to filter by active space --- src/stores/room-list-v3/RoomListStoreV3.ts | 21 ++++++++ src/stores/room-list-v3/skip-list/RoomNode.ts | 22 +++++++++ .../room-list-v3/skip-list/RoomSkipList.ts | 38 +++++++++++++++ .../room-list-v3/RoomListStoreV3-test.ts | 48 ++++++++++++++++++- 4 files changed, 128 insertions(+), 1 deletion(-) diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index 417f4df413b..369708b817d 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -21,6 +21,8 @@ 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. @@ -34,6 +36,10 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { 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()); } /** @@ -53,6 +59,14 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { 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. */ @@ -78,6 +92,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { 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); } @@ -165,6 +180,12 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { 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 { 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/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts index dae9a3d58e5..aeaa6ffdbef 100644 --- a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -11,11 +11,13 @@ 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, stubClient, upsertRoomStateEvents } from "../../../test-utils"; +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() { @@ -262,5 +264,49 @@ describe("RoomListStoreV3", () => { 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, "isReady", "get").mockImplementation(() => Promise.resolve()); + 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); + } + }); + }); }); }); From 19ed3e9770641fd2ba67d4d69b67e41a6c579889 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Mon, 3 Mar 2025 11:35:51 +0530 Subject: [PATCH 24/32] Fix broken jest tests --- .../stores/room-list-v3/RoomListStoreV3-test.ts | 9 +++++++-- .../stores/room-list-v3/skip-list/RoomSkipList-test.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts index aeaa6ffdbef..2d4d9783504 100644 --- a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -26,10 +26,16 @@ describe("RoomListStoreV3", () => { client.getVisibleRooms = jest.fn().mockReturnValue(rooms); jest.spyOn(AsyncStoreWithClient.prototype, "matrixClient", "get").mockReturnValue(client); const store = new RoomListStoreV3Class(dispatcher); - store.start(); + 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); @@ -280,7 +286,6 @@ describe("RoomListStoreV3", () => { jest.spyOn(AsyncStoreWithClient.prototype, "matrixClient", "get").mockReturnValue(client); // Mock the space store - jest.spyOn(SpaceStore.instance, "isReady", "get").mockImplementation(() => Promise.resolve()); 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; 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 b644aa30e9d..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 @@ -14,6 +14,8 @@ import { RoomSkipList } from "../../../../../src/stores/room-list-v3/skip-list/R 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 generateSkipList(roomCount?: number): { @@ -30,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); From 37196422508e53eb971a1085b1014ca7db04dcf1 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Mon, 3 Mar 2025 14:47:27 +0530 Subject: [PATCH 25/32] Add more vm functionality - Listen for updates from the store - Provide a method to open rooms --- .../viewmodels/roomlist/RoomListViewModel.tsx | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/components/viewmodels/roomlist/RoomListViewModel.tsx b/src/components/viewmodels/roomlist/RoomListViewModel.tsx index 1dacd030e2d..9422676cf8a 100644 --- a/src/components/viewmodels/roomlist/RoomListViewModel.tsx +++ b/src/components/viewmodels/roomlist/RoomListViewModel.tsx @@ -5,14 +5,26 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com 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; } /** @@ -20,6 +32,20 @@ export interface RoomListViewState { * @see {@link RoomListViewState} for more information about what this view model returns. */ export function useRoomListViewModel(): RoomListViewState { - const rooms = RoomListStoreV3.instance.getSortedRooms(); - return { rooms }; + 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 }; } From 4beeffcdeb49c0c4ba00763eeeaa9a630e998b01 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Thu, 27 Feb 2025 10:49:51 +0100 Subject: [PATCH 26/32] chore: make the room list panel a flexbox --- .../rooms/RoomListPanel/_RoomListHeaderView.pcss | 2 +- .../views/rooms/RoomListPanel/_RoomListSearch.pcss | 2 +- .../views/rooms/RoomListPanel/RoomListPanel.tsx | 11 +++++++++-- 3 files changed, 11 insertions(+), 4 deletions(-) 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/views/rooms/RoomListPanel/RoomListPanel.tsx b/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx index a52b6196510..caa6241de7f 100644 --- a/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx @@ -13,6 +13,7 @@ import { shouldShowComponent } from "../../../../customisations/helpers/UICompon import { UIComponent } from "../../../../settings/UIFeature"; import { RoomListSearch } from "./RoomListSearch"; import { RoomListHeaderView } from "./RoomListHeaderView"; +import { Flex } from "../../../utils/Flex"; import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewModel"; type RoomListPanelProps = { @@ -39,7 +40,13 @@ export const RoomListPanel: React.FC = ({ activeSpace }) => }; return ( -
+ {displayRoomSearch && } @@ -53,6 +60,6 @@ export const RoomListPanel: React.FC = ({ activeSpace }) => /> )} -
+ ); }; From aa0dc11e6806d45f5d202248e891ad223a56501c Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 26 Feb 2025 10:54:18 +0100 Subject: [PATCH 27/32] draft --- res/css/_components.pcss | 2 + .../views/rooms/RoomListPanel/_RoomList.pcss | 15 ++++++ .../rooms/RoomListPanel/_RoomListCell.pcss | 44 ++++++++++++++++ .../views/rooms/RoomListPanel/RoomList.tsx | 51 +++++++++++++++++++ .../rooms/RoomListPanel/RoomListCell.tsx | 44 ++++++++++++++++ .../rooms/RoomListPanel/RoomListPanel.tsx | 25 +-------- .../rooms/RoomListPanel/RoomListView.tsx | 17 +++++++ src/i18n/strings/en_EN.json | 4 ++ 8 files changed, 179 insertions(+), 23 deletions(-) create mode 100644 res/css/views/rooms/RoomListPanel/_RoomList.pcss create mode 100644 res/css/views/rooms/RoomListPanel/_RoomListCell.pcss create mode 100644 src/components/views/rooms/RoomListPanel/RoomList.tsx create mode 100644 src/components/views/rooms/RoomListPanel/RoomListCell.tsx create mode 100644 src/components/views/rooms/RoomListPanel/RoomListView.tsx 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/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 caa6241de7f..291794399fb 100644 --- a/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx @@ -6,15 +6,13 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { AutoSizer, List } from "react-virtualized"; -import type { ListRowProps } from "react-virtualized"; import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../../settings/UIFeature"; import { RoomListSearch } from "./RoomListSearch"; import { RoomListHeaderView } from "./RoomListHeaderView"; +import { RoomListView } from "./RoomListView"; import { Flex } from "../../../utils/Flex"; -import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewModel"; type RoomListPanelProps = { /** @@ -29,15 +27,6 @@ type RoomListPanelProps = { */ export const RoomListPanel: React.FC = ({ activeSpace }) => { const displayRoomSearch = shouldShowComponent(UIComponent.FilterContainer); - const { rooms } = useRoomListViewModel(); - - const rowRenderer = ({ key, index, style }: ListRowProps): React.JSX.Element => { - return ( -
- {rooms[index].name} -
- ); - }; return ( = ({ activeSpace }) => > {displayRoomSearch && } - - {({ height, width }) => ( - - )} - + ); }; 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", From 7f4a142158567ac5bb7c753fe49341e3855280d2 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Thu, 27 Feb 2025 18:09:51 +0100 Subject: [PATCH 28/32] test(new room list): add test for room cell --- .../rooms/RoomListPanel/RoomListCell-test.tsx | 44 ++++++++++++++++ .../__snapshots__/RoomListCell-test.tsx.snap | 50 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 test/unit-tests/components/views/rooms/RoomListPanel/RoomListCell-test.tsx create mode 100644 test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListCell-test.tsx.snap 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__/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`] = ` + + + +`; From 463249bc8b25f61d85aa9317b4cf18e7e20e6acb Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Fri, 28 Feb 2025 09:48:11 +0100 Subject: [PATCH 29/32] test(new room list): update room list panel tests --- .../__snapshots__/RoomListPanel-test.tsx.snap | 84 +++++++++++-------- 1 file changed, 48 insertions(+), 36 deletions(-) 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 35643e394fb..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
-
-
-
+ class="resize-triggers" + > +
+
+
+
+
@@ -56,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
-
-
-
+ class="resize-triggers" + > +
+
+
+
+
From d8f48341d29145cb69436796fd53262426bfcbdb Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Fri, 28 Feb 2025 10:15:31 +0100 Subject: [PATCH 30/32] test(new room list): add test to virtualized room list --- .../rooms/RoomListPanel/RoomList-test.tsx | 52 ++ .../__snapshots__/RoomList-test.tsx.snap | 504 ++++++++++++++++++ 2 files changed, 556 insertions(+) create mode 100644 test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx create mode 100644 test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomList-test.tsx.snap 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/__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`] = ` + +
+
+
+
+ + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+ +`; From 6f6a21448d5d57665dc18a121543146e93804382 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Fri, 28 Feb 2025 11:02:08 +0100 Subject: [PATCH 31/32] test(e2e): add room list tests --- .../room-list-panel/room-list.spec.ts | 50 ++++++++++++++++++ .../room-list.spec.ts/room-list-linux.png | Bin 0 -> 25405 bytes .../room-list-scrolled-linux.png | Bin 0 -> 22627 bytes 3 files changed, 50 insertions(+) create mode 100644 playwright/e2e/left-panel/room-list-panel/room-list.spec.ts create mode 100644 playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-linux.png create mode 100644 playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-scrolled-linux.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.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 0000000000000000000000000000000000000000..f9275dd2111bca7b7fabaf60043991a6f35f26eb GIT binary patch literal 25405 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 GFXbJP;FoB?ka-ng9T=fD8|Q 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 Date: Mon, 3 Mar 2025 11:59:02 +0100 Subject: [PATCH 32/32] test(e2e): update room panel tests --- .../room-list-panel/room-list-panel.spec.ts | 7 +++++++ .../room-list-panel-linux.png | Bin 7985 -> 29335 bytes 2 files changed, 7 insertions(+) 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/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 d03dbf992b02661f4589e7e124671d058574e4d1..1ebfde5ea70bf76d71cae0851566d8ccfa1cb4db 100644 GIT binary patch literal 29335 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 literal 7985 zcmeHM=T}qdw?^8o=mZG8geEnRKmsHXLT>K8f530n{d7OfT4$|&-gDly*WUX)=XrODdSGHGa$Nd2 zA0MB{-8(lQ@$nsU;N#=(`;DLX#8M_9fF}<5JTkn&SKNDcg^y2;a`)!nj{`E-rx3x9 zw?mnmj?KoNNuh~C=e5oqy1+C#6E<_t=HzSXRaw>Q3(CbmN{URUJibiP#VhKps>a)D zJ9B@_v-@>Osrr==|Ip9Q`{C!3uD`l@=f;h8E{!Xa3GG_m!n{8V5bn=nJ#%_u8? z!Hn*85iK5vZ?DLnJTvD~S{>=O(w`e6c+BplU0fV>cg3rwH^uyer3F)jMpC^{?q4#a zGCi`8$J3EPc<|s+|2OqfO78;ZFHaauT2kWlfXMX88RxsULh4}Af%<}Q5Fd3p$_(#&s51>@up+f*dr`n5>U(cG+yE%a*Xl-Qmn&**GDT1V%UdaJH_#2MK; z2IOy~{&!Dh0g_(!F{?5usPCi68u}O~6?DPJ4d15^OS^_fDRM8y_s+kpFeeAyqxr0_5p*7hBa3^99HWrfkNeYL2z$6K(a(bMEZW?{98Ruf+@VHw zeqT*djo3LRm7=Y^9K6uATfgcq1~-jcrYyy$GrFX_ryA4( zdaXl(xs_RUC*)MIb^JGS=i5w8jbu*G^%Iw0fX!UY7n3ZtQU}8sm)_J!XcMUp9E-hA zjkYNIufnHf@@5LgQKF)U9v8llyZ(X1i38eMv@fKd+c4#ug%EY31^4XfaM!o z!nYt0GwWL){O!H8GaC@j9XzX@AURMndWXo>M)X3|P}+fWrDBd;HVW^@y5ni6xu`VF zrN*EpGu`Gq>zK0&&_CLSX^wlHo!u8WJ%awBN0_r1BghW(B!E80wn z@?a;U%hIt}h^jC%f-ctfWxXANJa5UB5MTH(^b}ct?ZC0h+vuV9-A-jOC>fl)Ku$O| z-)3^}pbqV5yEm*yJMfz&dNgIr{?aZ05w#c31`%J^U0k_8((z>K9#Do)uD*2d!|qksa0gPXU$1Hipl)&Ypki= z`OWrK{y~>WIm0592sz*UdLto#FRi)GV+%2Nukt0FC2+eEv!f;f( zo6iBJ!|u7%$=wF(fzqY#jue&ZR>DnvT?X~biW`WvksC?F%x+$?@7=Ru2a>vwfz=u? z`kI1*qmAEY1>s%uagrKzd3lADADiHfN`Nt#&hA|-`o@Yb`j}*Z*=jdJ;pb8NYoEL2 z60iO6LSjm{a4@XZUOnfwe(9j9Q08(--y$tP*E`_Dh7IAtzjj_j*zfQY*px(k<$lqRiE=v&*GoJv}|( z>TPIX$ui43*Rp6T)n_X;KLZ;PYbv$VfvK$Dpy^cgq`sJFXZ^5XCTJTm^Hr@efOqe8 z87Ws>hGvmcM(DwG7da`ws?v1TW?aRQQbkt=iQgD`P$^&%iWpK`ja_i(l%?upEmKgxRaZ&p@ERkcY$y4PGVGF!bdwgONvTNL6!NVS1v zZs(T?9i3*BG(-uN+q^br=kM8{j;#C)SALs$)g|QK<0W`XnT_(!xDVQPyjR=}1{<$f z5`q|KsQkRST?ZK#O(Mi@ZElV+Tbou_V^lK_YS|3Lz$0XEu&(a^V6J7>clyxyLzk|< zVu8SZG|t55x}EXCU83qxS%kbkegD<4e%UT>@eVajadR`vGpqqq(d0eme|$X6DL;mo zSyBS`X}`kmKYHnsLdE{luq>cze|K`1(du$9mmL+8Hx4k6naYUB);e6{sA%sQUR&#v zA}7n%mU4SO_ue|n3~I3e*n7crVf!Df6un@Wn#NuY&o5ZZS97#W91B`#AFzSkh%MD! z*QrxG-aA^*o7oNa+j$-3SEEwwbgbNdZ@K`lT{ZY7@8?eKmcPiVUT_XuzJMet)>i;$ zwcZ-Op3&p9wq4KN%_e{A@k=oDz1vGCwz)iVZT>U40)0X zyu=J3f1T9|LAZK&*w3ycooqRU8!OlN1EeS)GPumz8X2K>7D=Tq`=&P@Bt!25=v9`9 z7H$tPUIpKXQ|0?{vESPt=C%n->qG4ZQtf559tUBGR1Wl$L19xVGK`36S1cFh5>|o{ zO;qIW@TZP>i0gR|xx@9xWcfxvY?wustwbq~`sEc>|ITs?Qv_~afqd)a&Mp5_A(pBx0tns zXO7kEGYhVQh#mU%C|bmjn`Wl)+2pI&x}{SC5UY|{)XGGLKLBiNmR_g9$wG6>cNvfJ zO9dmxl}<(#Xw#0)J!&Buu+B2r-aF+V59fCvKn;)N(+Z!1gw@(A^>VkQ+H z?|DW&pmVL9m2OX~XVL3quI|td)FI}3whhxZXTY57D4+$T@bAz=dH7XI=XZLv$-Z#d z+yFH1#JA}N&lYr6VA69hZ4U09f$!sIl;n5*#+*>LatBc~cy}^}yVSE#FcGiy=as)6 zVQM^3!X5$QmbS!m`HZW?a?4Yv$CzVW4chjX%PC5>QsR=5Cr<|PPC0t?_r=AoWmCW;=w8mgnI1lZH@Rz zLHI95dnIOWXKXq>GN_84pxi^E0aeLmq(y7^&x zeGN6zjEUa~(i%?vWk`z0ReLgOKY9BkcC;{PI#uN@&Vr1UPqD3;@6+ zl5vE1IlLQ9f{W$Mm(h>c92AMclkydcwRzlI`=GYGzO1l5=x5xfoaferT`{5C9?{SV zN@s2>Mz#dbC%&;&kWY}(*pK^i8Q%%g_3VDD-~yhT=mE3~1xMm_lSZ_1OmBY0=Y8ZT zT3Gs1(J>(-GhT-U53|^8^AWq9ins>MG%vx}S^KtxVnd$ykW2$%?yx-yIZRQk&EC5w z{zZ-SbO54tHgi%hpR-};Z?aBb7qE;<@L$JV^UkC>XA?`bTTeIxI3-%71#?qR>MbyG zHOh}X0!IlKoqqp#%c}f&|LGCN_U5;Sz9*$yzX@+N%yr7nQ79Se(8sr&x8n>>?QO5G zwYl?K=6{FPZEm_Qt?Wgc6~b>?*%=QumAsIa*)H+lWMDxWF2i3>qLE9~Xvp)Tykas* zPqn}e#yB3Gd^_l6&dF*#(tCM%<`57!jype|tY~3vj$1+b1&7RZ4a1FD)Ar^6Y0JHMv3YN!pML%5dvgnPrXoPYz3=qMmRGb+R&nx?a4O;-CK0IPX!`Oa*?gSpGeN8~>rT24$FptBC-kSbA;X5Fv~VH3&G`;h1T zp~?JBT8G28PV7)guAvlCzgYeagP`sD*=Ya3bZUl^tTfMrEY;W*iKoVlZ#inRXJx$f z_iBIq92rs2y*kN=^xYL_I0<`__oLI3!{rHW!f!Vh{o#BgQM?rh1mZyIcSl*p=}ry_ zO&N|4A+b`wPn3LWO3U!?jA`Q^3FDPqI+-T4+CFo$!eq$C!N}~H4?GV?Zf=V(_OP?B zXqd9jRi5d6i4G2DTdHQa#VPxey4x9Laln@^mw0itSFsGX^SwhWSV%FS(R*m_LA?Cu z4%TXpZCC^T_L-rp+gb|~&bl9FTY)RCF0U^yyijOL!#_Q5Q7|-3TwQXh*jn!IP!_OM zwuI#JN?9*$S(Bx)vhVcH)pq=+hbN!PIhYNeM`t)u*@blwVf$pks=T~KdM6AqrGr?n z5sW0wdTDB|T-FVxh5m2(ivkxYvjlB7M4lpp5HH|2T!InNH6;nuc%ZMdG+!`0ATWZz zktyfOcP>$okN-aitK;{!;JpG~`t!X;d>-CQ@_cdXe81lj;0ygfiYB_fDBpFow{{l5 zo!HH;eM0}dIo}q))}41pz)PgPqo<3tg{60X*L0)-WVTrb{&n277aGM54PQ2VqkvFK zZ-b_WhJr#IJt(36BVX~Mw7Q!Tu|PatBHs%FA*F*+4+3icXq-kpqH9CCBLT>C|FS-c zeo(;RlWFX7@zE5F<^kjk9LX&G`=AJIue~u4TQ}Vegs#2rp)83CZ3HRtspJ;sbV@QB zP#{MY4;ajbc`#fe6wNQN4?rzb-){8rcy_=POt-!?FuzNwS5pFWstthi|oXX%0e zc-q)A-xh#d@YI72 zRlD!qH+gF$nXxxWk-8-j`>i8{=^{$jb@#f_{If2ketAm3rJ-gay*>fWndBsj;QmRhx#qf;>n z!PUV}Z^t7^(fJWaB_ZA6&Fe9Xy|y2GRyIW{0uf*B!l9^8{ktLiQhVu3Mq`eKsaN zDGe3zkTMv<6=K}YQN@)#hrIzp`;ZOyep9JKkoR)vqL%C0z}k3Xe5IS1VW$&p@wf!b0 zXS+cWKr9uh>Q;N2^$N7;cr|X%#T2#>QWx`}kuBDb?P_04q4ctfN0&oHr~UI=2XYw` zv^@#&o%(J8KZx;0wJYSJ4PF}PH$M7f`_{rvrm4v&z*UFsDkNHEAB>!ZGnoEdy~#z? ziqWhl3LZD}mi{7{6&?@bDdwAil6LA>!Kj^3%t!zb1Esm*EOsF}O1nK>jFisqh;8|4_vN`?0Z1_nm@Srlr@F*FsbI?ZP?$&r$sz@n&DR*}C zxb0$F2O!;VW#AdX*y%4SzEQ=dVam{EuZg2lJL3WXXjYcy_MBTZ)*1l)AOqPKOFB9h zySXacVq~k**`Zi>g*qVgPRRM_Rc4k5K-bgi#^FfeY7mRPtD&iR>4bEub^wnps#OAg zKE)NA8eH!#QoD56#l1v1h$caNGXheh3~!VnFLO7V_bxz;y*dS|2!{jGB2tuTfz*}O z*%FYN@+WkuOGA~{L5ZJu+Kel=!g)-XR>I*gKF{4sHmpTROnKS!^u|utzqjAoD$5*? zm2YUbqF2ec$Z^(l$`8{p74%?rGKOJk47y20_9(z{0o$SPp5x4k^O&97c8!IS6#T$EhPkR Q2F`c)mdVZH8&6*U7pzRP8UO$Q