Skip to content

Commit b1898d5

Browse files
committed
Add "show unread first" option
1 parent aba2795 commit b1898d5

File tree

12 files changed

+221
-71
lines changed

12 files changed

+221
-71
lines changed

src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,19 @@ export interface RoomListHeaderViewState {
121121
/**
122122
* Change the sort order of the room-list.
123123
*/
124-
sort: (option: SortOption, grouped: boolean) => void;
124+
sort: (option: SortOption, useSections: boolean, unreadFirst: boolean) => void;
125125
/**
126126
* The currently active sort option.
127127
*/
128128
activeSortOption: SortOption;
129-
grouped: boolean;
129+
/**
130+
* Whether to group rooms into sections
131+
*/
132+
useSections: boolean;
133+
/**
134+
* Whether to show unread rooms first
135+
*/
136+
unreadFirst: boolean;
130137
}
131138

132139
/**
@@ -148,7 +155,7 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
148155

149156
/* Actions */
150157

151-
const { activeSortOption, grouped, sort } = useSorter();
158+
const { activeSortOption, useSections, unreadFirst, sort } = useSorter();
152159

153160
const createChatRoom = useCallback((e: Event) => {
154161
defaultDispatcher.fire(Action.CreateChat);
@@ -220,7 +227,8 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
220227
openSpacePreferences,
221228
openSpaceSettings,
222229
activeSortOption,
223-
grouped,
230+
useSections,
231+
unreadFirst,
224232
sort,
225233
};
226234
}

src/components/viewmodels/roomlist/useSorter.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ import { useState } from "react";
99
import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3";
1010
import { SortingAlgorithm } from "../../../stores/room-list-v3/skip-list/sorters";
1111
import SettingsStore from "../../../settings/SettingsStore";
12-
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
13-
import { arrayHasDiff } from "../../../utils/arrays.ts";
1412

