* 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:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user