Room list: assign room to custom section (#33238)

* feat(sc): add new toast type for room list

* feat(sc): add section entries in room list item menu

* feat(rls): expose util functions

* feat: allows to tag room with custom sections

* feat(vm): add new Chat moved toast to room list vm

* feat(vm): add section selection to room list item vm

* feat(e2e): add tests for adding room in a custom section

* test(e2e): update existing screenshots

* chore: fix lint after merge

* chore: remove outline in test
This commit is contained in:
Florian Duros
2026-04-22 21:50:54 +02:00
committed by GitHub
parent 73e1b87075
commit f4c62abbcd
28 changed files with 532 additions and 26 deletions
@@ -11,6 +11,7 @@ import { Room } from "matrix-js-sdk/src/matrix";
import RoomListActions from "../../../../src/actions/RoomListActions";
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
import { DefaultTagID, type TagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
import { CUSTOM_SECTION_TAG_PREFIX } from "../../../../src/stores/room-list-v3/section";
import { tagRoom } from "../../../../src/utils/room/tagRoom";
import { getMockClientWithEventEmitter } from "../../../test-utils";
import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom";
@@ -18,6 +19,7 @@ import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom"
describe("tagRoom()", () => {
const userId = "@alice:server.org";
const roomId = "!room:server.org";
const customTag = `${CUSTOM_SECTION_TAG_PREFIX}my-section`;
const makeRoom = (tags: TagID[] = []): Room => {
const client = getMockClientWithEventEmitter({
@@ -59,7 +61,7 @@ describe("tagRoom()", () => {
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
room.client,
room,
DefaultTagID.LowPriority, // remove
null, // remove
DefaultTagID.Favourite, // add
);
});
@@ -73,10 +75,24 @@ describe("tagRoom()", () => {
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
room.client,
room,
DefaultTagID.Favourite, // remove
null, // remove
DefaultTagID.LowPriority, // add
);
});
it("should tag a room with a custom section", () => {
const room = makeRoom();
tagRoom(room, customTag);
expect(defaultDispatcher.dispatch).toHaveBeenCalled();
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
room.client,
room,
null, // remove
customTag, // add
);
});
});
describe("when a room is tagged as favourite", () => {
@@ -137,4 +153,26 @@ describe("tagRoom()", () => {
);
});
});
describe("when a room is tagged with a custom section", () => {
const otherCustomTag = `${CUSTOM_SECTION_TAG_PREFIX}other-section`;
it.each([
{ label: "untag the custom section", applyTag: customTag, expectedAdd: null },
{ label: "replace with favourite", applyTag: DefaultTagID.Favourite, expectedAdd: DefaultTagID.Favourite },
{ label: "replace with another custom section", applyTag: otherCustomTag, expectedAdd: otherCustomTag },
])("should $label", ({ applyTag, expectedAdd }) => {
const room = makeRoom([customTag]);
tagRoom(room, applyTag);
expect(defaultDispatcher.dispatch).toHaveBeenCalled();
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
room.client,
room,
customTag, // remove
expectedAdd, // add
);
});
});
});
@@ -21,7 +21,7 @@ import { RoomNotificationState } from "../../../src/stores/notifications/RoomNot
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
import { type MessagePreview, MessagePreviewStore } from "../../../src/stores/message-preview";
import SettingsStore from "../../../src/settings/SettingsStore";
import SettingsStore, { type CallbackFn } from "../../../src/settings/SettingsStore";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
import dispatcher from "../../../src/dispatcher/dispatcher";
@@ -29,7 +29,8 @@ 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";
import RoomListStoreV3, { CHATS_TAG } from "../../../src/stores/room-list-v3/RoomListStoreV3";
import * as tagRoomModule from "../../../src/utils/room/tagRoom";
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
@@ -83,6 +84,7 @@ describe("RoomListItemViewModel", () => {
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue(null);
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null);
jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([]);
});
afterEach(() => {
@@ -213,8 +215,8 @@ describe("RoomListItemViewModel", () => {
let watchCallback: any;
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => showPreview);
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_setting, _room, callback) => {
watchCallback = callback;
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((setting, _room, callback) => {
if (setting === "RoomList.showMessagePreview") watchCallback = callback;
return "watcher-id";
});
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
@@ -595,6 +597,93 @@ describe("RoomListItemViewModel", () => {
viewModel.onCreateSection();
expect(createSectionSpy).toHaveBeenCalled();
});
it("should call tagRoom when onToggleSection is called", () => {
const tagRoomSpy = jest.spyOn(tagRoomModule, "tagRoom").mockImplementation(() => {});
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
viewModel.onToggleSection(DefaultTagID.Favourite);
expect(tagRoomSpy).toHaveBeenCalledWith(room, DefaultTagID.Favourite);
});
});
describe("Sections", () => {
const customTag = "element.io.section.custom1";
beforeEach(() => {
jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([
DefaultTagID.Favourite,
customTag,
CHATS_TAG,
DefaultTagID.LowPriority,
]);
});
it("should include sections from orderedSectionTags excluding CHATS_TAG", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "feature_room_list_sections") return true;
return false;
});
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const sections = viewModel.getSnapshot().sections;
expect(sections.map((s) => s.tag)).toEqual([DefaultTagID.Favourite, customTag, DefaultTagID.LowPriority]);
});
it("should mark the room current section as selected", () => {
room.tags = { [DefaultTagID.Favourite]: { order: 0 } };
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "feature_room_list_sections") return true;
return false;
});
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const sections = viewModel.getSnapshot().sections;
expect(sections.find((s) => s.tag === DefaultTagID.Favourite)?.isSelected).toBe(true);
expect(sections.find((s) => s.tag === DefaultTagID.LowPriority)?.isSelected).toBe(false);
});
it("should use custom section name from CustomSectionData", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "feature_room_list_sections") return true;
if (setting === "RoomList.CustomSectionData")
return { [customTag]: { name: "My Custom Section", tag: customTag } };
return false;
});
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const section = viewModel.getSnapshot().sections.find((s) => s.tag === customTag);
expect(section?.name).toBe("My Custom Section");
});
it("should update sections when OrderedCustomSections setting changes", () => {
let watchCallback: CallbackFn = () => {};
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((setting, _room, callback) => {
if (setting === "RoomList.OrderedCustomSections") watchCallback = callback;
return "watcher-id";
});
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "feature_room_list_sections") return true;
return false;
});
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(viewModel.getSnapshot().sections).toHaveLength(3); // Favourite, custom, LowPriority
// Simulate reordering: custom section removed
jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([
DefaultTagID.Favourite,
CHATS_TAG,
DefaultTagID.LowPriority,
]);
watchCallback("RoomList.OrderedCustomSections", null, null as any, null, null);
expect(viewModel.getSnapshot().sections.map((s) => s.tag)).toEqual([
DefaultTagID.Favourite,
DefaultTagID.LowPriority,
]);
});
});
describe("Cleanup", () => {
@@ -619,6 +619,12 @@ describe("RoomListViewModel", () => {
expect(viewModel.getSnapshot().toast).toBe("section_created");
});
it("should show toast when RoomTagged event fires", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
RoomListStoreV3.instance.emit(RoomListStoreV3Event.RoomTagged);
expect(viewModel.getSnapshot().toast).toBe("chat_moved");
});
it("should clear toast when closeToast is called", () => {
viewModel = new RoomListViewModel({ client: matrixClient });