1513
/**
1614
* Sorting options made available to the view.
@@ -37,14 +35,12 @@ const sortOptionToSortingAlgorithm = {
3735
};
3836

3937
interface SortState {
40-
sort: (option: SortOption, grouped: boolean) => void;
38+
sort: (option: SortOption, useSections: boolean, unreadFirst: boolean) => void;
4139
activeSortOption: SortOption;
42-
grouped: boolean;
40+
useSections: boolean;
41+
unreadFirst: boolean;
4342
}
4443

45-
const defaultSections = [FilterKey.FavouriteFilter, FilterKey.PeopleFilter, null];
46-
const noSections = [null];
47-
4844
/**
4945
* This hook does two things:
5046
* - Provides a way to track the currently active sort option.
@@ -54,19 +50,21 @@ export function useSorter(): SortState {
5450
const [activeSortingAlgorithm, setActiveSortingAlgorithm] = useState(() =>
5551
SettingsStore.getValue("RoomList.preferredSorting"),
5652
);
57-
const [activeSections, setActiveSections] = useState(() => SettingsStore.getValue("RoomList.sections"));
53+
const [useSections, setUseSections] = useState(() => SettingsStore.getValue("RoomList.useSections"));
54+
const [unreadFirst, setUnreadFirst] = useState(() => SettingsStore.getValue("RoomList.unreadFirst"));
5855

59-
const sort = (option: SortOption, grouped: boolean): void => {
56+
const sort = (option: SortOption, useSections: boolean, unreadFirst: boolean): void => {
6057
const sortingAlgorithm = sortOptionToSortingAlgorithm[option];
61-
const sections = grouped ? defaultSections : noSections;
62-
RoomListStoreV3.instance.resort(sortingAlgorithm, sections);
58+
RoomListStoreV3.instance.resort(sortingAlgorithm, useSections, unreadFirst);
6359
setActiveSortingAlgorithm(sortingAlgorithm);
64-
setActiveSections(sections);
60+
setUseSections(useSections);
61+
setUnreadFirst(unreadFirst);
6562
};
6663

6764
return {
6865
sort,
6966
activeSortOption: sortingAlgorithmToSortingOption[activeSortingAlgorithm!],
70-
grouped: arrayHasDiff(noSections, activeSections ?? noSections),
67+
unreadFirst,
68+
useSections,
7169
};
7270
}

src/components/views/rooms/RoomListPanel/RoomListOptionsMenu.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,19 @@ export function RoomListOptionsMenu({ vm }: Props): JSX.Element {
3636
const [open, setOpen] = useState(false);
3737

3838
const onActivitySelected = useCallback(() => {
39-
vm.sort(SortOption.Activity, vm.grouped);
39+
vm.sort(SortOption.Activity, vm.useSections, vm.unreadFirst);
4040
}, [vm]);
4141

4242
const onAtoZSelected = useCallback(() => {
43-
vm.sort(SortOption.AToZ, vm.grouped);
43+
vm.sort(SortOption.AToZ, vm.useSections, vm.unreadFirst);
4444
}, [vm]);
4545

46-
const onGroupedSelected = useCallback(() => {
47-
vm.sort(vm.activeSortOption, !vm.grouped);
46+
const onUseSectionsSelected = useCallback(() => {
47+
vm.sort(vm.activeSortOption, !vm.useSections, vm.unreadFirst);
48+
}, [vm]);
49+
50+
const onUnreadFirstSelected = useCallback(() => {
51+
vm.sort(vm.activeSortOption, vm.useSections, !vm.unreadFirst);
4852
}, [vm]);
4953

5054
return (
@@ -67,7 +71,16 @@ export function RoomListOptionsMenu({ vm }: Props): JSX.Element {
6771
checked={vm.activeSortOption === SortOption.AToZ}
6872
onSelect={onAtoZSelected}
6973
/>
70-
<CheckboxMenuItem label={_t("room_list|grouped")} checked={vm.grouped} onSelect={onGroupedSelected} />
74+
<CheckboxMenuItem
75+
label={_t("room_list|sort_sections")}
76+
checked={vm.useSections}
77+
onSelect={onUseSectionsSelected}
78+
/>
79+
<CheckboxMenuItem
80+
label={_t("room_list|sort_unread_first")}
81+
checked={vm.unreadFirst}
82+
onSelect={onUnreadFirstSelected}
83+
/>
7184
</Menu>
7285
);
7386
}

src/i18n/strings/en_EN.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2153,7 +2153,6 @@
21532153
"rooms": "Rooms",
21542154
"unread": "Unreads"
21552155
},
2156-
"grouped": "Show favourites first",
21572156
"home_menu_label": "Home options",
21582157
"join_public_room_label": "Join public room",
21592158
"joining_rooms_status": {
@@ -2211,6 +2210,7 @@
22112210
"sort_by": "Sort by",
22122211
"sort_by_activity": "Activity",
22132212
"sort_by_alphabet": "A-Z",
2213+
"sort_sections": "Group rooms by type",
22142214
"sort_type": {
22152215
"activity": "Activity",
22162216
"atoz": "A-Z"

src/settings/Settings.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ import { SortingAlgorithm } from "../stores/room-list-v3/skip-list/sorters/index
5050
import MediaPreviewConfigController from "./controllers/MediaPreviewConfigController.ts";
5151
import InviteRulesConfigController from "./controllers/InviteRulesConfigController.ts";
5252
import { type ComputedInviteConfig } from "../@types/invite-rules.ts";
53-
import { FilterKey } from "../stores/room-list-v3/skip-list/filters";
5453

5554
export const defaultWatchManager = new WatchManager();
5655

@@ -325,7 +324,8 @@ export interface Settings {
325324
"lowBandwidth": IBaseSetting<boolean>;
326325
"fallbackICEServerAllowed": IBaseSetting<boolean | null>;
327326
"RoomList.preferredSorting": IBaseSetting<SortingAlgorithm>;
328-
"RoomList.sections": IBaseSetting<(FilterKey | null)[]>;
327+
"RoomList.useSections": IBaseSetting<boolean>;
328+
"RoomList.unreadFirst": IBaseSetting<boolean>;
329329
"RoomList.showMessagePreview": IBaseSetting<boolean>;
330330
"RightPanel.phasesGlobal": IBaseSetting<IRightPanelForRoomStored | null>;
331331
"RightPanel.phases": IBaseSetting<IRightPanelForRoomStored | null>;
@@ -1201,9 +1201,13 @@ export const SETTINGS: Settings = {
12011201
supportedLevels: [SettingLevel.DEVICE],
12021202
default: SortingAlgorithm.Recency,
12031203
},
1204-
"RoomList.sections": {
1204+
"RoomList.useSections": {
12051205
supportedLevels: [SettingLevel.DEVICE],
1206-
default: [FilterKey.FavouriteFilter, FilterKey.PeopleFilter, null],
1206+
default: false,
1207+
},
1208+
"RoomList.unreadFirst": {
1209+
supportedLevels: [SettingLevel.DEVICE],
1210+
default: false,
12071211
},
12081212
"RoomList.showMessagePreview": {
12091213
supportedLevels: [SettingLevel.DEVICE],

src/stores/room-list-v3/RoomListStoreV3.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -132,21 +132,23 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
132132
/**
133133
* Resort the list of rooms using a different algorithm.
134134
* @param algorithm The sorting algorithm to use.
135-
* @param sections Which sections to group rooms into
135+
* @param useSections Whether to group rooms into sections
136+
* @param unreadFirst Whether to show unread rooms first
136137
*/
137-
public resort(algorithm: SortingAlgorithm, sections: (FilterKey | null)[]): void {
138+
public resort(algorithm: SortingAlgorithm, useSections: boolean, unreadFirst: boolean): void {
138139
if (!this.roomSkipList) throw new Error("Cannot resort room list before skip list is created.");
139140
if (!this.matrixClient) throw new Error("Cannot resort room list without matrix client.");
140141
const sorter =
141142
algorithm === SortingAlgorithm.Alphabetic
142143
? new AlphabeticSorter()
143144
: new RecencySorter(this.matrixClient.getSafeUserId());
144-
const sectionSorter = new SectionSorter(sorter, sections);
145+
const sectionSorter = new SectionSorter(sorter, useSections, unreadFirst);
145146
if (this.roomSkipList.activeSortAlgorithm === sectionSorter.type) return;
146147
this.roomSkipList.useNewSorter(sectionSorter, this.getRooms());
147148
this.emit(LISTS_UPDATE_EVENT);
148149
SettingsStore.setValue("RoomList.preferredSorting", null, SettingLevel.DEVICE, algorithm);
149-
SettingsStore.setValue("RoomList.sections", null, SettingLevel.DEVICE, sections);
150+
SettingsStore.setValue("RoomList.useSections", null, SettingLevel.DEVICE, useSections);
151+
SettingsStore.setValue("RoomList.unreadFirst", null, SettingLevel.DEVICE, unreadFirst);
150152
}
151153

152154
/**
@@ -325,12 +327,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
325327
*/
326328
private getPreferredSorter(myUserId: string): Sorter {
327329
const preferred = SettingsStore.getValue("RoomList.preferredSorting");
328-
const sections = SettingsStore.getValue("RoomList.sections");
330+
const useSections = SettingsStore.getValue("RoomList.useSections");
331+
const unreadFirst = SettingsStore.getValue("RoomList.unreadFirst");
329332
switch (preferred) {
330333
case SortingAlgorithm.Alphabetic:
331-
return new SectionSorter(new AlphabeticSorter(), sections);
334+
return new SectionSorter(new AlphabeticSorter(), useSections, unreadFirst);
332335
case SortingAlgorithm.Recency:
333-
return new SectionSorter(new RecencySorter(myUserId), sections);
336+
return new SectionSorter(new RecencySorter(myUserId), useSections, unreadFirst);
334337
default:
335338
throw new Error(`Got unknown sort preference from RoomList.preferredSorting setting`);
336339
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { type Room } from "matrix-js-sdk/src/matrix";
9+
10+
import { type Filter, FilterKey } from "../filters";
11+
import { FavouriteFilter } from "../filters/FavouriteFilter.ts";
12+
import { UnreadFilter } from "../filters/UnreadFilter.ts";
13+
import { PeopleFilter } from "../filters/PeopleFilter.ts";
14+
import { RoomsFilter } from "../filters/RoomsFilter.ts";
15+
import { InvitesFilter } from "../filters/InvitesFilter.ts";
16+
import { MentionsFilter } from "../filters/MentionsFilter.ts";
17+
import { LowPriorityFilter } from "../filters/LowPriorityFilter.ts";
18+
19+
export type Section = FilterKey | FilterKey[] | null;
20+
21+
const filters: { [T in FilterKey]?: Filter } = {
22+
[FilterKey.FavouriteFilter]: new FavouriteFilter(),
23+
[FilterKey.UnreadFilter]: new UnreadFilter(),
24+
[FilterKey.PeopleFilter]: new PeopleFilter(),
25+
[FilterKey.RoomsFilter]: new RoomsFilter(),
26+
[FilterKey.InvitesFilter]: new InvitesFilter(),
27+
[FilterKey.MentionsFilter]: new MentionsFilter(),
28+
[FilterKey.LowPriorityFilter]: new LowPriorityFilter(),
29+
};
30+
31+
export function sectionMatches(section: Section, room: Room): boolean {
32+
if (section === null) {
33+
return false;
34+
} else if (Array.isArray(section)) {
35+
return section.every((key) => !!filters[key]?.matches(room));
36+
} else {
37+
return !!filters[section]?.matches(room);
38+
}
39+
}
40+
41+
const sectionsDefault: Section[] = [
42+
// Invite
43+
[FilterKey.InvitesFilter],
44+
// Favourite
45+
[FilterKey.FavouriteFilter],
46+
// People
47+
[FilterKey.PeopleFilter],
48+
// Other
49+
null,
50+
// Low Priority
51+
[FilterKey.LowPriorityFilter],
52+
];
53+
const sectionsUnreadFirst: Section[] = [
54+
// Invite
55+
[FilterKey.InvitesFilter],
56+
// Mention
57+
[FilterKey.MentionsFilter],
58+
// Unread & Favourite
59+
[FilterKey.UnreadFilter, FilterKey.FavouriteFilter],
60+
// Unread & People
61+
[FilterKey.UnreadFilter, FilterKey.PeopleFilter],
62+
// Unread Other
63+
[FilterKey.UnreadFilter],
64+
// Favourite
65+
[FilterKey.FavouriteFilter],
66+
// People
67+
[FilterKey.PeopleFilter],
68+
// Other
69+
null,
70+
// Low Priority
71+
[FilterKey.LowPriorityFilter],
72+
];
73+
const listUnreadFirst: Section[] = [
74+
// Mention
75+
[FilterKey.MentionsFilter],
76+
// Unread Other
77+
[FilterKey.UnreadFilter],
78+
// Other
79+
null,
80+
];
81+
export const listDefault: Section[] = [null];
82+
83+
export function buildSections(useSections: boolean, unreadFirst: boolean): Section[] {
84+
if (useSections && unreadFirst) return sectionsUnreadFirst;
85+
else if (useSections) return sectionsDefault;
86+
else if (unreadFirst) return listUnreadFirst;
87+
else return listDefault;
88+
}

src/stores/room-list-v3/skip-list/sorters/SectionSorter.ts

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,30 +7,17 @@ Please see LICENSE files in the repository root for full details.
77

88
import type { Room } from "matrix-js-sdk/src/matrix";
99
import type { Sorter } from ".";
10-
import { type Filter, FilterKey } from "../filters";
11-
import { FavouriteFilter } from "../filters/FavouriteFilter.ts";
12-
import { PeopleFilter } from "../filters/PeopleFilter.ts";
13-
import { InvitesFilter } from "../filters/InvitesFilter.ts";
14-
import { UnreadFilter } from "../filters/UnreadFilter.ts";
15-
import { RoomsFilter } from "../filters/RoomsFilter.ts";
16-
import { MentionsFilter } from "../filters/MentionsFilter.ts";
17-
import { LowPriorityFilter } from "../filters/LowPriorityFilter.ts";
18-
19-
const filters: { [T in FilterKey]?: Filter } = {
20-
[FilterKey.FavouriteFilter]: new FavouriteFilter(),
21-
[FilterKey.UnreadFilter]: new UnreadFilter(),
22-
[FilterKey.PeopleFilter]: new PeopleFilter(),
23-
[FilterKey.RoomsFilter]: new RoomsFilter(),
24-
[FilterKey.InvitesFilter]: new InvitesFilter(),
25-
[FilterKey.MentionsFilter]: new MentionsFilter(),
26-
[FilterKey.LowPriorityFilter]: new LowPriorityFilter(),
27-
};
10+
import { buildSections, type Section, sectionMatches } from "../sections";
2811

2912
export class SectionSorter implements Sorter {
13+
public readonly sections: Section[];
3014
public constructor(
3115
public readonly wrapped: Sorter,
32-
public readonly sections: (FilterKey | null)[],
33-
) {}
16+
public readonly useSections: boolean,
17+
public readonly unreadFirst: boolean,
18+
) {
19+
this.sections = buildSections(useSections, unreadFirst);
20+
}
3421

3522
public sort(rooms: Room[]): Room[] {
3623
return [...rooms].sort((a, b) => {
@@ -40,8 +27,7 @@ export class SectionSorter implements Sorter {
4027

4128
private getSectionIndex(room: Room): number {
4229
for (let index = 0; index < this.sections.length; index++) {
43-
const key = this.sections[index];
44-
if (key !== null && filters[key] && filters[key].matches(room)) {
30+
if (sectionMatches(this.sections[index], room)) {
4531
return index;
4632
}
4733
}
@@ -59,6 +45,8 @@ export class SectionSorter implements Sorter {
5945
}
6046

6147
public get type(): string {
62-
return ["grouping", this.wrapped.type, ...this.sections].join("|");
48+
return [this.wrapped.type, this.useSections ? "useSections" : null, this.unreadFirst ? "unreadFirst" : null]
49+
.filter((it) => it !== null)
50+
.join("-");
6351
}
6452
}

test/unit-tests/components/viewmodels/roomlist/RoomListHeaderViewModel-test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,11 +221,11 @@ describe("useRoomListHeaderViewModel", () => {
221221

222222
// Change the sort option
223223
act(() => {
224-
vm.current.sort(SortOption.AToZ, false);
224+
vm.current.sort(SortOption.AToZ, false, false);
225225
});
226226

227227
// Resort method in RLS must have been called
228-
expect(resort).toHaveBeenCalledWith(SortingAlgorithm.Alphabetic, [null]);
228+
expect(resort).toHaveBeenCalledWith(SortingAlgorithm.Alphabetic, false, false);
229229
});
230230

231231
it("should set activeSortOption based on value from settings", () => {

0 commit comments

Comments
 (0)