diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 439b0c112f..3046813f4d 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -44,6 +44,7 @@ import { type ReleaseAnnouncementData } from "../stores/ReleaseAnnouncementStore import { type Json, type JsonValue } from "../@types/json.ts"; import { type RecentEmojiData } from "../emojipicker/recent.ts"; import { type Assignable } from "../@types/common.ts"; +import { SortingAlgorithm } from "../stores/room-list-v3/skip-list/sorters/index.ts"; export const defaultWatchManager = new WatchManager(); @@ -311,6 +312,7 @@ export interface Settings { "lowBandwidth": IBaseSetting; "fallbackICEServerAllowed": IBaseSetting; "showImages": IBaseSetting; + "RoomList.preferredSorting": IBaseSetting; "RightPanel.phasesGlobal": IBaseSetting; "RightPanel.phases": IBaseSetting; "enableEventIndexing": IBaseSetting; @@ -1114,6 +1116,10 @@ export const SETTINGS: Settings = { displayName: _td("settings|image_thumbnails"), default: true, }, + "RoomList.preferredSorting": { + supportedLevels: [SettingLevel.DEVICE], + default: SortingAlgorithm.Recency, + }, "RightPanel.phasesGlobal": { supportedLevels: [SettingLevel.DEVICE], default: null, diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index 8d1bf8331f..286a5f0554 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -31,6 +31,8 @@ import { RoomsFilter } from "./skip-list/filters/RoomsFilter"; import { InvitesFilter } from "./skip-list/filters/InvitesFilter"; import { MentionsFilter } from "./skip-list/filters/MentionsFilter"; import { LowPriorityFilter } from "./skip-list/filters/LowPriorityFilter"; +import { type Sorter, SortingAlgorithm } from "./skip-list/sorters"; +import { SettingLevel } from "../../settings/SettingLevel"; /** * These are the filters passed to the room skip list. @@ -93,28 +95,32 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { } /** - * Re-sort the list of rooms by alphabetic order. + * Resort the list of rooms using a different algorithm. + * @param algorithm The sorting algorithm to use. */ - public useAlphabeticSorting(): void { - if (this.roomSkipList) { - const sorter = new AlphabeticSorter(); - this.roomSkipList.useNewSorter(sorter, this.getRooms()); - } + public resort(algorithm: SortingAlgorithm): void { + 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()); + this.roomSkipList.useNewSorter(sorter, this.getRooms()); + this.emit(LISTS_UPDATE_EVENT); + SettingsStore.setValue("RoomList.preferredSorting", null, SettingLevel.DEVICE, algorithm); } /** - * Re-sort the list of rooms by recency. + * Currently active sorting algorithm if the store is ready or undefined otherwise. */ - public useRecencySorting(): void { - if (this.roomSkipList && this.matrixClient) { - const sorter = new RecencySorter(this.matrixClient?.getSafeUserId() ?? ""); - this.roomSkipList.useNewSorter(sorter, this.getRooms()); - } + public get activeSortAlgorithm(): SortingAlgorithm | undefined { + return this.roomSkipList?.activeSortAlgorithm; } protected async onReady(): Promise { if (this.roomSkipList?.initialized || !this.matrixClient) return; - const sorter = new RecencySorter(this.matrixClient.getSafeUserId()); + const sorter = this.getPreferredSorter(this.matrixClient.getSafeUserId()); this.roomSkipList = new RoomSkipList(sorter, FILTERS); await SpaceStore.instance.storeReadyPromise; const rooms = this.getRooms(); @@ -214,6 +220,23 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { } } + /** + * Create the correct sorter depending on the persisted user preference. + * @param myUserId The user-id of our user. + * @returns Sorter object that can be passed to the skip list. + */ + private getPreferredSorter(myUserId: string): Sorter { + const preferred = SettingsStore.getValue("RoomList.preferredSorting"); + switch (preferred) { + case SortingAlgorithm.Alphabetic: + return new AlphabeticSorter(); + case SortingAlgorithm.Recency: + return new RecencySorter(myUserId); + default: + throw new Error(`Got unknown sort preference from RoomList.preferredSorting setting`); + } + } + /** * Add a room to the skiplist and emit an update. * @param room The room to add to the skiplist diff --git a/src/stores/room-list-v3/skip-list/RoomSkipList.ts b/src/stores/room-list-v3/skip-list/RoomSkipList.ts index 1653d8068d..5de15eaa46 100644 --- a/src/stores/room-list-v3/skip-list/RoomSkipList.ts +++ b/src/stores/room-list-v3/skip-list/RoomSkipList.ts @@ -6,7 +6,7 @@ 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 { Sorter, SortingAlgorithm } from "./sorters"; import type { Filter, FilterKey } from "./filters"; import { RoomNode } from "./RoomNode"; import { shouldPromote } from "./utils"; @@ -204,4 +204,11 @@ export class RoomSkipList implements Iterable { public get size(): number { return this.levels[0].size; } + + /** + * The currently active sorting algorithm. + */ + public get activeSortAlgorithm(): SortingAlgorithm { + return this.sorter.type; + } } diff --git a/src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter.ts b/src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter.ts index 5c279efb08..50a1b09d17 100644 --- a/src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter.ts +++ b/src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter.ts @@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details. */ import type { Room } from "matrix-js-sdk/src/matrix"; -import type { Sorter } from "."; +import { type Sorter, SortingAlgorithm } from "."; export class AlphabeticSorter implements Sorter { private readonly collator = new Intl.Collator(); @@ -20,4 +20,8 @@ export class AlphabeticSorter implements Sorter { public comparator(roomA: Room, roomB: Room): number { return this.collator.compare(roomA.name, roomB.name); } + + public get type(): SortingAlgorithm.Alphabetic { + return SortingAlgorithm.Alphabetic; + } } 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 7e8b3bd8c7..38eb9a298b 100644 --- a/src/stores/room-list-v3/skip-list/sorters/RecencySorter.ts +++ b/src/stores/room-list-v3/skip-list/sorters/RecencySorter.ts @@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details. */ import type { Room } from "matrix-js-sdk/src/matrix"; -import type { Sorter } from "."; +import { type Sorter, SortingAlgorithm } from "."; import { getLastTs } from "../../../room-list/algorithms/tag-sorting/RecentAlgorithm"; export class RecencySorter implements Sorter { @@ -23,6 +23,10 @@ export class RecencySorter implements Sorter { return roomBLastTs - roomALastTs; } + public get type(): SortingAlgorithm.Recency { + return SortingAlgorithm.Recency; + } + private getTs(room: Room, cache?: { [roomId: string]: number }): number { const ts = cache?.[room.roomId] ?? getLastTs(room, this.myUserId); if (cache) { 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 c71553665e..40381448c8 100644 --- a/src/stores/room-list-v3/skip-list/sorters/index.ts +++ b/src/stores/room-list-v3/skip-list/sorters/index.ts @@ -8,6 +8,29 @@ Please see LICENSE files in the repository root for full details. import type { Room } from "matrix-js-sdk/src/matrix"; export interface Sorter { + /** + * Performs an initial sort of rooms and returns a new array containing + * the result. + * @param rooms An array of rooms. + */ sort(rooms: Room[]): Room[]; + /** + * The comparator used for sorting. + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#comparefn + * @param roomA Room + * @param roomB Room + */ comparator(roomA: Room, roomB: Room): number; + /** + * A string that uniquely identifies this given sorter. + */ + type: SortingAlgorithm; +} + +/** + * All the available sorting algorithms. + */ +export const enum SortingAlgorithm { + Recency = "Recency", + Alphabetic = "Alphabetic", } 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 ec27049c89..0fdef53729 100644 --- a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -24,6 +24,8 @@ 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"; +import { SortingAlgorithm } from "../../../../src/stores/room-list-v3/skip-list/sorters"; +import SettingsStore from "../../../../src/settings/SettingsStore"; describe("RoomListStoreV3", () => { async function getRoomListStore() { @@ -53,6 +55,10 @@ describe("RoomListStoreV3", () => { }) as () => DMRoomMap); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it("Provides an unsorted list of rooms", async () => { const { store, rooms } = await getRoomListStore(); expect(store.getRooms()).toEqual(rooms); @@ -69,14 +75,24 @@ describe("RoomListStoreV3", () => { const { store, rooms, client } = await getRoomListStore(); // List is sorted by recency, sort by alphabetical now - store.useAlphabeticSorting(); + store.resort(SortingAlgorithm.Alphabetic); let sortedRooms = new AlphabeticSorter().sort(rooms); expect(store.getSortedRooms()).toEqual(sortedRooms); + expect(store.activeSortAlgorithm).toEqual(SortingAlgorithm.Alphabetic); // Go back to recency sorting - store.useRecencySorting(); + store.resort(SortingAlgorithm.Recency); sortedRooms = new RecencySorter(client.getSafeUserId()).sort(rooms); expect(store.getSortedRooms()).toEqual(sortedRooms); + expect(store.activeSortAlgorithm).toEqual(SortingAlgorithm.Recency); + }); + + it("Uses preferred sorter on startup", async () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation(() => { + return SortingAlgorithm.Alphabetic; + }); + const { store } = await getRoomListStore(); + expect(store.activeSortAlgorithm).toEqual(SortingAlgorithm.Alphabetic); }); describe("Updates", () => {