/* * Copyright 2026 Element Creations 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 { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; import { RoomListSectionHeaderViewModel } from "../../../src/viewmodels/room-list/RoomListSectionHeaderViewModel"; import { RoomNotificationState } from "../../../src/stores/notifications/RoomNotificationState"; import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore"; import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState"; import { createTestClient, mkRoom } from "../../test-utils"; import SettingsStore from "../../../src/settings/SettingsStore"; import RoomListStoreV3, { CHATS_TAG } from "../../../src/stores/room-list-v3/RoomListStoreV3"; import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag"; describe("RoomListSectionHeaderViewModel", () => { let onToggleExpanded: jest.Mock; let matrixClient: MatrixClient; beforeEach(() => { onToggleExpanded = jest.fn(); matrixClient = createTestClient(); jest.spyOn(SettingsStore, "watchSetting").mockReturnValue("watcher-id"); jest.spyOn(SettingsStore, "unwatchSetting").mockReturnValue(undefined); jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => { if (setting === "RoomList.OrderedCustomSections") return []; return null; }); }); afterEach(() => { jest.restoreAllMocks(); }); it("should initialize snapshot from props", () => { const vm = new RoomListSectionHeaderViewModel({ tag: "m.favourite", title: "Favourites", spaceId: "!space:server", onToggleExpanded, }); const snapshot = vm.getSnapshot(); expect(snapshot.id).toBe("m.favourite"); expect(snapshot.title).toBe("Favourites"); expect(snapshot.isExpanded).toBe(true); }); it("should toggle expanded state on click", () => { const vm = new RoomListSectionHeaderViewModel({ tag: "m.favourite", title: "Favourites", spaceId: "!space:server", onToggleExpanded, }); expect(vm.isExpanded).toBe(true); vm.onClick(); expect(vm.isExpanded).toBe(false); expect(vm.getSnapshot().isExpanded).toBe(false); expect(onToggleExpanded).toHaveBeenCalledWith(false); vm.onClick(); expect(vm.isExpanded).toBe(true); expect(vm.getSnapshot().isExpanded).toBe(true); expect(onToggleExpanded).toHaveBeenCalledWith(true); }); it("should track expanded state per space", () => { const vm = new RoomListSectionHeaderViewModel({ tag: "m.favourite", title: "Favourites", spaceId: "!space:server", onToggleExpanded, }); // Default space: collapse vm.onClick(); expect(vm.isExpanded).toBe(false); // Switch to a different space: should default to expanded vm.setSpace("!space2:server"); expect(vm.isExpanded).toBe(true); // Collapse in the new space vm.onClick(); expect(vm.isExpanded).toBe(false); vm.onClick(); expect(vm.isExpanded).toBe(true); // Switch to the other space: should still be collapsed vm.setSpace("!space:server"); expect(vm.isExpanded).toBe(false); }); describe("displaySectionMenu", () => { it.each([ [DefaultTagID.Favourite, false], [DefaultTagID.LowPriority, false], [CHATS_TAG, false], ["element.io.section.custom", true], ])("should be %s for tag %s", (tag, expected) => { const vm = new RoomListSectionHeaderViewModel({ tag, title: "Section", spaceId: "!space:server", onToggleExpanded, }); expect(vm.getSnapshot().displaySectionMenu).toBe(expected); }); }); describe("onCustomSectionDataChange", () => { let watchCallback: () => void; beforeEach(() => { jest.spyOn(SettingsStore, "watchSetting").mockImplementation((settingName, _roomId, callback) => { if (settingName === "RoomList.CustomSectionData") watchCallback = callback as () => void; return "watcher-id"; }); }); it("should update title when custom section data changes", () => { const tag = "element.io.section.custom"; const vm = new RoomListSectionHeaderViewModel({ tag, title: "Old Title", spaceId: "!space:server", onToggleExpanded, }); expect(vm.getSnapshot().title).toBe("Old Title"); jest.spyOn(SettingsStore, "getValue").mockReturnValue({ [tag]: { tag, name: "New Title" } }); watchCallback(); expect(vm.getSnapshot().title).toBe("New Title"); }); it("should not update title when section data is missing", () => { const tag = "element.io.section.custom"; const vm = new RoomListSectionHeaderViewModel({ tag, title: "My Section", spaceId: "!space:server", onToggleExpanded, }); jest.spyOn(SettingsStore, "getValue").mockReturnValue({}); watchCallback(); expect(vm.getSnapshot().title).toBe("My Section"); }); }); describe("editSection", () => { it("should delegate to RoomListStoreV3.instance.editSection", async () => { const editSectionSpy = jest.spyOn(RoomListStoreV3.instance, "editSection").mockResolvedValue(undefined); const tag = "element.io.section.custom"; const vm = new RoomListSectionHeaderViewModel({ tag, title: "Section", spaceId: "!space:server", onToggleExpanded, }); await vm.editSection(); expect(editSectionSpy).toHaveBeenCalledWith(tag); }); }); describe("removeSection", () => { beforeEach(() => { const mockState = { on: jest.fn(), off: jest.fn(), hasAnyNotificationOrActivity: false, } as unknown as RoomNotificationState; jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(mockState); }); it("should delegate to RoomListStoreV3.instance.removeSection with isEmpty=true when no rooms", async () => { const removeSectionSpy = jest.spyOn(RoomListStoreV3.instance, "removeSection").mockResolvedValue(undefined); const tag = "element.io.section.custom"; const vm = new RoomListSectionHeaderViewModel({ tag, title: "Section", spaceId: "!space:server", onToggleExpanded, }); await vm.removeSection(); expect(removeSectionSpy).toHaveBeenCalledWith(tag, true); }); it("should delegate to RoomListStoreV3.instance.removeSection with isEmpty=false when rooms exist", async () => { const removeSectionSpy = jest.spyOn(RoomListStoreV3.instance, "removeSection").mockResolvedValue(undefined); const tag = "element.io.section.custom"; const vm = new RoomListSectionHeaderViewModel({ tag, title: "Section", spaceId: "!space:server", onToggleExpanded, }); vm.setRooms([mkRoom(matrixClient, "!room:server")]); await vm.removeSection(); expect(removeSectionSpy).toHaveBeenCalledWith(tag, false); }); }); describe("unread status", () => { let room: Room; let notificationState: RoomNotificationState; beforeEach(() => { room = mkRoom(matrixClient, "!room:server"); notificationState = new RoomNotificationState(room, false); jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState); }); it("should set isUnread to false when no rooms have notifications", () => { const vm = new RoomListSectionHeaderViewModel({ tag: "m.favourite", title: "Favourites", spaceId: "!space:server", onToggleExpanded, }); vm.setRooms([room]); expect(vm.getSnapshot().isUnread).toBe(false); }); it("should set isUnread to true when a room has notifications", () => { jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true); const vm = new RoomListSectionHeaderViewModel({ tag: "m.favourite", title: "Favourites", spaceId: "!space:server", onToggleExpanded, }); vm.setRooms([room]); expect(vm.getSnapshot().isUnread).toBe(true); }); it("should subscribe to new rooms and unsubscribe from removed rooms", () => { const room2 = mkRoom(matrixClient, "!room2:server"); const notificationState2 = new RoomNotificationState(room2, false); jest.spyOn(RoomNotificationStateStore.instance, "getRoomState") .mockReturnValueOnce(notificationState) .mockReturnValue(notificationState2); jest.spyOn(notificationState, "on"); jest.spyOn(notificationState, "off"); jest.spyOn(notificationState2, "on"); const vm = new RoomListSectionHeaderViewModel({ tag: "m.favourite", title: "Favourites", spaceId: "!space:server", onToggleExpanded, }); vm.setRooms([room]); expect(notificationState.on).toHaveBeenCalledWith(NotificationStateEvents.Update, expect.any(Function)); vm.setRooms([room2]); expect(notificationState.off).toHaveBeenCalledWith(NotificationStateEvents.Update, expect.any(Function)); expect(notificationState2.on).toHaveBeenCalledWith(NotificationStateEvents.Update, expect.any(Function)); // Calling setRooms again with the same room should not re-subscribe vm.setRooms([room2]); expect(notificationState2.on).toHaveBeenCalledTimes(1); }); it("should update isUnread when a notification state update event fires", () => { const vm = new RoomListSectionHeaderViewModel({ tag: "m.favourite", title: "Favourites", spaceId: "!space:server", onToggleExpanded, }); vm.setRooms([room]); expect(vm.getSnapshot().isUnread).toBe(false); jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true); notificationState.emit(NotificationStateEvents.Update); expect(vm.getSnapshot().isUnread).toBe(true); }); it("should unsubscribe from all notification states on dispose", () => { jest.spyOn(notificationState, "off"); const vm = new RoomListSectionHeaderViewModel({ tag: "m.favourite", title: "Favourites", spaceId: "!space:server", onToggleExpanded, }); vm.setRooms([room]); vm.dispose(); expect(notificationState.off).toHaveBeenCalledWith(NotificationStateEvents.Update, expect.any(Function)); }); }); });