Skip to content

Commit 3f3fba9

Browse files
RoomListViewModel: Support secondary filters in the view model (#29465)
* Support secondary filters in the view model * Write view model tests * Fix RoomList test * Add more comments
1 parent 26a17f9 commit 3f3fba9

File tree

4 files changed

+307
-77
lines changed

4 files changed

+307
-77
lines changed

src/components/viewmodels/roomlist/RoomListViewModel.tsx

Lines changed: 15 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,13 @@ 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, useMemo, useState } from "react";
8+
import { useCallback } 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";
13-
import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3";
14-
import { useEventEmitter } from "../../../hooks/useEventEmitter";
15-
import { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
1612
import dispatcher from "../../../dispatcher/dispatcher";
1713
import { Action } from "../../../dispatcher/actions";
18-
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
19-
import { _t, _td } from "../../../languageHandler";
14+
import { type PrimaryFilter, type SecondaryFilters, useFilteredRooms } from "./useFilteredRooms";
2015

2116
export interface RoomListViewState {
2217
/**
@@ -34,14 +29,24 @@ export interface RoomListViewState {
3429
* to render primary room filters.
3530
*/
3631
primaryFilters: PrimaryFilter[];
32+
33+
/**
34+
* A function to activate a given secondary filter.
35+
*/
36+
activateSecondaryFilter: (filter: SecondaryFilters) => void;
37+
38+
/**
39+
* The currently active secondary filter.
40+
*/
41+
activeSecondaryFilter: SecondaryFilters;
3742
}
3843

3944
/**
4045
* View model for the new room list
4146
* @see {@link RoomListViewState} for more information about what this view model returns.
4247
*/
4348
export function useRoomListViewModel(): RoomListViewState {
44-
const { primaryFilters, rooms } = useFilteredRooms();
49+
const { primaryFilters, rooms, activateSecondaryFilter, activeSecondaryFilter } = useFilteredRooms();
4550

4651
const openRoom = useCallback((roomId: string): void => {
4752
dispatcher.dispatch<ViewRoomPayload>({
@@ -55,73 +60,7 @@ export function useRoomListViewModel(): RoomListViewState {
5560
rooms,
5661
openRoom,
5762
primaryFilters,
63+
activateSecondaryFilter,
64+
activeSecondaryFilter,
5865
};
5966
}
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 };
127-
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
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 { useCallback, useMemo, useState } from "react";
9+
10+
import type { Room } from "matrix-js-sdk/src/matrix";
11+
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
12+
import { _t, _td, type TranslationKey } from "../../../languageHandler";
13+
import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3";
14+
import { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
15+
import { useEventEmitter } from "../../../hooks/useEventEmitter";
16+
17+
/**
18+
* Provides information about a primary filter.
19+
* A primary filter is a commonly used filter that is given
20+
* more precedence in the UI. For eg, primary filters may be
21+
* rendered as pills above the room list.
22+
*/
23+
export interface PrimaryFilter {
24+
// A function to toggle this filter on and off.
25+
toggle: () => void;
26+
// Whether this filter is currently applied
27+
active: boolean;
28+
// Text that can be used in the UI to represent this filter.
29+
name: string;
30+
}
31+
32+
interface FilteredRooms {
33+
primaryFilters: PrimaryFilter[];
34+
rooms: Room[];
35+
activateSecondaryFilter: (filter: SecondaryFilters) => void;
36+
activeSecondaryFilter: SecondaryFilters;
37+
}
38+
39+
const filterKeyToNameMap: Map<FilterKey, TranslationKey> = new Map([
40+
[FilterKey.UnreadFilter, _td("room_list|filters|unread")],
41+
[FilterKey.FavouriteFilter, _td("room_list|filters|favourite")],
42+
[FilterKey.PeopleFilter, _td("room_list|filters|people")],
43+
[FilterKey.RoomsFilter, _td("room_list|filters|rooms")],
44+
]);
45+
46+
/**
47+
* These are the secondary filters which are not prominently shown
48+
* in the UI.
49+
*/
50+
export const enum SecondaryFilters {
51+
AllActivity,
52+
MentionsOnly,
53+
InvitesOnly,
54+
LowPriority,
55+
}
56+
57+
/**
58+
* A map from {@link SecondaryFilters} which the UI understands to
59+
* {@link FilterKey} which the store understands.
60+
*/
61+
const secondaryFiltersToFilterKeyMap = new Map([
62+
[SecondaryFilters.AllActivity, undefined],
63+
[SecondaryFilters.MentionsOnly, FilterKey.MentionsFilter],
64+
[SecondaryFilters.InvitesOnly, FilterKey.InvitesFilter],
65+
[SecondaryFilters.LowPriority, FilterKey.LowPriorityFilter],
66+
]);
67+
68+
/**
69+
* Use this function to determine if a given primary filter is compatible with
70+
* a given secondary filter. Practically, this determines whether it makes sense
71+
* to expose two filters together in the UI - for eg, it does not make sense to show the
72+
* favourite primary filter if the active secondary filter is low priority.
73+
* @param primary Primary filter key
74+
* @param secondary Secondary filter key
75+
* @returns true if compatible, false otherwise
76+
*/
77+
function isPrimaryFilterCompatible(primary: FilterKey, secondary: FilterKey): boolean {
78+
if (secondary === FilterKey.MentionsFilter) {
79+
if (primary === FilterKey.UnreadFilter) return false;
80+
} else if (secondary === FilterKey.InvitesFilter) {
81+
if (primary === FilterKey.UnreadFilter || primary === FilterKey.FavouriteFilter) return false;
82+
} else if (secondary === FilterKey.LowPriorityFilter) {
83+
if (primary === FilterKey.FavouriteFilter) return false;
84+
}
85+
return true;
86+
}
87+
88+
/**
89+
* Track available filters and provide a filtered list of rooms.
90+
*/
91+
export function useFilteredRooms(): FilteredRooms {
92+
/**
93+
* Primary filter refers to the pill based filters
94+
* rendered above the room list.
95+
*/
96+
const [primaryFilter, setPrimaryFilter] = useState<FilterKey | undefined>();
97+
/**
98+
* Secondary filters are also filters but they are hidden
99+
* away in a popup menu.
100+
*/
101+
const [activeSecondaryFilter, setActiveSecondaryFilter] = useState<SecondaryFilters>(SecondaryFilters.AllActivity);
102+
103+
const secondaryFilter = useMemo(
104+
() => secondaryFiltersToFilterKeyMap.get(activeSecondaryFilter),
105+
[activeSecondaryFilter],
106+
);
107+
108+
const [rooms, setRooms] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
109+
110+
const updateRoomsFromStore = useCallback((filters: FilterKey[] = []): void => {
111+
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters);
112+
setRooms(newRooms);
113+
}, []);
114+
115+
const filterUndefined = (array: (FilterKey | undefined)[]): FilterKey[] =>
116+
array.filter((f) => f !== undefined) as FilterKey[];
117+
118+
const getAppliedFilters = (): FilterKey[] => {
119+
return filterUndefined([primaryFilter, secondaryFilter]);
120+
};
121+
122+
useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => {
123+
const filters = getAppliedFilters();
124+
updateRoomsFromStore(filters);
125+
});
126+
127+
/**
128+
* Secondary filters are activated using this function.
129+
* This is different to how primary filters work because the secondary
130+
* filters are static i.e they are always available and don't need to be
131+
* hidden.
132+
*/
133+
const activateSecondaryFilter = useCallback(
134+
(filter: SecondaryFilters): void => {
135+
// If the filter is already active, just return.
136+
if (filter === activeSecondaryFilter) return;
137+
138+
// SecondaryFilter is an enum for the UI, let's convert it to something
139+
// that the store will understand.
140+
const secondary = secondaryFiltersToFilterKeyMap.get(filter);
141+
142+
// Active primary filter may need to be toggled off when applying this secondary filer.
143+
let primary = primaryFilter;
144+
if (
145+
primaryFilter !== undefined &&
146+
secondary !== undefined &&
147+
!isPrimaryFilterCompatible(primaryFilter, secondary)
148+
) {
149+
primary = undefined;
150+
}
151+
152+
setActiveSecondaryFilter(filter);
153+
setPrimaryFilter(primary);
154+
updateRoomsFromStore(filterUndefined([primary, secondary]));
155+
},
156+
[activeSecondaryFilter, primaryFilter, updateRoomsFromStore],
157+
);
158+
159+
/**
160+
* This tells the view which primary filters are available, how to toggle them
161+
* and whether a given primary filter is active. @see {@link PrimaryFilter}
162+
*/
163+
const primaryFilters = useMemo(() => {
164+
const createPrimaryFilter = (key: FilterKey, name: string): PrimaryFilter => {
165+
return {
166+
toggle: () => {
167+
setPrimaryFilter((currentFilter) => {
168+
const filter = currentFilter === key ? undefined : key;
169+
updateRoomsFromStore(filterUndefined([filter, secondaryFilter]));
170+
return filter;
171+
});
172+
},
173+
active: primaryFilter === key,
174+
name,
175+
};
176+
};
177+
const filters: PrimaryFilter[] = [];
178+
for (const [key, name] of filterKeyToNameMap.entries()) {
179+
if (secondaryFilter && !isPrimaryFilterCompatible(key, secondaryFilter)) {
180+
continue;
181+
}
182+
filters.push(createPrimaryFilter(key, _t(name)));
183+
}
184+
return filters;
185+
}, [primaryFilter, updateRoomsFromStore, secondaryFilter]);
186+
187+
return { primaryFilters, rooms, activateSecondaryFilter, activeSecondaryFilter };
188+
}

0 commit comments

Comments
 (0)