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 };
|
||||
}
|
||||
Reference in New Issue
Block a user