New room list: move secondary filters into primary filters (#29972)

* feat: move secondary filters into primary filters in vm

* test: update room list view model tests

* feat: remove secondary filter menu

* test: update and remove secondary filter component tests

* feat: update i18n

* test: update remaining tests

* test(e2e): update screenshots and tests

* feat: add new cases for empty room list

* test(e2e): add more tests for empty room list for new primary filters
This commit is contained in:
Florian Duros
2025-05-20 16:44:29 +02:00
committed by GitHub
parent 69fe2ad06c
commit 5d2d4947f4
49 changed files with 215 additions and 868 deletions

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import { useCallback } from "react";
import type { Room } from "matrix-js-sdk/src/matrix";
import { type PrimaryFilter, type SecondaryFilters, useFilteredRooms } from "./useFilteredRooms";
import { type PrimaryFilter, useFilteredRooms } from "./useFilteredRooms";
import { createRoom as createRoomFunc, hasCreateRoomRights } from "./utils";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
@@ -59,16 +59,6 @@ export interface RoomListViewState {
*/
activePrimaryFilter?: PrimaryFilter;
/**
* A function to activate a given secondary filter.
*/
activateSecondaryFilter: (filter: SecondaryFilters) => void;
/**
* The currently active secondary filter.
*/
activeSecondaryFilter: SecondaryFilters;
/**
* The index of the active room in the room list.
*/
@@ -81,14 +71,7 @@ export interface RoomListViewState {
*/
export function useRoomListViewModel(): RoomListViewState {
const matrixClient = useMatrixClientContext();
const {
isLoadingRooms,
primaryFilters,
activePrimaryFilter,
rooms: filteredRooms,
activateSecondaryFilter,
activeSecondaryFilter,
} = useFilteredRooms();
const { isLoadingRooms, primaryFilters, activePrimaryFilter, rooms: filteredRooms } = useFilteredRooms();
const { activeIndex, rooms } = useStickyRoomList(filteredRooms);
useRoomListNavigation(rooms);
@@ -111,8 +94,6 @@ export function useRoomListViewModel(): RoomListViewState {
createChatRoom,
primaryFilters,
activePrimaryFilter,
activateSecondaryFilter,
activeSecondaryFilter,
activeIndex,
};
}

View File

@@ -36,8 +36,6 @@ interface FilteredRooms {
primaryFilters: PrimaryFilter[];
isLoadingRooms: boolean;
rooms: Room[];
activateSecondaryFilter: (filter: SecondaryFilters) => void;
activeSecondaryFilter: SecondaryFilters;
/**
* The currently active primary filter.
* If no primary filter is active, this will be undefined.
@@ -49,51 +47,11 @@ const filterKeyToNameMap: Map<FilterKey, TranslationKey> = new Map([
[FilterKey.UnreadFilter, _td("room_list|filters|unread")],
[FilterKey.PeopleFilter, _td("room_list|filters|people")],
[FilterKey.RoomsFilter, _td("room_list|filters|rooms")],
[FilterKey.MentionsFilter, _td("room_list|filters|mentions")],
[FilterKey.InvitesFilter, _td("room_list|filters|invites")],
[FilterKey.FavouriteFilter, _td("room_list|filters|favourite")],
]);
/**
* 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.
*/
@@ -103,16 +61,6 @@ export function useFilteredRooms(): FilteredRooms {
* 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 [isLoadingRooms, setIsLoadingRooms] = useState(() => RoomListStoreV3.instance.isLoadingRooms);
@@ -123,16 +71,13 @@ export function useFilteredRooms(): FilteredRooms {
}, []);
// Reset filters when active space changes
useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
setPrimaryFilter(undefined);
activateSecondaryFilter(SecondaryFilters.AllActivity);
});
useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => setPrimaryFilter(undefined));
const filterUndefined = (array: (FilterKey | undefined)[]): FilterKey[] =>
array.filter((f) => f !== undefined) as FilterKey[];
const getAppliedFilters = (): FilterKey[] => {
return filterUndefined([primaryFilter, secondaryFilter]);
return filterUndefined([primaryFilter]);
};
useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => {
@@ -144,30 +89,6 @@ export function useFilteredRooms(): FilteredRooms {
setIsLoadingRooms(false);
});
/**
* 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);
setActiveSecondaryFilter(filter);
// Reset any active primary filters.
setPrimaryFilter(undefined);
updateRoomsFromStore(filterUndefined([secondary]));
},
[activeSecondaryFilter, 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}
@@ -178,7 +99,7 @@ export function useFilteredRooms(): FilteredRooms {
toggle: () => {
setPrimaryFilter((currentFilter) => {
const filter = currentFilter === key ? undefined : key;
updateRoomsFromStore(filterUndefined([filter, secondaryFilter]));
updateRoomsFromStore(filterUndefined([filter]));
return filter;
});
},
@@ -189,13 +110,10 @@ export function useFilteredRooms(): FilteredRooms {
};
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]);
}, [primaryFilter, updateRoomsFromStore]);
const activePrimaryFilter = useMemo(() => primaryFilters.find((filter) => filter.active), [primaryFilters]);
@@ -204,7 +122,5 @@ export function useFilteredRooms(): FilteredRooms {
primaryFilters,
activePrimaryFilter,
rooms,
activateSecondaryFilter,
activeSecondaryFilter,
};
}

View File

@@ -53,7 +53,29 @@ export function EmptyRoomList({ vm }: EmptyRoomListProps): JSX.Element | undefin
/>
);
case FilterKey.UnreadFilter:
return <UnreadPlaceholder filter={vm.activePrimaryFilter} />;
return (
<ActionPlaceholder
title={_t("room_list|empty|no_unread")}
action={_t("room_list|empty|show_chats")}
filter={vm.activePrimaryFilter}
/>
);
case FilterKey.InvitesFilter:
return (
<ActionPlaceholder
title={_t("room_list|empty|no_invites")}
action={_t("room_list|empty|show_activity")}
filter={vm.activePrimaryFilter}
/>
);
case FilterKey.MentionsFilter:
return (
<ActionPlaceholder
title={_t("room_list|empty|no_mentions")}
action={_t("room_list|empty|show_activity")}
filter={vm.activePrimaryFilter}
/>
);
default:
return undefined;
}
@@ -131,18 +153,21 @@ function DefaultPlaceholder({ vm }: DefaultPlaceholderProps): JSX.Element {
);
}
interface UnreadPlaceholderProps {
interface ActionPlaceholderProps {
filter: PrimaryFilter;
title: string;
action: string;
}
/**
* The empty state for the room list when the unread filter is active
* A placeholder for the room list when a filter is active
* The user can take action to toggle the filter
*/
function UnreadPlaceholder({ filter }: UnreadPlaceholderProps): JSX.Element {
function ActionPlaceholder({ filter, title, action }: ActionPlaceholderProps): JSX.Element {
return (
<GenericPlaceholder title={_t("room_list|empty|no_unread")}>
<GenericPlaceholder title={title}>
<Button kind="tertiary" onClick={filter.toggle}>
{_t("room_list|empty|show_chats")}
{action}
</Button>
</GenericPlaceholder>
);

View File

@@ -1,121 +0,0 @@
/*
* 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 { IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
import React, { type Ref, type JSX, useState } from "react";
import {
ArrowDownIcon,
ChatIcon,
ChatNewIcon,
CheckIcon,
FilterIcon,
MentionIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../../languageHandler";
import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { SecondaryFilters } from "../../../viewmodels/roomlist/useFilteredRooms";
import { textForSecondaryFilter } from "./textForFilter";
interface MenuTriggerProps extends React.ComponentProps<typeof IconButton> {
ref?: Ref<HTMLButtonElement>;
}
const MenuTrigger = ({ ref, ...props }: MenuTriggerProps): JSX.Element => (
<Tooltip label={_t("room_list|filter")}>
<IconButton size="28px" aria-label={_t("room_list|filter")} {...props} ref={ref}>
<FilterIcon />
</IconButton>
</Tooltip>
);
interface FilterOptionProps {
/**
* The filter to display
*/
filter: SecondaryFilters;
/**
* True if the filter is selected
*/
selected: boolean;
/**
* The function to call when the filter is selected
*/
onSelect: (filter: SecondaryFilters) => void;
}
function iconForFilter(filter: SecondaryFilters, size: string): JSX.Element {
switch (filter) {
case SecondaryFilters.AllActivity:
return <ChatIcon width={size} height={size} />;
case SecondaryFilters.MentionsOnly:
return <MentionIcon width={size} height={size} />;
case SecondaryFilters.InvitesOnly:
return <ChatNewIcon width={size} height={size} />;
case SecondaryFilters.LowPriority:
return <ArrowDownIcon width={size} height={size} />;
}
}
function FilterOption({ filter, selected, onSelect }: FilterOptionProps): JSX.Element {
const checkComponent = <CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-primary)" />;
return (
<MenuItem
aria-selected={selected}
hideChevron={true}
Icon={iconForFilter(filter, "20px")}
label={textForSecondaryFilter(filter)}
onSelect={() => {
onSelect(filter);
}}
>
{selected && checkComponent}
</MenuItem>
);
}
interface Props {
/**
* The view model for the room list view
*/
vm: RoomListViewState;
}
export function RoomListFilterMenu({ vm }: Props): JSX.Element {
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={setOpen}
title={_t("room_list|filter")}
showTitle={true}
align="start"
trigger={<MenuTrigger />}
>
{[
SecondaryFilters.AllActivity,
SecondaryFilters.MentionsOnly,
SecondaryFilters.InvitesOnly,
SecondaryFilters.LowPriority,
].map((filter) => (
<FilterOption
key={filter}
filter={filter}
selected={vm.activeSecondaryFilter === filter}
onSelect={(selectedFilter) => {
vm.activateSecondaryFilter(selectedFilter);
setOpen(false);
}}
/>
))}
</Menu>
);
}

