Skip to content

Commit fd91e78

Browse files
RoomListViewModel: Support primary filters in the view model (#29454)
* Track available filters and expose this info from the vm - Adds a separate hook that tracks the filtered rooms and the available filters. - When secondary filters are added, some of the primary filters will be selectively hidden. So track this info in the vm. * Write tests * Fix typescript error * Fix translation * Explain what a primary filter is
1 parent af47690 commit fd91e78

4 files changed

Lines changed: 145 additions & 14 deletions

File tree

src/components/viewmodels/roomlist/RoomListViewModel.tsx

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
55
Please see LICENSE files in the repository root for full details.
66
*/
77

8-
import { useCallback, useState } from "react";
8+
import { useCallback, useMemo, useState } from "react";
99

1010
import type { Room } from "matrix-js-sdk/src/matrix";
1111
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
12+
import type { TranslationKey } from "../../../languageHandler";
1213
import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3";
1314
import { useEventEmitter } from "../../../hooks/useEventEmitter";
1415
import { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
1516
import dispatcher from "../../../dispatcher/dispatcher";
1617
import { Action } from "../../../dispatcher/actions";
18+
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
19+
import { _t, _td } from "../../../languageHandler";
1720

1821
export interface RoomListViewState {
1922
/**
@@ -25,19 +28,20 @@ export interface RoomListViewState {
2528
* Open the room having given roomId.
2629
*/
2730
openRoom: (roomId: string) => void;
31+
32+
/**
33+
* A list of objects that provide the view enough information
34+
* to render primary room filters.
35+
*/
36+
primaryFilters: PrimaryFilter[];
2837
}
2938

3039
/**
3140
* View model for the new room list
3241
* @see {@link RoomListViewState} for more information about what this view model returns.
3342
*/
3443
export function useRoomListViewModel(): RoomListViewState {
35-
const [rooms, setRooms] = useState(RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
36-
37-
useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => {
38-
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace();
39-
setRooms(newRooms);
40-
});
44+
const { primaryFilters, rooms } = useFilteredRooms();
4145

4246
const openRoom = useCallback((roomId: string): void => {
4347
dispatcher.dispatch<ViewRoomPayload>({
@@ -47,5 +51,77 @@ export function useRoomListViewModel(): RoomListViewState {
4751
});
4852
}, []);
4953

50-
return { rooms, openRoom };
54+
return {
55+
rooms,
56+
openRoom,
57+
primaryFilters,
58+
};
59+
}
60+
61+
/**
62+
* Provides information about a primary filter.
63+
* A primary filter is a commonly used filter that is given
64+
* more precedence in the UI. For eg, primary filters may be
65+
* rendered as pills above the room list.
66+
*/
67+
interface PrimaryFilter {
68+
// A function to toggle this filter on and off.
69+
toggle: () => void;
70+
// Whether this filter is currently applied
71+
active: boolean;
72+
// Text that can be used in the UI to represent this filter.
73+
name: string;
74+
}
75+
76+
interface FilteredRooms {
77+
primaryFilters: PrimaryFilter[];
78+
rooms: Room[];
79+
}
80+
81+
const filterKeyToNameMap: Map<FilterKey, TranslationKey> = new Map([
82+
[FilterKey.UnreadFilter, _td("room_list|filters|unread")],
83+
[FilterKey.FavouriteFilter, _td("room_list|filters|favourite")],
84+
[FilterKey.PeopleFilter, _td("room_list|filters|people")],
85+
[FilterKey.RoomsFilter, _td("room_list|filters|rooms")],
86+
]);
87+
88+
/**
89+
* Track available filters and provide a filtered list of rooms.
90+
*/
91+
function useFilteredRooms(): FilteredRooms {
92+
const [primaryFilter, setPrimaryFilter] = useState<FilterKey | undefined>();
93+
const [rooms, setRooms] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
94+
95+
const updateRoomsFromStore = useCallback((filter?: FilterKey): void => {
96+
const filters = filter !== undefined ? [filter] : [];
97+
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters);
98+
setRooms(newRooms);
99+
}, []);
100+
101+
useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => {
102+
updateRoomsFromStore(primaryFilter);
103+
});
104+
105+
const primaryFilters = useMemo(() => {
106+
const createPrimaryFilter = (key: FilterKey, name: string): PrimaryFilter => {
107+
return {
108+
toggle: () => {
109+
setPrimaryFilter((currentFilter) => {
110+
const filter = currentFilter === key ? undefined : key;
111+
updateRoomsFromStore(filter);
112+
return filter;
113+
});
114+
},
115+
active: primaryFilter === key,
116+
name,
117+
};
118+
};
119+
const filters: PrimaryFilter[] = [];
120+
for (const [key, name] of filterKeyToNameMap.entries()) {
121+
filters.push(createPrimaryFilter(key, _t(name)));
122+
}
123+
return filters;
124+
}, [primaryFilter, updateRoomsFromStore]);
125+
126+
return { primaryFilters, rooms };
51127
}

src/i18n/strings/en_EN.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2099,6 +2099,12 @@
20992099
"failed_add_tag": "Failed to add tag %(tagName)s to room",
21002100
"failed_remove_tag": "Failed to remove tag %(tagName)s from room",
21012101
"failed_set_dm_tag": "Failed to set direct message tag",
2102+
"filters": {
2103+
"favourite": "Favourites",
2104+
"people": "People",
2105+
"rooms": "Rooms",
2106+
"unread": "Unread"
2107+
},
21022108
"home_menu_label": "Home options",
21032109
"join_public_room_label": "Join public room",
21042110
"joining_rooms_status": {

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

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,19 @@ import { LISTS_UPDATE_EVENT } from "../../../../../src/stores/room-list/SlidingR
1414
import { useRoomListViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
1515
import dispatcher from "../../../../../src/dispatcher/dispatcher";
1616
import { Action } from "../../../../../src/dispatcher/actions";
17+
import { FilterKey } from "../../../../../src/stores/room-list-v3/skip-list/filters";
1718

1819
describe("RoomListViewModel", () => {
1920
function mockAndCreateRooms() {
2021
const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined));
21-
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockImplementation(() => [...rooms]);
22-
return rooms;
22+
const fn = jest
23+
.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace")
24+
.mockImplementation(() => [...rooms]);
25+
return { rooms, fn };
2326
}
2427

2528
it("should return a list of rooms", async () => {
26-
const rooms = mockAndCreateRooms();
29+
const { rooms } = mockAndCreateRooms();
2730
const { result: vm } = renderHook(() => useRoomListViewModel());
2831

2932
expect(vm.current.rooms).toHaveLength(10);
@@ -33,7 +36,7 @@ describe("RoomListViewModel", () => {
3336
});
3437

3538
it("should update list of rooms on event from room list store", async () => {
36-
const rooms = mockAndCreateRooms();
39+
const { rooms } = mockAndCreateRooms();
3740
const { result: vm } = renderHook(() => useRoomListViewModel());
3841

3942
const newRoom = mkStubRoom("bar:matrix.org", "Bar", undefined);
@@ -46,7 +49,7 @@ describe("RoomListViewModel", () => {
4649
});
4750

4851
it("should dispatch view room action on openRoom", async () => {
49-
const rooms = mockAndCreateRooms();
52+
const { rooms } = mockAndCreateRooms();
5053
const { result: vm } = renderHook(() => useRoomListViewModel());
5154

5255
const fn = jest.spyOn(dispatcher, "dispatch");
@@ -59,4 +62,50 @@ describe("RoomListViewModel", () => {
5962
}),
6063
);
6164
});
65+
66+
describe("Filters", () => {
67+
it("should provide list of available filters", () => {
68+
mockAndCreateRooms();
69+
const { result: vm } = renderHook(() => useRoomListViewModel());
70+
// should have 4 filters
71+
expect(vm.current.primaryFilters).toHaveLength(4);
72+
// check the order
73+
for (const [i, name] of ["Unread", "Favourites", "People", "Rooms"].entries()) {
74+
expect(vm.current.primaryFilters[i].name).toEqual(name);
75+
expect(vm.current.primaryFilters[i].active).toEqual(false);
76+
}
77+
});
78+
79+
it("should get filtered rooms from RLS on toggle", () => {
80+
const { fn } = mockAndCreateRooms();
81+
const { result: vm } = renderHook(() => useRoomListViewModel());
82+
// Let's say we toggle the People toggle
83+
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
84+
act(() => {
85+
vm.current.primaryFilters[i].toggle();
86+
});
87+
expect(fn).toHaveBeenCalledWith([FilterKey.PeopleFilter]);
88+
expect(vm.current.primaryFilters[i].active).toEqual(true);
89+
});
90+
91+
it("should change active property on toggle", () => {
92+
mockAndCreateRooms();
93+
const { result: vm } = renderHook(() => useRoomListViewModel());
94+
// Let's say we toggle the People filter
95+
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
96+
expect(vm.current.primaryFilters[i].active).toEqual(false);
97+
act(() => {
98+
vm.current.primaryFilters[i].toggle();
99+
});
100+
expect(vm.current.primaryFilters[i].active).toEqual(true);
101+
102+
// Let's say that we toggle the Favourite filter
103+
const j = vm.current.primaryFilters.findIndex((f) => f.name === "Favourites");
104+
act(() => {
105+
vm.current.primaryFilters[j].toggle();
106+
});
107+
expect(vm.current.primaryFilters[i].active).toEqual(false);
108+
expect(vm.current.primaryFilters[j].active).toEqual(true);
109+
});
110+
});
62111
});

test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ describe("<RoomList />", () => {
2727

2828
matrixClient = stubClient();
2929
const rooms = Array.from({ length: 10 }, (_, i) => mkRoom(matrixClient, `room${i}`));
30-
vm = { rooms, openRoom: jest.fn() };
30+
vm = { rooms, openRoom: jest.fn(), primaryFilters: [] };
3131

3232
// Needed to render a room list cell
3333
DMRoomMap.makeShared(matrixClient);

0 commit comments

Comments
 (0)