From 3f3fba99eb1c0dea1aa83a315f8064ea7d4d3844 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Tue, 11 Mar 2025 18:59:55 +0530 Subject: [PATCH] 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 --- .../viewmodels/roomlist/RoomListViewModel.tsx | 91 ++------- .../viewmodels/roomlist/useFilteredRooms.tsx | 188 ++++++++++++++++++ .../roomlist/RoomListViewModel-test.tsx | 96 +++++++++ .../rooms/RoomListPanel/RoomList-test.tsx | 9 +- 4 files changed, 307 insertions(+), 77 deletions(-) create mode 100644 src/components/viewmodels/roomlist/useFilteredRooms.tsx diff --git a/src/components/viewmodels/roomlist/RoomListViewModel.tsx b/src/components/viewmodels/roomlist/RoomListViewModel.tsx index 2a7dac04d3..fb827c4889 100644 --- a/src/components/viewmodels/roomlist/RoomListViewModel.tsx +++ b/src/components/viewmodels/roomlist/RoomListViewModel.tsx @@ -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({ @@ -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 = 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(); - 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 }; -} diff --git a/src/components/viewmodels/roomlist/useFilteredRooms.tsx b/src/components/viewmodels/roomlist/useFilteredRooms.tsx new file mode 100644 index 0000000000..a21918e5fa --- /dev/null +++ b/src/components/viewmodels/roomlist/useFilteredRooms.tsx @@ -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 = 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(); + /** + * Secondary filters are also filters but they are hidden + * away in a popup menu. + */ + const [activeSecondaryFilter, setActiveSecondaryFilter] = useState(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 }; +} diff --git a/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx b/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx index 9babc6cf24..055feb84e6 100644 --- a/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx +++ b/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx @@ -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(); + }); + }); }); }); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx index 53a721a13a..e720798f04 100644 --- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx @@ -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("", () => { let matrixClient: MatrixClient; @@ -27,7 +28,13 @@ describe("", () => { 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);