Room list: add default sections (#32785)

* feat: add sections to RLSV3

* feat: add sections in vms

* feat: add room list section labs flag

* fix: wrong margin for room list item when in sections

* feat: hide favourites and low priority filters

* fix: crash when changing filter

* feat: support sticky room in sections

* test: update SC snapshot

* test: update SC screenshot

* test: update RLS tests

* test: add tests to RoomListSectionHeaderViewModel

* test: fix existing test in RoomListViewModel

* test: add sections tests for RoomListViewModel

* test: add e2e tests for sections

* fix: incorrect selected room when expanding/collasping a section

* fix: typo in `roomSkipList`

* feat: use one skip list with all filters instead of one list by tag

* chore: put back comment about `roomIndexInSection`

* chore: add missing `readonly`

* chore: add doc about possible undefined value for room item vm
This commit is contained in:
Florian Duros
2026-03-31 18:43:32 +00:00
committed by GitHub
parent 1974b50213
commit 0f515f581e
28 changed files with 1299 additions and 202 deletions
@@ -11,7 +11,12 @@ import { mocked } from "jest-mock";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import type { RoomNotificationState } from "../../../../src/stores/notifications/RoomNotificationState";
import { LISTS_UPDATE_EVENT, RoomListStoreV3Class } from "../../../../src/stores/room-list-v3/RoomListStoreV3";
import {
CHATS_TAG,
LISTS_UPDATE_EVENT,
RoomListStoreV3Class,
type Section,
} from "../../../../src/stores/room-list-v3/RoomListStoreV3";
import { AsyncStoreWithClient } from "../../../../src/stores/AsyncStoreWithClient";
import { RecencySorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/RecencySorter";
import { mkEvent, mkMessage, mkSpace, mkStubRoom, stubClient, upsertRoomStateEvents } from "../../../test-utils";
@@ -21,7 +26,7 @@ import dispatcher from "../../../../src/dispatcher/dispatcher";
import SpaceStore from "../../../../src/stores/spaces/SpaceStore";
import { MetaSpace, UPDATE_SELECTED_SPACE } from "../../../../src/stores/spaces";
import { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
import { FilterKey } from "../../../../src/stores/room-list-v3/skip-list/filters";
import { FilterEnum } from "../../../../src/stores/room-list-v3/skip-list/filters";
import { RoomNotificationStateStore } from "../../../../src/stores/notifications/RoomNotificationStateStore";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import { SortingAlgorithm } from "../../../../src/stores/room-list-v3/skip-list/sorters";
@@ -502,7 +507,10 @@ describe("RoomListStoreV3", () => {
store.on(LISTS_UPDATE_EVENT, fn);
// The rooms which belong to the space should not be shown
const result = store.getSortedRoomsInActiveSpace().rooms.map((r) => r.roomId);
const result = store
.getSortedRoomsInActiveSpace()
.sections.flatMap((s) => s.rooms)
.map((r) => r.roomId);
for (const id of roomIds) {
expect(result).not.toContain(id);
}
@@ -511,7 +519,10 @@ describe("RoomListStoreV3", () => {
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => spaceRoom.roomId);
SpaceStore.instance.emit(UPDATE_SELECTED_SPACE);
expect(fn).toHaveBeenCalled();
const result2 = store.getSortedRoomsInActiveSpace().rooms.map((r) => r.roomId);
const result2 = store
.getSortedRoomsInActiveSpace()
.sections.flatMap((s) => s.rooms)
.map((r) => r.roomId);
for (const id of roomIds) {
expect(result2).toContain(id);
}
@@ -534,7 +545,9 @@ describe("RoomListStoreV3", () => {
await store.start();
// Sorted, filtered rooms should be 8, 27 and 75
const result = store.getSortedRoomsInActiveSpace([FilterKey.FavouriteFilter]).rooms;
const result = store
.getSortedRoomsInActiveSpace([FilterEnum.FavouriteFilter])
.sections.flatMap((s) => s.rooms);
expect(result).toHaveLength(3);
for (const i of [8, 27, 75]) {
expect(result).toContain(rooms[i]);
@@ -569,7 +582,9 @@ describe("RoomListStoreV3", () => {
expect(fn).toHaveBeenCalled();
// Sorted, filtered rooms should be 27 and 75
const result = store.getSortedRoomsInActiveSpace([FilterKey.FavouriteFilter]).rooms;
const result = store
.getSortedRoomsInActiveSpace([FilterEnum.FavouriteFilter])
.sections.flatMap((s) => s.rooms);
expect(result).toHaveLength(2);
for (const i of [8, 75]) {
expect(result).toContain(rooms[i]);
@@ -594,7 +609,9 @@ describe("RoomListStoreV3", () => {
await store.start();
// Should only give us rooms at index 8 and 27
const result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]).rooms;
const result = store
.getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter])
.sections.flatMap((s) => s.rooms);
expect(result).toHaveLength(2);
for (const i of [8, 27]) {
expect(result).toContain(rooms[i]);
@@ -611,7 +628,9 @@ describe("RoomListStoreV3", () => {
await store.start();
// Since there's no unread yet, we expect zero results
let result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]).rooms;
let result = store
.getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter])
.sections.flatMap((s) => s.rooms);
expect(result).toHaveLength(0);
// Mock so that room at index 8 is marked as unread
@@ -626,7 +645,7 @@ describe("RoomListStoreV3", () => {
);
// Now we expect room at index 8 to show as unread
result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]).rooms;
result = store.getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter]).sections.flatMap((s) => s.rooms);
expect(result).toHaveLength(1);
expect(result).toContain(rooms[8]);
});
@@ -649,14 +668,18 @@ describe("RoomListStoreV3", () => {
await store.start();
// Should only give us rooms at index 8 and 27
const peopleRooms = store.getSortedRoomsInActiveSpace([FilterKey.PeopleFilter]).rooms;
const peopleRooms = store
.getSortedRoomsInActiveSpace([FilterEnum.PeopleFilter])
.sections.flatMap((s) => s.rooms);
expect(peopleRooms).toHaveLength(2);
for (const i of [8, 27]) {
expect(peopleRooms).toContain(rooms[i]);
}
// Rest are normal rooms
const nonDms = store.getSortedRoomsInActiveSpace([FilterKey.RoomsFilter]).rooms;
const nonDms = store
.getSortedRoomsInActiveSpace([FilterEnum.RoomsFilter])
.sections.flatMap((s) => s.rooms);
expect(nonDms).toHaveLength(3);
for (const i of [6, 13, 75]) {
expect(nonDms).toContain(rooms[i]);
@@ -680,7 +703,9 @@ describe("RoomListStoreV3", () => {
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const result = store.getSortedRoomsInActiveSpace([FilterKey.InvitesFilter]).rooms;
const result = store
.getSortedRoomsInActiveSpace([FilterEnum.InvitesFilter])
.sections.flatMap((s) => s.rooms);
expect(result).toHaveLength(5);
for (const room of invitedRooms) {
expect(result).toContain(room);
@@ -705,7 +730,9 @@ describe("RoomListStoreV3", () => {
await store.start();
// Should only give us rooms at index 8 and 27
const result = store.getSortedRoomsInActiveSpace([FilterKey.MentionsFilter]).rooms;
const result = store
.getSortedRoomsInActiveSpace([FilterEnum.MentionsFilter])
.sections.flatMap((s) => s.rooms);
expect(result).toHaveLength(2);
for (const i of [8, 27]) {
expect(result).toContain(rooms[i]);
@@ -727,7 +754,9 @@ describe("RoomListStoreV3", () => {
await store.start();
// Sorted, filtered rooms should be 8, 27 and 75
const result = store.getSortedRoomsInActiveSpace([FilterKey.LowPriorityFilter]).rooms;
const result = store
.getSortedRoomsInActiveSpace([FilterEnum.LowPriorityFilter])
.sections.flatMap((s) => s.rooms);
expect(result).toHaveLength(3);
for (const i of [8, 27, 75]) {
expect(result).toContain(rooms[i]);
@@ -755,10 +784,9 @@ describe("RoomListStoreV3", () => {
await store.start();
// Should give us only room at 8 since that's the only room which matches both filters
const result = store.getSortedRoomsInActiveSpace([
FilterKey.UnreadFilter,
FilterKey.FavouriteFilter,
]).rooms;
const result = store
.getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter, FilterEnum.FavouriteFilter])
.sections.flatMap((s) => s.rooms);
expect(result).toHaveLength(1);
expect(result).toContain(rooms[8]);
});
@@ -777,7 +805,9 @@ describe("RoomListStoreV3", () => {
},
true,
);
expect(store.getSortedRoomsInActiveSpace([FilterKey.InvitesFilter]).rooms).not.toContain(room);
expect(
store.getSortedRoomsInActiveSpace([FilterEnum.InvitesFilter]).sections.flatMap((s) => s.rooms),
).not.toContain(room);
room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Invite);
dispatcher.dispatch(
@@ -789,11 +819,196 @@ describe("RoomListStoreV3", () => {
},
true,
);
expect(store.getSortedRoomsInActiveSpace([FilterKey.InvitesFilter]).rooms).toContain(room);
expect(
store.getSortedRoomsInActiveSpace([FilterEnum.InvitesFilter]).sections.flatMap((s) => s.rooms),
).toContain(room);
});
});
});
describe("Sections", () => {
function enableSections(): void {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
if (setting === "feature_room_list_sections") return true;
return false;
});
}
function findSection(sections: Section[], tag: string): Section | undefined {
return sections.find((s) => s.tag === tag);
}
function getClientAndRooms() {
const client = stubClient();
const rooms = getMockedRooms(client);
client.getVisibleRooms = jest.fn().mockReturnValue(rooms);
jest.spyOn(AsyncStoreWithClient.prototype, "matrixClient", "get").mockReturnValue(client);
return { client, rooms };
}
it("returns a single chats section when sections feature is disabled", async () => {
const { rooms } = getClientAndRooms();
// Mark some rooms as favourite so we can verify they are NOT split out
[0, 1, 2].forEach((i) => {
rooms[i].tags[DefaultTagID.Favourite] = {};
});
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const result = store.getSortedRoomsInActiveSpace();
expect(result.sections).toHaveLength(1);
expect(result.sections[0].tag).toBe(CHATS_TAG);
// All rooms, including favourites, are in the single section
for (const i of [0, 1, 2]) {
expect(result.sections[0].rooms).toContain(rooms[i]);
}
});
it("returns three sections in the correct order when enabled", async () => {
enableSections();
getClientAndRooms();
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const result = store.getSortedRoomsInActiveSpace();
expect(result.sections).toHaveLength(3);
expect(result.sections[0].tag).toBe(DefaultTagID.Favourite);
expect(result.sections[1].tag).toBe(CHATS_TAG);
expect(result.sections[2].tag).toBe(DefaultTagID.LowPriority);
});
it.each([
{ tag: DefaultTagID.Favourite, label: "Favourite" },
{ tag: DefaultTagID.LowPriority, label: "LowPriority" },
])("places tagged rooms only in the $label section", async ({ tag }) => {
enableSections();
const { rooms } = getClientAndRooms();
// Mark rooms 3, 7 with the given tag
[3, 7].forEach((i) => {
rooms[i].tags[tag] = {};
});
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const { sections } = store.getSortedRoomsInActiveSpace();
const targetSection = findSection(sections, tag)!;
const chatsSection = findSection(sections, CHATS_TAG)!;
for (const i of [3, 7]) {
expect(targetSection.rooms).toContain(rooms[i]);
expect(chatsSection.rooms).not.toContain(rooms[i]);
}
});
it("places regular rooms only in the Chats section", async () => {
enableSections();
const { rooms } = getClientAndRooms();
// Mark some rooms as favourite / low priority so the rest are regular
rooms[0].tags[DefaultTagID.Favourite] = {};
rooms[1].tags[DefaultTagID.LowPriority] = {};
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const { sections } = store.getSortedRoomsInActiveSpace();
const favSection = findSection(sections, DefaultTagID.Favourite)!;
const chatsSection = findSection(sections, CHATS_TAG)!;
const lowPrioritySection = findSection(sections, DefaultTagID.LowPriority)!;
// A regular room (index 5) should be in chats only
expect(chatsSection.rooms).toContain(rooms[5]);
expect(favSection.rooms).not.toContain(rooms[5]);
expect(lowPrioritySection.rooms).not.toContain(rooms[5]);
});
it("all rooms are accounted for across all sections", async () => {
enableSections();
const { rooms } = getClientAndRooms();
[2, 5].forEach((i) => {
rooms[i].tags[DefaultTagID.Favourite] = {};
});
[11].forEach((i) => {
rooms[i].tags[DefaultTagID.LowPriority] = {};
});
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const { sections } = store.getSortedRoomsInActiveSpace();
const totalRooms = sections.flatMap((s) => s.rooms).length;
// All 100 rooms should be distributed across the three sections
expect(totalRooms).toBe(rooms.length);
});
it("applies additional filter keys within each section", async () => {
enableSections();
const { rooms } = getClientAndRooms();
// Rooms 3 and 7 are favourites; room 7 is also unread
[3, 7].forEach((i) => {
rooms[i].tags[DefaultTagID.Favourite] = {};
});
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockImplementation((room) => {
const state = {
hasUnreadCount: room === rooms[7],
} as unknown as RoomNotificationState;
return state;
});
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const { sections } = store.getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter]);
const favSection = findSection(sections, DefaultTagID.Favourite)!;
// Only room 7 is both favourite AND unread
expect(favSection.rooms).toHaveLength(1);
expect(favSection.rooms).toContain(rooms[7]);
});
it("sections respect space filtering", async () => {
enableSections();
const { rooms } = getClientAndRooms();
// Room 3 is a favourite room in the space
rooms[3].tags[DefaultTagID.Favourite] = {};
const spaceRoomId = "!space1:matrix.org";
const inSpaceIds = [3, 10, 20].map((i) => rooms[i].roomId);
jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space, id) => {
if (space === spaceRoomId && inSpaceIds.includes(id)) return true;
return false;
});
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => spaceRoomId);
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const { sections, spaceId } = store.getSortedRoomsInActiveSpace();
expect(spaceId).toBe(spaceRoomId);
const allRooms = sections.flatMap((s) => s.rooms);
const allRoomIds = allRooms.map((r) => r.roomId);
// Only rooms in the space should appear
for (const id of inSpaceIds) {
expect(allRoomIds).toContain(id);
}
// Rooms not in the space should not appear
expect(allRoomIds).not.toContain(rooms[50].roomId);
// Room 3 should be in the Favourite section specifically
const favSection = findSection(sections, DefaultTagID.Favourite)!;
expect(favSection.rooms).toContain(rooms[3]);
});
});
describe("Muted rooms", () => {
async function getRoomListStoreWithMutedRooms() {
const client = stubClient();