Room List Store: Support filters by implementing just the favourite filter (#29433)
* Implement the favourite filter * Make the room node capable of dealing with filters - Holds data to indicate which filters apply - Provides method to check if a given set of filters apply to this node - Provides a method to recalculate which filters apply * Wire up the filtering mechanism in skip list * Use filters in the store * Remove else * Use a set instead of map
This commit is contained in:
@@ -11,6 +11,7 @@ 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 type { FilterKey } from "./skip-list/filters";
|
||||
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { VisibilityProvider } from "../room-list/filters/VisibilityProvider";
|
||||
@@ -23,6 +24,7 @@ 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";
|
||||
import { FavouriteFilter } from "./skip-list/filters/FavouriteFilter";
|
||||
|
||||
/**
|
||||
* This store allows for fast retrieval of the room list in a sorted and filtered manner.
|
||||
@@ -61,9 +63,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
||||
|
||||
/**
|
||||
* Get a list of sorted rooms that belong to the currently active space.
|
||||
* If filterKeys is passed, only the rooms that match the given filters are
|
||||
* returned.
|
||||
|
||||
* @param filterKeys Optional array of filters that the rooms must match against.
|
||||
*/
|
||||
public getSortedRoomsInActiveSpace(): Room[] {
|
||||
if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList.getRoomsInActiveSpace());
|
||||
public getSortedRoomsInActiveSpace(filterKeys?: FilterKey[]): Room[] {
|
||||
if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList.getRoomsInActiveSpace(filterKeys));
|
||||
else return [];
|
||||
}
|
||||
|
||||
@@ -90,7 +96,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
||||
protected async onReady(): Promise<any> {
|
||||
if (this.roomSkipList?.initialized || !this.matrixClient) return;
|
||||
const sorter = new RecencySorter(this.matrixClient.getSafeUserId());
|
||||
this.roomSkipList = new RoomSkipList(sorter);
|
||||
this.roomSkipList = new RoomSkipList(sorter, [new FavouriteFilter()]);
|
||||
const rooms = this.getRooms();
|
||||
await SpaceStore.instance.storeReadyPromise;
|
||||
this.roomSkipList.seed(rooms);
|
||||
|
||||
@@ -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 type { Filter, FilterKey } from "./filters";
|
||||
import SpaceStore from "../../spaces/SpaceStore";
|
||||
|
||||
/**
|
||||
@@ -48,4 +49,31 @@ export class RoomNode {
|
||||
const activeSpace = SpaceStore.instance.activeSpace;
|
||||
this._isInActiveSpace = SpaceStore.instance.isRoomInSpace(activeSpace, this.room.roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates all the filter keys that apply to this room.
|
||||
* eg: if filterKeysSet.has(Filter.FavouriteFilter) is true, then this room is a favourite room.
|
||||
*/
|
||||
private filterKeysSet: Set<FilterKey> = new Set();
|
||||
|
||||
/**
|
||||
* Returns true if the associated room matches all the provided filters.
|
||||
* Returns false otherwise.
|
||||
* @param filterKeys An array of filter keys to check against.
|
||||
*/
|
||||
public doesRoomMatchFilters(filterKeys: FilterKey[]): boolean {
|
||||
return !filterKeys.some((key) => !this.filterKeysSet.has(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates {@link RoomNode#filterKeysSet} by checking if the associated room
|
||||
* satisfies the given filters.
|
||||
* @param filters A list of filters
|
||||
*/
|
||||
public applyFilters(filters: Filter[]): void {
|
||||
this.filterKeysSet = new Set();
|
||||
for (const filter of filters) {
|
||||
if (filter.matches(this.room)) this.filterKeysSet.add(filter.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import type { Room } from "matrix-js-sdk/src/matrix";
|
||||
import type { Sorter } from "./sorters";
|
||||
import type { Filter, FilterKey } from "./filters";
|
||||
import { RoomNode } from "./RoomNode";
|
||||
import { shouldPromote } from "./utils";
|
||||
import { Level } from "./Level";
|
||||
import { SortedRoomIterator, SortedSpaceFilteredIterator } from "./iterators";
|
||||
|
||||
/**
|
||||
* Implements a skip list that stores rooms using a given sorting algorithm.
|
||||
@@ -20,7 +22,10 @@ export class RoomSkipList implements Iterable<Room> {
|
||||
private roomNodeMap: Map<string, RoomNode> = new Map();
|
||||
public initialized: boolean = false;
|
||||
|
||||
public constructor(private sorter: Sorter) {}
|
||||
public constructor(
|
||||
private sorter: Sorter,
|
||||
private filters: Filter[] = [],
|
||||
) {}
|
||||
|
||||
private reset(): void {
|
||||
this.levels = [new Level(0)];
|
||||
@@ -35,6 +40,7 @@ export class RoomSkipList implements Iterable<Room> {
|
||||
const sortedRoomNodes = this.sorter.sort(rooms).map((room) => new RoomNode(room));
|
||||
let currentLevel = this.levels[0];
|
||||
for (const node of sortedRoomNodes) {
|
||||
node.applyFilters(this.filters);
|
||||
currentLevel.setNext(node);
|
||||
this.roomNodeMap.set(node.room.roomId, node);
|
||||
}
|
||||
@@ -95,6 +101,7 @@ export class RoomSkipList implements Iterable<Room> {
|
||||
|
||||
const newNode = new RoomNode(room);
|
||||
newNode.checkIfRoomBelongsToActiveSpace();
|
||||
newNode.applyFilters(this.filters);
|
||||
this.roomNodeMap.set(room.roomId, newNode);
|
||||
|
||||
/**
|
||||
@@ -173,8 +180,22 @@ export class RoomSkipList implements Iterable<Room> {
|
||||
return new SortedRoomIterator(this.levels[0].head!);
|
||||
}
|
||||
|
||||
public getRoomsInActiveSpace(): SortedSpaceFilteredIterator {
|
||||
return new SortedSpaceFilteredIterator(this.levels[0].head!);
|
||||
/**
|
||||
* Returns an iterator that can be used to generate a list of sorted rooms that belong
|
||||
* to the currently active space. Passing filterKeys will further filter the list such
|
||||
* that only rooms that match the filters are returned.
|
||||
*
|
||||
* @example To get an array of rooms:
|
||||
* Array.from(RLS.getRoomsInActiveSpace());
|
||||
*
|
||||
* @example Use a for ... of loop to iterate over rooms:
|
||||
* for(const room of RLS.getRoomsInActiveSpace()) { something(room); }
|
||||
*
|
||||
* @example Additional filtering:
|
||||
* Array.from(RLS.getRoomsInActiveSpace([FilterKeys.Favourite]));
|
||||
*/
|
||||
public getRoomsInActiveSpace(filterKeys: FilterKey[] = []): SortedSpaceFilteredIterator {
|
||||
return new SortedSpaceFilteredIterator(this.levels[0].head!, filterKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,36 +205,3 @@ export class RoomSkipList implements Iterable<Room> {
|
||||
return this.levels[0].size;
|
||||
}
|
||||
}
|
||||
|
||||
class SortedRoomIterator implements Iterator<Room> {
|
||||
public constructor(private current: RoomNode) {}
|
||||
|
||||
public next(): IteratorResult<Room> {
|
||||
const current = this.current;
|
||||
if (!current) return { value: undefined, done: true };
|
||||
this.current = current.next[0];
|
||||
return {
|
||||
value: current.room,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class SortedSpaceFilteredIterator implements Iterator<Room> {
|
||||
public constructor(private current: RoomNode) {}
|
||||
|
||||
public [Symbol.iterator](): SortedSpaceFilteredIterator {
|
||||
return this;
|
||||
}
|
||||
|
||||
public next(): IteratorResult<Room> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
20
src/stores/room-list-v3/skip-list/filters/FavouriteFilter.ts
Normal file
20
src/stores/room-list-v3/skip-list/filters/FavouriteFilter.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
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 type { Filter } from ".";
|
||||
import { FilterKey } from ".";
|
||||
import { DefaultTagID } from "../../../room-list/models";
|
||||
|
||||
export class FavouriteFilter implements Filter {
|
||||
public matches(room: Room): boolean {
|
||||
return !!room.tags[DefaultTagID.Favourite];
|
||||
}
|
||||
|
||||
public get key(): FilterKey.FavouriteFilter {
|
||||
return FilterKey.FavouriteFilter;
|
||||
}
|
||||
}
|
||||
24
src/stores/room-list-v3/skip-list/filters/index.ts
Normal file
24
src/stores/room-list-v3/skip-list/filters/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
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";
|
||||
|
||||
export const enum FilterKey {
|
||||
FavouriteFilter,
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
/**
|
||||
* Boolean return value indicates whether this room satisfies
|
||||
* the filter condition.
|
||||
*/
|
||||
matches(room: Room): boolean;
|
||||
|
||||
/**
|
||||
* Used to identify this particular filter.
|
||||
*/
|
||||
key: FilterKey;
|
||||
}
|
||||
47
src/stores/room-list-v3/skip-list/iterators.ts
Normal file
47
src/stores/room-list-v3/skip-list/iterators.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
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 type { RoomNode } from "./RoomNode";
|
||||
import type { FilterKey } from "./filters";
|
||||
|
||||
export class SortedRoomIterator implements Iterator<Room> {
|
||||
public constructor(private current: RoomNode) {}
|
||||
|
||||
public next(): IteratorResult<Room> {
|
||||
const current = this.current;
|
||||
if (!current) return { value: undefined, done: true };
|
||||
this.current = current.next[0];
|
||||
return {
|
||||
value: current.room,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SortedSpaceFilteredIterator implements Iterator<Room> {
|
||||
public constructor(
|
||||
private current: RoomNode,
|
||||
private readonly filters: FilterKey[],
|
||||
) {}
|
||||
|
||||
public [Symbol.iterator](): SortedSpaceFilteredIterator {
|
||||
return this;
|
||||
}
|
||||
|
||||
public next(): IteratorResult<Room> {
|
||||
let current = this.current;
|
||||
while (current) {
|
||||
if (current.isInActiveSpace && current.doesRoomMatchFilters(this.filters)) break;
|
||||
current = current.next[0];
|
||||
}
|
||||
if (!current) return { value: undefined, done: true };
|
||||
this.current = current.next[0];
|
||||
return {
|
||||
value: current.room,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,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 type { MatrixClient } 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";
|
||||
@@ -18,6 +19,8 @@ import { LISTS_UPDATE_EVENT } from "../../../../src/stores/room-list/RoomListSto
|
||||
import dispatcher from "../../../../src/dispatcher/dispatcher";
|
||||
import SpaceStore from "../../../../src/stores/spaces/SpaceStore";
|
||||
import { MetaSpace, UPDATE_SELECTED_SPACE } from "../../../../src/stores/spaces";
|
||||
import { DefaultTagID } from "../../../../src/stores/room-list/models";
|
||||
import { FilterKey } from "../../../../src/stores/room-list-v3/skip-list/filters";
|
||||
|
||||
describe("RoomListStoreV3", () => {
|
||||
async function getRoomListStore() {
|
||||
@@ -273,19 +276,40 @@ describe("RoomListStoreV3", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a space and add it to rooms
|
||||
* @param rooms An array of rooms to which the new space is added.
|
||||
* @param inSpaceIndices A list of indices from which rooms are added to the space.
|
||||
*/
|
||||
function createSpace(rooms: Room[], inSpaceIndices: number[], client: MatrixClient) {
|
||||
const roomIds = inSpaceIndices.map((i) => rooms[i].roomId);
|
||||
const spaceRoom = mkSpace(client, "!space1:matrix.org", [], roomIds);
|
||||
rooms.push(spaceRoom);
|
||||
return { spaceRoom, roomIds };
|
||||
}
|
||||
|
||||
function setupMocks(spaceRoom: Room, roomIds: string[]) {
|
||||
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;
|
||||
});
|
||||
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => spaceRoom.roomId);
|
||||
}
|
||||
|
||||
function getClientAndRooms() {
|
||||
const client = stubClient();
|
||||
const rooms = getMockedRooms(client);
|
||||
client.getVisibleRooms = jest.fn().mockReturnValue(rooms);
|
||||
jest.spyOn(AsyncStoreWithClient.prototype, "matrixClient", "get").mockReturnValue(client);
|
||||
return { client, rooms };
|
||||
}
|
||||
|
||||
describe("Spaces", () => {
|
||||
it("Filtering by spaces work", async () => {
|
||||
const client = stubClient();
|
||||
const rooms = getMockedRooms(client);
|
||||
|
||||
const { client, rooms } = getClientAndRooms();
|
||||
// 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);
|
||||
const { spaceRoom, roomIds } = createSpace(rooms, [6, 8, 13, 27, 75], client);
|
||||
|
||||
// Mock the space store
|
||||
jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space, id) => {
|
||||
@@ -315,5 +339,64 @@ describe("RoomListStoreV3", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Filters", () => {
|
||||
it("filters by both space and favourite", async () => {
|
||||
const { client, rooms } = getClientAndRooms();
|
||||
// Let's choose 5 rooms to put in space
|
||||
const { spaceRoom, roomIds } = createSpace(rooms, [6, 8, 13, 27, 75], client);
|
||||
|
||||
// Let's say that 8, 27 an 75 are favourite rooms
|
||||
[8, 27, 75].forEach((i) => {
|
||||
rooms[i].tags[DefaultTagID.Favourite] = {};
|
||||
});
|
||||
|
||||
setupMocks(spaceRoom, roomIds);
|
||||
const store = new RoomListStoreV3Class(dispatcher);
|
||||
await store.start();
|
||||
|
||||
// Sorted, filtered rooms should be 8, 27 and 75
|
||||
const result = store.getSortedRoomsInActiveSpace([FilterKey.FavouriteFilter]);
|
||||
expect(result).toHaveLength(3);
|
||||
for (const i of [8, 27, 75]) {
|
||||
expect(result).toContain(rooms[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it("filters are recalculated on room update", async () => {
|
||||
const { client, rooms } = getClientAndRooms();
|
||||
// Let's choose 5 rooms to put in space
|
||||
const { spaceRoom, roomIds } = createSpace(rooms, [6, 8, 13, 27, 75], client);
|
||||
|
||||
// Let's say that 8, 27 an 75 are favourite rooms
|
||||
[8, 27, 75].forEach((i) => {
|
||||
rooms[i].tags[DefaultTagID.Favourite] = {};
|
||||
});
|
||||
|
||||
setupMocks(spaceRoom, roomIds);
|
||||
const store = new RoomListStoreV3Class(dispatcher);
|
||||
await store.start();
|
||||
|
||||
// Let's say 27 got unfavourited
|
||||
const fn = jest.fn();
|
||||
store.on(LISTS_UPDATE_EVENT, fn);
|
||||
rooms[27].tags = {};
|
||||
dispatcher.dispatch(
|
||||
{
|
||||
action: "MatrixActions.Room.tags",
|
||||
room: rooms[27],
|
||||
},
|
||||
true,
|
||||
);
|
||||
expect(fn).toHaveBeenCalled();
|
||||
|
||||
// Sorted, filtered rooms should be 27 and 75
|
||||
const result = store.getSortedRoomsInActiveSpace([FilterKey.FavouriteFilter]);
|
||||
expect(result).toHaveLength(2);
|
||||
for (const i of [8, 75]) {
|
||||
expect(result).toContain(rooms[i]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user