mirror of
https://github.com/vector-im/element-web.git
synced 2025-08-20 05:51:08 +02:00
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:
parent
3c57323595
commit
2da21248bb
25
src/components/viewmodels/roomlist/RoomListViewModel.tsx
Normal file
25
src/components/viewmodels/roomlist/RoomListViewModel.tsx
Normal 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 };
|
||||||
|
}
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
97
src/stores/room-list-v3/RoomListStoreV3.ts
Normal file
97
src/stores/room-list-v3/RoomListStoreV3.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
||||||
`;
|
`;
|
||||||
|
53
test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts
Normal file
53
test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
@ -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[];
|
||||||
|
@ -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;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user