Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
May 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,6 @@ test.describe("Room list filters and sort", () => {
return page.getByRole("listbox", { name: "Room list filters" });
}

function getSecondaryFilters(page: Page): Locator {
return page.getByRole("button", { name: "Filter" });
}

function getRoomOptionsMenu(page: Page): Locator {
return page.getByRole("button", { name: "Room Options" });
}
Expand Down Expand Up @@ -181,6 +177,33 @@ test.describe("Room list filters and sort", () => {
await app.client.evaluate(async (client, id) => {
await client.setRoomTag(id, "m.lowpriority", { order: 0.5 });
}, lowPrioId);

await bot.createRoom({
name: "invited room",
invite: [user.userId],
is_direct: true,
});

const mentionRoomId = await app.client.createRoom({ name: "room with mention" });
await app.client.inviteUser(mentionRoomId, bot.credentials.userId);
await bot.joinRoom(mentionRoomId);

const clientBot = await bot.prepareClient();
await clientBot.evaluate(
async (client, { mentionRoomId, userId }) => {
await client.sendMessage(mentionRoomId, {
// @ts-ignore ignore usage of MsgType.text
"msgtype": "m.text",
"body": "User",
"format": "org.matrix.custom.html",
"formatted_body": `<a href="https://matrix.to/#/${userId}">User</a>`,
"m.mentions": {
user_ids: [userId],
},
});
},
{ mentionRoomId, userId: user.userId },
);
});

test("should filter the list (with primary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => {
Expand All @@ -197,7 +220,7 @@ test.describe("Room list filters and sort", () => {
// only one room should be visible
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(2);
expect(await roomList.locator("role=gridcell").count()).toBe(4);
await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png");

await primaryFilters.getByRole("option", { name: "Favourite" }).click();
Expand All @@ -206,24 +229,23 @@ test.describe("Room list filters and sort", () => {

await primaryFilters.getByRole("option", { name: "People" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(2);

await primaryFilters.getByRole("option", { name: "Rooms" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(4);
});

test("should filter the list (with secondary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomList = getRoomList(page);
const secondaryFilters = getSecondaryFilters(page);
await secondaryFilters.click();
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "Low prio room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(5);

await expect(page.getByRole("menu", { name: "Filter" })).toMatchScreenshot("filter-menu.png");
await primaryFilters.getByRole("option", { name: "Mentions" }).click();
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);

await page.getByRole("menuitem", { name: "Low priority" }).click();
await expect(roomList.getByRole("gridcell", { name: "Low prio room" })).toBeVisible();
await primaryFilters.getByRole("option", { name: "Invites" }).click();
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
});

Expand Down Expand Up @@ -294,15 +316,25 @@ test.describe("Room list filters and sort", () => {
},
);

test("should render the placeholder for unread filter", { tag: "@screenshot" }, async ({ page, app, user }) => {
const primaryFilters = getPrimaryFilters(page);
await primaryFilters.getByRole("option", { name: "Unread" }).click();
[
{ filter: "Unreads", action: "Show all chats" },
{ filter: "Mentions", action: "See all activity" },
{ filter: "Invites", action: "See all activity" },
].forEach(({ filter, action }) => {
test(
`should render the placeholder for ${filter} filter`,
{ tag: "@screenshot" },
async ({ page, app, user }) => {
const primaryFilters = getPrimaryFilters(page);
await primaryFilters.getByRole("option", { name: filter }).click();

const emptyRoomList = getEmptyRoomList(page);
await expect(emptyRoomList).toMatchScreenshot("unread-empty-room-list.png");
const emptyRoomList = getEmptyRoomList(page);
await expect(emptyRoomList).toMatchScreenshot(`${filter}-empty-room-list.png`);

await emptyRoomList.getByRole("button", { name: "show all chats" }).click();
await expect(primaryFilters.getByRole("option", { name: "Unread" })).not.toBeChecked();
await emptyRoomList.getByRole("button", { name: action }).click();
await expect(primaryFilters.getByRole("option", { name: filter })).not.toBeChecked();
},
);
});

["People", "Rooms", "Favourite"].forEach((filter) => {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cell height should be 48px. I wonder why the screenshot height was 49px since the cell is untouched in this PR (and its 48px on develop).
I suspect a playwright shenanigan

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess fractional pixel shifts again

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 2 additions & 21 deletions src/components/viewmodels/roomlist/RoomListViewModel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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);
Expand All @@ -111,8 +94,6 @@ export function useRoomListViewModel(): RoomListViewState {
createChatRoom,
primaryFilters,
activePrimaryFilter,
activateSecondaryFilter,
activeSecondaryFilter,
activeIndex,
};
}
96 changes: 6 additions & 90 deletions src/components/viewmodels/roomlist/useFilteredRooms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
*/
Expand All @@ -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);
Expand All @@ -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, () => {
Expand All @@ -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}
Expand All @@ -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;
});
},
Expand All @@ -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]);

Expand All @@ -204,7 +122,5 @@ export function useFilteredRooms(): FilteredRooms {
primaryFilters,
activePrimaryFilter,
rooms,
activateSecondaryFilter,
activeSecondaryFilter,
};
}
37 changes: 31 additions & 6 deletions src/components/views/rooms/RoomListPanel/EmptyRoomList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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>
);
Expand Down
Loading
Loading