mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-07 13:16:41 +02:00
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:
parent
fb8a93acbe
commit
9dc03dbf03
327
src/components/viewmodels/roomlist/RoomListItemViewModel.ts
Normal file
327
src/components/viewmodels/roomlist/RoomListItemViewModel.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user