View File

@@ -1,40 +0,0 @@
/*
* 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 React, { type JSX } from "react";
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { Flex } from "../../../utils/Flex";
import { _t } from "../../../../languageHandler";
import { RoomListFilterMenu } from "./RoomListFilterMenu";
import { textForSecondaryFilter } from "./textForFilter";
interface Props {
/**
* The view model for the room list
*/
vm: RoomListViewState;
}
/**
* The secondary filters for the room list (eg. mentions only / invites only).
*/
export function RoomListSecondaryFilters({ vm }: Props): JSX.Element {
const activeFilterText = textForSecondaryFilter(vm.activeSecondaryFilter);
return (
<Flex
aria-label={_t("room_list|secondary_filters")}
className="mx_RoomListSecondaryFilters"
align="center"
gap="4px"
>
<RoomListFilterMenu vm={vm} />
{activeFilterText}
</Flex>
);
}

View File

@@ -11,7 +11,6 @@ import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewM
import { RoomList } from "./RoomList";
import { EmptyRoomList } from "./EmptyRoomList";
import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
import { RoomListSecondaryFilters } from "./RoomListSecondaryFilters";
/**
* Host the room list and the (future) room filters
@@ -30,7 +29,6 @@ export function RoomListView(): JSX.Element {
return (
<>
<RoomListPrimaryFilters vm={vm} />
<RoomListSecondaryFilters vm={vm} />
{listBody}
</>
);

View File

@@ -1,29 +0,0 @@
/*
* 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 { _t } from "../../../../languageHandler";
import { SecondaryFilters } from "../../../viewmodels/roomlist/useFilteredRooms";
/**
* Gives the human readable text name for a secondary filter.
* @param filter The filter in question
* @returns The translated, human readable name for the filter
*/
export function textForSecondaryFilter(filter: SecondaryFilters): string {
switch (filter) {
case SecondaryFilters.AllActivity:
return _t("room_list|secondary_filter|all_activity");
case SecondaryFilters.MentionsOnly:
return _t("room_list|secondary_filter|mentions_only");
case SecondaryFilters.InvitesOnly:
return _t("room_list|secondary_filter|invites_only");
case SecondaryFilters.LowPriority:
return _t("room_list|secondary_filter|low_priority");
default:
throw new Error("Unknown filter");
}
}

