Refactor EventTile using the MVVM pattern - #6 (#33621)

* Thin EventTile render state wiring

* Move EventTile E2E verification into a view model

* Derive EventTile E2E padlock slots in view model

* Derive EventTile timestamp slots in render state

* Derive EventTile footer slots in render state

* Fix SonarCloud issues
This commit is contained in:
rbondesson
2026-05-27 11:34:56 +00:00
committed by GitHub
parent 1ef544c5c6
commit 6c0f9960b5
5 changed files with 819 additions and 104 deletions
@@ -0,0 +1,302 @@
/*
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 { waitFor } from "@testing-library/dom";
import { EventType, MatrixEventEvent, type MatrixClient, type MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
import {
CryptoEvent,
type EventEncryptionInfo,
EventShieldColour,
EventShieldReason,
} from "matrix-js-sdk/src/crypto-api";
import { E2ePadlockIcon } from "@element-hq/web-shared-components";
import { mkEvent, MockClientWithEventEmitter } from "../../test-utils";
import { EventTileE2eViewModel } from "../../../src/viewmodels/room/timeline/event-tile/EventTileE2eViewModel";
function makeClient(
getEncryptionInfoForEvent = jest.fn<Promise<EventEncryptionInfo | null>, [MatrixEvent]>(),
): MockClientWithEventEmitter & MatrixClient {
return new MockClientWithEventEmitter({
getCrypto: jest.fn(() => ({
getEncryptionInfoForEvent,
})),
}) as MockClientWithEventEmitter & MatrixClient;
}
describe("EventTileE2eViewModel", () => {
const roomId = "!room:example.org";
const userId = "@alice:example.org";
let viewModels: EventTileE2eViewModel[];
beforeEach(() => {
viewModels = [];
});
afterEach(() => {
for (const vm of viewModels) {
vm.dispose();
}
});
function makeEvent(): MatrixEvent {
return mkEvent({
event: true,
type: EventType.RoomMessage,
room: roomId,
user: userId,
content: {
msgtype: MsgType.Text,
body: "Hello",
},
});
}
function makeViewModel(props: ConstructorParameters<typeof EventTileE2eViewModel>[0]): EventTileE2eViewModel {
const vm = new EventTileE2eViewModel(props);
viewModels.push(vm);
return vm;
}
it("verifies encrypted events on start", async () => {
const mxEvent = makeEvent();
jest.spyOn(mxEvent, "isEncrypted").mockReturnValue(true);
const cli = makeClient(
jest.fn().mockResolvedValue({
shieldColour: EventShieldColour.GREY,
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
} as EventEncryptionInfo),
);
const vm = makeViewModel({
cli,
mxEvent,
isRoomEncrypted: false,
enableListeners: true,
});
vm.start();
await waitFor(() =>
expect(vm.getSnapshot()).toEqual({
kind: "icon",
icon: E2ePadlockIcon.Normal,
title: "Encrypted by a device not verified by its owner.",
}),
);
});
it("re-verifies when the sender verification changes", async () => {
const mxEvent = makeEvent();
jest.spyOn(mxEvent, "isEncrypted").mockReturnValue(true);
const cli = makeClient(
jest
.fn()
.mockResolvedValueOnce({
shieldColour: EventShieldColour.GREY,
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
} as EventEncryptionInfo)
.mockResolvedValueOnce({
shieldColour: EventShieldColour.RED,
shieldReason: EventShieldReason.UNKNOWN_DEVICE,
} as EventEncryptionInfo),
);
const vm = makeViewModel({
cli,
mxEvent,
enableListeners: true,
});
vm.start();
await waitFor(() => expect(vm.getSnapshot().kind).toBe("icon"));
cli.emit(CryptoEvent.UserTrustStatusChanged, userId, {});
await waitFor(() =>
expect(vm.getSnapshot()).toEqual({
kind: "icon",
icon: E2ePadlockIcon.Warning,
title: "Encrypted by an unknown or deleted device.",
}),
);
});
it("uses the replacing event for verification after edits", async () => {
const mxEvent = makeEvent();
const replacementEvent = makeEvent();
jest.spyOn(mxEvent, "isEncrypted").mockReturnValue(true);
jest.spyOn(replacementEvent, "isEncrypted").mockReturnValue(true);
const getEncryptionInfoForEvent = jest.fn(async (event: MatrixEvent) => {
return {
shieldColour: event === replacementEvent ? EventShieldColour.RED : EventShieldColour.NONE,
shieldReason: event === replacementEvent ? EventShieldReason.UNKNOWN_DEVICE : null,
} as EventEncryptionInfo;
});
const cli = makeClient(getEncryptionInfoForEvent);
const vm = makeViewModel({
cli,
mxEvent,
enableListeners: true,
});
vm.start();
await waitFor(() => expect(vm.getSnapshot().kind).toBe("none"));
mxEvent.makeReplaced(replacementEvent);
await waitFor(() =>
expect(vm.getSnapshot()).toEqual({
kind: "icon",
icon: E2ePadlockIcon.Warning,
title: "Encrypted by an unknown or deleted device.",
}),
);
expect(getEncryptionInfoForEvent).toHaveBeenLastCalledWith(replacementEvent);
});
it("derives the unencrypted warning from room encryption state", () => {
const vm = makeViewModel({
cli: makeClient(),
mxEvent: makeEvent(),
isRoomEncrypted: true,
enableListeners: false,
});
expect(vm.getSnapshot()).toEqual({
kind: "icon",
icon: E2ePadlockIcon.Warning,
title: "Not encrypted",
});
});
it("removes registered listeners on dispose", () => {
const mxEvent = makeEvent();
const cli = makeClient();
const vm = makeViewModel({
cli,
mxEvent,
enableListeners: true,
});
vm.start();
expect(cli.listenerCount(CryptoEvent.UserTrustStatusChanged)).toBe(1);
expect(mxEvent.listenerCount(MatrixEventEvent.Decrypted)).toBe(1);
expect(mxEvent.listenerCount(MatrixEventEvent.Replaced)).toBe(1);
vm.dispose();
expect(cli.listenerCount(CryptoEvent.UserTrustStatusChanged)).toBe(0);
expect(mxEvent.listenerCount(MatrixEventEvent.Decrypted)).toBe(0);
expect(mxEvent.listenerCount(MatrixEventEvent.Replaced)).toBe(0);
});
it("moves event listeners when the event prop changes", () => {
const oldEvent = makeEvent();
const newEvent = makeEvent();
const cli = makeClient();
const vm = makeViewModel({
cli,
mxEvent: oldEvent,
enableListeners: true,
});
vm.start();
vm.setProps({ mxEvent: newEvent });
expect(oldEvent.listenerCount(MatrixEventEvent.Decrypted)).toBe(0);
expect(oldEvent.listenerCount(MatrixEventEvent.Replaced)).toBe(0);
expect(newEvent.listenerCount(MatrixEventEvent.Decrypted)).toBe(1);
expect(newEvent.listenerCount(MatrixEventEvent.Replaced)).toBe(1);
expect(cli.listenerCount(CryptoEvent.UserTrustStatusChanged)).toBe(1);
});
it("does not register live listeners when disabled", () => {
const mxEvent = makeEvent();
const cli = makeClient();
const vm = makeViewModel({
cli,
mxEvent,
enableListeners: false,
});
vm.start();
expect(cli.listenerCount(CryptoEvent.UserTrustStatusChanged)).toBe(0);
expect(mxEvent.listenerCount(MatrixEventEvent.Decrypted)).toBe(0);
expect(mxEvent.listenerCount(MatrixEventEvent.Replaced)).toBe(0);
});
it("does not re-verify when a different user's verification changes", async () => {
const mxEvent = makeEvent();
jest.spyOn(mxEvent, "isEncrypted").mockReturnValue(true);
const getEncryptionInfoForEvent = jest.fn().mockResolvedValue({
shieldColour: EventShieldColour.GREY,
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
} as EventEncryptionInfo);
const cli = makeClient(getEncryptionInfoForEvent);
const vm = makeViewModel({
cli,
mxEvent,
enableListeners: true,
});
vm.start();
await waitFor(() => expect(getEncryptionInfoForEvent).toHaveBeenCalledTimes(1));
cli.emit(CryptoEvent.UserTrustStatusChanged, "@bob:example.org", {});
expect(getEncryptionInfoForEvent).toHaveBeenCalledTimes(1);
});
it("ignores stale async verification results", async () => {
const mxEvent = makeEvent();
jest.spyOn(mxEvent, "isEncrypted").mockReturnValue(true);
let resolveFirst: (value: EventEncryptionInfo) => void;
const firstResult = new Promise<EventEncryptionInfo>((resolve) => {
resolveFirst = resolve;
});
const getEncryptionInfoForEvent = jest
.fn()
.mockReturnValueOnce(firstResult)
.mockResolvedValueOnce({
shieldColour: EventShieldColour.RED,
shieldReason: EventShieldReason.UNKNOWN_DEVICE,
} as EventEncryptionInfo);
const cli = makeClient(getEncryptionInfoForEvent);
const vm = makeViewModel({
cli,
mxEvent,
enableListeners: true,
});
vm.start();
cli.emit(CryptoEvent.UserTrustStatusChanged, userId, {});
await waitFor(() =>
expect(vm.getSnapshot()).toEqual({
kind: "icon",
icon: E2ePadlockIcon.Warning,
title: "Encrypted by an unknown or deleted device.",
}),
);
resolveFirst!({
shieldColour: EventShieldColour.GREY,
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
} as EventEncryptionInfo);
await Promise.resolve();
expect(vm.getSnapshot()).toEqual({
kind: "icon",
icon: E2ePadlockIcon.Warning,
title: "Encrypted by an unknown or deleted device.",
});
});
});
@@ -112,6 +112,135 @@ describe("EventTileViewModel", () => {
expect(snapshot.root.classState.mx_EventTile_sending).toBe(true);
});
it("derives render-ready root and line state", () => {
const mxEvent = makeMessageEvent({ status: EventStatus.SENDING });
const renderState = EventTileViewModel.createRenderState(
makeProps({
event: {
mxEvent,
eventSendStatus: EventStatus.SENDING,
},
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({
@@ -279,4 +408,46 @@ describe("EventTileViewModel", () => {
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,
});
});
});