Files
Thore Cimbal 88c4e155ce Merge upstream v1.12.26 - reconnect the fork to Element Web (management #0099)
The repo had no upstream ancestry: a whole tree arrived in one commit in May, so
every update meant re-applying our patches by hand onto a fresh checkout, and a file
Element moved would take our lines with it silently.

The real base was found by measuring tree distance across develop rather than trusting
the changelog: deadd548, not the v1.12.17 tag. With that set as a temporary graft, this
merge computed as a proper three-way merge - 32 conflicts instead of 1757.

Resolutions, each decided rather than defaulted:

- 24 GitHub workflows stay deleted; we build on GitLab CI.
- MImageBody.tsx is gone upstream, migrated to MVVM. Our ClamAV error label moved into
  ImageBodyViewModel.computeErrorLabel, ahead of the DecryptError branch, matching what
  VideoBodyViewModel and FileBodyViewModel already do.
- Upstream extracted the room list item body into RoomListItemContent. Our call
  participants list and its getInitials helper moved there; both sides' CSS classes and
  both sides' props are kept.
- matrix-js-sdk follows upstream at 42.2.0 - our git ref pin was a workaround for a
  stale ref, and following upstream is the point of this merge.
- Element Call stays ours. Checked before deciding: @element-hq/element-call-embedded
  is referenced nowhere in the tree, while webpack.config.ts needs
  @sorb/threadnet-call-embedded, so taking upstream's line would have deleted the noise
  suppression from #0054 without a word.

The lockfile was regenerated with pnpm 11.20.0, which upstream now requires through
devEngines. CI already runs corepack enable, and onFail: download makes it fetch that
version by itself.

Not yet accepted: this needs a build and the ClamAV functional test - send an encrypted
file, receive a rejected one - before it goes near main.
2026-08-19 12:00:00 +00:00

719 lines
29 KiB
TypeScript

/*
* Copyright 2025 New Vector 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 EventEmitter from "node:events";
import {
type MatrixClient,
type MatrixEvent,
Room,
RoomEvent,
PendingEventOrdering,
type RoomMember,
} from "matrix-js-sdk/src/matrix";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { waitFor } from "jest-matrix-react";
import { createTestClient, flushPromises } from "../../test-utils";
import { RoomNotificationState } from "../../../src/stores/notifications/RoomNotificationState";
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, { 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";
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 * as tagRoomModule from "../../../src/utils/room/tagRoom";
import { CHATS_TAG } from "../../../src/stores/room-list-v3/section";
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
hasAccessToNotificationMenu: jest.fn().mockReturnValue(true),
}));
jest.mock("../../../src/stores/CallStore", () => ({
__esModule: true,
CallStore: {
instance: {
getCall: jest.fn(),
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
},
},
CallStoreEvent: {
ConnectedCalls: "connected_calls",
},
}));
describe("RoomListItemViewModel", () => {
let matrixClient: MatrixClient;
let room: Room;
let notificationState: RoomNotificationState;
let viewModel: RoomListItemViewModel;
beforeEach(() => {
matrixClient = createTestClient();
room = new Room("!room:server", matrixClient, matrixClient.getSafeUserId(), {
pendingEventOrdering: PendingEventOrdering.Detached,
});
// Set room name
room.name = "Test Room";
notificationState = new RoomNotificationState(room, false);
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState);
const dmRoomMap = {
getUserIdForRoomId: jest.fn().mockReturnValue(undefined),
} as unknown as DMRoomMap;
DMRoomMap.setShared(dmRoomMap);
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.showMessagePreview") return false;
if (setting === "RoomList.OrderedCustomSections") return [];
if (setting === "RoomList.CustomSectionData") return {};
return false;
});
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
jest.spyOn(SettingsStore, "watchSetting").mockImplementation(() => "watcher-id");
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue(null);
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null);
jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([]);
});
afterEach(() => {
viewModel?.dispose();
jest.restoreAllMocks();
});
describe("Initialization", () => {
it("should initialize with room data", async () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
// Wait for async initialization
await flushPromises();
const snapshot = viewModel.getSnapshot();
expect(snapshot.id).toBe("!room:server");
expect(snapshot.name).toBe("Test Room");
});
it("should load message preview when enabled", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
text: "Hello world!",
} as MessagePreview);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
// Wait for async message preview load
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBe("Hello world!");
});
it("should not load message preview when disabled", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBeUndefined();
});
});
describe("Notification state", () => {
it("should reflect notification state", async () => {
jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
jest.spyOn(notificationState, "count", "get").mockReturnValue(5);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
const snapshot = viewModel.getSnapshot();
expect(snapshot.notification.hasAnyNotificationOrActivity).toBe(true);
expect(snapshot.notification.count).toBe(5);
});
it("should update when notification state changes", async () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.count).toBe(0);
jest.spyOn(notificationState, "count", "get").mockReturnValue(3);
notificationState.emit(NotificationStateEvents.Update);
await flushPromises();
expect(viewModel.getSnapshot().notification.count).toBe(3);
});
it("should show bold text when has notifications", async () => {
jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().isBold).toBe(true);
});
it("should show mention badge", async () => {
jest.spyOn(notificationState, "isMention", "get").mockReturnValue(true);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.isMention).toBe(true);
});
it("should show invitation state", async () => {
jest.spyOn(notificationState, "invited", "get").mockReturnValue(true);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.invited).toBe(true);
});
});
describe("Message preview", () => {
it("should update message preview when store emits update", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
text: "Initial message",
} as MessagePreview);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBe("Initial message");
// Update preview
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
text: "Updated message",
} as MessagePreview);
MessagePreviewStore.instance.emit(MessagePreviewStore.getPreviewChangedEventName(room));
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBe("Updated message");
});
it("should show/hide preview when setting changes", async () => {
let showPreview = false;
let watchCallback: any;
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => showPreview);
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((setting, _room, callback) => {
if (setting === "RoomList.showMessagePreview") watchCallback = callback;
return "watcher-id";
});
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
text: "Test message",
} as MessagePreview);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBeUndefined();
// Enable previews
showPreview = true;
watchCallback(null, "device", true);
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBe("Test message");
});
});
describe("Room tags", () => {
it("should reflect favorite tag", async () => {
room.tags = { [DefaultTagID.Favourite]: { order: 0 } };
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().isFavourite).toBe(true);
});
it("should reflect low priority tag", async () => {
room.tags = { [DefaultTagID.LowPriority]: { order: 0 } };
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().isLowPriority).toBe(true);
});
it("should update when room tags change", async () => {
room.tags = {};
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().isFavourite).toBe(false);
room.tags = { [DefaultTagID.Favourite]: { order: 0 } };
const tagEvent = {
getContent: () => ({ tags: { [DefaultTagID.Favourite]: { order: 0 } } }),
} as MatrixEvent;
room.emit(RoomEvent.Tags, tagEvent, room);
await flushPromises();
expect(viewModel.getSnapshot().isFavourite).toBe(true);
});
});
describe("Call state", () => {
it("should show voice call indicator and participant data", async () => {
const mockCall = {
callType: CallType.Voice,
participants: new Map([[matrixClient.getUserId()!, {}]]),
off: jest.fn(),
on: jest.fn(),
} as unknown as Call;
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.callType).toBe("voice");
expect(viewModel.getSnapshot().callParticipants).toEqual(
expect.arrayContaining([
expect.objectContaining({
userId: matrixClient.getUserId()!,
displayName: matrixClient.getUserId()!,
}),
]),
);
});
it("should show video call indicator", async () => {
const mockCall = {
callType: CallType.Video,
participants: new Map([[matrixClient.getUserId()!, {}]]),
off: jest.fn(),
on: jest.fn(),
} as unknown as Call;
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.callType).toBe("video");
});
it("should not show call indicator when no participants", async () => {
const mockCall = {
callType: CallType.Voice,
participants: new Map(),
off: jest.fn(),
on: jest.fn(),
} as unknown as Call;
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.callType).toBeUndefined();
});
it("should listen to call participant changes", () => {
const mockCall = {
callType: CallType.Voice,
participants: new Map(),
off: jest.fn(),
on: jest.fn(),
};
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall as unknown as Call);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(viewModel.getSnapshot().notification.callType).toBeUndefined();
// Get the callback registered for call state changes
const mockCalls = (CallStore.instance.on as jest.Mock).mock.calls;
const callStateCallback = mockCalls[mockCalls.length - 1][1];
callStateCallback();
// Simulate participant joining
mockCall.participants.set(matrixClient.getUserId()! as unknown as RoomMember, new Set());
// Get the callback registered for participant changes
const participantsChangeCallback = mockCall.on.mock.calls[0][1];
participantsChangeCallback();
expect(viewModel.getSnapshot().notification.callType).toBe("voice");
});
it("should update the item when there is already an active call and participants join", () => {
const mockCall = {
callType: CallType.Voice,
participants: new Map([[matrixClient.getUserId()! as unknown as RoomMember, new Set<string>()]]),
off: jest.fn(),
on: jest.fn(),
};
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall as unknown as Call);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
// Trigger onCallStateChanged so the call is tracked and the participant listener is registered
const mockCalls = (CallStore.instance.on as jest.Mock).mock.calls;
const callStateCallback = mockCalls[mockCalls.length - 1][1];
callStateCallback();
expect(viewModel.getSnapshot().notification.callType).toBe("voice");
// Simulate another participant joining while the call is already active
mockCall.participants.set("@other:server" as unknown as RoomMember, new Set<string>());
const participantsChangeCallback = mockCall.on.mock.calls[0][1];
participantsChangeCallback(mockCall.participants);
expect(viewModel.getSnapshot().callParticipants).toEqual(
expect.arrayContaining([
expect.objectContaining({ userId: "@other:server", displayName: "@other:server" }),
]),
);
});
it("should react to participant changes when a call already exists at instantiation time", () => {
const mockCall = {
callType: CallType.Voice,
participants: new Map([]),
off: jest.fn(),
on: jest.fn(),
};
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall as unknown as Call);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(viewModel.getSnapshot().notification.callType).toBeUndefined();
// Simulate participant joining
mockCall.participants.set(matrixClient.getUserId()! as unknown as RoomMember, new Set());
// Get the callback registered for participant changes
const participantsChangeCallback = mockCall.on.mock.calls[0][1];
participantsChangeCallback();
expect(viewModel.getSnapshot().notification.callType).toBe("voice");
});
it("should unsubscribe from old call participants when the call changes", () => {
const firstCall = {
callType: CallType.Voice,
participants: new Map([[matrixClient.getUserId()! as unknown as RoomMember, new Set<string>()]]),
off: jest.fn(),
on: jest.fn(),
};
const secondCall = {
callType: CallType.Video,
participants: new Map([[matrixClient.getUserId()! as unknown as RoomMember, new Set<string>()]]),
off: jest.fn(),
on: jest.fn(),
};
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(firstCall as unknown as Call);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
// Trigger onCallStateChanged to register the first call
const mockCalls = (CallStore.instance.on as jest.Mock).mock.calls;
const callStateCallback = mockCalls[mockCalls.length - 1][1];
callStateCallback();
const participantsCallback = firstCall.on.mock.calls[0][1];
expect(firstCall.on).toHaveBeenCalledWith("participants", participantsCallback);
// Now switch to a different call
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(secondCall as unknown as Call);
callStateCallback();
// The old call's listener must have been removed
expect(firstCall.off).toHaveBeenCalledWith("participants", participantsCallback);
// The new call must have a listener registered
expect(secondCall.on).toHaveBeenCalledWith("participants", expect.any(Function));
});
it("should listen to call type changes", async () => {
// Start with a voice call
let callType = CallType.Voice;
const mockCall = new (class extends EventEmitter {
get callType() {
return callType;
}
participants = new Map([[matrixClient.getUserId()!, {}]]);
})() as unknown as Call;
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.callType).toBe("voice");
// Now turn it into a video call
callType = CallType.Video;
mockCall.emit(CallEvent.CallTypeChanged, callType);
expect(viewModel.getSnapshot().notification.callType).toBe("video");
});
});
describe("Room name updates", () => {
it("should update when room name changes", async () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().name).toBe("Test Room");
room.name = "Updated Room";
room.emit(RoomEvent.Name, room);
await flushPromises();
expect(viewModel.getSnapshot().name).toBe("Updated Room");
});
});
describe("DM detection", () => {
it("should detect DM rooms", async () => {
const dmRoomMap = DMRoomMap.shared();
jest.spyOn(dmRoomMap, "getUserIdForRoomId").mockReturnValue("@user:server");
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
// DM rooms should not show copy room link option
expect(viewModel.getSnapshot().canCopyRoomLink).toBe(false);
});
it("should detect non-DM rooms", async () => {
const dmRoomMap = DMRoomMap.shared();
jest.spyOn(dmRoomMap, "getUserIdForRoomId").mockReturnValue(undefined);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().canCopyRoomLink).toBe(true);
});
});
describe("Actions", () => {
it("should dispatch view room action on openRoom", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.onOpenRoom();
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: "!room:server",
metricsTrigger: "RoomList",
});
});
it("should return room object", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(viewModel.getSnapshot().room).toBe(room);
});
it("should dispatch view_invite action when onInvite is called", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.onInvite();
expect(dispatchSpy).toHaveBeenCalledWith({
action: "view_invite",
roomId: "!room:server",
});
});
it("should dispatch copy_room action when onCopyRoomLink is called", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.onCopyRoomLink();
expect(dispatchSpy).toHaveBeenCalledWith({
action: "copy_room",
room_id: "!room:server",
});
});
it("should dispatch leave_room action when onLeaveRoom is called for normal room", () => {
room.tags = {};
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.onLeaveRoom();
expect(dispatchSpy).toHaveBeenCalledWith({
action: "leave_room",
room_id: "!room:server",
});
});
it("should dispatch forget_room action when onLeaveRoom is called for archived room", () => {
room.tags = { [DefaultTagID.Archived]: { order: 0 } };
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.onLeaveRoom();
expect(dispatchSpy).toHaveBeenCalledWith({
action: "forget_room",
room_id: "!room:server",
});
});
it("should call createSection on RoomListStoreV3 when onCreateSection is called", async () => {
const createSectionSpy = jest
.spyOn(RoomListStoreV3.instance, "createSection")
.mockResolvedValue("element.io.section.work");
const tagRoomSpy = jest.spyOn(tagRoomModule, "tagRoom").mockImplementation(() => {});
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
viewModel.onCreateSection();
expect(createSectionSpy).toHaveBeenCalled();
await waitFor(() => expect(tagRoomSpy).toHaveBeenCalledWith(room, "element.io.section.work"));
});
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, favourite, and low priority", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const sections = viewModel.getSnapshot().sections;
expect(sections.map((s) => s.tag)).toEqual([customTag]);
});
it("should mark the room current section as selected", () => {
room.tags = { [customTag]: { order: 0 } };
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const sections = viewModel.getSnapshot().sections;
expect(sections.find((s) => s.tag === customTag)?.isSelected).toBe(true);
});
it("should use custom section name from CustomSectionData", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
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<"RoomList.OrderedCustomSections"> = () => {};
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((setting, _room, callback) => {
if (setting === "RoomList.OrderedCustomSections") watchCallback = callback;
return "watcher-id";
});
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(viewModel.getSnapshot().sections).toHaveLength(1);
// 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([]);
});
it("should set areSectionsEnabled to true when RoomList.showSections is enabled", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.showSections") return true;
return false;
});
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(viewModel.getSnapshot().areSectionsEnabled).toBe(true);
});
it("should update areSectionsEnabled when RoomList.showSections setting changes", () => {
let watchCallback: CallbackFn<"RoomList.showSections"> = () => {};
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((setting, _room, callback) => {
if (setting === "RoomList.showSections") watchCallback = callback;
return "watcher-id";
});
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(viewModel.getSnapshot().areSectionsEnabled).toBe(false);
// Enable sections
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.showSections") return true;
return false;
});
watchCallback("RoomList.showSections", null, null as any, null, null);
expect(viewModel.getSnapshot().areSectionsEnabled).toBe(true);
});
});
describe("Cleanup", () => {
it("should unsubscribe from all events on dispose", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const offSpy = jest.spyOn(notificationState, "off");
viewModel.dispose();
expect(offSpy).toHaveBeenCalled();
});
});
});