diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index 6133fcfd19..b643755a2a 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -35,6 +35,7 @@ import { SettingLevel } from "../../settings/SettingLevel"; import { MARKED_UNREAD_TYPE_STABLE, MARKED_UNREAD_TYPE_UNSTABLE } from "../../utils/notifications"; import { getChangedOverrideRoomMutePushRules } from "../room-list/utils/roomMute"; import { Action } from "../../dispatcher/actions"; +import { UnreadSorter } from "./skip-list/sorters/UnreadSorter"; /** * These are the filters passed to the room skip list. @@ -136,10 +137,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { if (!this.roomSkipList) throw new Error("Cannot resort room list before skip list is created."); if (!this.matrixClient) throw new Error("Cannot resort room list without matrix client."); if (this.roomSkipList.activeSortAlgorithm === algorithm) return; - const sorter = - algorithm === SortingAlgorithm.Alphabetic - ? new AlphabeticSorter() - : new RecencySorter(this.matrixClient.getSafeUserId()); + const sorter = this.getSorterFromSortingAlgorithm(algorithm, this.matrixClient.getSafeUserId()); this.roomSkipList.useNewSorter(sorter, this.getRooms()); this.emit(LISTS_UPDATE_EVENT); SettingsStore.setValue("RoomList.preferredSorting", null, SettingLevel.DEVICE, algorithm); @@ -321,13 +319,28 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { */ private getPreferredSorter(myUserId: string): Sorter { const preferred = SettingsStore.getValue("RoomList.preferredSorting"); - switch (preferred) { + return this.getSorterFromSortingAlgorithm(preferred, myUserId); + } + + /** + * Get a sorter instance from the sorting algorithm enum value. + * @param algorithm The sorting algorithm + * @param myUserId The user-id of the current user + * @returns the sorter instance + */ + private getSorterFromSortingAlgorithm(algorithm: SortingAlgorithm, myUserId: string): Sorter { + switch (algorithm) { case SortingAlgorithm.Alphabetic: return new AlphabeticSorter(); case SortingAlgorithm.Recency: return new RecencySorter(myUserId); + case SortingAlgorithm.Unread: + return new UnreadSorter(myUserId); default: - throw new Error(`Got unknown sort preference from RoomList.preferredSorting setting`); + logger.info( + `RoomListStoreV3: There is no sorting implementation for algorithm ${algorithm}, defaulting to recency sorter`, + ); + return new RecencySorter(myUserId); } } diff --git a/src/stores/room-list-v3/skip-list/sorters/BaseRecencySorter.ts b/src/stores/room-list-v3/skip-list/sorters/BaseRecencySorter.ts new file mode 100644 index 0000000000..1dc1ac81f3 --- /dev/null +++ b/src/stores/room-list-v3/skip-list/sorters/BaseRecencySorter.ts @@ -0,0 +1,52 @@ +/* +Copyright 2026 Element Creations 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 { Sorter, SortingAlgorithm } from "."; +import { getLastTs } from "../../../room-list/algorithms/tag-sorting/RecentAlgorithm"; + +export abstract class BaseRecencySorter implements Sorter { + public constructor(protected myUserId: string) {} + + public sort(rooms: Room[]): Room[] { + const tsCache: { [roomId: string]: number } = {}; + return [...rooms].sort((a, b) => this.comparator(a, b, tsCache)); + } + + public comparator(roomA: Room, roomB: Room, cache?: any): number { + // First check if any of the rooms are special cases + const exceptionalOrdering = this.getScore(roomA) - this.getScore(roomB); + if (exceptionalOrdering !== 0) return exceptionalOrdering; + + // Then check recency; recent rooms should be at the top + const roomALastTs = this.getTs(roomA, cache); + const roomBLastTs = this.getTs(roomB, cache); + return roomBLastTs - roomALastTs; + } + + private getTs(room: Room, cache?: { [roomId: string]: number }): number { + const ts = cache?.[room.roomId] ?? getLastTs(room, this.myUserId); + if (cache) { + cache[room.roomId] = ts; + } + return ts; + } + + public abstract get type(): SortingAlgorithm; + + /** + * Rooms are sorted based on: + * - the score of the room + * - the timestamp of the last message in that room + * + * The score takes precedence over the timestamp of the last message. This allows + * some rooms to be sorted before/after others regardless of when the last message + * was received in that room. Eg: muted rooms can be placed at the bottom of the list + * even if they received messages recently. + */ + protected abstract getScore(room: Room): number; +} diff --git a/src/stores/room-list-v3/skip-list/sorters/RecencySorter.ts b/src/stores/room-list-v3/skip-list/sorters/RecencySorter.ts index 53e8ae4331..4f5889f132 100644 --- a/src/stores/room-list-v3/skip-list/sorters/RecencySorter.ts +++ b/src/stores/room-list-v3/skip-list/sorters/RecencySorter.ts @@ -7,29 +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, SortingAlgorithm } from "."; -import { getLastTs } from "../../../room-list/algorithms/tag-sorting/RecentAlgorithm"; import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore"; import { DefaultTagID } from "../../../room-list/models"; +import { BaseRecencySorter } from "./BaseRecencySorter"; -export class RecencySorter implements Sorter { - public constructor(private myUserId: string) {} - - public sort(rooms: Room[]): Room[] { - const tsCache: { [roomId: string]: number } = {}; - return [...rooms].sort((a, b) => this.comparator(a, b, tsCache)); - } - - public comparator(roomA: Room, roomB: Room, cache?: any): number { - // First check if the rooms are low priority or muted - const exceptionalOrdering = this.getScore(roomA) - this.getScore(roomB); - if (exceptionalOrdering !== 0) return exceptionalOrdering; - - // Then check recency; recent rooms should be at the top - const roomALastTs = this.getTs(roomA, cache); - const roomBLastTs = this.getTs(roomB, cache); - return roomBLastTs - roomALastTs; - } - +export class RecencySorter extends BaseRecencySorter implements Sorter { public get type(): SortingAlgorithm.Recency { return SortingAlgorithm.Recency; } @@ -45,7 +27,7 @@ export class RecencySorter implements Sorter { * - If getScore(A) - getScore(B) < 0, A should come before B * - If getScore(A) - getScore(B) = 0, no special ordering needed, just use recency */ - private getScore(room: Room): number { + protected getScore(room: Room): number { const isLowPriority = !!room.tags[DefaultTagID.LowPriority]; const isMuted = RoomNotificationStateStore.instance.getRoomState(room).muted; // These constants are chosen so that the following order is maintained: @@ -55,12 +37,4 @@ export class RecencySorter implements Sorter { else if (isLowPriority) return 2; else return 0; } - - private getTs(room: Room, cache?: { [roomId: string]: number }): number { - const ts = cache?.[room.roomId] ?? getLastTs(room, this.myUserId); - if (cache) { - cache[room.roomId] = ts; - } - return ts; - } } diff --git a/src/stores/room-list-v3/skip-list/sorters/UnreadSorter.ts b/src/stores/room-list-v3/skip-list/sorters/UnreadSorter.ts new file mode 100644 index 0000000000..3d919b8251 --- /dev/null +++ b/src/stores/room-list-v3/skip-list/sorters/UnreadSorter.ts @@ -0,0 +1,55 @@ +/* +Copyright 2026 Element Creations 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 { KnownMembership, RoomType, type Room } from "matrix-js-sdk/src/matrix"; + +import { type Sorter, SortingAlgorithm } from "."; +import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore"; +import { DefaultTagID } from "../../../room-list/models"; +import { CallStore } from "../../../CallStore"; +import { getMarkedUnreadState } from "../../../../utils/notifications"; +import { BaseRecencySorter } from "./BaseRecencySorter"; + +/** + * Similar to RecencySorter but with the following special order: + * Invites -> Calls (new and ongoing) -> Mentions (@) -> Count ([1])/ Marked as unread -> Activity (dot) -> None -> Low Priority -> Mute + */ +export class UnreadSorter extends BaseRecencySorter implements Sorter { + public get type(): SortingAlgorithm.Unread { + return SortingAlgorithm.Unread; + } + + protected getScore(room: Room): number { + // Invites first + if (room.getMyMembership() === KnownMembership.Invite) return 100; + + // Then rooms that have calls (but not video rooms) + const roomType = room.getType(); + const isVideoRoom = roomType === RoomType.UnstableCall || roomType === RoomType.ElementVideo; + if (!isVideoRoom && !!CallStore.instance.getCall(room.roomId)) return 101; + + const roomNotificationState = RoomNotificationStateStore.instance.getRoomState(room); + // Then mentions + if (roomNotificationState.isMention) return 102; + + // Then rooms that have a count or was marked as unread + if (roomNotificationState.hasUnreadCount || !!getMarkedUnreadState(room)) return 103; + + // Then rooms that have a dot + if (roomNotificationState.isActivityNotification) return 104; + + // Then all other non special rooms, see last return + + // Then low priority rooms + if (!!room.tags[DefaultTagID.LowPriority]) return 106; + + // Muted rooms at the bottom + if (roomNotificationState.muted) return 107; + + return 105; + } +} diff --git a/src/stores/room-list-v3/skip-list/sorters/index.ts b/src/stores/room-list-v3/skip-list/sorters/index.ts index 40381448c8..82fbaebc02 100644 --- a/src/stores/room-list-v3/skip-list/sorters/index.ts +++ b/src/stores/room-list-v3/skip-list/sorters/index.ts @@ -31,6 +31,7 @@ export interface Sorter { * All the available sorting algorithms. */ export const enum SortingAlgorithm { + Unread = "Unread", Recency = "Recency", Alphabetic = "Alphabetic", } diff --git a/test/unit-tests/stores/room-list-v3/skip-list/sorters/UnreadSorter-test.ts b/test/unit-tests/stores/room-list-v3/skip-list/sorters/UnreadSorter-test.ts new file mode 100644 index 0000000000..b5ca4c777e --- /dev/null +++ b/test/unit-tests/stores/room-list-v3/skip-list/sorters/UnreadSorter-test.ts @@ -0,0 +1,109 @@ +/* +Copyright 2026 Element Creations 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 { KnownMembership } from "matrix-js-sdk/src/types"; + +import { stubClient } from "../../../../../test-utils"; +import { getMockedRooms } from "../getMockedRooms"; +import { CallStore } from "../../../../../../src/stores/CallStore"; +import type { Call } from "../../../../../../src/models/Call"; +import { DefaultTagID } from "../../../../../../src/stores/room-list/models"; +import { RoomNotificationStateStore } from "../../../../../../src/stores/notifications/RoomNotificationStateStore"; +import type { RoomNotificationState } from "../../../../../../src/stores/notifications/RoomNotificationState"; +import * as utils from "../../../../../../src/utils/notifications"; +import { UnreadSorter } from "../../../../../../src/stores/room-list-v3/skip-list/sorters/UnreadSorter"; +import { NotificationLevel } from "../../../../../../src/stores/notifications/NotificationLevel"; + +describe("UnreadSorter", () => { + it("should sort correctly", () => { + // Let's create some rooms first + const cli = stubClient(); + const rooms = getMockedRooms(cli); + + // Let's make rooms 23, 67, 53, 5 invites + const inviteRooms = [23, 67, 53, 5].map((i) => rooms[i]); + for (const room of inviteRooms) { + room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Invite); + } + + // Let's make rooms 66, 10, 78 have calls + const callRooms = [66, 10, 78].map((i) => rooms[i]); + jest.spyOn(CallStore.instance, "getCall").mockImplementation((roomId) => { + if (callRooms.map((r) => r.roomId).includes(roomId)) { + // We don't really care about the call object + return true as unknown as Call; + } else return null; + }); + + // Let's make rooms 13, 96, 40 have mentions + const mentionRooms = [13, 96, 40].map((i) => rooms[i]); + // Let's make 74, 62, 50, 34, 52, 61 have dots + const dotRooms = [74, 62, 50, 34, 52, 61].map((i) => rooms[i]); + // Let's make 12, 47 have unread count (number) + const unreadRooms = [12, 47].map((i) => rooms[i]); + // Let's make 98, 80, 49, 24 muted rooms + const mutedRooms = [98, 80, 49, 24].map((i) => rooms[i]); + + jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockImplementation((room) => { + const isMention = mentionRooms.includes(room); + const hasUnreadCount = unreadRooms.includes(room); + const isActivityNotification = dotRooms.includes(room); + const muted = mutedRooms.includes(room); + const state = { + isMention, + hasUnreadCount, + isActivityNotification, + muted, + level: NotificationLevel.None, + } as unknown as RoomNotificationState; + return state; + }); + + // Let's make 28, 25 as rooms that are marked as unread + const markedAsUnreadRooms = [28, 25].map((i) => rooms[i]); + jest.spyOn(utils, "getMarkedUnreadState").mockImplementation((room) => markedAsUnreadRooms.includes(room)); + + // Let's make 6, 48, 76, low priority rooms + const lowPriorityRooms = [6, 48, 76].map((i) => rooms[i]); + for (const room of lowPriorityRooms) { + room.tags[DefaultTagID.LowPriority] = {}; + } + + // Now we can actually test the sorting algorithm + const sorter = new UnreadSorter("@foobar:matrix.org"); + const sortedRoomIds = sorter.sort(rooms).map((r) => r.roomId); + const roomIds = rooms.map((r) => r.roomId); + + // First we expect the invites to be shown: 67, 53, 23, 5 + const expectedInvites = sortedRoomIds.slice(0, 4); + expect(expectedInvites).toEqual([roomIds[67], roomIds[53], roomIds[23], roomIds[5]]); + + // Next we expect the calls to be shown + const expectedCalls = sortedRoomIds.slice(4, 7); + expect(expectedCalls).toEqual([roomIds[78], roomIds[66], roomIds[10]]); + + // Next we expect the mentions + const expectedMentions = sortedRoomIds.slice(7, 10); + expect(expectedMentions).toEqual([roomIds[96], roomIds[40], roomIds[13]]); + + // Next we expect the rooms that have count/ or was marked as unread + const expectedUnread = sortedRoomIds.slice(10, 14); + expect(expectedUnread).toEqual([roomIds[47], roomIds[28], roomIds[25], roomIds[12]]); + + // Next we expect the rooms that have activity dot + const expectedDots = sortedRoomIds.slice(14, 20); + expect(expectedDots).toEqual([roomIds[74], roomIds[62], roomIds[61], roomIds[52], roomIds[50], roomIds[34]]); + + // The bottom 4 rooms should be muted + const expectedMuted = sortedRoomIds.slice(96); + expect(expectedMuted).toEqual([roomIds[98], roomIds[80], roomIds[49], roomIds[24]]); + + // The next 3 rooms from the bottom should be low priority rooms + const expectedLowPriority = sortedRoomIds.slice(93, 96); + expect(expectedLowPriority).toEqual([roomIds[76], roomIds[48], roomIds[6]]); + }); +});