Add RoomListItemViewModel

Add view model for individual room list items.
Manages per-room subscriptions and updates only when specific room data changes.
This commit is contained in:
David Langley 2026-01-30 09:45:02 +00:00
parent fb8a93acbe
commit 9dc03dbf03
2 changed files with 701 additions and 215 deletions

View File

@ -0,0 +1,327 @@
/*
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 {
BaseViewModel,
RoomNotifState,
type RoomListItemSnapshot,
type RoomListItemActions,
} from "@element-hq/web-shared-components";
import { RoomEvent } from "matrix-js-sdk/src/matrix";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import type { Room, MatrixClient } from "matrix-js-sdk/src/matrix";
import type { RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { DefaultTagID } from "../../../stores/room-list/models";
import DMRoomMap from "../../../utils/DMRoomMap";
import SettingsStore from "../../../settings/SettingsStore";
import { NotificationLevel } from "../../../stores/notifications/NotificationLevel";
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils";
import { EchoChamber } from "../../../stores/local-echo/EchoChamber";
import { RoomNotifState as ElementRoomNotifState } from "../../../RoomNotifs";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { CallStore, CallStoreEvent } from "../../../stores/CallStore";
import { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications";
import { tagRoom } from "../../../utils/room/tagRoom";
import dispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import PosthogTrackers from "../../../PosthogTrackers";
interface RoomItemProps {
room: Room;
client: MatrixClient;
}
/**
* View model for an individual room list item.
* Manages per-room subscriptions and updates only when this specific room's data changes.
* Implements RoomListItemActions to provide interaction callbacks.
*/
export class RoomListItemViewModel
extends BaseViewModel<RoomListItemSnapshot, RoomItemProps>
implements RoomListItemActions
{
private notifState: RoomNotificationState;
public constructor(props: RoomItemProps) {
// Get notification state first so we can generate a complete initial snapshot
const notifState = RoomNotificationStateStore.instance.getRoomState(props.room);
const initialItem = RoomListItemViewModel.generateItemSync(props.room, props.client, notifState);
super(props, initialItem);
this.notifState = notifState;
// Subscribe to notification state changes for this room
this.disposables.trackListener(this.notifState, NotificationStateEvents.Update, this.onNotificationChanged);
// Subscribe to message preview changes (will filter to this room)
this.disposables.trackListener(MessagePreviewStore.instance, UPDATE_EVENT, this.onMessagePreviewChanged);
// Subscribe to settings changes for message preview toggle
const settingsWatchRef = SettingsStore.watchSetting(
"RoomList.showMessagePreview",
null,
this.onMessagePreviewSettingChanged,
);
this.disposables.track(() => {
SettingsStore.unwatchSetting(settingsWatchRef);
});
// Subscribe to call state changes
this.disposables.trackListener(CallStore.instance, CallStoreEvent.ConnectedCalls, this.onCallStateChanged);
// Subscribe to room-specific events
this.disposables.trackListener(props.room, RoomEvent.Name, this.onRoomChanged);
this.disposables.trackListener(props.room, RoomEvent.Tags, this.onRoomChanged);
// Load message preview asynchronously (sync data is already complete)
void this.loadAndSetMessagePreview();
}
private onNotificationChanged = (): void => {
this.updateItem();
};
private onMessagePreviewChanged = (): void => {
void this.loadAndSetMessagePreview();
};
private onMessagePreviewSettingChanged = (): void => {
void this.loadAndSetMessagePreview();
};
private onCallStateChanged = (): void => {
// Only update if call state for this room actually changed
const call = CallStore.instance.getCall(this.props.room.roomId);
const currentCallType = this.snapshot.current.notification.callType;
const newCallType =
call && call.participants.size > 0 ? (call.callType === CallType.Voice ? "voice" : "video") : undefined;
if (currentCallType !== newCallType) {
this.updateItem();
}
};
private onRoomChanged = (): void => {
this.updateItem();
};
/**
* Update the item snapshot with current sync data.
* Preserves the message preview which is managed separately.
*/
private updateItem(): void {
const newItem = RoomListItemViewModel.generateItemSync(this.props.room, this.props.client, this.notifState);
// Preserve message preview - it's managed separately by loadAndSetMessagePreview
this.snapshot.set({ ...newItem, messagePreview: this.snapshot.current.messagePreview });
}
private getMessagePreviewTag(): string {
const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(this.props.room.roomId));
return isDm ? DefaultTagID.DM : DefaultTagID.Untagged;
}
/**
* Load the message preview for this room if enabled.
* Returns undefined if previews are disabled or couldn't be loaded.
*/
private async loadMessagePreview(): Promise<string | undefined> {
const shouldShowMessagePreview = SettingsStore.getValue("RoomList.showMessagePreview");
if (!shouldShowMessagePreview) {
return undefined;
}
const messagePreviewTag = this.getMessagePreviewTag();
const preview = await MessagePreviewStore.instance.getPreviewForRoom(this.props.room, messagePreviewTag);
return preview?.text;
}
/**
* Load and set the message preview if it differs from current.
*/
private async loadAndSetMessagePreview(): Promise<void> {
const messagePreview = await this.loadMessagePreview();
if (messagePreview !== this.snapshot.current.messagePreview) {
this.snapshot.merge({ messagePreview });
}
}
/**
* Generate a complete RoomListItem with all synchronous data.
* Message preview is loaded separately to avoid blocking initial render.
*/
private static generateItemSync(
room: Room,
client: MatrixClient,
notifState: RoomNotificationState,
): RoomListItemSnapshot {
// Get room tags for menu state
const roomTags = room.tags;
const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
// Message preview will be loaded asynchronously and updated separately
const messagePreview = undefined;
const isFavourite = Boolean(roomTags[DefaultTagID.Favourite]);
const isLowPriority = Boolean(roomTags[DefaultTagID.LowPriority]);
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
// More options menu state
const showMoreOptionsMenu = hasAccessToOptionsMenu(room);
const showNotificationMenu = hasAccessToNotificationMenu(room, client.isGuest(), isArchived);
// Notification levels
const canMarkAsRead = notifState.level > NotificationLevel.None;
const canMarkAsUnread = !canMarkAsRead && !isArchived;
const canInvite = room.canInvite(client.getUserId()!) && !isDm && shouldShowComponent(UIComponent.InviteUsers);
const canCopyRoomLink = !isDm;
// Get the current room notification state from EchoChamber
const echoChamber = EchoChamber.forRoom(room);
const elementRoomNotifState = echoChamber.notificationVolume;
// Convert element-web RoomNotifState to shared-components RoomNotifState
let roomNotifState: RoomNotifState;
switch (elementRoomNotifState) {
case ElementRoomNotifState.AllMessages:
roomNotifState = RoomNotifState.AllMessages;
break;
case ElementRoomNotifState.AllMessagesLoud:
roomNotifState = RoomNotifState.AllMessagesLoud;
break;
case ElementRoomNotifState.MentionsOnly:
roomNotifState = RoomNotifState.MentionsOnly;
break;
case ElementRoomNotifState.Mute:
roomNotifState = RoomNotifState.Mute;
break;
default:
roomNotifState = RoomNotifState.AllMessages;
}
const isNotificationMute = elementRoomNotifState === ElementRoomNotifState.Mute;
// Video room and call state tracking
const call = CallStore.instance.getCall(room.roomId);
const participantCount = call?.participants.size ?? 0;
const hasParticipantsInCall = participantCount > 0;
const callType =
call?.callType === CallType.Voice ? "voice" : call?.callType === CallType.Video ? "video" : undefined;
return {
id: room.roomId,
room,
name: room.name,
isBold: notifState.hasAnyNotificationOrActivity,
messagePreview,
notification: {
hasAnyNotificationOrActivity: notifState.hasAnyNotificationOrActivity || hasParticipantsInCall,
isUnsentMessage: notifState.isUnsentMessage,
invited: notifState.invited,
isMention: notifState.isMention,
isActivityNotification: notifState.isActivityNotification,
isNotification: notifState.isNotification,
hasUnreadCount: notifState.hasUnreadCount,
count: notifState.count,
muted: isNotificationMute,
callType: hasParticipantsInCall ? callType : undefined,
},
showMoreOptionsMenu,
showNotificationMenu,
isFavourite,
isLowPriority,
canInvite,
canCopyRoomLink,
canMarkAsRead,
canMarkAsUnread,
roomNotifState,
};
}
public onOpenRoom = (): void => {
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: this.props.room.roomId,
metricsTrigger: "RoomList",
});
};
public onMarkAsRead = async (): Promise<void> => {
await clearRoomNotification(this.props.room, this.props.client);
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkRead");
};
public onMarkAsUnread = async (): Promise<void> => {
await setMarkedUnreadState(this.props.room, this.props.client, true);
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkUnread");
};
public onToggleFavorite = (): void => {
tagRoom(this.props.room, DefaultTagID.Favourite);
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle");
};
public onToggleLowPriority = (): void => {
tagRoom(this.props.room, DefaultTagID.LowPriority);
};
public onInvite = (): void => {
dispatcher.dispatch({
action: "view_invite",
roomId: this.props.room.roomId,
});
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuInviteItem");
};
public onCopyRoomLink = (): void => {
dispatcher.dispatch({
action: "copy_room",
room_id: this.props.room.roomId,
});
};
public onLeaveRoom = (): void => {
const isArchived = Boolean(this.props.room.tags[DefaultTagID.Archived]);
dispatcher.dispatch({
action: isArchived ? "forget_room" : "leave_room",
room_id: this.props.room.roomId,
});
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem");
};
public onSetRoomNotifState = (notifState: RoomNotifState): void => {
// Convert shared-components RoomNotifState to element-web RoomNotifState
let elementNotifState: ElementRoomNotifState;
switch (notifState) {
case "all_messages":
elementNotifState = ElementRoomNotifState.AllMessages;
break;
case "all_messages_loud":
elementNotifState = ElementRoomNotifState.AllMessagesLoud;
break;
case "mentions_only":
elementNotifState = ElementRoomNotifState.MentionsOnly;
break;
case "mute":
elementNotifState = ElementRoomNotifState.Mute;
break;
default:
elementNotifState = ElementRoomNotifState.AllMessages;
}
// Set the notification state using EchoChamber
const echoChamber = EchoChamber.forRoom(this.props.room);
echoChamber.notificationVolume = elementNotifState;
};
}

