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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
55
src/stores/room-list-v3/skip-list/sorters/UnreadSorter.ts
Normal file
55
src/stores/room-list-v3/skip-list/sorters/UnreadSorter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export interface Sorter {
|
||||
* All the available sorting algorithms.
|
||||
*/
|
||||
export const enum SortingAlgorithm {
|
||||
Unread = "Unread",
|
||||
Recency = "Recency",
|
||||
Alphabetic = "Alphabetic",
|
||||
}
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user