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
This commit is contained in:
@@ -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.
|
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 { Room } from "matrix-js-sdk/src/matrix";
|
||||||
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||||
|
import type { TranslationKey } from "../../../languageHandler";
|
||||||
import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3";
|
import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3";
|
||||||
import { useEventEmitter } from "../../../hooks/useEventEmitter";
|
import { useEventEmitter } from "../../../hooks/useEventEmitter";
|
||||||
import { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
|
import { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
|
||||||
import dispatcher from "../../../dispatcher/dispatcher";
|
import dispatcher from "../../../dispatcher/dispatcher";
|
||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
|
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
|
||||||
|
import { _t, _td } from "../../../languageHandler";
|
||||||
|
|
||||||
export interface RoomListViewState {
|
export interface RoomListViewState {
|
||||||
/**
|
/**
|
||||||
@@ -25,6 +28,12 @@ export interface RoomListViewState {
|
|||||||
* Open the room having given roomId.
|
* Open the room having given roomId.
|
||||||
*/
|
*/
|
||||||
openRoom: (roomId: string) => void;
|
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.
|
* @see {@link RoomListViewState} for more information about what this view model returns.
|
||||||
*/
|
*/
|
||||||
export function useRoomListViewModel(): RoomListViewState {
|
export function useRoomListViewModel(): RoomListViewState {
|
||||||
const [rooms, setRooms] = useState(RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
|
const { primaryFilters, rooms } = useFilteredRooms();
|
||||||
|
|
||||||
useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => {
|
|
||||||
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace();
|
|
||||||
setRooms(newRooms);
|
|
||||||
});
|
|
||||||
|
|
||||||
const openRoom = useCallback((roomId: string): void => {
|
const openRoom = useCallback((roomId: string): void => {
|
||||||
dispatcher.dispatch<ViewRoomPayload>({
|
dispatcher.dispatch<ViewRoomPayload>({
|
||||||
@@ -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<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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2099,6 +2099,12 @@
|
|||||||
"failed_add_tag": "Failed to add tag %(tagName)s to room",
|
"failed_add_tag": "Failed to add tag %(tagName)s to room",
|
||||||
"failed_remove_tag": "Failed to remove tag %(tagName)s from room",
|
"failed_remove_tag": "Failed to remove tag %(tagName)s from room",
|
||||||
"failed_set_dm_tag": "Failed to set direct message tag",
|
"failed_set_dm_tag": "Failed to set direct message tag",
|
||||||
|
"filters": {
|
||||||
|
"favourite": "Favourites",
|
||||||
|
"people": "People",
|
||||||
|
"rooms": "Rooms",
|
||||||
|
"unread": "Unread"
|
||||||
|
},
|
||||||
"home_menu_label": "Home options",
|
"home_menu_label": "Home options",
|
||||||
"join_public_room_label": "Join public room",
|
"join_public_room_label": "Join public room",
|
||||||
"joining_rooms_status": {
|
"joining_rooms_status": {
|
||||||
|
|||||||
@@ -14,16 +14,19 @@ import { LISTS_UPDATE_EVENT } from "../../../../../src/stores/room-list/SlidingR
|
|||||||
import { useRoomListViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
|
import { useRoomListViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
|
||||||
import dispatcher from "../../../../../src/dispatcher/dispatcher";
|
import dispatcher from "../../../../../src/dispatcher/dispatcher";
|
||||||
import { Action } from "../../../../../src/dispatcher/actions";
|
import { Action } from "../../../../../src/dispatcher/actions";
|
||||||
|
import { FilterKey } from "../../../../../src/stores/room-list-v3/skip-list/filters";
|
||||||
|
|
||||||
describe("RoomListViewModel", () => {
|
describe("RoomListViewModel", () => {
|
||||||
function mockAndCreateRooms() {
|
function mockAndCreateRooms() {
|
||||||
const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined));
|
const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined));
|
||||||
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockImplementation(() => [...rooms]);
|
const fn = jest
|
||||||
return rooms;
|
.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace")
|
||||||
|
.mockImplementation(() => [...rooms]);
|
||||||
|
return { rooms, fn };
|
||||||
}
|
}
|
||||||
|
|
||||||
it("should return a list of rooms", async () => {
|
it("should return a list of rooms", async () => {
|
||||||
const rooms = mockAndCreateRooms();
|
const { rooms } = mockAndCreateRooms();
|
||||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||||
|
|
||||||
expect(vm.current.rooms).toHaveLength(10);
|
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 () => {
|
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 { result: vm } = renderHook(() => useRoomListViewModel());
|
||||||
|
|
||||||
const newRoom = mkStubRoom("bar:matrix.org", "Bar", undefined);
|
const newRoom = mkStubRoom("bar:matrix.org", "Bar", undefined);
|
||||||
@@ -46,7 +49,7 @@ describe("RoomListViewModel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should dispatch view room action on openRoom", async () => {
|
it("should dispatch view room action on openRoom", async () => {
|
||||||
const rooms = mockAndCreateRooms();
|
const { rooms } = mockAndCreateRooms();
|
||||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||||
|
|
||||||
const fn = jest.spyOn(dispatcher, "dispatch");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ describe("<RoomList />", () => {
|
|||||||
|
|
||||||
matrixClient = stubClient();
|
matrixClient = stubClient();
|
||||||
const rooms = Array.from({ length: 10 }, (_, i) => mkRoom(matrixClient, `room${i}`));
|
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
|
// Needed to render a room list cell
|
||||||
DMRoomMap.makeShared(matrixClient);
|
DMRoomMap.makeShared(matrixClient);
|
||||||
|
|||||||
Reference in New Issue
Block a user