Refactor EventTile using the MVVM pattern - #5b (#33600)

* Extract EventTile E2E padlock state

* Refactor EventTile E2E padlock rendering after review comments
This commit is contained in:
rbondesson
2026-05-26 08:36:05 +02:00
committed by GitHub
parent f3bbf2c67a
commit 5486a22922
4 changed files with 528 additions and 141 deletions
@@ -0,0 +1,267 @@
/*
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 { EventStatus, EventType, type MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
import { DecryptionFailureCode, EventShieldColour, EventShieldReason } from "matrix-js-sdk/src/crypto-api";
import { E2ePadlockIcon } from "@element-hq/web-shared-components";
import { mkEvent } from "../../test-utils";
import {
getEventTileE2ePadlockState,
getEventTileE2ePadlockViewState,
type EventTileE2ePadlockStateInput,
} from "../../../src/viewmodels/room/timeline/event-tile/EventTileE2eState";
const roomId = "!room:example.org";
const userId = "@alice:example.org";
function makeEvent(): MatrixEvent {
return mkEvent({
event: true,
type: EventType.RoomMessage,
room: roomId,
user: userId,
content: {
msgtype: MsgType.Text,
body: "Hello",
},
});
}
function makeInput(overrides: Partial<EventTileE2ePadlockStateInput> = {}): EventTileE2ePadlockStateInput {
const mxEvent = overrides.mxEvent ?? makeEvent();
return {
mxEvent,
verificationEvent: overrides.verificationEvent ?? mxEvent,
shieldColour: EventShieldColour.NONE,
shieldReason: null,
isRoomEncrypted: false,
isLocalRoom: false,
...overrides,
};
}
function mockDecryptionFailure(mxEvent: MatrixEvent, reason: DecryptionFailureCode): void {
jest.spyOn(mxEvent, "isDecryptionFailure").mockReturnValue(true);
Object.defineProperty(mxEvent, "decryptionFailureReason", {
configurable: true,
value: reason,
});
}
describe("EventTileE2eState", () => {
it("does not show a padlock for local rooms", () => {
const state = getEventTileE2ePadlockState(makeInput({ isLocalRoom: true }));
expect(state.kind).toBe("none");
});
it("shows a decryption failure padlock for generic decryption failures", () => {
const mxEvent = makeEvent();
mockDecryptionFailure(mxEvent, DecryptionFailureCode.UNKNOWN_ERROR);
const state = getEventTileE2ePadlockState(makeInput({ mxEvent }));
expect(state.kind).toBe("decryptionFailure");
});
it("uses the verification event for edited event padlock decisions", () => {
const mxEvent = makeEvent();
const verificationEvent = makeEvent();
mockDecryptionFailure(verificationEvent, DecryptionFailureCode.UNKNOWN_ERROR);
const state = getEventTileE2ePadlockState(makeInput({ mxEvent, verificationEvent }));
expect(state.kind).toBe("decryptionFailure");
});
it.each([DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED, DecryptionFailureCode.UNSIGNED_SENDER_DEVICE])(
"does not show a padlock for %s decryption failures",
(reason) => {
const mxEvent = makeEvent();
mockDecryptionFailure(mxEvent, reason);
const state = getEventTileE2ePadlockState(makeInput({ mxEvent }));
expect(state.kind).toBe("none");
},
);
it("shows a message-shared state when the authenticity warning has a key forwarding user", () => {
const mxEvent = makeEvent();
jest.spyOn(mxEvent, "getKeyForwardingUser").mockReturnValue("@bob:example.org");
const state = getEventTileE2ePadlockState(
makeInput({
mxEvent,
shieldColour: EventShieldColour.GREY,
shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
}),
);
expect(state).toEqual({
kind: "messageShared",
keyForwardingUserId: "@bob:example.org",
roomId,
});
});
it("shows a normal shield for grey shield state", () => {
const state = getEventTileE2ePadlockState(
makeInput({
shieldColour: EventShieldColour.GREY,
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
}),
);
expect(state).toEqual({
kind: "shield",
shieldColour: EventShieldColour.GREY,
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
});
});
it("shows a warning shield for red shield state", () => {
const state = getEventTileE2ePadlockState(
makeInput({
shieldColour: EventShieldColour.RED,
shieldReason: EventShieldReason.UNKNOWN_DEVICE,
}),
);
expect(state).toEqual({
kind: "shield",
shieldColour: EventShieldColour.RED,
shieldReason: EventShieldReason.UNKNOWN_DEVICE,
});
});
it("shows an unencrypted warning for unencrypted events in encrypted rooms", () => {
const state = getEventTileE2ePadlockState(makeInput({ isRoomEncrypted: true }));
expect(state.kind).toBe("unencrypted");
});
it.each([EventStatus.ENCRYPTING, EventStatus.NOT_SENT])(
"does not show an unencrypted warning for %s local echo state",
(status) => {
const mxEvent = makeEvent();
mxEvent.status = status;
const state = getEventTileE2ePadlockState(makeInput({ mxEvent, isRoomEncrypted: true }));
expect(state.kind).toBe("none");
},
);
it("does not show an unencrypted warning for state events in encrypted rooms", () => {
const mxEvent = makeEvent();
jest.spyOn(mxEvent, "isState").mockReturnValue(true);
const state = getEventTileE2ePadlockState(makeInput({ mxEvent, isRoomEncrypted: true }));
expect(state.kind).toBe("none");
});
it("does not show an unencrypted warning for redacted events in encrypted rooms", () => {
const mxEvent = makeEvent();
jest.spyOn(mxEvent, "isRedacted").mockReturnValue(true);
const state = getEventTileE2ePadlockState(makeInput({ mxEvent, isRoomEncrypted: true }));
expect(state.kind).toBe("none");
});
it("does not show an unencrypted warning for encrypted events", () => {
const mxEvent = makeEvent();
jest.spyOn(mxEvent, "isEncrypted").mockReturnValue(true);
const state = getEventTileE2ePadlockState(makeInput({ mxEvent, isRoomEncrypted: true }));
expect(state.kind).toBe("none");
});
it("maps decryption failure state to the decryption failure icon view state", () => {
const mxEvent = makeEvent();
mockDecryptionFailure(mxEvent, DecryptionFailureCode.UNKNOWN_ERROR);
const state = getEventTileE2ePadlockViewState(makeInput({ mxEvent }));
expect(state).toEqual({
kind: "icon",
icon: E2ePadlockIcon.DecryptionFailure,
title: "This message could not be decrypted",
});
});
it("maps unencrypted state to the warning icon view state", () => {
const state = getEventTileE2ePadlockViewState(makeInput({ isRoomEncrypted: true }));
expect(state).toEqual({
kind: "icon",
icon: E2ePadlockIcon.Warning,
title: "Not encrypted",
});
});
it.each([
[EventShieldReason.UNKNOWN, E2ePadlockIcon.Normal, "Unknown error"],
[EventShieldReason.UNVERIFIED_IDENTITY, E2ePadlockIcon.Normal, "Encrypted by an unverified user."],
[EventShieldReason.UNSIGNED_DEVICE, E2ePadlockIcon.Normal, "Encrypted by a device not verified by its owner."],
[EventShieldReason.UNKNOWN_DEVICE, E2ePadlockIcon.Normal, "Encrypted by an unknown or deleted device."],
[
EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
E2ePadlockIcon.Warning,
"The authenticity of this encrypted message can't be guaranteed on this device.",
],
[EventShieldReason.MISMATCHED_SENDER_KEY, E2ePadlockIcon.Warning, "Encrypted by an unverified session"],
[EventShieldReason.SENT_IN_CLEAR, E2ePadlockIcon.Warning, "Not encrypted"],
[
EventShieldReason.VERIFICATION_VIOLATION,
E2ePadlockIcon.Warning,
"Sender's verified digital identity was reset",
],
[
EventShieldReason.MISMATCHED_SENDER,
E2ePadlockIcon.Warning,
"The sender of the event does not match the owner of the device that sent it.",
],
])("maps shield reason %s to an icon view state", (shieldReason, expectedIcon, expectedTitle) => {
const state = getEventTileE2ePadlockViewState(
makeInput({
shieldColour: expectedIcon === E2ePadlockIcon.Normal ? EventShieldColour.GREY : EventShieldColour.RED,
shieldReason,
}),
);
expect(state).toEqual({
kind: "icon",
icon: expectedIcon,
title: expectedTitle,
});
});
it("keeps forwarded-message state renderable by the shared-message indicator", () => {
const mxEvent = makeEvent();
jest.spyOn(mxEvent, "getKeyForwardingUser").mockReturnValue("@bob:example.org");
const state = getEventTileE2ePadlockViewState(
makeInput({
mxEvent,
shieldColour: EventShieldColour.GREY,
shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
}),
);
expect(state).toEqual({
kind: "messageShared",
keyForwardingUserId: "@bob:example.org",
roomId,
});
});
});