View File

@ -5,276 +5,435 @@
* Please see LICENSE files in the repository root for full details.
*/
import { renderHook, waitFor } from "jest-matrix-react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { type MatrixClient, type MatrixEvent, Room, RoomEvent, PendingEventOrdering } from "matrix-js-sdk/src/matrix";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import dispatcher from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions";
import { useRoomListItemViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListItemViewModel";
import { createTestClient, mkStubRoom, withClientContextRenderOptions } from "../../../../test-utils";
import {
hasAccessToNotificationMenu,
hasAccessToOptionsMenu,
} from "../../../../../src/components/viewmodels/roomlist/utils";
import { RoomListItemViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListItemViewModel";
import { createTestClient, flushPromises } from "../../../../test-utils";
import { RoomNotificationState } from "../../../../../src/stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../../../../src/stores/notifications/RoomNotificationStateStore";
import * as UseCallModule from "../../../../../src/hooks/useCall";
import { NotificationStateEvents } from "../../../../../src/stores/notifications/NotificationState";
import { type MessagePreview, MessagePreviewStore } from "../../../../../src/stores/room-list/MessagePreviewStore";
import { UPDATE_EVENT } from "../../../../../src/stores/AsyncStore";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { useMessagePreviewToggle } from "../../../../../src/components/viewmodels/roomlist/useMessagePreviewToggle";
import { DefaultTagID } from "../../../../../src/stores/room-list/models";
import dispatcher from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions";
import { CallStore } from "../../../../../src/stores/CallStore";
import type { Call } from "../../../../../src/models/Call";
jest.mock("../../../../../src/components/viewmodels/roomlist/utils", () => ({
hasAccessToOptionsMenu: jest.fn().mockReturnValue(false),
hasAccessToNotificationMenu: jest.fn().mockReturnValue(false),
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
hasAccessToNotificationMenu: jest.fn().mockReturnValue(true),
}));
jest.mock("../../../../../src/components/viewmodels/roomlist/useMessagePreviewToggle", () => ({
useMessagePreviewToggle: jest.fn().mockReturnValue({ shouldShowMessagePreview: 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(() => {
const matrixClient = createTestClient();
room = mkStubRoom("roomId", "roomName", matrixClient);
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(),
getDMRoomsForUserId: jest.fn(),
getUserIdForRoomId: jest.fn().mockReturnValue(undefined),
} as unknown as DMRoomMap;
DMRoomMap.setShared(dmRoomMap);
mocked(useMessagePreviewToggle).mockReturnValue({
shouldShowMessagePreview: false,
toggleMessagePreview: jest.fn(),
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.showMessagePreview") return false;
return false;
});
jest.spyOn(SettingsStore, "watchSetting").mockImplementation(() => "watcher-id");
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue(null);
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null);
});
afterEach(() => {
viewModel?.dispose();
jest.restoreAllMocks();
});
it("should dispatch view room action on openRoom", async () => {
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
describe("Initialization", () => {
it("should initialize with room data", async () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const fn = jest.spyOn(dispatcher, "dispatch");
vm.current.openRoom();
expect(fn).toHaveBeenCalledWith(
expect.objectContaining({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: "RoomList",
}),
);
});
// Wait for async initialization
await flushPromises();
it("should show context menu if user has access to options menu", async () => {
mocked(hasAccessToOptionsMenu).mockReturnValue(true);
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.showContextMenu).toBe(true);
});
it("should show hover menu if user has access to options menu", async () => {
mocked(hasAccessToOptionsMenu).mockReturnValue(true);
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.showHoverMenu).toBe(true);
});
it("should show hover menu if user has access to notification menu", async () => {
mocked(hasAccessToNotificationMenu).mockReturnValue(true);
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.showHoverMenu).toBe(true);
});
it("should not show hover menu if user has an invitation notification", async () => {
mocked(hasAccessToOptionsMenu).mockReturnValue(true);
const notificationState = new RoomNotificationState(room, false);
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState);
jest.spyOn(notificationState, "invited", "get").mockReturnValue(false);
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.showHoverMenu).toBe(true);
});
it("should return a message preview if one is available and they are enabled", async () => {
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
text: "Message look like this",
} as MessagePreview);
mocked(useMessagePreviewToggle).mockReturnValue({
shouldShowMessagePreview: true,
toggleMessagePreview: jest.fn(),
const snapshot = viewModel.getSnapshot();
expect(snapshot.id).toBe("!room:server");
expect(snapshot.name).toBe("Test Room");
});
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
await waitFor(() => expect(vm.current.messagePreview).toBe("Message look like this"));
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();
});
});
it("should hide message previews when disabled", async () => {
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
text: "Message look like this",
} as MessagePreview);
describe("Notification state", () => {
it("should reflect notification state", async () => {
jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
jest.spyOn(notificationState, "count", "get").mockReturnValue(5);
const { result: vm, rerender } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
// This doesn't seem to test that the hook actually triggers an update,
// but I can't see how to test that.
rerender();
await flushPromises();
expect(vm.current.messagePreview).toBe(undefined);
});
it("should check message preview when room change", async () => {
const otherRoom = mkStubRoom("roomId2", "roomName2", room.client);
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
text: "Message look like this",
} as MessagePreview);
mocked(useMessagePreviewToggle).mockReturnValue({
shouldShowMessagePreview: true,
toggleMessagePreview: jest.fn(),
const snapshot = viewModel.getSnapshot();
expect(snapshot.notification.hasAnyNotificationOrActivity).toBe(true);
expect(snapshot.notification.count).toBe(5);
});
const { result: vm, rerender } = renderHook((props) => useRoomListItemViewModel(props), {
initialProps: room,
...withClientContextRenderOptions(room.client),
});
await waitFor(() => expect(vm.current.messagePreview).toBe("Message look like this"));
it("should update when notification state changes", async () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue(null);
rerender(otherRoom);
await waitFor(() => expect(vm.current.messagePreview).toBe(undefined));
});
await flushPromises();
expect(viewModel.getSnapshot().notification.count).toBe(0);
describe("notification", () => {
let notificationState: RoomNotificationState;
beforeEach(() => {
notificationState = new RoomNotificationState(room, false);
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState);
jest.spyOn(notificationState, "count", "get").mockReturnValue(3);
notificationState.emit(NotificationStateEvents.Update);
await flushPromises();
expect(viewModel.getSnapshot().notification.count).toBe(3);
});
it("should show notification decoration if there is call has participant", () => {
jest.spyOn(UseCallModule, "useParticipantCount").mockReturnValue(1);
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.showNotificationDecoration).toBe(true);
});
it.each([
{
label: "hasAnyNotificationOrActivity",
mock: () => jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true),
},
{ label: "muted", mock: () => jest.spyOn(notificationState, "muted", "get").mockReturnValue(true) },
])("should show notification decoration if $label=true", ({ mock }) => {
mock();
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.showNotificationDecoration).toBe(true);
});
it("should be bold if there is a notification", () => {
it("should show bold text when has notifications", async () => {
jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.isBold).toBe(true);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().isBold).toBe(true);
});
it("should recompute notification state when room changes", () => {
const newRoom = mkStubRoom("room2", "Room 2", room.client);
const newNotificationState = new RoomNotificationState(newRoom, false);
it("should show mention badge", async () => {
jest.spyOn(notificationState, "isMention", "get").mockReturnValue(true);
const { result, rerender } = renderHook((room) => useRoomListItemViewModel(room), {
...withClientContextRenderOptions(room.client),
initialProps: room,
});
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(result.current.showNotificationDecoration).toBe(false);
await flushPromises();
jest.spyOn(newNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(newNotificationState);
rerender(newRoom);
expect(viewModel.getSnapshot().notification.isMention).toBe(true);
});
expect(result.current.showNotificationDecoration).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("a11yLabel", () => {
let notificationState: RoomNotificationState;
beforeEach(() => {
notificationState = new RoomNotificationState(room, false);
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState);
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(UPDATE_EVENT);
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBe("Updated message");
});
it.each([
{
label: "unsent message",
mock: () => jest.spyOn(notificationState, "isUnsentMessage", "get").mockReturnValue(true),
expected: "Open room roomName with an unsent message.",
},
{
label: "invitation",
mock: () => jest.spyOn(notificationState, "invited", "get").mockReturnValue(true),
expected: "Open room roomName invitation.",
},
{
label: "mention",
mock: () => {
jest.spyOn(notificationState, "isMention", "get").mockReturnValue(true);
jest.spyOn(notificationState, "count", "get").mockReturnValue(3);
},
expected: "Open room roomName with 3 unread messages including mentions.",
},
{
label: "unread",
mock: () => {
jest.spyOn(notificationState, "hasUnreadCount", "get").mockReturnValue(true);
jest.spyOn(notificationState, "count", "get").mockReturnValue(3);
},
expected: "Open room roomName with 3 unread messages.",
},
{
label: "default",
expected: "Open room roomName",
},
])("should return the $label label", ({ mock, expected }) => {
mock?.();
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.a11yLabel).toBe(expected);
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) => {
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", async () => {
const mockCall = {
callType: CallType.Voice,
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");
});
it("should show video call indicator", async () => {
const mockCall = {
callType: CallType.Video,
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("video");
});
it("should not show call indicator when no participants", async () => {
const mockCall = {
callType: CallType.Voice,
participants: new Map(),
} 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();
});
});
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",
});
});
});
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();
});
});
});