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
This commit is contained in:
@@ -5,18 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { Room } from "matrix-js-sdk/src/matrix";
|
||||
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import type { TranslationKey } from "../../../languageHandler";
|
||||
import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3";
|
||||
import { useEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
|
||||
import dispatcher from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
|
||||
import { _t, _td } from "../../../languageHandler";
|
||||
import { type PrimaryFilter, type SecondaryFilters, useFilteredRooms } from "./useFilteredRooms";
|
||||
|
||||
export interface RoomListViewState {
|
||||
/**
|
||||
@@ -34,6 +29,16 @@ export interface RoomListViewState {
|
||||
* to render primary room filters.
|
||||
*/
|
||||
primaryFilters: PrimaryFilter[];
|
||||
|
||||
/**
|
||||
* A function to activate a given secondary filter.
|
||||
*/
|
||||
activateSecondaryFilter: (filter: SecondaryFilters) => void;
|
||||
|
||||
/**
|
||||
* The currently active secondary filter.
|
||||
*/
|
||||
activeSecondaryFilter: SecondaryFilters;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +46,7 @@ export interface RoomListViewState {
|
||||
* @see {@link RoomListViewState} for more information about what this view model returns.
|
||||
*/
|
||||
export function useRoomListViewModel(): RoomListViewState {
|
||||
const { primaryFilters, rooms } = useFilteredRooms();
|
||||
const { primaryFilters, rooms, activateSecondaryFilter, activeSecondaryFilter } = useFilteredRooms();
|
||||
|
||||
const openRoom = useCallback((roomId: string): void => {
|
||||
dispatcher.dispatch<ViewRoomPayload>({
|
||||
@@ -55,73 +60,7 @@ export function useRoomListViewModel(): RoomListViewState {
|
||||
rooms,
|
||||
openRoom,
|
||||
primaryFilters,
|
||||
activateSecondaryFilter,
|
||||
activeSecondaryFilter,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides information about a primary filter.
|
||||
* A primary filter is a commonly used filter that is given
|
||||
* more precedence in the UI. For eg, primary filters may be
|
||||
* rendered as pills above the room list.
|
||||
*/
|
||||
interface PrimaryFilter {
|
||||
// A function to toggle this filter on and off.
|
||||
toggle: () => void;
|
||||
// Whether this filter is currently applied
|
||||
active: boolean;
|
||||
// Text that can be used in the UI to represent this filter.
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface FilteredRooms {
|
||||
primaryFilters: PrimaryFilter[];
|
||||
rooms: Room[];
|
||||
}
|
||||
|
||||
const filterKeyToNameMap: Map<FilterKey, TranslationKey> = new Map([
|
||||
[FilterKey.UnreadFilter, _td("room_list|filters|unread")],
|
||||
[FilterKey.FavouriteFilter, _td("room_list|filters|favourite")],
|
||||
[FilterKey.PeopleFilter, _td("room_list|filters|people")],
|
||||
[FilterKey.RoomsFilter, _td("room_list|filters|rooms")],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Track available filters and provide a filtered list of rooms.
|
||||
*/
|
||||
function useFilteredRooms(): FilteredRooms {
|
||||
const [primaryFilter, setPrimaryFilter] = useState<FilterKey | undefined>();
|
||||
const [rooms, setRooms] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
|
||||
|
||||
const updateRoomsFromStore = useCallback((filter?: FilterKey): void => {
|
||||
const filters = filter !== undefined ? [filter] : [];
|
||||
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters);
|
||||
setRooms(newRooms);
|
||||
}, []);
|
||||
|
||||
useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => {
|
||||
updateRoomsFromStore(primaryFilter);
|
||||
});
|
||||
|
||||
const primaryFilters = useMemo(() => {
|
||||
const createPrimaryFilter = (key: FilterKey, name: string): PrimaryFilter => {
|
||||
return {
|
||||
toggle: () => {
|
||||
setPrimaryFilter((currentFilter) => {
|
||||
const filter = currentFilter === key ? undefined : key;
|
||||
updateRoomsFromStore(filter);
|
||||
return filter;
|
||||
});
|
||||
},
|
||||
active: primaryFilter === key,
|
||||
name,
|
||||
};
|
||||
};
|
||||
const filters: PrimaryFilter[] = [];
|
||||
for (const [key, name] of filterKeyToNameMap.entries()) {
|
||||
filters.push(createPrimaryFilter(key, _t(name)));
|
||||
}
|
||||
return filters;
|
||||
}, [primaryFilter, updateRoomsFromStore]);
|
||||
|
||||
return { primaryFilters, rooms };
|
||||
}
|
||||
|
||||
188
src/components/viewmodels/roomlist/useFilteredRooms.tsx
Normal file
188
src/components/viewmodels/roomlist/useFilteredRooms.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
Copyright 2025 New Vector 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 { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import type { Room } from "matrix-js-sdk/src/matrix";
|
||||
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
|
||||
import { _t, _td, type TranslationKey } from "../../../languageHandler";
|
||||
import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3";
|
||||
import { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
|
||||
import { useEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
|
||||
/**
|
||||
* Provides information about a primary filter.
|
||||
* A primary filter is a commonly used filter that is given
|
||||
* more precedence in the UI. For eg, primary filters may be
|
||||
* rendered as pills above the room list.
|
||||
*/
|
||||
export interface PrimaryFilter {
|
||||
// A function to toggle this filter on and off.
|
||||
toggle: () => void;
|
||||
// Whether this filter is currently applied
|
||||
active: boolean;
|
||||
// Text that can be used in the UI to represent this filter.
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface FilteredRooms {
|
||||
primaryFilters: PrimaryFilter[];
|
||||
rooms: Room[];
|
||||
activateSecondaryFilter: (filter: SecondaryFilters) => void;
|
||||
activeSecondaryFilter: SecondaryFilters;
|
||||
}
|
||||
|
||||
const filterKeyToNameMap: Map<FilterKey, TranslationKey> = new Map([
|
||||
[FilterKey.UnreadFilter, _td("room_list|filters|unread")],
|
||||
[FilterKey.FavouriteFilter, _td("room_list|filters|favourite")],
|
||||
[FilterKey.PeopleFilter, _td("room_list|filters|people")],
|
||||
[FilterKey.RoomsFilter, _td("room_list|filters|rooms")],
|
||||
]);
|
||||
|
||||
/**
|
||||
* These are the secondary filters which are not prominently shown
|
||||
* in the UI.
|
||||
*/
|
||||
export const enum SecondaryFilters {
|
||||
AllActivity,
|
||||
MentionsOnly,
|
||||
InvitesOnly,
|
||||
LowPriority,
|
||||
}
|
||||
|
||||
/**
|
||||
* A map from {@link SecondaryFilters} which the UI understands to
|
||||
* {@link FilterKey} which the store understands.
|
||||
*/
|
||||
const secondaryFiltersToFilterKeyMap = new Map([
|
||||
[SecondaryFilters.AllActivity, undefined],
|
||||
[SecondaryFilters.MentionsOnly, FilterKey.MentionsFilter],
|
||||
[SecondaryFilters.InvitesOnly, FilterKey.InvitesFilter],
|
||||
[SecondaryFilters.LowPriority, FilterKey.LowPriorityFilter],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Use this function to determine if a given primary filter is compatible with
|
||||
* a given secondary filter. Practically, this determines whether it makes sense
|
||||
* to expose two filters together in the UI - for eg, it does not make sense to show the
|
||||
* favourite primary filter if the active secondary filter is low priority.
|
||||
* @param primary Primary filter key
|
||||
* @param secondary Secondary filter key
|
||||
* @returns true if compatible, false otherwise
|
||||
*/
|
||||
function isPrimaryFilterCompatible(primary: FilterKey, secondary: FilterKey): boolean {
|
||||
if (secondary === FilterKey.MentionsFilter) {
|
||||
if (primary === FilterKey.UnreadFilter) return false;
|
||||
} else if (secondary === FilterKey.InvitesFilter) {
|
||||
if (primary === FilterKey.UnreadFilter || primary === FilterKey.FavouriteFilter) return false;
|
||||
} else if (secondary === FilterKey.LowPriorityFilter) {
|
||||
if (primary === FilterKey.FavouriteFilter) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track available filters and provide a filtered list of rooms.
|
||||
*/
|
||||
export function useFilteredRooms(): FilteredRooms {
|
||||
/**
|
||||
* Primary filter refers to the pill based filters
|
||||
* rendered above the room list.
|
||||
*/
|
||||
const [primaryFilter, setPrimaryFilter] = useState<FilterKey | undefined>();
|
||||
/**
|
||||
* Secondary filters are also filters but they are hidden
|
||||
* away in a popup menu.
|
||||
*/
|
||||
const [activeSecondaryFilter, setActiveSecondaryFilter] = useState<SecondaryFilters>(SecondaryFilters.AllActivity);
|
||||
|
||||
const secondaryFilter = useMemo(
|
||||
() => secondaryFiltersToFilterKeyMap.get(activeSecondaryFilter),
|
||||
[activeSecondaryFilter],
|
||||
);
|
||||
|
||||
const [rooms, setRooms] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
|
||||
|
||||
const updateRoomsFromStore = useCallback((filters: FilterKey[] = []): void => {
|
||||
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters);
|
||||
setRooms(newRooms);
|
||||
}, []);
|
||||
|
||||
const filterUndefined = (array: (FilterKey | undefined)[]): FilterKey[] =>
|
||||
array.filter((f) => f !== undefined) as FilterKey[];
|
||||
|
||||
const getAppliedFilters = (): FilterKey[] => {
|
||||
return filterUndefined([primaryFilter, secondaryFilter]);
|
||||
};
|
||||
|
||||
useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => {
|
||||
const filters = getAppliedFilters();
|
||||
updateRoomsFromStore(filters);
|
||||
});
|
||||
|
||||
/**
|
||||
* Secondary filters are activated using this function.
|
||||
* This is different to how primary filters work because the secondary
|
||||
* filters are static i.e they are always available and don't need to be
|
||||
* hidden.
|
||||
*/
|
||||
const activateSecondaryFilter = useCallback(
|
||||
(filter: SecondaryFilters): void => {
|
||||
// If the filter is already active, just return.
|
||||
if (filter === activeSecondaryFilter) return;
|
||||
|
||||
// SecondaryFilter is an enum for the UI, let's convert it to something
|
||||
// that the store will understand.
|
||||
const secondary = secondaryFiltersToFilterKeyMap.get(filter);
|
||||
|
||||
// Active primary filter may need to be toggled off when applying this secondary filer.
|
||||
let primary = primaryFilter;
|
||||
if (
|
||||
primaryFilter !== undefined &&
|
||||
secondary !== undefined &&
|
||||
!isPrimaryFilterCompatible(primaryFilter, secondary)
|
||||
) {
|
||||
primary = undefined;
|
||||
}
|
||||
|
||||
setActiveSecondaryFilter(filter);
|
||||
setPrimaryFilter(primary);
|
||||
updateRoomsFromStore(filterUndefined([primary, secondary]));
|
||||
},
|
||||
[activeSecondaryFilter, primaryFilter, updateRoomsFromStore],
|
||||
);
|
||||
|
||||
/**
|
||||
* This tells the view which primary filters are available, how to toggle them
|
||||
* and whether a given primary filter is active. @see {@link PrimaryFilter}
|
||||
*/
|
||||
const primaryFilters = useMemo(() => {
|
||||
const createPrimaryFilter = (key: FilterKey, name: string): PrimaryFilter => {
|
||||
return {
|
||||
toggle: () => {
|
||||
setPrimaryFilter((currentFilter) => {
|
||||
const filter = currentFilter === key ? undefined : key;
|
||||
updateRoomsFromStore(filterUndefined([filter, secondaryFilter]));
|
||||
return filter;
|
||||
});
|
||||
},
|
||||
active: primaryFilter === key,
|
||||
name,
|
||||
};
|
||||
};
|
||||
const filters: PrimaryFilter[] = [];
|
||||
for (const [key, name] of filterKeyToNameMap.entries()) {
|
||||
if (secondaryFilter && !isPrimaryFilterCompatible(key, secondaryFilter)) {
|
||||
continue;
|
||||
}
|
||||
filters.push(createPrimaryFilter(key, _t(name)));
|
||||
}
|
||||
return filters;
|
||||
}, [primaryFilter, updateRoomsFromStore, secondaryFilter]);
|
||||
|
||||
return { primaryFilters, rooms, activateSecondaryFilter, activeSecondaryFilter };
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { useRoomListViewModel } from "../../../../../src/components/viewmodels/r
|
||||
import dispatcher from "../../../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../../../src/dispatcher/actions";
|
||||
import { FilterKey } from "../../../../../src/stores/room-list-v3/skip-list/filters";
|
||||
import { SecondaryFilters } from "../../../../../src/components/viewmodels/roomlist/useFilteredRooms";
|
||||
|
||||
describe("RoomListViewModel", () => {
|
||||
function mockAndCreateRooms() {
|
||||
@@ -107,5 +108,100 @@ describe("RoomListViewModel", () => {
|
||||
expect(vm.current.primaryFilters[i].active).toEqual(false);
|
||||
expect(vm.current.primaryFilters[j].active).toEqual(true);
|
||||
});
|
||||
|
||||
it("should select all activity as default secondary filter", () => {
|
||||
mockAndCreateRooms();
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
|
||||
// By default, all activity should be the active secondary filter
|
||||
expect(vm.current.activeSecondaryFilter).toEqual(SecondaryFilters.AllActivity);
|
||||
});
|
||||
|
||||
it("should be able to filter using secondary filters", () => {
|
||||
const { fn } = mockAndCreateRooms();
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
|
||||
// Let's say we toggle the mentions secondary filter
|
||||
act(() => {
|
||||
vm.current.activateSecondaryFilter(SecondaryFilters.MentionsOnly);
|
||||
});
|
||||
expect(fn).toHaveBeenCalledWith([FilterKey.MentionsFilter]);
|
||||
});
|
||||
|
||||
it("primary filters are applied on top of secondary filers", () => {
|
||||
const { fn } = mockAndCreateRooms();
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
|
||||
// Let's say we toggle the mentions secondary filter
|
||||
act(() => {
|
||||
vm.current.activateSecondaryFilter(SecondaryFilters.MentionsOnly);
|
||||
});
|
||||
|
||||
// Let's say we toggle the People filter
|
||||
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
|
||||
act(() => {
|
||||
vm.current.primaryFilters[i].toggle();
|
||||
});
|
||||
|
||||
// RLS call must include both these filters
|
||||
expect(fn).toHaveBeenLastCalledWith(
|
||||
expect.arrayContaining([FilterKey.PeopleFilter, FilterKey.MentionsFilter]),
|
||||
);
|
||||
});
|
||||
|
||||
const testcases: Array<[string, { secondary: SecondaryFilters; filterKey: FilterKey }, string]> = [
|
||||
[
|
||||
"Mentions only",
|
||||
{ secondary: SecondaryFilters.MentionsOnly, filterKey: FilterKey.MentionsFilter },
|
||||
"Unread",
|
||||
],
|
||||
["Invites only", { secondary: SecondaryFilters.InvitesOnly, filterKey: FilterKey.InvitesFilter }, "Unread"],
|
||||
[
|
||||
"Invites only",
|
||||
{ secondary: SecondaryFilters.InvitesOnly, filterKey: FilterKey.InvitesFilter },
|
||||
"Favourites",
|
||||
],
|
||||
[
|
||||
"Low priority",
|
||||
{ secondary: SecondaryFilters.LowPriority, filterKey: FilterKey.LowPriorityFilter },
|
||||
"Favourites",
|
||||
],
|
||||
];
|
||||
|
||||
describe.each(testcases)("For secondary filter: %s", (secondaryFilterName, secondary, primaryFilterName) => {
|
||||
it(`should unapply incompatible primary filter that is already active: ${primaryFilterName}`, () => {
|
||||
const { fn } = mockAndCreateRooms();
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
|
||||
// Apply the primary filter
|
||||
const i = vm.current.primaryFilters.findIndex((f) => f.name === primaryFilterName);
|
||||
act(() => {
|
||||
vm.current.primaryFilters[i].toggle();
|
||||
});
|
||||
|
||||
// Apply the secondary filter
|
||||
act(() => {
|
||||
vm.current.activateSecondaryFilter(secondary.secondary);
|
||||
});
|
||||
|
||||
// RLS call should only include the secondary filter
|
||||
expect(fn).toHaveBeenLastCalledWith([secondary.filterKey]);
|
||||
// Primary filter should have been unapplied
|
||||
expect(vm.current.primaryFilters[i].active).toEqual(false);
|
||||
});
|
||||
|
||||
it(`should hide incompatible primary filter: ${primaryFilterName}`, () => {
|
||||
mockAndCreateRooms();
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
|
||||
// Apply the secondary filter
|
||||
act(() => {
|
||||
vm.current.activateSecondaryFilter(secondary.secondary);
|
||||
});
|
||||
|
||||
// Incompatible primary filter must be hidden
|
||||
expect(vm.current.primaryFilters.find((f) => f.name === primaryFilterName)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { mkRoom, stubClient } from "../../../../../test-utils";
|
||||
import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
|
||||
import { RoomList } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomList";
|
||||
import DMRoomMap from "../../../../../../src/utils/DMRoomMap";
|
||||
import { SecondaryFilters } from "../../../../../../src/components/viewmodels/roomlist/useFilteredRooms";
|
||||
|
||||
describe("<RoomList />", () => {
|
||||
let matrixClient: MatrixClient;
|
||||
@@ -27,7 +28,13 @@ describe("<RoomList />", () => {
|
||||
|
||||
matrixClient = stubClient();
|
||||
const rooms = Array.from({ length: 10 }, (_, i) => mkRoom(matrixClient, `room${i}`));
|
||||
vm = { rooms, openRoom: jest.fn(), primaryFilters: [] };
|
||||
vm = {
|
||||
rooms,
|
||||
openRoom: jest.fn(),
|
||||
primaryFilters: [],
|
||||
activateSecondaryFilter: () => {},
|
||||
activeSecondaryFilter: SecondaryFilters.AllActivity,
|
||||
};
|
||||
|
||||
// Needed to render a room list cell
|
||||
DMRoomMap.makeShared(matrixClient);
|
||||
|
||||
Reference in New Issue
Block a user