Room list: add custom section creation (#33155)

* feat: add creation section dialog

* feat: add in skip list a method to change filters

* feat: add helper to creation section

* feat: add custom sections data to Settings

* feat: add custom section to room list store v3

* feat: update header and room list item vms

* feat: add toast to room list vm

* feat: add new translation

* chore: move util functions of room list specs

* test: add custom section playwright tests

* chore: call loadCustomSections in RoomListStoreV3 ctor
This commit is contained in:
Florian Duros
2026-04-17 14:02:42 +02:00
committed by GitHub
parent 73d4b63ada
commit 6b67b24254
27 changed files with 985 additions and 97 deletions
@@ -61,6 +61,7 @@ describe("RoomListHeaderViewModel", () => {
if (settingName === "RoomList.preferredSorting") return SortingAlgorithm.Recency;
if (settingName === "feature_video_rooms") return true;
if (settingName === "feature_element_call_video_rooms") return true;
if (settingName === "RoomList.OrderedCustomSections") return [];
return false;
});
});
@@ -159,6 +160,23 @@ describe("RoomListHeaderViewModel", () => {
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
expect(vm.getSnapshot().isMessagePreviewEnabled).toBe(true);
});
it.each([
[true, true, false],
[false, false, true],
])(
"when feature_room_list_sections is %s: canCreateSection=%s, useComposeIcon=%s",
(featureEnabled, expectedCanCreateSection, expectedUseComposeIcon) => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "feature_room_list_sections") return featureEnabled;
return false;
});
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
expect(vm.getSnapshot().canCreateSection).toBe(expectedCanCreateSection);
expect(vm.getSnapshot().useComposeIcon).toBe(expectedUseComposeIcon);
},
);
});
describe("event listeners", () => {
@@ -296,6 +314,13 @@ describe("RoomListHeaderViewModel", () => {
);
});
it("should call createSection on RoomListStoreV3 when createSection is called", () => {
const createSectionSpy = jest.spyOn(RoomListStoreV3.instance, "createSection").mockResolvedValue();
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.createSection();
expect(createSectionSpy).toHaveBeenCalled();
});
it("should toggle message preview from enabled to disabled", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "RoomList.showMessagePreview") return true;
@@ -29,6 +29,7 @@ import { Action } from "../../../src/dispatcher/actions";
import { CallStore } from "../../../src/stores/CallStore";
import { CallEvent, type Call } from "../../../src/models/Call";
import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel";
import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
@@ -75,6 +76,7 @@ describe("RoomListItemViewModel", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.showMessagePreview") return false;
if (setting === "RoomList.OrderedCustomSections") return [];
return false;
});
jest.spyOn(SettingsStore, "watchSetting").mockImplementation(() => "watcher-id");
@@ -501,6 +503,20 @@ describe("RoomListItemViewModel", () => {
});
});
describe("canMoveToSection", () => {
it.each([
[true, true],
[false, false],
])("should be %s when feature_room_list_sections is %s", (featureEnabled, expected) => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "feature_room_list_sections") return featureEnabled;
return false;
});
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(viewModel.getSnapshot().canMoveToSection).toBe(expected);
});
});
describe("Actions", () => {
it("should dispatch view room action on openRoom", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
@@ -572,6 +588,13 @@ describe("RoomListItemViewModel", () => {
room_id: "!room:server",
});
});
it("should call createSection on RoomListStoreV3 when onCreateSection is called", () => {
const createSectionSpy = jest.spyOn(RoomListStoreV3.instance, "createSection").mockResolvedValue();
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
viewModel.onCreateSection();
expect(createSectionSpy).toHaveBeenCalled();
});
});
describe("Cleanup", () => {
@@ -9,7 +9,7 @@ import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { waitFor } from "jest-matrix-react";
import { createTestClient, flushPromises, mkStubRoom, stubClient } from "../../test-utils";
import { createTestClient, flushPromises, flushPromisesWithFakeTimers, mkStubRoom, stubClient } from "../../test-utils";
import RoomListStoreV3, { CHATS_TAG, RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3";
import SpaceStore from "../../../src/stores/spaces/SpaceStore";
import { FilterEnum } from "../../../src/stores/room-list-v3/skip-list/filters";
@@ -589,6 +589,14 @@ describe("RoomListViewModel", () => {
});
describe("Cleanup", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("should dispose all room item view models on dispose", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
@@ -604,6 +612,51 @@ describe("RoomListViewModel", () => {
expect(disposeSpy2).toHaveBeenCalled();
});
describe("Toast", () => {
it("should show toast when SectionCreated event fires", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated);
expect(viewModel.getSnapshot().toast).toBe("section_created");
});
it("should clear toast when closeToast is called", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated);
expect(viewModel.getSnapshot().toast).toBe("section_created");
viewModel.closeToast();
expect(viewModel.getSnapshot().toast).toBeUndefined();
});
it("should auto-close toast after 15 seconds", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated);
expect(viewModel.getSnapshot().toast).toBe("section_created");
jest.advanceTimersByTime(15 * 1000);
expect(viewModel.getSnapshot().toast).toBeUndefined();
});
it("should reset the auto-close timer when a new section is created", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated);
jest.advanceTimersByTime(10 * 1000);
// Second section created — resets the timer
RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated);
jest.advanceTimersByTime(10 * 1000);
// Toast should still be visible (only 10s since last emit)
expect(viewModel.getSnapshot().toast).toBe("section_created");
jest.advanceTimersByTime(5 * 1000);
expect(viewModel.getSnapshot().toast).toBeUndefined();
});
});
describe("Sections (feature_room_list_sections)", () => {
let favRoom1: Room;
let favRoom2: Room;
@@ -922,7 +975,7 @@ describe("RoomListViewModel", () => {
action: Action.ActiveRoomChanged,
newRoomId: "!fav1:server",
});
await flushPromises();
await flushPromisesWithFakeTimers();
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(0);