Room List - Implement a minimal view model (#29357)

* Implement enough of the new store to get a list of rooms

* Make it possible to swap sorting algorithm

* Don't attach to window object

We don't want the store to be created if the labs flag is off

* Remove the store class

Probably best to include this PR with the minimal vm implmentation

* Create a new room list store that wraps around the skip list

* Create a minimal view model

* Fix CI

* Add some basic tests for the store

* Write more tests

* Add some jsdoc comments

* Add more documentation

* Add more docs
This commit is contained in:
R Midhun Suresh 2025-03-03 16:42:00 +05:30 committed by GitHub
parent 3c57323595
commit 2da21248bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 274 additions and 14 deletions

View File

@ -0,0 +1,25 @@
/*
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 {
/**
* 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 };
}

View File

@ -6,11 +6,14 @@ Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import React from "react";
import { AutoSizer, List } from "react-virtualized";
import type { ListRowProps } from "react-virtualized";
import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents"; import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../../settings/UIFeature"; import { UIComponent } from "../../../../settings/UIFeature";
import { RoomListSearch } from "./RoomListSearch"; import { RoomListSearch } from "./RoomListSearch";
import { RoomListHeaderView } from "./RoomListHeaderView"; import { RoomListHeaderView } from "./RoomListHeaderView";
import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewModel";
type RoomListPanelProps = { type RoomListPanelProps = {
/** /**
@ -25,11 +28,31 @@ type RoomListPanelProps = {
*/ */
export const RoomListPanel: React.FC<RoomListPanelProps> = ({ activeSpace }) => { export const RoomListPanel: React.FC<RoomListPanelProps> = ({ activeSpace }) => {
const displayRoomSearch = shouldShowComponent(UIComponent.FilterContainer); const displayRoomSearch = shouldShowComponent(UIComponent.FilterContainer);
const { rooms } = useRoomListViewModel();
const rowRenderer = ({ key, index, style }: ListRowProps): React.JSX.Element => {
return (
<div key={key} style={style}>
{rooms[index].name}
</div>
);
};
return ( return (
<section className="mx_RoomListPanel" data-testid="room-list-panel"> <section className="mx_RoomListPanel" data-testid="room-list-panel">
{displayRoomSearch && <RoomListSearch activeSpace={activeSpace} />} {displayRoomSearch && <RoomListSearch activeSpace={activeSpace} />}
<RoomListHeaderView /> <RoomListHeaderView />
<AutoSizer>
{({ height, width }) => (
<List
rowRenderer={rowRenderer}
rowCount={rooms.length}
rowHeight={20}
height={height}
width={width}
/>
)}
</AutoSizer>
</section> </section>
); );
}; };

View File

@ -0,0 +1,97 @@
/*
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";
/**
* 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<EmptyObject> {
private roomSkipList?: RoomSkipList;
private readonly msc3946ProcessDynamicPredecessor: boolean;
public constructor(dispatcher: MatrixDispatcher) {
super(dispatcher);
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();
this.roomSkipList.useNewSorter(sorter, this.getRooms());
}
}
/**
* Re-sort the list of rooms by recency.
*/
public useRecencySorting(): void {
if (this.roomSkipList && this.matrixClient) {
const sorter = new RecencySorter(this.matrixClient?.getSafeUserId() ?? "");
this.roomSkipList.useNewSorter(sorter, this.getRooms());
}
}
protected async onReady(): Promise<any> {
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<void> {
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;
}
}

View File

@ -23,6 +23,32 @@ exports[`<RoomListPanel /> should not render the RoomListSearch component when U
</h1> </h1>
</div> </div>
</header> </header>
<div
style="overflow: visible; height: 0px; width: 0px;"
>
<div
aria-label="grid"
aria-readonly="true"
class="ReactVirtualized__Grid ReactVirtualized__List"
role="grid"
style="box-sizing: border-box; direction: ltr; height: 0px; position: relative; width: 0px; will-change: transform; overflow-x: hidden; overflow-y: hidden;"
tabindex="0"
/>
</div>
<div
class="resize-triggers"
>
<div
class="expand-trigger"
>
<div
style="width: 1px; height: 1px;"
/>
</div>
<div
class="contract-trigger"
/>
</div>
</section> </section>
</DocumentFragment> </DocumentFragment>
`; `;
@ -141,6 +167,32 @@ exports[`<RoomListPanel /> should render the RoomListSearch component when UICom
</div> </div>
</button> </button>
</header> </header>
<div
style="overflow: visible; height: 0px; width: 0px;"
>
<div
aria-label="grid"
aria-readonly="true"
class="ReactVirtualized__Grid ReactVirtualized__List"
role="grid"
style="box-sizing: border-box; direction: ltr; height: 0px; position: relative; width: 0px; will-change: transform; overflow-x: hidden; overflow-y: hidden;"
tabindex="0"
/>
</div>
<div
class="resize-triggers"
>
<div
class="expand-trigger"
>
<div
style="width: 1px; height: 1px;"
/>
</div>
<div
class="contract-trigger"
/>
</div>
</section> </section>
</DocumentFragment> </DocumentFragment>
`; `;

View File

@ -0,0 +1,53 @@
/*
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";
import { AlphabeticSorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter";
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);
});
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);
});
});

View File

@ -7,26 +7,15 @@ Please see LICENSE files in the repository root for full details.
import { shuffle } from "lodash"; 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 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 { RoomSkipList } from "../../../../../src/stores/room-list-v3/skip-list/RoomSkipList";
import { RecencySorter } from "../../../../../src/stores/room-list-v3/skip-list/sorters/RecencySorter"; 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 { AlphabeticSorter } from "../../../../../src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter";
import { getMockedRooms } from "./getMockedRooms";
describe("RoomSkipList", () => { 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): { function generateSkipList(roomCount?: number): {
skipList: RoomSkipList; skipList: RoomSkipList;
rooms: Room[]; rooms: Room[];

View File

@ -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;
}