Unread sorting - Implement sorter and use it in the room list store (#31723)

* Extract base recency sorter class

* Create unread sorter

* Write test

* Use new sorter in RLS

* Fix incorrect sort type

* Replace with a better comment

* Fall back to RecencySorter instead of throwing error
This commit is contained in:
R Midhun Suresh
2026-01-22 13:08:56 +05:30
committed by GitHub
parent a43dc3a3b5
commit b9cdc0390a
6 changed files with 239 additions and 35 deletions

View File

@@ -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<EmptyObject> {
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<EmptyObject> {
*/
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);
}
}

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,7 @@ export interface Sorter {
* All the available sorting algorithms.
*/
export const enum SortingAlgorithm {
Unread = "Unread",
Recency = "Recency",
Alphabetic = "Alphabetic",
}

View File

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