View File

@@ -2112,19 +2112,23 @@
"no_chats_description_no_room_rights": "Get started by messaging someone",
"no_favourites": "You don't have favourite chat yet",
"no_favourites_description": "You can add a chat to your favourites in the chat settings",
"no_invites": "You don't have any unread invites",
"no_mentions": "You don't have any unread mentions",
"no_people": "You dont have direct chats with anyone yet",
"no_people_description": "You can deselect filters in order to see your other chats",
"no_rooms": "Youre not in any room yet",
"no_rooms_description": "You can deselect filters in order to see your other chats",
"no_unread": "Congrats! You dont have any unread messages",
"show_activity": "See all activity",
"show_chats": "Show all chats"
},
"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",
"filter": "Filter",
"filters": {
"favourite": "Favourites",
"invites": "Invites",
"mentions": "Mentions",
"people": "People",
"rooms": "Rooms",
"unread": "Unreads"
@@ -2156,13 +2160,6 @@
"open_room": "Open room %(roomName)s"
},
"room_options": "Room Options",
"secondary_filter": {
"all_activity": "All activity",
"invites_only": "Invites only",
"low_priority": "Low priority",
"mentions_only": "Mentions only"
},
"secondary_filters": "Secondary filters",
"show_less": "Show less",
"show_message_previews": "Show message previews",
"show_n_more": {