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:
R Midhun Suresh
2025-03-11 18:59:55 +05:30
committed by GitHub
parent 26a17f9314
commit 3f3fba99eb
4 changed files with 307 additions and 77 deletions

View File

@@ -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 };
}

View 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 };
}