diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index 83e73e1b7f..df7246e95c 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -25,6 +25,14 @@ import { EffectiveMembership, getEffectiveMembership, getEffectiveMembershipTag import SpaceStore from "../spaces/SpaceStore"; import { UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces"; import { FavouriteFilter } from "./skip-list/filters/FavouriteFilter"; +import { UnreadFilter } from "./skip-list/filters/UnreadFilter"; +import { PeopleFilter } from "./skip-list/filters/PeopleFilter"; +import { RoomsFilter } from "./skip-list/filters/RoomsFilter"; + +/** + * These are the filters passed to the room skip list. + */ +const FILTERS = [new FavouriteFilter(), new UnreadFilter(), new PeopleFilter(), new RoomsFilter()]; /** * This store allows for fast retrieval of the room list in a sorted and filtered manner. @@ -96,7 +104,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { protected async onReady(): Promise { if (this.roomSkipList?.initialized || !this.matrixClient) return; const sorter = new RecencySorter(this.matrixClient.getSafeUserId()); - this.roomSkipList = new RoomSkipList(sorter, [new FavouriteFilter()]); + this.roomSkipList = new RoomSkipList(sorter, FILTERS); const rooms = this.getRooms(); await SpaceStore.instance.storeReadyPromise; this.roomSkipList.seed(rooms); diff --git a/src/stores/room-list-v3/skip-list/filters/PeopleFilter.ts b/src/stores/room-list-v3/skip-list/filters/PeopleFilter.ts new file mode 100644 index 0000000000..742eb40abe --- /dev/null +++ b/src/stores/room-list-v3/skip-list/filters/PeopleFilter.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 { Room } from "matrix-js-sdk/src/matrix"; +import type { Filter } from "."; +import { FilterKey } from "."; +import DMRoomMap from "../../../../utils/DMRoomMap"; + +export class PeopleFilter implements Filter { + public matches(room: Room): boolean { + // Match rooms that are DMs + return !!DMRoomMap.shared().getUserIdForRoomId(room.roomId); + } + + public get key(): FilterKey.PeopleFilter { + return FilterKey.PeopleFilter; + } +} diff --git a/src/stores/room-list-v3/skip-list/filters/RoomsFilter.ts b/src/stores/room-list-v3/skip-list/filters/RoomsFilter.ts new file mode 100644 index 0000000000..58349dcea2 --- /dev/null +++ b/src/stores/room-list-v3/skip-list/filters/RoomsFilter.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 { Room } from "matrix-js-sdk/src/matrix"; +import type { Filter } from "."; +import { FilterKey } from "."; +import DMRoomMap from "../../../../utils/DMRoomMap"; + +export class RoomsFilter implements Filter { + public matches(room: Room): boolean { + // This should filter rooms that are not DMs + return !DMRoomMap.shared().getUserIdForRoomId(room.roomId); + } + + public get key(): FilterKey.RoomsFilter { + return FilterKey.RoomsFilter; + } +} diff --git a/src/stores/room-list-v3/skip-list/filters/UnreadFilter.ts b/src/stores/room-list-v3/skip-list/filters/UnreadFilter.ts new file mode 100644 index 0000000000..c830f1d55b --- /dev/null +++ b/src/stores/room-list-v3/skip-list/filters/UnreadFilter.ts @@ -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 { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore"; + +export class UnreadFilter implements Filter { + public matches(room: Room): boolean { + return RoomNotificationStateStore.instance.getRoomState(room).isUnread; + } + + public get key(): FilterKey.UnreadFilter { + return FilterKey.UnreadFilter; + } +} diff --git a/src/stores/room-list-v3/skip-list/filters/index.ts b/src/stores/room-list-v3/skip-list/filters/index.ts index 0580c95764..29bcabb27e 100644 --- a/src/stores/room-list-v3/skip-list/filters/index.ts +++ b/src/stores/room-list-v3/skip-list/filters/index.ts @@ -8,6 +8,9 @@ import type { Room } from "matrix-js-sdk/src/matrix"; export const enum FilterKey { FavouriteFilter, + UnreadFilter, + PeopleFilter, + RoomsFilter, } export interface Filter { 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 7edd5b3ef9..6d4b81239e 100644 --- a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -9,6 +9,7 @@ import { EventType, KnownMembership, MatrixEvent, Room } from "matrix-js-sdk/src import { logger } from "matrix-js-sdk/src/logger"; import type { MatrixClient } from "matrix-js-sdk/src/matrix"; +import type { RoomNotificationState } from "../../../../src/stores/notifications/RoomNotificationState"; 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"; @@ -21,6 +22,8 @@ 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"; +import { RoomNotificationStateStore } from "../../../../src/stores/notifications/RoomNotificationStateStore"; +import DMRoomMap from "../../../../src/utils/DMRoomMap"; describe("RoomListStoreV3", () => { async function getRoomListStore() { @@ -37,6 +40,17 @@ describe("RoomListStoreV3", () => { jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space) => space === MetaSpace.Home); jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => MetaSpace.Home); jest.spyOn(SpaceStore.instance, "storeReadyPromise", "get").mockImplementation(() => Promise.resolve()); + jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockImplementation((room) => { + const state = { + isUnread: false, + } as unknown as RoomNotificationState; + return state; + }); + jest.spyOn(DMRoomMap, "shared").mockImplementation((() => { + return { + getUserIdForRoomId: (id) => "", + }; + }) as () => DMRoomMap); }); it("Provides an unsorted list of rooms", async () => { @@ -397,6 +411,89 @@ describe("RoomListStoreV3", () => { expect(result).toContain(rooms[i]); } }); + + it("supports filtering unread rooms", 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 8, 27 are unread + jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockImplementation((room) => { + const state = { + isUnread: [rooms[8], rooms[27]].includes(room), + } as unknown as RoomNotificationState; + return state; + }); + + setupMocks(spaceRoom, roomIds); + const store = new RoomListStoreV3Class(dispatcher); + await store.start(); + + // Should only give us rooms at index 8 and 27 + const result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]); + expect(result).toHaveLength(2); + for (const i of [8, 27]) { + expect(result).toContain(rooms[i]); + } + }); + + it("supports filtering by people and rooms", 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 8, 27 are dms + const ids = [8, 27].map((i) => rooms[i].roomId); + jest.spyOn(DMRoomMap, "shared").mockImplementation((() => { + return { + getUserIdForRoomId: (id) => (ids.includes(id) ? "@myuser:matrix.org" : ""), + }; + }) as () => DMRoomMap); + + setupMocks(spaceRoom, roomIds); + const store = new RoomListStoreV3Class(dispatcher); + await store.start(); + + // Should only give us rooms at index 8 and 27 + const peopleRooms = store.getSortedRoomsInActiveSpace([FilterKey.PeopleFilter]); + expect(peopleRooms).toHaveLength(2); + for (const i of [8, 27]) { + expect(peopleRooms).toContain(rooms[i]); + } + + // Rest are normal rooms + const nonDms = store.getSortedRoomsInActiveSpace([FilterKey.RoomsFilter]); + expect(nonDms).toHaveLength(3); + for (const i of [6, 13, 75]) { + expect(nonDms).toContain(rooms[i]); + } + }); + + it("supports multiple filters", 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 is a favourite room + rooms[8].tags[DefaultTagID.Favourite] = {}; + + // Let's say 8, 27 are unread + jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockImplementation((room) => { + const state = { + isUnread: [rooms[8], rooms[27]].includes(room), + } as unknown as RoomNotificationState; + return state; + }); + + setupMocks(spaceRoom, roomIds); + const store = new RoomListStoreV3Class(dispatcher); + await store.start(); + + // Should give us only room at 8 since that's the only room which matches both filters + const result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter, FilterKey.FavouriteFilter]); + expect(result).toHaveLength(1); + expect(result).toContain(rooms[8]); + }); }); }); });