From fd91e7815247ad4d00a846bb0347e80f4c8529d7 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Mon, 10 Mar 2025 18:53:38 +0530 Subject: [PATCH] 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 --- .../viewmodels/roomlist/RoomListViewModel.tsx | 92 +++++++++++++++++-- src/i18n/strings/en_EN.json | 6 ++ .../roomlist/RoomListViewModel-test.tsx | 59 +++++++++++- .../rooms/RoomListPanel/RoomList-test.tsx | 2 +- 4 files changed, 145 insertions(+), 14 deletions(-) diff --git a/src/components/viewmodels/roomlist/RoomListViewModel.tsx b/src/components/viewmodels/roomlist/RoomListViewModel.tsx index d1c1d994e0..2a7dac04d3 100644 --- a/src/components/viewmodels/roomlist/RoomListViewModel.tsx +++ b/src/components/viewmodels/roomlist/RoomListViewModel.tsx @@ -5,15 +5,18 @@ 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, useState } from "react"; +import { useCallback, useMemo, useState } 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"; export interface RoomListViewState { /** @@ -25,6 +28,12 @@ export interface RoomListViewState { * Open the room having given roomId. */ openRoom: (roomId: string) => void; + + /** + * A list of objects that provide the view enough information + * to render primary room filters. + */ + primaryFilters: PrimaryFilter[]; } /** @@ -32,12 +41,7 @@ export interface RoomListViewState { * @see {@link RoomListViewState} for more information about what this view model returns. */ export function useRoomListViewModel(): RoomListViewState { - const [rooms, setRooms] = useState(RoomListStoreV3.instance.getSortedRoomsInActiveSpace()); - - useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => { - const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(); - setRooms(newRooms); - }); + const { primaryFilters, rooms } = useFilteredRooms(); const openRoom = useCallback((roomId: string): void => { dispatcher.dispatch({ @@ -47,5 +51,77 @@ export function useRoomListViewModel(): RoomListViewState { }); }, []); - return { rooms, openRoom }; + return { + rooms, + openRoom, + primaryFilters, + }; +} + +/** + * 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/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index bd577e7418..133ab7360b 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2099,6 +2099,12 @@ "failed_add_tag": "Failed to add tag %(tagName)s to room", "failed_remove_tag": "Failed to remove tag %(tagName)s from room", "failed_set_dm_tag": "Failed to set direct message tag", + "filters": { + "favourite": "Favourites", + "people": "People", + "rooms": "Rooms", + "unread": "Unread" + }, "home_menu_label": "Home options", "join_public_room_label": "Join public room", "joining_rooms_status": { diff --git a/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx b/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx index b1da67ac18..9babc6cf24 100644 --- a/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx +++ b/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx @@ -14,16 +14,19 @@ import { LISTS_UPDATE_EVENT } from "../../../../../src/stores/room-list/SlidingR import { useRoomListViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListViewModel"; import dispatcher from "../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../src/dispatcher/actions"; +import { FilterKey } from "../../../../../src/stores/room-list-v3/skip-list/filters"; describe("RoomListViewModel", () => { function mockAndCreateRooms() { const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined)); - jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockImplementation(() => [...rooms]); - return rooms; + const fn = jest + .spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace") + .mockImplementation(() => [...rooms]); + return { rooms, fn }; } it("should return a list of rooms", async () => { - const rooms = mockAndCreateRooms(); + const { rooms } = mockAndCreateRooms(); const { result: vm } = renderHook(() => useRoomListViewModel()); expect(vm.current.rooms).toHaveLength(10); @@ -33,7 +36,7 @@ describe("RoomListViewModel", () => { }); it("should update list of rooms on event from room list store", async () => { - const rooms = mockAndCreateRooms(); + const { rooms } = mockAndCreateRooms(); const { result: vm } = renderHook(() => useRoomListViewModel()); const newRoom = mkStubRoom("bar:matrix.org", "Bar", undefined); @@ -46,7 +49,7 @@ describe("RoomListViewModel", () => { }); it("should dispatch view room action on openRoom", async () => { - const rooms = mockAndCreateRooms(); + const { rooms } = mockAndCreateRooms(); const { result: vm } = renderHook(() => useRoomListViewModel()); const fn = jest.spyOn(dispatcher, "dispatch"); @@ -59,4 +62,50 @@ describe("RoomListViewModel", () => { }), ); }); + + describe("Filters", () => { + it("should provide list of available filters", () => { + mockAndCreateRooms(); + const { result: vm } = renderHook(() => useRoomListViewModel()); + // should have 4 filters + expect(vm.current.primaryFilters).toHaveLength(4); + // check the order + for (const [i, name] of ["Unread", "Favourites", "People", "Rooms"].entries()) { + expect(vm.current.primaryFilters[i].name).toEqual(name); + expect(vm.current.primaryFilters[i].active).toEqual(false); + } + }); + + it("should get filtered rooms from RLS on toggle", () => { + const { fn } = mockAndCreateRooms(); + const { result: vm } = renderHook(() => useRoomListViewModel()); + // Let's say we toggle the People toggle + const i = vm.current.primaryFilters.findIndex((f) => f.name === "People"); + act(() => { + vm.current.primaryFilters[i].toggle(); + }); + expect(fn).toHaveBeenCalledWith([FilterKey.PeopleFilter]); + expect(vm.current.primaryFilters[i].active).toEqual(true); + }); + + it("should change active property on toggle", () => { + mockAndCreateRooms(); + const { result: vm } = renderHook(() => useRoomListViewModel()); + // Let's say we toggle the People filter + const i = vm.current.primaryFilters.findIndex((f) => f.name === "People"); + expect(vm.current.primaryFilters[i].active).toEqual(false); + act(() => { + vm.current.primaryFilters[i].toggle(); + }); + expect(vm.current.primaryFilters[i].active).toEqual(true); + + // Let's say that we toggle the Favourite filter + const j = vm.current.primaryFilters.findIndex((f) => f.name === "Favourites"); + act(() => { + vm.current.primaryFilters[j].toggle(); + }); + expect(vm.current.primaryFilters[i].active).toEqual(false); + expect(vm.current.primaryFilters[j].active).toEqual(true); + }); + }); }); 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 bbf0edbf5e..53a721a13a 100644 --- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx @@ -27,7 +27,7 @@ describe("", () => { matrixClient = stubClient(); const rooms = Array.from({ length: 10 }, (_, i) => mkRoom(matrixClient, `room${i}`)); - vm = { rooms, openRoom: jest.fn() }; + vm = { rooms, openRoom: jest.fn(), primaryFilters: [] }; // Needed to render a room list cell DMRoomMap.makeShared(matrixClient);