/* * 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 { TimelineRenderingType } from "../../../src/contexts/RoomContext"; import { Layout } from "../../../src/settings/enums/Layout"; import { mkEvent, stubClient } from "../../test-utils"; import { EventTileViewModel, type EventTileViewModelDependencies, type NormalizedEventTileViewModelProps, type EventTileViewModelProps, } from "../../../src/viewmodels/room/timeline/event-tile/EventTileViewModel"; describe("EventTileViewModel", () => { const matrixClient = stubClient(); const makeEvent = () => mkEvent({ event: true, id: "$event", room: "!room:example.org", ts: 123, type: "m.room.message", user: "@alice:example.org", content: { msgtype: "m.text" }, }); const makeDependencies = (mxEvent = makeEvent()): EventTileViewModelDependencies => ({ mxEvent, matrixClient, showHiddenEvents: false, }); type EventTileViewModelPropsOverrides = { event?: Partial; display?: Partial; interaction?: Partial; sender?: Partial; timestamp?: Partial; footer?: Partial; }; function makeProps(overrides: EventTileViewModelPropsOverrides = {}): NormalizedEventTileViewModelProps { return { event: { eventType: "m.room.message", msgtype: "m.text", eventTs: 123, eventId: "$event", isState: false, hasReplyChain: false, isLocalEcho: false, isSending: false, ariaLive: "off", isRoomCreate: false, isCallInvite: false, isRtcNotification: false, isEditing: false, isEncryptionFailure: false, hasRenderer: true, isSeeingThroughMessageHiddenForModeration: false, forExport: false, ...overrides.event, }, display: { timelineRenderingType: TimelineRenderingType.Room, layout: Layout.Group, isProbablyMedia: false, isBubbleMessage: false, isLeftAlignedBubbleMessage: false, isAlignedBetweenBubbles: false, isInfoMessage: false, noBubbleEvent: false, isHighlighted: false, isSelected: false, ...overrides.display, }, interaction: { hover: false, showActionBarFromFocus: false, focusWithin: false, isActionBarFocused: false, hasContextMenu: false, ...overrides.interaction, }, sender: { senderId: "@alice:example.org", member: null, isEmote: false, ...overrides.sender, }, timestamp: { ...overrides.timestamp, }, footer: { isOwnEvent: false, hasReactionsRow: false, hasReactions: false, hasPinnedMessageBadge: false, ...overrides.footer, }, }; } it("derives sending, aria-live, and scroll state from plain event data", () => { const snapshot = EventTileViewModel.createSnapshot( makeProps({ event: { isSending: true, isLocalEcho: true, }, }), ); expect(snapshot.event.isSending).toBe(true); expect(snapshot.event).toMatchObject({ eventId: "$event", eventTs: 123, isLocalEcho: true, isEncryptionFailure: false, }); expect(snapshot.root.ariaLive).toBe("off"); expect(snapshot.root.scrollToken).toBeUndefined(); expect(snapshot.root.data).toEqual({ eventId: "$event", layout: Layout.Group, shape: TimelineRenderingType.Room, isOwnEvent: false, hasReply: false, }); expect(snapshot.root.classState.mx_EventTile_sending).toBe(true); }); it("derives a scroll token for non-local-echo events", () => { const snapshot = EventTileViewModel.createSnapshot( makeProps({ event: { eventId: "$remote-event", isLocalEcho: false, }, }), ); expect(snapshot.root.scrollToken).toBe("$remote-event"); }); it("derives render-ready root and line state", () => { const renderState = EventTileViewModel.createRenderState( makeProps({ event: { isSending: true, isLocalEcho: true, }, display: { isHighlighted: true, }, }), ); expect(renderState.snapshot.event.isSending).toBe(true); expect(renderState.root.className).toContain("mx_EventTile"); expect(renderState.root.className).toContain("mx_EventTile_sending"); expect(renderState.root.className).toContain("mx_EventTile_highlight"); expect(renderState.root.ariaLive).toBe("off"); expect(renderState.root.scrollToken).toBeUndefined(); expect(renderState.root.isRenderingNotification).toBe(false); expect(renderState.line.className).toContain("mx_EventTile_line"); expect(renderState.timestamp).toMatchObject(renderState.snapshot.timestamp); }); it("derives E2E padlock placement for group layout", () => { const renderState = EventTileViewModel.createRenderState( makeProps({ display: { layout: Layout.Group, isBubbleMessage: false, }, }), ); expect(renderState.e2ePadlock).toEqual({ showInGroupLine: true, showInIrcLine: false, }); }); it("derives E2E padlock placement for IRC layout", () => { const renderState = EventTileViewModel.createRenderState( makeProps({ display: { layout: Layout.IRC, isBubbleMessage: false, }, }), ); expect(renderState.e2ePadlock).toEqual({ showInGroupLine: false, showInIrcLine: true, }); }); it("does not place E2E padlocks for bubble messages", () => { const renderState = EventTileViewModel.createRenderState( makeProps({ display: { layout: Layout.Group, isBubbleMessage: true, }, }), ); expect(renderState.e2ePadlock).toEqual({ showInGroupLine: false, showInIrcLine: false, }); }); it("derives timestamp slots for group layout", () => { const renderState = EventTileViewModel.createRenderState( makeProps({ display: { layout: Layout.Group, }, interaction: { hover: true, }, }), ); expect(renderState.timestamp.showDummy).toBe(false); expect(renderState.timestamp.showInGroupLine).toBe(true); expect(renderState.timestamp.showInIrcLine).toBe(false); }); it("derives timestamp slots for IRC layout", () => { const renderState = EventTileViewModel.createRenderState( makeProps({ display: { layout: Layout.IRC, }, interaction: { hover: true, }, }), ); expect(renderState.timestamp.showDummy).toBe(true); expect(renderState.timestamp.showInGroupLine).toBe(false); expect(renderState.timestamp.showInIrcLine).toBe(true); }); it("keeps the IRC timestamp slot for the dummy timestamp when timestamps are hidden", () => { const renderState = EventTileViewModel.createRenderState( makeProps({ display: { layout: Layout.IRC, }, interaction: { hover: true, }, timestamp: { hideTimestamp: true, }, }), ); expect(renderState.timestamp.showDummy).toBe(true); expect(renderState.timestamp.displayState.showLinkedTimestamp).toBe(false); expect(renderState.timestamp.showInGroupLine).toBe(false); expect(renderState.timestamp.showInIrcLine).toBe(true); }); it("normalizes continuation by rendering mode and bubble layout", () => { const fileSnapshot = EventTileViewModel.createSnapshot( makeProps({ display: { continuation: true, timelineRenderingType: TimelineRenderingType.File, layout: Layout.Group, }, }), ); const bubbleSnapshot = EventTileViewModel.createSnapshot( makeProps({ display: { continuation: true, timelineRenderingType: TimelineRenderingType.File, layout: Layout.Bubble, }, }), ); expect(fileSnapshot.event.isContinuation).toBe(false); expect(bubbleSnapshot.event.isContinuation).toBe(true); }); it("derives line classes from event type, message type, and media eligibility", () => { const snapshot = EventTileViewModel.createSnapshot( makeProps({ event: { msgtype: "m.image", }, display: { isProbablyMedia: true, }, }), ); expect(snapshot.line.classState).toMatchObject({ mx_EventTile_mediaLine: true, mx_EventTile_image: true, mx_EventTile_sticker: false, mx_EventTile_emote: false, }); }); it("derives aligned-between-bubbles root class state", () => { const snapshot = EventTileViewModel.createSnapshot( makeProps({ display: { isAlignedBetweenBubbles: true, }, }), ); expect(snapshot.root.classState.mx_EventTile_alignedBetweenBubbles).toBe(true); }); it("derives avatar and sender profile state for thread timelines", () => { const snapshot = EventTileViewModel.createSnapshot( makeProps({ display: { timelineRenderingType: TimelineRenderingType.Thread, continuation: false, }, }), ); expect(snapshot.sender.profileState).toEqual({ avatarSize: "32px", needsSenderProfile: true, }); expect(snapshot.sender.profileMode).toBe("clickable"); expect(snapshot.sender.viewUserOnClick).toBe(true); }); it("carries pure sender render inputs", () => { const snapshot = EventTileViewModel.createSnapshot( makeProps({ sender: { senderId: "@moderator:example.org", isEmote: true, }, }), ); expect(snapshot.sender.senderId).toBe("@moderator:example.org"); expect(snapshot.sender.isEmote).toBe(true); }); it("marks room member avatars as historical", () => { const snapshot = EventTileViewModel.createSnapshot( makeProps({ event: { eventType: "m.room.member", }, }), ); expect(snapshot.sender.forceHistoricalAvatar).toBe(true); }); it("derives action bar visibility from interaction state", () => { const hoverSnapshot = EventTileViewModel.createSnapshot(makeProps({ interaction: { hover: true } })); const contextMenuSnapshot = EventTileViewModel.createSnapshot( makeProps({ interaction: { isActionBarFocused: true, hasContextMenu: true, }, }), ); expect(hoverSnapshot.actionBar.show).toBe(true); expect(contextMenuSnapshot.actionBar.show).toBe(false); }); it("derives timestamp state for thread list events", () => { const snapshot = EventTileViewModel.createSnapshot( makeProps({ display: { timelineRenderingType: TimelineRenderingType.ThreadsList, }, interaction: { hover: true, }, timestamp: { threadReplyEventTs: 456, }, }), ); expect(snapshot.timestamp.show).toBe(true); expect(snapshot.timestamp.value).toBe(456); expect(snapshot.timestamp.displayState.showRealTimestamp).toBe(true); }); it("suppresses RTC notification timestamps", () => { const snapshot = EventTileViewModel.createSnapshot( makeProps({ event: { isRtcNotification: true, }, interaction: { hover: true, }, }), ); expect(snapshot.timestamp.show).toBe(false); expect(snapshot.timestamp.displayState.showRealTimestamp).toBe(false); }); it("derives footer placement state", () => { const groupSnapshot = EventTileViewModel.createSnapshot( makeProps({ display: { layout: Layout.Group, }, footer: { isOwnEvent: true, hasReactionsRow: true, hasReactions: true, hasPinnedMessageBadge: true, }, }), ); const bubbleSnapshot = EventTileViewModel.createSnapshot( makeProps({ display: { layout: Layout.Bubble, }, footer: { isOwnEvent: true, hasPinnedMessageBadge: true, }, }), ); expect(groupSnapshot.footer).toMatchObject({ hasFooter: true, showMainPinnedMessageBadge: true, showBubblePinnedMessageBadge: false, }); expect(bubbleSnapshot.footer).toMatchObject({ hasFooter: true, showMainPinnedMessageBadge: false, showBubblePinnedMessageBadge: true, }); }); it("derives footer render placement for default layouts", () => { const renderState = EventTileViewModel.createRenderState( makeProps({ display: { layout: Layout.Group, }, footer: { isOwnEvent: true, hasReactionsRow: true, hasReactions: true, }, }), ); expect(renderState.footer).toMatchObject({ hasFooter: true, showInIrcLayout: false, showInDefaultLayout: true, }); }); it("derives footer render placement for IRC layout", () => { const renderState = EventTileViewModel.createRenderState( makeProps({ display: { layout: Layout.IRC, }, footer: { isOwnEvent: true, hasReactionsRow: true, hasReactions: true, }, }), ); expect(renderState.footer).toMatchObject({ hasFooter: true, showInIrcLayout: true, showInDefaultLayout: false, }); }); it("updates an instance snapshot when inputs change", () => { const vm = new EventTileViewModel(makeDependencies(), makeProps()); const listener = jest.fn(); const unsubscribe = vm.subscribe(listener); expect(vm.getSnapshot().snapshot.timestamp.show).toBe(false); vm.setInputs(makeDependencies(), makeProps({ interaction: { hover: true } })); expect(vm.getSnapshot().snapshot.timestamp.show).toBe(true); expect(listener).toHaveBeenCalled(); unsubscribe(); vm.dispose(); }); it("emits once when dependencies and inputs are updated together", () => { const vm = new EventTileViewModel(makeDependencies(), makeProps()); const listener = jest.fn(); const unsubscribe = vm.subscribe(listener); vm.setInputs(makeDependencies(), makeProps({ interaction: { hover: true } })); expect(listener).toHaveBeenCalledTimes(1); unsubscribe(); vm.dispose(); }); it("normalizes event and sender state from its SDK dependency", () => { const event = mkEvent({ event: true, id: "$member-event", room: "!room:example.org", ts: 456, type: "m.room.member", user: "@bob:example.org", content: { membership: "join" }, }); const vm = new EventTileViewModel( makeDependencies(event), makeProps({ event: { eventType: "m.room.message", eventId: "$wrong-event", eventTs: 123, }, sender: { senderId: "@wrong:example.org", isEmote: false, }, }), ); expect(vm.getSnapshot().snapshot.event).toMatchObject({ eventType: "m.room.member", eventId: "$member-event", eventTs: 456, isState: true, }); expect(vm.getSnapshot().snapshot.sender).toMatchObject({ senderId: "@bob:example.org", forceHistoricalAvatar: true, isEmote: false, }); vm.setInputs( makeDependencies( mkEvent({ event: true, id: "$updated-event", room: "!room:example.org", ts: 789, type: "m.call.invite", user: "@carol:example.org", content: { msgtype: "m.call.invite" }, }), ), makeProps(), ); expect(vm.getSnapshot().snapshot.event).toMatchObject({ eventType: "m.call.invite", eventId: "$updated-event", eventTs: 789, }); expect(vm.getSnapshot().snapshot.sender.senderId).toBe("@carol:example.org"); vm.dispose(); }); it("normalizes event identity, replacement, renderer, and decryption state", () => { const event = makeEvent(); jest.spyOn(event, "replacingEventId").mockReturnValue("$replaced-event"); jest.spyOn(event, "isDecryptionFailure").mockReturnValue(true); const vm = new EventTileViewModel(makeDependencies(event), makeProps()); const snapshot = vm.getSnapshot().snapshot; expect(snapshot.event).toMatchObject({ eventType: "m.room.message", eventId: "$event", replacingEventId: "$replaced-event", isEncryptionFailure: true, hasRenderer: true, }); vm.dispose(); }); it("does not show a reply chain for replacement events", () => { const event = mkEvent({ event: true, id: "$replacement-event", room: "!room:example.org", ts: 123, type: "m.room.message", user: "@alice:example.org", content: { "msgtype": "m.text", "m.relates_to": { "rel_type": "m.replace", "event_id": "$original-event", "m.in_reply_to": { event_id: "$parent-event", }, }, }, }); const vm = new EventTileViewModel(makeDependencies(event), makeProps()); expect(vm.getSnapshot().snapshot.root.data.hasReply).toBe(false); vm.dispose(); }); it("derives an unavailable renderer for unsupported events", () => { const event = mkEvent({ event: true, room: "!room:example.org", type: "org.example.unsupported", user: "@alice:example.org", content: {}, }); const vm = new EventTileViewModel(makeDependencies(event), makeProps()); expect(vm.getSnapshot().snapshot.event.hasRenderer).toBe(false); vm.dispose(); }); it("recalculates renderer state when dependencies change", () => { const event = mkEvent({ event: true, room: "!room:example.org", type: "org.example.unsupported", user: "@alice:example.org", content: {}, }); const vm = new EventTileViewModel(makeDependencies(event), makeProps()); expect(vm.getSnapshot().snapshot.event.hasRenderer).toBe(false); vm.setInputs(makeDependencies(makeEvent()), makeProps()); expect(vm.getSnapshot().snapshot.event.hasRenderer).toBe(true); vm.dispose(); }); it("lazily owns timestamp child view models", () => { const vm = new EventTileViewModel(makeDependencies(), makeProps()); const messageTimestampViewModel = vm.getMessageTimestampViewModel({ ts: 123 }); const linkedMessageTimestampViewModel = vm.getLinkedMessageTimestampViewModel({ ts: 456 }); expect(messageTimestampViewModel.getSnapshot().href).toBeUndefined(); expect(linkedMessageTimestampViewModel.getSnapshot().href).toBeUndefined(); vm.dispose(); }); it("does not initialize timestamp child view models for events without an origin timestamp", () => { const vm = new EventTileViewModel( makeDependencies(), makeProps({ event: { eventTs: 0, }, timestamp: { hideTimestamp: true, }, }), ); expect(vm.getSnapshot().timestamp.displayState.showRealTimestamp).toBe(false); vm.dispose(); }); it("owns and updates the thread-list action bar child view model", () => { const vm = new EventTileViewModel(makeDependencies(), makeProps()); const onViewInRoomClick = jest.fn(); const onCopyLinkClick = jest.fn(); const threadListActionBarViewModel = vm.getThreadListActionBarViewModel({ onViewInRoomClick, onCopyLinkClick, }); threadListActionBarViewModel.onViewInRoomClick(null); threadListActionBarViewModel.onCopyLinkClick(null); expect(onViewInRoomClick).toHaveBeenCalledWith(null); expect(onCopyLinkClick).toHaveBeenCalledWith(null); vm.dispose(); }); });